Compare commits

..
Author SHA1 Message Date
Jeppe B 4430345831 fix(api): log new order id when POST /orders succeeds (#374)
Merges api PR #374.
2026-08-16 13:05:25 +02:00
5cde8103f9 fix(api): include wash_id in xlvask_missing_order_link flag text (AUT-49/TRU-49) (#375)
## Summary

The XL Vask missing-order-link flag previously rendered the literal
placeholder text `'XL Vask wash'` as the interactive link text.
`wash_id` was already present in the per-flag message context but was
not being threaded into the link text, so users couldn't tell which wash
the warning referred to.

This wires `wash_id` through both `messageParts()` (used to render the
clickable link) and `automaticMessage()` (the plain-text fallback) for
the `xlvask_missing_order_link` flag.

## Changes

`services/nginx/app/classes/invoice_period_flag_service.php`
- `messageParts()` `xlvask_missing_order_link` branch: split into three
parts so the `xlvask_usage_log` button text comes from
`$params['wash_id']`, with the literal "XL Vask wash " as a leading text
part and " is neither ignored nor linked to an order in the selected
period." as a trailing text part.
- `automaticMessage()` `xlvask_missing_order_link` branch: now
interpolates `wash_id` into the message string ("XL Vask wash {wash_id}
is neither ignored nor linked to an order in the selected period."),
with a sensible fallback to the original wording when `wash_id` is
missing.


`services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php`
- Updated the `message_parts` expectation in `it builds interactive
message parts for order and wash certificate warnings` to assert the new
three-part structure with the actual `wash-55` id.

## Verification

- `vendor/bin/pest tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php
--compact` → `Tests: 34 passed (183 assertions)`, exit 0
- `vendor/bin/pest tests/Unit/Invoicing/ --compact` → `Tests: 245 passed
(1 warning, 1 skipped)`, exit 0
- `vendor/bin/phpstan analyse classes/invoice_period_flag_service.php` →
`[OK] No errors`

## Issue

AUT-49 / TRU-49 — XL Vask missing-order-link flag text should reference
the actual wash (not 'XL Vask wash').

## Out of scope

- The Vue side at `copenhagentruckwash/pleno-vue` still has a hardcoded
fallback `"XL Vask wash"` for the i18n token
`invoice_period.flags.tokens.xlvask_usage_log`. That path is only used
when `flag.message_parts` is absent, which no longer happens for this
flag now that the backend populates it correctly. A follow-up on the Vue
side could remove that fallback or repurpose it as a tooltip label.
- No `Writerside2` topic covers `message_parts`, and `openapi.yaml` does
not formally document the field, so no spec update was required for this
content-only fix.

---

_This pull request was created by an AI agent (OpenHands) on behalf of
Jeppe B._

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 22:18:18 +02:00
f4ba70623e feat(edge-broker): expose lastActivityAt on /api/health (AUT-2/TRU-6) (#373)
Adds the `lastActivityAt` field to the response body of the
`/api/health` endpoint exposed by the edge broker. The field reports
the most recent successful request timestamp from the container,
defaulting to the container's start time when no requests have been
served yet.

The field is also surfaced on `broker.state` (alongside a new
`containerStartedAt`) so callers can observe the activity timestamp
without performing an HTTP round-trip. The Writerside
`API-Reference.topic` and its generator are updated to document the
new field.

Resolves TRU-6 (AUT-2).

## Example request

```sh
curl -s http://edge-broker:8080/api/health
```

```json
{
  "ok": true,
  "service": "edge-broker",
  "auth_mode": "manager",
  "manager_url_configured": true,
  "shared_secret_configured": true,
  "agents_connected": 0,
  "lastActivityAt": "2026-08-15T19:15:34.898Z"
}
```

## Documentation

The diff for the writerside topic that documents the new field lives in
this PR — see
[`documentation/topics/API-Reference.topic`](https://github.com/copenhagentruckwash/api/blob/10f28d6/documentation/topics/API-Reference.topic)
(vs. [the previous version at
`origin/develop`](https://github.com/copenhagentruckwash/api/blob/cdf8541/documentation/topics/API-Reference.topic))
in the [PR "Files changed"
view](https://github.com/copenhagentruckwash/api/pull/373/files).
The same paragraph is reproduced by
`scripts/generate_writerside_openapi_docs.py` so future regenerations
preserve it.

**What consumers need to re-read.** `API-Reference.topic` adds a
paragraph
documenting the new `lastActivityAt` ISO 8601 timestamp on the edge
broker's `/api/health` response. Consumers that previously inferred
broker activity from indirect signals (e.g. comparing `agents_connected`
across polls or assuming a fresh process meant a fresh state) should now
read `lastActivityAt` directly: it is the timestamp of the most recent
successful HTTP request handled by the broker container, and defaults to
`containerStartedAt` until the first request lands. No request or
response shape changes; the field is purely additive.

## Tests

`node --test services/edge-broker/test/broker.test.mjs` covers both the
shape of the new field on `/api/health` and the fact that
`lastActivityAt`
advances on every successful request after `containerStartedAt`. All 21
broker tests pass locally.

---

_This PR description was generated by an OpenHands AI agent on behalf of
jepp9350._

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 21:19:37 +02:00
Jeppe Bandopenhands cdf8541e78 fix(api): exclude spot-free-lastbil from audited add-on reason policy (#372)
## Problem

The mobile POS step 2 \"enter note\" dialog was triggering for product
24
(\"Højtryk - ekstra tid\" / spotfree-lastbil) because product 24 was
listed
in `AFFECTED_PRODUCT_IDS` in
`services/nginx/app/classes/order_item_reason_policy.php`.

Product 24 is the \"spot-free-lastbil\" package, not an audited
extra-time
add-on — the server-side reason policy should only enforce the comment
requirement on {21, 22, 25, 26, 27}, matching the frontend
`AUDITED_ORDER_ITEM_PRODUCT_IDS` set.

Companion to the frontend PR copenhagentruckwash/pleno-vue#301.

## Fix

Drop product 24 from `AFFECTED_PRODUCT_IDS`.

## Verification

Tracked under workboard-94209138-31f6-422e-ac8c-181ad391b8a7.

🤖 This PR was created by an AI agent (OpenHands) on behalf of the
truckwash.io team.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 21:11:44 +02:00
19 changed files with 87 additions and 464 deletions
+1
View File
@@ -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"
)
+8
View File
@@ -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;
},
},
};
}
+39
View File
@@ -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 -51
View File
@@ -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) {
-14
View File
@@ -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
-56
View File
@@ -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
+3 -50
View File
@@ -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
+1 -1
View File
@@ -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'] . ')'");
});