502 lines
21 KiB
PHP
502 lines
21 KiB
PHP
<?php
|
|
|
|
namespace objects;
|
|
|
|
use classes\db;
|
|
use classes\customer_order_product_policy;
|
|
use classes\object_property;
|
|
use classes\order_item_extra_sale_audit_policy;
|
|
use classes\order_payment_lock;
|
|
use classes\orders_schema_bootstrap;
|
|
use Exception;
|
|
use RuntimeException;
|
|
use traits\db_object_t;
|
|
|
|
class order_items_o extends db
|
|
{
|
|
use db_object_t;
|
|
|
|
/**
|
|
* The associated order id
|
|
* @var object_property
|
|
*/
|
|
public object_property $order_id;
|
|
/**
|
|
* The associated product id
|
|
* @var object_property
|
|
*/
|
|
public object_property $product_id;
|
|
/**
|
|
* The text reference assigned to the product at the time of the order
|
|
* @var object_property
|
|
*/
|
|
public object_property $reference;
|
|
/**
|
|
* The notes added to the order item
|
|
* @var object_property
|
|
*/
|
|
public object_property $notes;
|
|
public object_property $extra_sale_reason_code;
|
|
public object_property $extra_sale_comment;
|
|
/**
|
|
* The cashier (id) who added the item to the order
|
|
* @var object_property
|
|
*/
|
|
public object_property $cashier_id;
|
|
/**
|
|
* The price of the product at the time of the order
|
|
* @var object_property
|
|
*/
|
|
public object_property $price;
|
|
/**
|
|
* The quantity of the product ordered
|
|
* @var object_property
|
|
*/
|
|
public object_property $quantity;
|
|
/**
|
|
* The id of the related item, if it exists
|
|
* @var object_property
|
|
*/
|
|
public object_property $related_item_id;
|
|
|
|
/**
|
|
* The include in invoice property
|
|
* @var object_property
|
|
*/
|
|
public object_property $include_in_invoice;
|
|
/**
|
|
* Approved reason code (e.g. customer_approved_extra_work) for audited products
|
|
* @var object_property
|
|
*/
|
|
public object_property $reason_code;
|
|
/**
|
|
* Snapshot of the human-readable label for the reason code at write time
|
|
* @var object_property
|
|
*/
|
|
public object_property $reason_label_snapshot;
|
|
/**
|
|
* Free-text reason comment when the policy requires one
|
|
* @var object_property
|
|
*/
|
|
public object_property $reason_comment;
|
|
|
|
public function structure(): void
|
|
{
|
|
orders_schema_bootstrap::ensureTables();
|
|
$this->setTable('order_items');
|
|
}
|
|
|
|
public function getOrderItemById(int $id): order_items_o
|
|
{
|
|
global $db;
|
|
// Get the record from the database
|
|
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
|
$result = $db->query($sql);
|
|
if ($result->num_rows > 0) {
|
|
$this->id = $id;
|
|
$this->getObjectProperties();
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
public function getObjectProperties(): void
|
|
{
|
|
$this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', true);
|
|
$this->product_id = new object_property($this->table, $this->id, 'product_id', 'int', true);
|
|
$this->reference = new object_property($this->table, $this->id, 'reference', 'string', true);
|
|
$this->notes = new object_property($this->table, $this->id, 'notes', 'string', false);
|
|
$this->extra_sale_reason_code = new object_property($this->table, $this->id, 'extra_sale_reason_code', 'string', false);
|
|
$this->extra_sale_comment = new object_property($this->table, $this->id, 'extra_sale_comment', 'string', false);
|
|
$this->cashier_id = new object_property($this->table, $this->id, 'cashier_id', 'int', true);
|
|
$this->price = new object_property($this->table, $this->id, 'price', 'int', true);
|
|
$this->quantity = new object_property($this->table, $this->id, 'quantity', 'int', true);
|
|
$this->related_item_id = new object_property($this->table, $this->id, 'related_item_id', 'int', false);
|
|
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
|
|
$this->reason_code = new object_property($this->table, $this->id, 'reason_code', 'string', false);
|
|
$this->reason_label_snapshot = new object_property($this->table, $this->id, 'reason_label_snapshot', 'string', false);
|
|
$this->reason_comment = new object_property($this->table, $this->id, 'reason_comment', 'string', false);
|
|
}
|
|
|
|
private static function validatedRelatedItemId(int $orderId, mixed $relatedItemId): ?int
|
|
{
|
|
if ($relatedItemId === null || $relatedItemId === '' || (int)$relatedItemId === 0) {
|
|
return null;
|
|
}
|
|
|
|
$normalizedRelatedItemId = (int)$relatedItemId;
|
|
if ($normalizedRelatedItemId < 1) {
|
|
throw new RuntimeException('Related item ID must be a positive integer');
|
|
}
|
|
|
|
global $db;
|
|
$result = $db->query(
|
|
"SELECT order_id
|
|
FROM order_items
|
|
WHERE id = {$normalizedRelatedItemId}
|
|
AND deleted_at IS NULL
|
|
LIMIT 1"
|
|
);
|
|
if (!$result || $result->num_rows < 1) {
|
|
throw new RuntimeException('Related order item not found');
|
|
}
|
|
|
|
$row = $result->fetch_assoc();
|
|
if ((int)($row['order_id'] ?? 0) !== $orderId) {
|
|
throw new RuntimeException('Related order item must belong to the same order');
|
|
}
|
|
|
|
return $normalizedRelatedItemId;
|
|
}
|
|
|
|
public function add(int $order_id, int $product_id, string $reference, string $notes, int $cashier_id, int $price, int $quantity, $related_item_id = null, ?string $extra_sale_reason_code = null, ?string $extra_sale_comment = null): void
|
|
{
|
|
global $db, $response;
|
|
try {
|
|
$paymentMutationLock = self::acquirePaymentMutationLock([$order_id]);
|
|
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
|
$product = (new products_o())->getProductById($product_id);
|
|
if (!$product->exists()) {
|
|
throw new RuntimeException('Product not found');
|
|
}
|
|
$audit = order_item_extra_sale_audit_policy::normalizeForProduct($product, [
|
|
'extra_sale_reason_code' => $extra_sale_reason_code,
|
|
'extra_sale_comment' => $extra_sale_comment,
|
|
]);
|
|
// Avoid SQL injection
|
|
$reference = $db->escape_string($reference);
|
|
$notes = $db->escape_string($notes);
|
|
$extraSaleReasonCodeSql = $audit['extra_sale_reason_code'] === null
|
|
? 'NULL'
|
|
: "'" . $db->escape_string($audit['extra_sale_reason_code']) . "'";
|
|
$extraSaleCommentSql = $audit['extra_sale_comment'] === null
|
|
? 'NULL'
|
|
: "'" . $db->escape_string($audit['extra_sale_comment']) . "'";
|
|
// Create a new record in the database
|
|
$sql = "INSERT INTO $this->table (order_id, product_id, reference, notes, extra_sale_reason_code, extra_sale_comment, cashier_id, price, quantity) VALUES ($order_id, $product_id, '$reference', '$notes', $extraSaleReasonCodeSql, $extraSaleCommentSql, $cashier_id, $price, $quantity)";
|
|
$db->query($sql);
|
|
|
|
// Get the id of the new record
|
|
$this->id = $db->insert_id();
|
|
|
|
// Set the values of the object properties
|
|
$this->getObjectProperties();
|
|
// Set the related item id, if it is set
|
|
if ($related_item_id) {
|
|
$this->related_item_id->set($related_item_id);
|
|
}
|
|
// Inform the order object that a new item has been added
|
|
$this->getOrder()->objectChanged();
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function objectChanged(): void
|
|
{
|
|
// This method is called when the object is changed, to inform the order object
|
|
// that an item has been added, edited or removed.
|
|
// This is used to update the order total and other related properties.
|
|
$order = $this->getOrder();
|
|
$order->objectChanged();
|
|
}
|
|
|
|
/**
|
|
* Get the order object associated with this order item
|
|
* @return orders_o The order object associated with this order item
|
|
* @throws Exception If the order item is not selected
|
|
* @throws Exception If no order is selected
|
|
*/
|
|
public function getOrder(): orders_o
|
|
{
|
|
self::requireSelected();
|
|
return (new orders_o())->select((int)$this->order_id->value());
|
|
}
|
|
|
|
public function edit(int $id, int $order_id, int $product_id, string $reference, string $notes, int $cashier_id, int $price, int $quantity): void
|
|
{
|
|
global $db, $response;
|
|
$this->id = $id;
|
|
try {
|
|
$this->getOrderItemById($id);
|
|
self::requireSelected();
|
|
$paymentMutationLock = self::acquirePaymentMutationLock([
|
|
(int)$this->order_id->value(),
|
|
$order_id,
|
|
]);
|
|
// Avoid SQL injection
|
|
$reference = $db->escape_string($reference);
|
|
$notes = $db->escape_string($notes);
|
|
// Update the record in the database
|
|
$sql = "UPDATE $this->table SET order_id = $order_id, product_id = $product_id, reference = '$reference', notes = '$notes', cashier_id = $cashier_id, price = $price, quantity = $quantity WHERE id = $this->id";
|
|
$db->query($sql);
|
|
|
|
// Set the values of the object properties
|
|
$this->getObjectProperties();
|
|
// Inform the order object that an item has been edited
|
|
$this->objectChanged();
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function addItemToOrder(int $order_id, int $product_id, int $cashier_id, int $quantity, $related_item_id = null, $notes = null, $forcePrice = null, ?string $reason_code = null, ?string $reason_label_snapshot = null, ?string $reason_comment = null, ?string $extra_sale_reason_code = null, ?string $extra_sale_comment = null): order_items_o
|
|
{
|
|
global $db, $response;
|
|
try {
|
|
$paymentMutationLock = self::acquirePaymentMutationLock([$order_id]);
|
|
// Get the order
|
|
$order = (new orders_o())->getOrderById($order_id);
|
|
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
|
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
|
// Get the product price
|
|
$product = (new products_o())->getProductById($product_id);
|
|
if (!$product->exists()) {
|
|
throw new RuntimeException('Product not found');
|
|
}
|
|
$audit = order_item_extra_sale_audit_policy::normalizeForProduct($product, [
|
|
'extra_sale_reason_code' => $extra_sale_reason_code,
|
|
'extra_sale_comment' => $extra_sale_comment,
|
|
]);
|
|
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
|
|
$price = $priceResolution['price'];
|
|
|
|
// Check if the user has a discount on the product, or category
|
|
$customer = (new orders_o())->getOrderCustomer($order_id);
|
|
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
|
|
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false, (int)$order->department_id->value());
|
|
}
|
|
|
|
// If the price is forced, set the price to the forced price
|
|
if ($forcePrice !== null) {
|
|
$price = (int)$forcePrice;
|
|
}
|
|
|
|
// Create a new record in the database
|
|
$reasonCodeSql = $reason_code !== null ? "'" . $db->escape_string($reason_code) . "'" : "NULL";
|
|
$reasonLabelSql = $reason_label_snapshot !== null ? "'" . $db->escape_string($reason_label_snapshot) . "'" : "NULL";
|
|
$reasonCommentSql = $reason_comment !== null ? "'" . $db->escape_string($reason_comment) . "'" : "NULL";
|
|
$extraSaleReasonCodeSql = $audit['extra_sale_reason_code'] === null
|
|
? 'NULL'
|
|
: "'" . $db->escape_string($audit['extra_sale_reason_code']) . "'";
|
|
$extraSaleCommentSql = $audit['extra_sale_comment'] === null
|
|
? 'NULL'
|
|
: "'" . $db->escape_string($audit['extra_sale_comment']) . "'";
|
|
$sql = "INSERT INTO $this->table (order_id, product_id, price, cashier_id, quantity, reason_code, reason_label_snapshot, reason_comment, extra_sale_reason_code, extra_sale_comment) VALUES ($order_id, $product_id, $price, $cashier_id, $quantity, $reasonCodeSql, $reasonLabelSql, $reasonCommentSql, $extraSaleReasonCodeSql, $extraSaleCommentSql)";
|
|
$db->query($sql);
|
|
// Get the id of the new record
|
|
$this->id = $db->insert_id();
|
|
// Set the values of the object properties
|
|
$this->getObjectProperties();
|
|
// Set the related item id, if it is set
|
|
if ($related_item_id) {
|
|
$this->related_item_id->set($related_item_id);
|
|
}
|
|
// Set the notes, if it is set
|
|
if ($notes) {
|
|
$this->notes->set($notes);
|
|
}
|
|
// Invalidate the order cache
|
|
$order->objectChanged();
|
|
return (new order_items_o())->select($this->id);
|
|
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function removeOrderItem(int $id): void
|
|
{
|
|
// TODO: Implement delete() method instead
|
|
global $db;
|
|
$this->id = $id;
|
|
$this->select($this->id);
|
|
$this->requireSelected();
|
|
$paymentMutationLock = self::acquirePaymentMutationLock([
|
|
(int)$this->order_id->value(),
|
|
]);
|
|
// Inform the order object that an item has been removed
|
|
$this->getOrder()->objectChanged();
|
|
$sql = "DELETE FROM $this->table WHERE id = $this->id or related_item_id = $this->id";
|
|
$db->query($sql);
|
|
}
|
|
|
|
public function getItemAsArray(): array
|
|
{
|
|
return [
|
|
'id' => (int)$this->id,
|
|
'order_id' => (int)$this->order_id->value(),
|
|
'product_id' => (int)$this->product_id->value(),
|
|
'reference' => (string)$this->reference->value(),
|
|
'notes' => (string)$this->notes->value(),
|
|
'extra_sale_reason_code' => $this->extra_sale_reason_code->value() === null ? null : (string)$this->extra_sale_reason_code->value(),
|
|
'extra_sale_comment' => $this->extra_sale_comment->value() === null ? null : (string)$this->extra_sale_comment->value(),
|
|
'cashier_id' => (int)$this->cashier_id->value(),
|
|
'price' => (int)$this->price->value(),
|
|
'quantity' => (int)$this->quantity->value(),
|
|
'related_item_id' => (int)$this->related_item_id->value(),
|
|
'product' => (array)(new products_o())->getProductById($this->product_id->value())->asArray(),
|
|
'cashier' => (array)(new users_o())->getUserById($this->cashier_id->value())->asArray(),
|
|
'include_in_invoice' => (bool)$this->include_in_invoice->value(),
|
|
'reason_code' => $this->reason_code->value(),
|
|
'reason_label_snapshot' => $this->reason_label_snapshot->value(),
|
|
'reason_comment' => $this->reason_comment->value(),
|
|
];
|
|
}
|
|
|
|
public function getAllItemsAsArray(int $orderId, ?array $onlySpecificColumns = null): array
|
|
{
|
|
global $db;
|
|
// Get all items for the order
|
|
if ($onlySpecificColumns) {
|
|
$columns = implode(', ', $onlySpecificColumns);
|
|
} else {
|
|
$columns = '*';
|
|
}
|
|
$sql = "SELECT $columns FROM $this->table WHERE order_id = $orderId AND deleted_at IS NULL";
|
|
$result = $db->query($sql);
|
|
// Circumvent the repeated instantiation of the object, by just selecting the fields
|
|
$items = [];
|
|
if ($result->num_rows > 0) {
|
|
// Fetch all rows
|
|
return $result->fetch_all(MYSQLI_ASSOC);
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function updateOrderItem(int $id, int $price, string $notes, string $reference, int $quantity, ?string $reason_code = null, ?string $reason_label_snapshot = null, ?string $reason_comment = null, ?string $extra_sale_reason_code = null, ?string $extra_sale_comment = null): void
|
|
{
|
|
global $db, $response;
|
|
$this->select((int)$id);
|
|
$this->requireSelected();
|
|
try {
|
|
$paymentMutationLock = self::acquirePaymentMutationLock([
|
|
(int)$this->order_id->value(),
|
|
]);
|
|
$product = $this->getProduct();
|
|
$audit = order_item_extra_sale_audit_policy::normalizeForProduct($product, [
|
|
'extra_sale_reason_code' => $extra_sale_reason_code,
|
|
'extra_sale_comment' => $extra_sale_comment,
|
|
]);
|
|
// Avoid SQL injection
|
|
$price = $db->escape_string($price);
|
|
$notes = $db->escape_string($notes);
|
|
$reference = $db->escape_string($reference);
|
|
// Update the record in the database
|
|
$this->price->set((int)$price);
|
|
$this->notes->set($notes);
|
|
$this->extra_sale_reason_code->set($audit['extra_sale_reason_code']);
|
|
$this->extra_sale_comment->set($audit['extra_sale_comment']);
|
|
$this->reference->set($reference);
|
|
$this->quantity->set($quantity);
|
|
if ($reason_code !== null) {
|
|
$this->reason_code->set($reason_code);
|
|
}
|
|
if ($reason_label_snapshot !== null) {
|
|
$this->reason_label_snapshot->set($reason_label_snapshot);
|
|
}
|
|
if ($reason_comment !== null) {
|
|
$this->reason_comment->set($reason_comment);
|
|
}
|
|
// Set the values of the object properties
|
|
$this->getObjectProperties();
|
|
$this->objectChanged();
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $orderIds
|
|
*/
|
|
private static function acquirePaymentMutationLock(array $orderIds): order_payment_lock
|
|
{
|
|
$lock = order_payment_lock::tryAcquireOrderMutations($orderIds);
|
|
if ($lock === null) {
|
|
throw new RuntimeException(
|
|
'The order or invoice collection is currently being changed or paid. Try again.'
|
|
);
|
|
}
|
|
return $lock;
|
|
}
|
|
|
|
/**
|
|
* Get the list of order items based on the criteria.
|
|
* @param int[] $department_ids
|
|
* @param int[] $product_ids
|
|
* @param int[] $customer_numbers
|
|
* @param \DateTime|null $datetime_start
|
|
* @param \DateTime|null $datetime_end
|
|
* @return order_items_o[]
|
|
*/
|
|
public static function getListByCriteria(
|
|
array $department_ids = [],
|
|
array $product_ids = [],
|
|
array $customer_numbers = [],
|
|
\DateTime $datetime_start = null,
|
|
\DateTime $datetime_end = null
|
|
): array
|
|
{
|
|
global $db;
|
|
$sql = "SELECT oi.* FROM order_items oi
|
|
JOIN orders o ON oi.order_id = o.id
|
|
WHERE oi.deleted_at IS NULL AND o.deleted_at IS NULL";
|
|
|
|
if (!empty($department_ids)) {
|
|
$sql .= " AND o.department_id IN (" . implode(',', array_map('intval', $department_ids)) . ")";
|
|
}
|
|
|
|
if (!empty($product_ids)) {
|
|
$sql .= " AND oi.product_id IN (" . implode(',', array_map('intval', $product_ids)) . ")";
|
|
}
|
|
|
|
if (!empty($customer_numbers)) {
|
|
// customer_id in orders table matches customer_number in users table (at least it is used this way in many places)
|
|
$sql .= " AND o.customer_id IN (" . implode(',', array_map('intval', $customer_numbers)) . ")";
|
|
}
|
|
|
|
if ($datetime_start) {
|
|
$sql .= " AND o.created_at >= '" . $datetime_start->format('Y-m-d H:i:s') . "'";
|
|
}
|
|
|
|
if ($datetime_end) {
|
|
$sql .= " AND o.created_at <= '" . $datetime_end->format('Y-m-d H:i:s') . "'";
|
|
}
|
|
|
|
$result = $db->query($sql);
|
|
$items = [];
|
|
if ($result && $result->num_rows > 0) {
|
|
while ($row = $result->fetch_assoc()) {
|
|
$item = new order_items_o();
|
|
$item->id = (int)$row['id'];
|
|
// We don't call getObjectProperties() here to avoid many SQL queries,
|
|
// but the count() method in goals_criteria_products only uses $item->quantity
|
|
// Let's check if we can populate quantity directly.
|
|
// Actually, goals_criteria_products uses $item->quantity.
|
|
// In this codebase, properties are often object_property objects.
|
|
$item->getObjectProperties();
|
|
$items[] = $item;
|
|
}
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function getProduct(): products_o
|
|
{
|
|
return (new products_o())->select((int)$this->product_id->value());
|
|
}
|
|
}
|