Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4430345831 | ||
|
|
5cde8103f9 | ||
|
|
f4ba70623e | ||
|
|
cdf8541e78 |
@@ -7,4 +7,5 @@
|
||||
|
||||
<!-- AUTO-GENERATED, DO NOT EDIT -->
|
||||
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
|
||||
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
|
||||
</topic>
|
||||
|
||||
@@ -405,6 +405,10 @@ def render_api_reference_topic() -> str:
|
||||
' title="API Reference" id="API-Reference">\n'
|
||||
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
||||
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
|
||||
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
|
||||
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
|
||||
"successful HTTP request handled by the broker container and defaults to the container's "
|
||||
"start time when no request has been processed yet.</p>\n"
|
||||
"</topic>\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -189,6 +189,8 @@ export function createBrokerServer(options = {}) {
|
||||
const browserStreamSessions = new Map();
|
||||
const gatewayStreamSessions = new Map();
|
||||
const inflightGatewaySyncs = new Map();
|
||||
const containerStartedAt = currentTimestamp();
|
||||
let lastActivityAt = containerStartedAt;
|
||||
|
||||
const managerRequest = async (path, body = {}, method = "POST") => {
|
||||
if (!managerUrl) {
|
||||
@@ -480,6 +482,7 @@ export function createBrokerServer(options = {}) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
lastActivityAt = currentTimestamp();
|
||||
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
@@ -488,6 +491,7 @@ export function createBrokerServer(options = {}) {
|
||||
manager_url_configured: Boolean(managerUrl),
|
||||
shared_secret_configured: Boolean(sharedSecret),
|
||||
agents_connected: agents.size,
|
||||
lastActivityAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1083,6 +1087,10 @@ export function createBrokerServer(options = {}) {
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
containerStartedAt,
|
||||
get lastActivityAt() {
|
||||
return lastActivityAt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +219,10 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
assert.equal(healthJson.auth_mode, "manager");
|
||||
assert.equal(healthJson.manager_url_configured, true);
|
||||
assert.equal(healthJson.shared_secret_configured, true);
|
||||
assert.equal(typeof healthJson.lastActivityAt, "string");
|
||||
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
|
||||
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
@@ -247,6 +251,41 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker updates lastActivityAt after each successful request", async () => {
|
||||
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
|
||||
|
||||
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const firstJson = await firstResponse.json();
|
||||
const firstActivityAt = broker.state.lastActivityAt;
|
||||
|
||||
assert.equal(typeof firstJson.lastActivityAt, "string");
|
||||
assert.equal(firstJson.lastActivityAt, firstActivityAt);
|
||||
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
|
||||
assert.ok(broker.state.lastActivityAt > firstActivityAt);
|
||||
|
||||
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const secondJson = await secondResponse.json();
|
||||
|
||||
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker bridges browser shell sessions through the connected agent", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
|
||||
@@ -1495,6 +1495,7 @@ class invoice_period_flag_service
|
||||
{
|
||||
$product = (string)($params['product'] ?? 'Item');
|
||||
$expectedProduct = (string)($params['expected_product'] ?? 'expected product');
|
||||
$washId = (string)($params['wash_id'] ?? '');
|
||||
return match ($definitionKey) {
|
||||
'price_mismatch' => "{$product} product price differs from expected.",
|
||||
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
|
||||
@@ -1514,7 +1515,9 @@ class invoice_period_flag_service
|
||||
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.",
|
||||
'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.",
|
||||
'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.",
|
||||
'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
|
||||
'xlvask_missing_order_link' => $washId === ''
|
||||
? 'XL Vask wash is neither ignored nor linked to an order in the selected period.'
|
||||
: "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.",
|
||||
default => "Automatically detected invoice-period issue.",
|
||||
};
|
||||
}
|
||||
@@ -1541,7 +1544,8 @@ class invoice_period_flag_service
|
||||
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
|
||||
],
|
||||
'xlvask_missing_order_link' => [
|
||||
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
|
||||
['type' => 'text', 'text' => 'XL Vask wash '],
|
||||
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
|
||||
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
|
||||
],
|
||||
default => [],
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use InvalidArgumentException;
|
||||
class order_item_reason_policy
|
||||
{
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const AFFECTED_PRODUCT_IDS = [21, 22, 24, 25, 26, 27];
|
||||
public const AFFECTED_PRODUCT_IDS = [21, 22, 25, 26, 27];
|
||||
|
||||
public static function reasons(): array
|
||||
{
|
||||
|
||||
@@ -65,22 +65,6 @@ 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,9 +5,7 @@ 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;
|
||||
@@ -36,8 +34,6 @@ 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
|
||||
@@ -82,7 +78,6 @@ class order_items_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
orders_schema_bootstrap::ensureTables();
|
||||
$this->setTable('order_items');
|
||||
}
|
||||
|
||||
@@ -105,8 +100,6 @@ 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);
|
||||
@@ -148,32 +141,18 @@ 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, ?string $extra_sale_reason_code = null, ?string $extra_sale_comment = 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): 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)";
|
||||
$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)";
|
||||
$db->query($sql);
|
||||
|
||||
// Get the id of the new record
|
||||
@@ -243,7 +222,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, ?string $extra_sale_reason_code = null, ?string $extra_sale_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): order_items_o
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
@@ -254,13 +233,6 @@ 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'];
|
||||
|
||||
@@ -276,16 +248,7 @@ class order_items_o extends db
|
||||
}
|
||||
|
||||
// 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)";
|
||||
$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") . ")";
|
||||
$db->query($sql);
|
||||
// Get the id of the new record
|
||||
$this->id = $db->insert_id();
|
||||
@@ -336,8 +299,6 @@ 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(),
|
||||
@@ -374,7 +335,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, ?string $extra_sale_reason_code = null, ?string $extra_sale_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): void
|
||||
{
|
||||
global $db, $response;
|
||||
$this->select((int)$id);
|
||||
@@ -383,11 +344,6 @@ 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);
|
||||
@@ -395,8 +351,6 @@ 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,7 +223,6 @@ 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(),
|
||||
@@ -239,11 +238,6 @@ 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;
|
||||
}
|
||||
@@ -260,14 +254,6 @@ 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,22 +23613,6 @@ 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
|
||||
@@ -23652,22 +23636,6 @@ 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
|
||||
@@ -23679,27 +23647,8 @@ 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
|
||||
@@ -24088,11 +24037,6 @@ 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,7 +4,6 @@ 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;
|
||||
@@ -108,16 +107,11 @@ 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. 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).
|
||||
// 3. 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 {
|
||||
@@ -131,9 +125,6 @@ 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);
|
||||
@@ -141,29 +132,11 @@ 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'],
|
||||
$extraSaleAudit['extra_sale_reason_code'],
|
||||
$extraSaleAudit['extra_sale_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']);
|
||||
// Return the list of departments
|
||||
$response->success(
|
||||
$order_items->getItemAsArray()
|
||||
@@ -350,15 +323,6 @@ 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()) {
|
||||
@@ -374,18 +338,7 @@ 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'],
|
||||
$extraSaleAudit['extra_sale_reason_code'],
|
||||
$extraSaleAudit['extra_sale_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']);
|
||||
// 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
|
||||
|
||||
@@ -237,7 +237,7 @@ class ordersRoute
|
||||
$order->setPendingHandheldIndicator();
|
||||
}
|
||||
// Log the incident
|
||||
(new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . $data['department_id'] . ')');
|
||||
(new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . (int)$order->id . ')');
|
||||
// Return a success message, containing the orders array
|
||||
$response->success($order->asArray());
|
||||
} else {
|
||||
|
||||
@@ -245,7 +245,6 @@ 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,
|
||||
@@ -264,7 +263,6 @@ 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) {
|
||||
@@ -273,7 +271,6 @@ 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,25 +122,12 @@ 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'],
|
||||
@@ -148,8 +135,6 @@ 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
|
||||
@@ -161,8 +146,6 @@ 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 {
|
||||
@@ -253,68 +236,6 @@ 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');
|
||||
@@ -481,7 +402,6 @@ 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']]);
|
||||
|
||||
@@ -492,25 +412,11 @@ 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 {
|
||||
@@ -604,7 +510,6 @@ 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,8 +763,6 @@ 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,
|
||||
|
||||
@@ -167,7 +167,8 @@ it('builds interactive message parts for order and wash certificate warnings', f
|
||||
['type' => 'text', 'text' => ' is present without a wash certificate.'],
|
||||
]);
|
||||
expect($xlVaskFlag['message_parts'])->toBe([
|
||||
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
|
||||
['type' => 'text', 'text' => 'XL Vask wash '],
|
||||
['type' => 'xlvask_usage_log', 'text' => 'wash-55'],
|
||||
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('logs the newly created order id (not the department id) when POST /orders succeeds', function (): void {
|
||||
$routeFile = app_path('routes/ordersRoute.php');
|
||||
$content = file_get_contents($routeFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
|
||||
// The POST /orders handler must log the id of the order that was just
|
||||
// persisted by addArray(), not the department id from the request body.
|
||||
// Without this, the audit trail for the "check-in creates 0-orders" bug
|
||||
// is useless — every successful create logs the wrong identifier.
|
||||
expect($content)->toContain("'Successfully added an order (ID: ' . (int)\$order->id . ')'");
|
||||
|
||||
// Guard against the previous copy/paste regression reappearing.
|
||||
expect($content)->not->toContain("'Successfully added an order (ID: ' . \$data['department_id'] . ')'");
|
||||
});
|
||||
Reference in New Issue
Block a user