Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
144775f632 | ||
|
|
6d14aa3471 |
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user