Adds `reason_code`, `reason_label_snapshot`, `reason_comment` columns to `order_items` and integrates the `order_item_reason_policy` class into the POST and PUT /order/items routes. Validation order on audited products (consistent across POST and PUT): 1. If `reason_code` is present, validate reason first — emits the most specific error (invalid code, deprecated code, missing reason_comment). 2. If notes are provided but empty/whitespace, return "Notes is required for this product" (the legacy message). 3. Otherwise run reason validation — covers the missing-reason_code case. PHP api suite went from 284/290 to 290/290 (was 6 OrderItemsApiTest failures, now 0). Wired `addItemToOrder`, `updateOrderItem`, and `getItemAsArray` to persist and return the new columns.
180 lines
6.0 KiB
PHP
180 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
/**
|
|
* Ensures additive schema for orders metadata.
|
|
*/
|
|
class orders_schema_bootstrap
|
|
{
|
|
private static bool $initialized = false;
|
|
|
|
public static function ensureTables(): void
|
|
{
|
|
if (self::$initialized) {
|
|
return;
|
|
}
|
|
|
|
global $db;
|
|
|
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
return;
|
|
}
|
|
|
|
if (!self::tableExists($db, 'orders')) {
|
|
return;
|
|
}
|
|
|
|
if (!self::columnExists($db, 'orders', 'include_in_invoice')) {
|
|
$db->query(
|
|
"ALTER TABLE orders
|
|
ADD COLUMN include_in_invoice TINYINT(1) NULL DEFAULT NULL
|
|
AFTER created_at"
|
|
);
|
|
}
|
|
|
|
if (!self::columnExists($db, 'orders', 'safety_seal')) {
|
|
$db->query(
|
|
"ALTER TABLE orders
|
|
ADD COLUMN safety_seal VARCHAR(255) NULL DEFAULT NULL
|
|
AFTER po"
|
|
);
|
|
}
|
|
|
|
if (self::tableExists($db, 'order_items')) {
|
|
if (!self::columnExists($db, 'order_items', 'reason_code')) {
|
|
$db->query(
|
|
"ALTER TABLE order_items
|
|
ADD COLUMN reason_code VARCHAR(64) NULL DEFAULT NULL
|
|
AFTER include_in_invoice"
|
|
);
|
|
}
|
|
|
|
if (!self::columnExists($db, 'order_items', 'reason_label_snapshot')) {
|
|
$db->query(
|
|
"ALTER TABLE order_items
|
|
ADD COLUMN reason_label_snapshot VARCHAR(255) NULL DEFAULT NULL
|
|
AFTER reason_code"
|
|
);
|
|
}
|
|
|
|
if (!self::columnExists($db, 'order_items', 'reason_comment')) {
|
|
$db->query(
|
|
"ALTER TABLE order_items
|
|
ADD COLUMN reason_comment TEXT NULL
|
|
AFTER reason_label_snapshot"
|
|
);
|
|
}
|
|
}
|
|
|
|
self::backfillBookingPoDefaults($db);
|
|
|
|
self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at');
|
|
self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id');
|
|
self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at');
|
|
self::ensureIndex($db, 'order_items', 'idx_order_items_reason_code', 'reason_code');
|
|
self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id');
|
|
|
|
self::$initialized = true;
|
|
}
|
|
|
|
private static function backfillBookingPoDefaults(object $db): void
|
|
{
|
|
if (
|
|
!self::tableExists($db, 'order_bookings')
|
|
|| !self::columnExists($db, 'orders', 'booking_id')
|
|
|| !self::columnExists($db, 'orders', 'po')
|
|
|| !self::columnExists($db, 'order_bookings', 'po')
|
|
|| !self::columnExists($db, 'order_bookings', 'customer_number')
|
|
|| !self::columnExists($db, 'order_bookings', 'department')
|
|
|| !self::columnExists($db, 'order_bookings', 'deleted_at')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
$db->query(
|
|
"UPDATE orders o
|
|
INNER JOIN order_bookings b ON b.id = o.booking_id
|
|
AND b.customer_number = o.customer_id
|
|
AND b.department = o.department_id
|
|
AND b.deleted_at IS NULL
|
|
SET o.po = b.po
|
|
WHERE o.booking_id IS NOT NULL
|
|
AND o.booking_id > 0
|
|
AND (o.po IS NULL OR TRIM(o.po) = '')
|
|
AND b.po IS NOT NULL
|
|
AND TRIM(b.po) <> ''"
|
|
);
|
|
}
|
|
|
|
private static function tableExists(object $db, string $table): bool
|
|
{
|
|
$table = self::escapeIdentifier($table);
|
|
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
|
|
|
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function columnExists(object $db, string $table, string $column): bool
|
|
{
|
|
$table = self::escapeIdentifier($table);
|
|
$column = self::escapeIdentifier($column);
|
|
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
|
|
|
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function ensureIndex(object $db, string $table, string $index, string $columns): void
|
|
{
|
|
if (
|
|
!self::tableExists($db, $table)
|
|
|| self::indexExists($db, $table, $index)
|
|
|| !self::columnsExist($db, $table, $columns)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
$table = self::escapeIdentifier($table);
|
|
$index = self::escapeIdentifier($index);
|
|
$db->query("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})");
|
|
}
|
|
|
|
private static function columnsExist(object $db, string $table, string $columns): bool
|
|
{
|
|
foreach (explode(',', $columns) as $column) {
|
|
$column = trim($column, " \t\n\r\0\x0B`");
|
|
if ($column === '' || !self::columnExists($db, $table, $column)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static function indexExists(object $db, string $table, string $index): bool
|
|
{
|
|
$table = self::escapeIdentifier($table);
|
|
$index = self::escapeIdentifier($index);
|
|
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
|
|
|
|
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function escapeIdentifier(string $value): string
|
|
{
|
|
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
|
}
|
|
}
|