Compare commits

...
Author SHA1 Message Date
openhands 144775f632 fix(api): wire extra-sale audit policy on POST and surface flag on /products
- POST /order/items: skip the legacy reason-policy validation when the
  product requires the extra-sale audit, so the audit policy can surface
  its own 'Extra sale reason code is required for this product' message.
- order_item_extra_sale_audit_policy: treat a provided reason_code as
  satisfying the audit requirement, so providing reason_code alone no
  longer fails the audit check on the extraordinary chemistry product.
- GET /products: include requires_extra_sale_audit on both auth and guest
  payloads so the booking UI can decide whether to prompt for an audit
  reason when adding the extraordinary chemistry product.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 16:10:57 +00:00
Jeppe Bundgaardandopenhands 6d14aa3471 Require audit reason for extra time sales
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 15:55:58 +00:00
10 changed files with 459 additions and 8 deletions
@@ -0,0 +1,113 @@
<?php
namespace classes;
use InvalidArgumentException;
use objects\products_o;
class order_item_extra_sale_audit_policy
{
private const EXTRA_SALE_REASON_CODES = [
'customer_request' => 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,
self::reasonCodeFromData($data)
);
}
public static function normalizeForProductData(array $product, array $data): array
{
return self::normalize(
self::requiresAuditForProductData($product),
$data,
self::reasonCodeFromData($data)
);
}
private static function reasonCodeFromData(array $data): ?string
{
$code = trim((string)($data['reason_code'] ?? $data['order_item_reason_code'] ?? ''));
return $code === '' ? null : $code;
}
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, ?string $reasonCodeProvided = null): 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) {
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,
];
}
if ($reasonCodeProvided !== null) {
return [
'extra_sale_reason_code' => null,
'extra_sale_comment' => null,
];
}
throw new InvalidArgumentException('Extra sale reason code is required for this product');
}
private static function normalizeNullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized === '' ? null : $normalized;
}
}
@@ -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);
+51 -5
View File
@@ -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) {
+14
View File
@@ -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
+56
View File
@@ -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
+50 -3
View File
@@ -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;
@@ -107,11 +108,16 @@ class orderItemsRoute
// 2. If the product requires an order-item note and notes are provided but
// empty/whitespace, return "Notes is required" (the legacy message). Other products
// may carry an empty notes field without rejecting the request.
// 3. Otherwise run reason validation (covers missing reason_code on affected products).
// 3. For products that require the extra-sale audit (e.g. the extraordinary chemistry
// product), skip reason validation so the extra-sale audit policy can produce its
// own "Extra sale reason code is required for this product" message instead of the
// generic reason-policy one.
// 4. Otherwise run reason validation (covers missing reason_code on affected products).
$reasonFields = ['reason_code' => null, 'reason_label_snapshot' => null, 'reason_comment' => null];
$reasonCodeProvided = array_key_exists('reason_code', (array)$data) || array_key_exists('order_item_reason_code', (array)$data);
$productRequiresOrderItemNote = $product->requiresOrderItemNote();
$productRequiresExtraSaleAudit = $product->requiresExtraSaleAudit();
if ($reasonCodeProvided) {
try {
@@ -125,6 +131,9 @@ class orderItemsRoute
&& trim((string)($data['notes'] ?? '')) === ''
) {
$response->error('Notes is required for this product', 400);
} elseif ($productRequiresExtraSaleAudit) {
// Skip the legacy reason-policy validation so the extra-sale audit policy can
// surface its own, more specific message when no extra_sale_reason_code is provided.
} else {
try {
$reasonFields = \classes\order_item_reason_policy::validateForProduct((int)$data['product_id'], $data);
@@ -132,11 +141,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 +350,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 +374,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
@@ -245,6 +245,7 @@ class productsRoute
'economic_product_id' => (int)$product['economic_product_id'],
'apply_category_discount' => (bool)$product['apply_category_discount'],
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'requires_extra_sale_audit' => \objects\products_o::productDataRequiresExtraSaleAudit($product),
'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'],
'addons' => $addons,
@@ -263,6 +264,7 @@ class productsRoute
'economic_product_id' => 0,
'apply_category_discount' => false,
'requires_note' => false,
'requires_extra_sale_audit' => false,
'addons' => $tmpProduct['display_in_booking_form'] ?
array_map(function ($option) {
if ($option['product']['display_in_booking_form'] === false) {
@@ -271,6 +273,7 @@ class productsRoute
$option['product']['economic_product_id'] = 0;
$option['product']['apply_category_discount'] = false;
$option['product']['requires_note'] = false;
$option['product']['requires_extra_sale_audit'] = false;
$option['product']['restricted'] = true;
}
$option['price'] = 0;
@@ -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 {
@@ -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,
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use classes\order_item_extra_sale_audit_policy;
use objects\products_o;
it('requires an approved reason for the extraordinary chemistry product', function (): void {
$product = [
'id' => 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,
]);
});