From 6d14aa3471628376a69d86fa44a6592b8e958816 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 13 Aug 2026 15:55:58 +0000 Subject: [PATCH] Require audit reason for extra time sales Co-authored-by: openhands --- .../order_item_extra_sale_audit_policy.php | 92 ++++++++++++++++++ .../app/classes/orders_schema_bootstrap.php | 16 ++++ services/nginx/app/objects/order_items_o.php | 56 ++++++++++- services/nginx/app/objects/products_o.php | 14 +++ services/nginx/app/openapi.yaml | 56 +++++++++++ services/nginx/app/routes/orderItemsRoute.php | 43 ++++++++- .../nginx/app/tests/Api/OrderItemsApiTest.php | 95 +++++++++++++++++++ .../tests/Support/Api/ApiSchemaBootstrap.php | 2 + .../Unit/Orders/ExtraSaleAuditPolicyTest.php | 59 ++++++++++++ 9 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 services/nginx/app/classes/order_item_extra_sale_audit_policy.php create mode 100644 services/nginx/app/tests/Unit/Orders/ExtraSaleAuditPolicyTest.php diff --git a/services/nginx/app/classes/order_item_extra_sale_audit_policy.php b/services/nginx/app/classes/order_item_extra_sale_audit_policy.php new file mode 100644 index 00000000..74e6d474 --- /dev/null +++ b/services/nginx/app/classes/order_item_extra_sale_audit_policy.php @@ -0,0 +1,92 @@ + false, + 'operational_delay' => false, + 'rewash_quality' => false, + 'other' => true, + ]; + + private const DEPRECATED_REASON_CODES = [ + 'manager_approved', + 'manual_override', + 'no_comment', + ]; + + public static function requiresAuditForProductData(array $product): bool + { + return products_o::productDataRequiresExtraSaleAudit($product); + } + + public static function requiresAuditForProduct(products_o $product): bool + { + return self::requiresAuditForProductData([ + 'id' => $product->id, + 'name' => (string)$product->name->value(), + 'requires_note' => (bool)$product->requires_note->value(), + ]); + } + + public static function normalizeForProduct(products_o $product, array $data): array + { + return self::normalize(self::requiresAuditForProduct($product), $data); + } + + public static function normalizeForProductData(array $product, array $data): array + { + return self::normalize(self::requiresAuditForProductData($product), $data); + } + + public static function commentRequiredForReason(?string $reasonCode): bool + { + return isset(self::EXTRA_SALE_REASON_CODES[$reasonCode]) + && self::EXTRA_SALE_REASON_CODES[$reasonCode] === true; + } + + private static function normalize(bool $requiresAudit, array $data): array + { + $reasonCode = self::normalizeNullableString($data['extra_sale_reason_code'] ?? null); + $comment = self::normalizeNullableString($data['extra_sale_comment'] ?? null); + + if (!$requiresAudit) { + return [ + 'extra_sale_reason_code' => $reasonCode, + 'extra_sale_comment' => $comment, + ]; + } + + if ($reasonCode === null) { + throw new InvalidArgumentException('Extra sale reason code is required for this product'); + } + + if (in_array($reasonCode, self::DEPRECATED_REASON_CODES, true) || !array_key_exists($reasonCode, self::EXTRA_SALE_REASON_CODES)) { + throw new InvalidArgumentException('Extra sale reason code is not approved'); + } + + if (self::commentRequiredForReason($reasonCode) && $comment === null) { + throw new InvalidArgumentException('Extra sale comment is required for this reason'); + } + + return [ + 'extra_sale_reason_code' => $reasonCode, + 'extra_sale_comment' => $comment, + ]; + } + + private static function normalizeNullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + return $normalized === '' ? null : $normalized; + } +} diff --git a/services/nginx/app/classes/orders_schema_bootstrap.php b/services/nginx/app/classes/orders_schema_bootstrap.php index 893c7c36..9c3d465e 100644 --- a/services/nginx/app/classes/orders_schema_bootstrap.php +++ b/services/nginx/app/classes/orders_schema_bootstrap.php @@ -65,6 +65,22 @@ class orders_schema_bootstrap AFTER reason_label_snapshot" ); } + + if (!self::columnExists($db, 'order_items', 'extra_sale_reason_code')) { + $db->query( + "ALTER TABLE order_items + ADD COLUMN extra_sale_reason_code VARCHAR(64) NULL DEFAULT NULL + AFTER notes" + ); + } + + if (!self::columnExists($db, 'order_items', 'extra_sale_comment')) { + $db->query( + "ALTER TABLE order_items + ADD COLUMN extra_sale_comment TEXT NULL + AFTER extra_sale_reason_code" + ); + } } self::backfillBookingPoDefaults($db); diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index 95e38b06..2f8aae25 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -5,7 +5,9 @@ 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; @@ -34,6 +36,8 @@ class order_items_o extends db * @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 @@ -78,6 +82,7 @@ class order_items_o extends db public function structure(): void { + orders_schema_bootstrap::ensureTables(); $this->setTable('order_items'); } @@ -100,6 +105,8 @@ class order_items_o extends db $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); @@ -141,18 +148,32 @@ class order_items_o extends db 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): void + 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, cashier_id, price, quantity) VALUES ($order_id, $product_id, '$reference', '$notes', $cashier_id, $price, $quantity)"; + $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 @@ -222,7 +243,7 @@ class order_items_o extends db } } - 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): order_items_o + 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 { @@ -233,6 +254,13 @@ class order_items_o extends db 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']; @@ -248,7 +276,16 @@ class order_items_o extends db } // Create a new record in the database - $sql = "INSERT INTO $this->table (order_id, product_id, price, cashier_id, quantity, reason_code, reason_label_snapshot, reason_comment) VALUES ($order_id, $product_id, $price, $cashier_id, $quantity, " . ($reason_code !== null ? "'" . $db->escape_string($reason_code) . "'" : "NULL") . ", " . ($reason_label_snapshot !== null ? "'" . $db->escape_string($reason_label_snapshot) . "'" : "NULL") . ", " . ($reason_comment !== null ? "'" . $db->escape_string($reason_comment) . "'" : "NULL") . ")"; + $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(); @@ -299,6 +336,8 @@ class order_items_o extends db '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(), @@ -335,7 +374,7 @@ class order_items_o extends db /** * @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): void + 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); @@ -344,6 +383,11 @@ class order_items_o extends db $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); @@ -351,6 +395,8 @@ class order_items_o extends db // 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) { diff --git a/services/nginx/app/objects/products_o.php b/services/nginx/app/objects/products_o.php index d289c286..f55916bf 100644 --- a/services/nginx/app/objects/products_o.php +++ b/services/nginx/app/objects/products_o.php @@ -223,6 +223,7 @@ class products_o extends db 'economic_product_id' => $this->economic_product_id->value(), 'apply_category_discount' => (bool)$this->apply_category_discount->value(), 'requires_note' => $this->requiresOrderItemNote(), + 'requires_extra_sale_audit' => $this->requiresExtraSaleAudit(), 'is_wash' => (bool)$this->is_wash->value(), 'display_in_booking_form' => (bool)$this->display_in_booking_form->value(), 'order_priority' => (int)$this->order_priority->value(), @@ -238,6 +239,11 @@ class products_o extends db return true; } + return self::productDataRequiresExtraSaleAudit($product); + } + + public static function productDataRequiresExtraSaleAudit(array $product): bool + { if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) { return true; } @@ -254,6 +260,14 @@ class products_o extends db ]); } + public function requiresExtraSaleAudit(): bool + { + return self::productDataRequiresExtraSaleAudit([ + 'id' => $this->id, + 'name' => (string)$this->name->value(), + ]); + } + /** * Apply department pricing to a list of products * @param array $products diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index d2ddec12..7f84f5d3 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -23613,6 +23613,22 @@ components: type: string quantity: type: integer + notes: + type: string + nullable: true + extra_sale_reason_code: + type: string + nullable: true + enum: + - customer_request + - operational_delay + - rewash_quality + - other + description: Required for "Ekstraordinær pr. 10 min inkl. kemi" order items. + extra_sale_comment: + type: string + nullable: true + description: Required when extra_sale_reason_code is "other". unit_price: type: number format: float @@ -23636,6 +23652,22 @@ components: type: integer quantity: type: integer + notes: + type: string + nullable: true + extra_sale_reason_code: + type: string + nullable: true + enum: + - customer_request + - operational_delay + - rewash_quality + - other + description: Required for "Ekstraordinær pr. 10 min inkl. kemi" order items. + extra_sale_comment: + type: string + nullable: true + description: Required when extra_sale_reason_code is "other". discount: type: number format: float @@ -23647,8 +23679,27 @@ components: properties: id: type: integer + notes: + type: string + nullable: true + reference: + type: string + nullable: true quantity: type: integer + extra_sale_reason_code: + type: string + nullable: true + enum: + - customer_request + - operational_delay + - rewash_quality + - other + description: Required for "Ekstraordinær pr. 10 min inkl. kemi" order items. + extra_sale_comment: + type: string + nullable: true + description: Required when extra_sale_reason_code is "other". discount: type: number format: float @@ -24037,6 +24088,11 @@ components: type: string visible: type: boolean + requires_note: + type: boolean + requires_extra_sale_audit: + type: boolean + description: True for "Ekstraordinær pr. 10 min inkl. kemi", which requires approved audit metadata on sale. created_at: type: string format: date-time diff --git a/services/nginx/app/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index 69b4fe89..45741616 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -4,6 +4,7 @@ namespace routes; use classes\authentication; use classes\customer_product_rule_service; +use classes\order_item_extra_sale_audit_policy; use classes\order_payment_lock; use objects\logs_o; use objects\order_items_o; @@ -132,11 +133,29 @@ class orderItemsRoute $response->error($e->getMessage(), 400); } } + try { + $extraSaleAudit = order_item_extra_sale_audit_policy::normalizeForProduct($product, $data); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } // Add the order item to the order This is done individually, to make the notes to the individual order items possible $order_items = (new order_items_o()); // Add the order item to the order - $order_items->addItemToOrder((int)$data['order_id'], (int)$data['product_id'], (int)$user->id, (int)$data['quantity'], $related_item_id, $notes, $price, $reasonFields['reason_code'], $reasonFields['reason_label_snapshot'], $reasonFields['reason_comment']); + $order_items->addItemToOrder( + (int)$data['order_id'], + (int)$data['product_id'], + (int)$user->id, + (int)$data['quantity'], + $related_item_id, + $notes, + $price, + $reasonFields['reason_code'], + $reasonFields['reason_label_snapshot'], + $reasonFields['reason_comment'], + $extraSaleAudit['extra_sale_reason_code'], + $extraSaleAudit['extra_sale_comment'] + ); // Return the list of departments $response->success( $order_items->getItemAsArray() @@ -323,6 +342,15 @@ class orderItemsRoute ]) && trim((string)$data['notes']) === '') { $response->error('Notes is required for this product', 400); } + try { + $extraSaleAudit = order_item_extra_sale_audit_policy::normalizeForProductData([ + 'id' => (int)$orderItemContext['product_id'], + 'name' => (string)$orderItemContext['product_name'], + 'requires_note' => (bool)$orderItemContext['product_requires_note'], + ], $data); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } $order = (new orders_o())->getOrderById((int)$orderItemContext['order_id']); if (!$order->exists()) { @@ -338,7 +366,18 @@ class orderItemsRoute $orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id); // Update the order item - (new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity'], $reasonFields['reason_code'], $reasonFields['reason_label_snapshot'], $reasonFields['reason_comment']); + (new order_items_o())->updateOrderItem( + (int)$data['id'], + (int)$data['price'], + (string)$data['notes'], + (string)$data['reference'], + (int)$data['quantity'], + $reasonFields['reason_code'], + $reasonFields['reason_label_snapshot'], + $reasonFields['reason_comment'], + $extraSaleAudit['extra_sale_reason_code'], + $extraSaleAudit['extra_sale_comment'] + ); // Log the incident (new logs_o())->add('departments', 'global', 1, $user->id, 'EDIT_ORDER_ITEMS', 'Changed order item: ' . $data['id']); // Return the list of departments diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 98bea313..7cb97f73 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -122,12 +122,25 @@ it('requires notes when adding the extraordinary chemistry product to an order', 'product_id' => $product['id'], 'quantity' => 1, 'notes' => ' ', + 'extra_sale_reason_code' => 'customer_request', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Notes is required for this product'); + api_client() + ->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => 'Graffiti removal on left side', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Extra sale reason code is required for this product'); + $response = api_client()->post('/order/items', [ 'order_id' => $order['id'], 'product_id' => $product['id'], @@ -135,6 +148,8 @@ it('requires notes when adding the extraordinary chemistry product to an order', 'notes' => 'Graffiti removal on left side', 'reason_code' => 'customer_approved_extra_work', 'reason_comment' => 'Graffiti removal on left side', + 'extra_sale_reason_code' => 'customer_request', + 'extra_sale_comment' => 'Graffiti removal on left side', ], $session['headers']); $response @@ -146,6 +161,8 @@ it('requires notes when adding the extraordinary chemistry product to an order', expect($response->data()['reason_code'] ?? null)->toBe('customer_approved_extra_work'); expect($response->data()['reason_label_snapshot'] ?? null)->toBe('Kunde godkendte ekstra arbejde'); expect($response->data()['reason_comment'] ?? null)->toBe('Graffiti removal on left side'); + expect($response->data()['extra_sale_reason_code'] ?? null)->toBe('customer_request'); + expect($response->data()['extra_sale_comment'] ?? null)->toBe('Graffiti removal on left side'); }); it('requires valid approved reason data for audited add-on order items', function (array $payload, string $message): void { @@ -236,6 +253,68 @@ it('requires reason data when editing audited add-on order items', function (): ->assertSuccess(); }); +it('rejects unapproved and comment-missing extraordinary chemistry reasons', function (): void { + api_test_covers('POST /order/items', 'validation'); + + $customer = api_fixtures()->createUser(['display_name' => 'Order Item Audit Customer']); + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'AUDIT-REASON', + ]); + $product = api_fixtures()->createProduct([ + 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, + 'price' => 299, + 'requires_note' => 0, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + api_client() + ->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => 'Extra time', + 'extra_sale_reason_code' => 'manual_override', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Extra sale reason code is not approved'); + + api_client() + ->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => 'Extra time', + 'extra_sale_reason_code' => 'other', + 'extra_sale_comment' => ' ', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Extra sale comment is required for this reason'); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => 'Extra time', + 'extra_sale_reason_code' => 'other', + 'extra_sale_comment' => 'Customer approved a non-standard extra wash.', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['extra_sale_reason_code'] ?? null)->toBe('other'); + expect($response->data()['extra_sale_comment'] ?? null)->toBe('Customer approved a non-standard extra wash.'); +}); + it('uses a product fixed price instead of the best discount when adding an order item', function (): void { api_test_covers('POST /order/items', 'pricing'); api_test_covers('GET /products', 'pricing'); @@ -402,6 +481,7 @@ it('does not allow clearing notes for order items whose product requires notes', 'price' => 199, 'quantity' => 1, 'notes' => 'Initial note', + 'extra_sale_reason_code' => 'customer_request', ]); $session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]); @@ -412,11 +492,25 @@ it('does not allow clearing notes for order items whose product requires notes', 'quantity' => 1, 'reference' => '', 'notes' => '', + 'extra_sale_reason_code' => 'customer_request', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Notes is required for this product'); + + api_client() + ->put('/order/items', [ + 'id' => $orderItem['id'], + 'price' => 199, + 'quantity' => 1, + 'reference' => '', + 'notes' => 'Initial note', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Extra sale reason code is required for this product'); }); it('allows empty notes for primary products that do not require a note', function (): void { @@ -510,6 +604,7 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun ->assertSuccess(); expect($response->data()['requires_note'] ?? null)->toBeTrue(); + expect($response->data()['requires_extra_sale_audit'] ?? null)->toBeTrue(); }); it('blocks standalone category 8 products for customers restricted from additional services', function (): void { diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 050956f1..8c802ef5 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -763,6 +763,8 @@ CREATE TABLE IF NOT EXISTS `order_items` ( `product_id` INT NOT NULL, `reference` VARCHAR(255) NULL, `notes` TEXT NULL, + `extra_sale_reason_code` VARCHAR(64) NULL, + `extra_sale_comment` TEXT NULL, `cashier_id` INT NOT NULL DEFAULT 0, `price` INT NOT NULL DEFAULT 0, `quantity` INT NOT NULL DEFAULT 1, diff --git a/services/nginx/app/tests/Unit/Orders/ExtraSaleAuditPolicyTest.php b/services/nginx/app/tests/Unit/Orders/ExtraSaleAuditPolicyTest.php new file mode 100644 index 00000000..22de562f --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/ExtraSaleAuditPolicyTest.php @@ -0,0 +1,59 @@ + 902702, + 'name' => products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, + 'requires_note' => false, + ]; + + expect(fn() => order_item_extra_sale_audit_policy::normalizeForProductData($product, [])) + ->toThrow(InvalidArgumentException::class, 'Extra sale reason code is required for this product'); + + expect(fn() => order_item_extra_sale_audit_policy::normalizeForProductData($product, [ + 'extra_sale_reason_code' => 'manual_override', + ]))->toThrow(InvalidArgumentException::class, 'Extra sale reason code is not approved'); + + expect(order_item_extra_sale_audit_policy::normalizeForProductData($product, [ + 'extra_sale_reason_code' => 'customer_request', + ]))->toBe([ + 'extra_sale_reason_code' => 'customer_request', + 'extra_sale_comment' => null, + ]); +}); + +it('requires a comment only for extra sale reasons whose policy requires it', function (): void { + $product = [ + 'id' => products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID, + 'name' => 'Legacy display name', + 'requires_note' => false, + ]; + + expect(fn() => order_item_extra_sale_audit_policy::normalizeForProductData($product, [ + 'extra_sale_reason_code' => 'other', + ]))->toThrow(InvalidArgumentException::class, 'Extra sale comment is required for this reason'); + + expect(order_item_extra_sale_audit_policy::normalizeForProductData($product, [ + 'extra_sale_reason_code' => 'other', + 'extra_sale_comment' => 'Approved by shift lead after customer request.', + ]))->toBe([ + 'extra_sale_reason_code' => 'other', + 'extra_sale_comment' => 'Approved by shift lead after customer request.', + ]); +}); + +it('does not require an extra sale reason for ordinary note-required products', function (): void { + expect(order_item_extra_sale_audit_policy::normalizeForProductData([ + 'id' => 101, + 'name' => 'Graffiti removal', + 'requires_note' => true, + ], []))->toBe([ + 'extra_sale_reason_code' => null, + 'extra_sale_comment' => null, + ]); +});