Add economic_transfer_executor and economic_transfer_queue classes for handling e-conomic invoice transfer logic, queue management, and processing. Include unit tests for Redis cache validation.

This commit is contained in:
Jeppe Bundgaard
2026-04-08 11:20:08 +02:00
parent 551161b692
commit ba23ad6e8f
23 changed files with 18863 additions and 388 deletions
+493 -17
View File
@@ -5992,6 +5992,139 @@ paths:
application/json:
schema: {}
/collected-invoices/economic:
post:
tags:
- Invoices
summary: Queue transfer of collected invoice to e-conomic
description: |
Queues collected invoice transfer to e-conomic.
Processing runs asynchronously and can be tracked through queue status endpoints.
operationId: queueCollectedInvoiceEconomicTransfer
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id:
type: integer
minimum: 1
send_as_is:
type: boolean
default: false
responses:
'202':
description: Collected invoice transfer queued
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue:
get:
tags:
- Invoices
summary: List collected-invoice e-conomic transfer queue jobs
operationId: listCollectedInvoiceEconomicQueueJobs
parameters:
- name: status
in: query
required: false
description: Comma-separated queue statuses to filter by.
schema:
type: string
example: "QUEUED,FAILED"
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 50
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
responses:
'200':
description: Queue jobs retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueListResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/status:
get:
tags:
- Invoices
summary: Get collected-invoice e-conomic transfer queue job status
operationId: getCollectedInvoiceEconomicQueueJobStatus
parameters:
- name: job_id
in: query
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Queue job status
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueStatusResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/retry:
post:
tags:
- Invoices
summary: Retry failed collected-invoice queue job
operationId: retryCollectedInvoiceEconomicQueueJob
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [job_id]
properties:
job_id:
type: integer
minimum: 1
responses:
'200':
description: Queue job retried
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueRetryResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/compare:
get:
tags:
@@ -7672,39 +7805,175 @@ paths:
post:
tags:
- Modules
summary: Export draft invoice to e-conomic
description: Export a draft invoice to e-conomic
operationId: exportDraftInvoiceToEconomic
summary: Queue draft invoice export to e-conomic
description: Queue a draft invoice export job for asynchronous processing.
operationId: queueDraftInvoiceExportToEconomic
requestBody:
required: false
required: true
content:
application/json:
schema: {}
schema:
type: object
required: [order_id]
properties:
order_id:
type: integer
minimum: 1
responses:
'200':
description: Draft invoice exported successfully
'202':
description: Draft invoice export queued
content:
application/json:
schema: {}
schema:
$ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/draft/export/status:
get:
tags:
- Modules
summary: Get queued draft export job status
operationId: getDraftInvoiceExportQueueStatus
parameters:
- name: job_id
in: query
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Queue job status
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueStatusResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/draft/export/retry:
post:
tags:
- Modules
summary: Retry failed draft export queue job
operationId: retryDraftInvoiceExportQueueJob
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [job_id]
properties:
job_id:
type: integer
minimum: 1
responses:
'200':
description: Queue job retried
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueRetryResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export:
post:
tags:
- Modules
summary: Export invoice to e-conomic
description: Export a booked invoice to e-conomic
operationId: exportInvoiceToEconomic
summary: Queue invoice export to e-conomic
description: Queue booked invoice export job for asynchronous processing.
operationId: queueInvoiceExportToEconomic
requestBody:
required: false
required: true
content:
application/json:
schema: {}
schema:
type: object
required: [order_id]
properties:
order_id:
type: integer
minimum: 1
responses:
'200':
description: Invoice exported successfully
'202':
description: Invoice export queued
content:
application/json:
schema: {}
schema:
$ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export/status:
get:
tags:
- Modules
summary: Get queued invoice export job status
operationId: getInvoiceExportQueueStatus
parameters:
- name: job_id
in: query
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Queue job status
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueStatusResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/economic/invoice/export/retry:
post:
tags:
- Modules
summary: Retry failed invoice export queue job
operationId: retryInvoiceExportQueueJob
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [job_id]
properties:
job_id:
type: integer
minimum: 1
responses:
'200':
description: Queue job retried
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueRetryResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
# Module - Stripe Endpoints
/modules/stripe/customers:
@@ -8135,7 +8404,7 @@ paths:
properties:
message:
type: string
example: Failed to execute command: Failed to open property access gate.
example: 'Failed to execute command: Failed to open property access gate.'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
@@ -12284,6 +12553,213 @@ components:
type: string
format: date-time
EconomicTransferQueueStatus:
type: string
enum:
- QUEUED
- PROCESSING
- COMPLETED
- FAILED
EconomicTransferQueueJob:
type: object
properties:
id:
type: integer
transfer_type:
type: string
enum:
- ORDER_DRAFT_EXPORT
- ORDER_INVOICE_EXPORT
- COLLECTED_INVOICE_EXPORT
status:
$ref: '#/components/schemas/EconomicTransferQueueStatus'
progress_percent:
type: integer
minimum: 0
maximum: 100
progress_message:
type: string
nullable: true
attempts:
type: integer
minimum: 0
max_attempts:
type: integer
minimum: 1
error_message:
type: string
nullable: true
payload:
type: object
nullable: true
additionalProperties: true
result:
type: object
nullable: true
additionalProperties: true
created_by:
type: integer
nullable: true
created_at:
type: string
format: date-time
nullable: true
updated_at:
type: string
format: date-time
nullable: true
started_at:
type: string
format: date-time
nullable: true
completed_at:
type: string
format: date-time
nullable: true
next_retry_at:
type: string
format: date-time
nullable: true
required:
- id
- transfer_type
- status
- progress_percent
- attempts
- max_attempts
EconomicTransferQueueEnqueueResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
job_id:
type: integer
minimum: 1
job:
$ref: '#/components/schemas/EconomicTransferQueueJob'
required:
- message
- job_id
- job
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
EconomicTransferQueueStatusResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/EconomicTransferQueueJob'
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
EconomicTransferQueueRetryResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
job:
$ref: '#/components/schemas/EconomicTransferQueueJob'
required:
- message
- job
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
EconomicTransferQueueListResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/EconomicTransferQueueJob'
count:
type: integer
minimum: 0
required:
- items
- count
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
CollectedInvoiceEconomicCompareResponse:
type: object
description: Result of comparing a collected invoice with its E-conomic counterpart
@@ -0,0 +1,432 @@
<?php
namespace classes;
use economic_invoice_draft_mo;
use Exception;
use objects\collected_order_invoices_o;
use objects\customer_fixed_pricing_o;
use objects\departments_o;
use objects\economic_module_orders;
use objects\logs_o;
use objects\orders_o;
use objects\users_o;
/**
* Executes e-conomic transfer flows that are now run by queue workers.
*/
class economic_transfer_executor
{
/**
* Export order draft invoice to e-conomic.
* @throws Exception
*/
public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array
{
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
}
$order_items = (new orders_o())->getOrderItems($order_id);
$order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value());
if (count($order_items) === 0) {
throw new Exception('No order items found');
}
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = new economic_invoice_draft_mo();
$economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number);
$economic_invoice_draft->setRecipient(
$customer_economic->name ?? 'Ukendt',
$customer_economic->address ?? 'Ukendt',
$customer_economic->zip ?? 'Ukendt',
$customer_economic->city ?? 'Ukendt'
);
$department = (new departments_o())->getDepartmentById($order->department_id->value());
$this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order);
$billable_order_items = 0;
foreach ($order_items as $order_item) {
$added = $this->addOrderItemToInvoice(
$customer,
$order,
$order_item,
$economic_invoice_draft,
(int)($order_item['quantity'] ?? 1)
);
if ($added) {
$billable_order_items++;
}
}
if ($billable_order_items < 1) {
throw new Exception('No billable order items found');
}
$result = null;
if ($customer->hasOpenInvoiceDraft() && !$customer->invoicePerOrder()) {
$open_invoice_draft = (int)$customer->getOpenInvoiceDraft();
$result = $this->addOrderToInvoiceDraft($open_invoice_draft, $order, $customer, $order_items);
}
if ($result === null) {
$result = $economic_invoice_draft->createInvoiceDraftExample();
}
if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) {
(new logs_o())->add(
'economic_invoice_draft',
'global',
3,
$user_id,
'ECONOMIC_INVOICE_DRAFT_EXPORT',
'Failed to create economic invoice draft'
);
$message = $result->message ?? 'Failed to create economic invoice draft';
throw new Exception((string)$message);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() > 0) {
$economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value());
}
$new_draft_id = (int)($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft());
$economic_module_orders->economic_invoice_draft_id->set($new_draft_id);
if (!$customer->invoicePerOrder()) {
$customer->setOpenInvoiceDraft($new_draft_id);
} else {
$customer->unsetOpenInvoiceDraft();
}
(new logs_o())->add(
'economic_invoice_draft',
'global',
1,
$user_id,
'ECONOMIC_INVOICE_DRAFT_EXPORT',
'Successfully exported an economic invoice draft'
);
return $economic_module_orders->getArray();
}
/**
* Export booked invoice from existing draft.
* @throws Exception
*/
public function exportOrderInvoice(int $order_id, int $user_id = 0): array
{
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
}
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
throw new Exception('No economic invoice draft found');
}
$invoice_id = (int)$economic_module_orders->economic_invoice_id->value();
if ($invoice_id > 0) {
throw new Exception('An invoice has already been created, invoice ID: ' . $invoice_id);
}
$invoice_draft_id = (int)$economic_module_orders->economic_invoice_draft_id->value();
$economic_invoice_draft = new economic_invoice_draft_mo();
$result = $economic_invoice_draft->publishInvoiceDraft($invoice_draft_id);
if (!isset($result->bookedInvoiceNumber)) {
(new logs_o())->add(
'economic_invoice',
'global',
3,
$user_id,
'ECONOMIC_INVOICE_EXPORT',
'Failed to create economic invoice from draft: ' . $invoice_draft_id
);
$message = $result->message ?? 'Failed to create economic invoice';
throw new Exception((string)$message);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
$economic_module_orders->economic_invoice_id->set((int)$result->bookedInvoiceNumber);
$customer->unsetOpenInvoiceDraft();
(new logs_o())->add(
'economic_invoice',
'global',
1,
$user_id,
'ECONOMIC_INVOICE_EXPORT',
'Successfully exported an economic invoice'
);
return $economic_module_orders->getArray();
}
/**
* Export collected invoice to e-conomic.
* @throws Exception
*/
public function exportCollectedInvoice(int $collected_invoice_id, bool $send_as_is = false, int $user_id = 0): array
{
$collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id);
$collected_order_invoices->requireSelected();
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
} else {
$collected_order_invoices->removeSpecialArrangements();
$collected_order_invoices->setAllItemsToBeIncludedInInvoice();
}
$collected_order_invoices->addToEconomic();
} else {
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
throw new Exception('Invoice has already been booked');
}
if ($collected_order_invoices->isDraftExisting()) {
throw new Exception('Invoice draft already exists in E-Conomic');
}
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
$collected_order_invoices->addToEconomic(true);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user_id,
'ADD_COLLECTED_INVOICE_ECONOMIC',
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
return $collected_order_invoices->asArray();
}
/**
* @throws Exception
*/
public function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, mixed $department_name, orders_o $order): void
{
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
$economic_invoice_draft->addLineTEXT("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
if ($order->reference->value() !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($order->reference->value(), "\n")) {
foreach (explode("\n", $order->reference->value()) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->reference->value());
}
}
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
}
$economic_invoice_draft->addLineTEXT($line_reg);
if ($order->notes->value() !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
if (str_contains($order->notes->value(), "\n")) {
foreach (explode("\n", $order->notes->value()) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->notes->value());
}
}
}
/**
* Returns true when a product line was added, false when skipped.
* @throws Exception
*/
public function addOrderItemToInvoice(
users_o $customer,
orders_o $order,
mixed $order_item,
economic_invoice_draft_mo $economic_invoice_draft,
int $quantity = 1
): bool {
if (self::shouldSkipOrderItemForInvoice($order_item, $quantity)) {
return false;
}
if (!is_array($order_item)) {
throw new Exception('Order item payload must be an array');
}
$product_number = trim((string)($order_item['product']['economic_product_id'] ?? ''));
if ($product_number === '') {
throw new Exception('Order item is missing economic product id');
}
$product_name = trim((string)($order_item['product']['name'] ?? ''));
if ($product_name === '') {
$product_name = 'Ukendt produkt';
}
$reference = isset($order_item['reference']) ? (string)$order_item['reference'] : '';
$notes = isset($order_item['notes']) ? (string)$order_item['notes'] : '';
$department = $order->getDepartmentByOrderId($order->id);
$economic_department_id = $department['economic_department_id'];
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$order_item_price = (float)($order_item['price'] ?? 0);
$product_price = (float)($order_item['product']['price'] ?? 0);
$economic_invoice_draft->addLine(
$product_number,
$product_name,
$quantity,
$order_item_price,
0,
(int)$economic_department_id ?? 0,
(int)$economic_dimension_id ?? 0
);
$show_discount = abs($order_item_price - $product_price) > 0.00001;
if ($show_discount && abs($product_price) > 0.00001) {
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0);
$economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)');
}
if ($reference !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($reference, "\n")) {
foreach (explode("\n", $reference) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $reference);
}
}
if ($customer->doesUserHaveAttribute('requiresRegistrationNumbersInvoice')) {
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
}
$economic_invoice_draft->addLineTEXT($line_reg);
}
if ($notes !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
if (str_contains($notes, "\n")) {
foreach (explode("\n", $notes) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $notes);
}
}
return true;
}
/**
* @throws Exception
*/
public function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object
{
$has_billable_items = false;
foreach ($order_items as $order_item) {
if (!self::shouldSkipOrderItemForInvoice($order_item, (int)($order_item['quantity'] ?? 1))) {
$has_billable_items = true;
break;
}
}
if (!$has_billable_items) {
throw new Exception('No billable order items found');
}
$economic_invoice_draft = new economic_invoice_draft_mo();
$economic_invoice_draft->addLineTEXT('');
$economic_invoice_draft->addLineTEXT('');
$this->addTheDepartmentDateReference($economic_invoice_draft, $order->getDepartmentByOrderId($order->id)['name'], $order);
foreach ($order_items as $order_item) {
$this->addOrderItemToInvoice($customer, $order, $order_item, $economic_invoice_draft, (int)($order_item['quantity'] ?? 1));
}
return $economic_invoice_draft->addLinesToInvoiceDraft($economic_invoice_draft_id);
}
/**
* Skip item lines that are not billable in e-conomic export (0 quantity or 0 unit cost).
*/
public static function shouldSkipOrderItemForInvoice(mixed $order_item, int $quantity = 1): bool
{
if (!is_array($order_item)) {
return true;
}
$line_quantity = $quantity;
if (isset($order_item['quantity']) && is_numeric($order_item['quantity'])) {
$line_quantity = (int)$order_item['quantity'];
}
if ($line_quantity <= 0) {
return true;
}
$line_price = null;
if (isset($order_item['price']) && is_numeric($order_item['price'])) {
$line_price = (float)$order_item['price'];
}
if ($line_price === null) {
return true;
}
if (abs($line_price) < 0.00001) {
return true;
}
return false;
}
}
@@ -0,0 +1,480 @@
<?php
namespace classes;
use Exception;
use mysqli_result;
use objects\logs_o;
/**
* Queue service for asynchronous e-conomic transfer jobs.
*/
class economic_transfer_queue
{
public const STATUS_QUEUED = 'QUEUED';
public const STATUS_PROCESSING = 'PROCESSING';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
public const TYPE_ORDER_DRAFT_EXPORT = 'ORDER_DRAFT_EXPORT';
public const TYPE_ORDER_INVOICE_EXPORT = 'ORDER_INVOICE_EXPORT';
public const TYPE_COLLECTED_INVOICE_EXPORT = 'COLLECTED_INVOICE_EXPORT';
private const STALE_PROCESSING_LOCK_SECONDS = 900;
private economic_transfer_executor $executor;
public function __construct(?economic_transfer_executor $executor = null)
{
$this->executor = $executor ?? new economic_transfer_executor();
economic_transfer_queue_schema_bootstrap::ensureTables();
}
/**
* @throws Exception
*/
public function enqueue(string $transfer_type, array $payload, int $created_by = 0, int $max_attempts = 3): array
{
global $db;
$transfer_type = $this->validateTransferType($transfer_type);
$max_attempts = max(1, min(10, $max_attempts));
$created_by = max(0, $created_by);
$payload_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload_json === false) {
throw new Exception('Failed to serialize queue payload');
}
$stmt = $db->prepare(
"INSERT INTO economic_transfer_queue_jobs
(transfer_type, payload_json, status, progress_percent, progress_message, attempts, max_attempts, created_by)
VALUES (?, ?, ?, 0, 'Queued', 0, ?, ?)"
);
if (!$stmt) {
throw new Exception('Failed to prepare queue insert statement');
}
$status = self::STATUS_QUEUED;
$stmt->bind_param('sssii', $transfer_type, $payload_json, $status, $max_attempts, $created_by);
if (!$stmt->execute()) {
throw new Exception('Failed to enqueue transfer job');
}
$job_id = (int)$db->insert_id();
$stmt->close();
(new logs_o())->add(
'economic_transfer_queue',
'global',
1,
$created_by,
'ECONOMIC_TRANSFER_JOB_ENQUEUED',
'Transfer job #' . $job_id . ' queued: ' . $transfer_type
);
$job = $this->getJobById($job_id);
if ($job === null) {
throw new Exception('Failed to load queued transfer job');
}
return $job;
}
public function getJobById(int $job_id): ?array
{
global $db;
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? LIMIT 1");
if (!$stmt) {
return null;
}
$stmt->bind_param('i', $job_id);
if (!$stmt->execute()) {
$stmt->close();
return null;
}
$result = $stmt->get_result();
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
$stmt->close();
if (!$row) {
return null;
}
return $this->normalizeJobRow($row);
}
public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0): array
{
global $db;
$limit = max(1, min(500, $limit));
$offset = max(0, $offset);
$where = '';
if (!empty($statuses)) {
$clean_statuses = array_values(array_filter(array_map(static function ($status): string {
return strtoupper(trim((string)$status));
}, $statuses), static function ($status): bool {
return in_array($status, [
self::STATUS_QUEUED,
self::STATUS_PROCESSING,
self::STATUS_COMPLETED,
self::STATUS_FAILED,
], true);
}));
if (!empty($clean_statuses)) {
$escaped = array_map(static function ($status) use ($db): string {
return "'" . $db->escape_string($status) . "'";
}, array_values(array_unique($clean_statuses)));
$where = 'WHERE status IN (' . implode(',', $escaped) . ')';
}
}
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
$jobs = [];
while ($row = $result->fetch_assoc()) {
$jobs[] = $this->normalizeJobRow($row);
}
return $jobs;
}
/**
* @throws Exception
*/
public function retryJob(int $job_id): array
{
global $db;
$existing_job = $this->getJobById($job_id);
if ($existing_job === null) {
throw new Exception('Queue job not found');
}
if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) {
throw new Exception('Only failed jobs can be retried');
}
if ((int)($existing_job['attempts'] ?? 0) >= (int)($existing_job['max_attempts'] ?? 1)) {
throw new Exception('Queue job reached max retry attempts');
}
$stmt = $db->prepare(
"UPDATE economic_transfer_queue_jobs
SET status = ?, progress_percent = 0, progress_message = 'Queued for retry',
error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL
WHERE id = ? AND status = ?"
);
if (!$stmt) {
throw new Exception('Failed to prepare retry statement');
}
$queued = self::STATUS_QUEUED;
$failed = self::STATUS_FAILED;
$stmt->bind_param('sis', $queued, $job_id, $failed);
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
if ($affected < 1) {
throw new Exception('Failed to retry queue job');
}
$job = $this->getJobById($job_id);
if ($job === null) {
throw new Exception('Retry updated job could not be loaded');
}
return $job;
}
public function processPending(int $limit = 5): array
{
$limit = max(1, min(100, $limit));
$this->releaseStaleProcessingLocks();
$processed = 0;
$completed = 0;
$failed = 0;
$jobs = [];
$empty_claims = 0;
for ($i = 0; $i < $limit; $i++) {
$job = $this->claimNextJob();
if ($job === null) {
$empty_claims++;
if ($empty_claims >= 3) {
break;
}
continue;
}
$empty_claims = 0;
$processed++;
$jobs[] = $job['id'];
$this->updateProgress((int)$job['id'], 15, 'Running transfer');
try {
$result = $this->executeJob($job);
$this->markCompleted((int)$job['id'], $result);
$completed++;
} catch (Exception $e) {
$this->markFailed((int)$job['id'], $e->getMessage());
$failed++;
} catch (\Throwable $e) {
$this->markFailed((int)$job['id'], $e->getMessage());
$failed++;
}
}
return [
'processed' => $processed,
'completed' => $completed,
'failed' => $failed,
'jobs' => $jobs,
];
}
private function claimNextJob(): ?array
{
global $db;
$sql = "SELECT id FROM economic_transfer_queue_jobs
WHERE status = '" . self::STATUS_QUEUED . "'
AND attempts < max_attempts
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY id ASC
LIMIT 1";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return null;
}
$row = $result->fetch_assoc();
if (!$row || !isset($row['id'])) {
return null;
}
$job_id = (int)$row['id'];
$stmt = $db->prepare(
"UPDATE economic_transfer_queue_jobs
SET status = ?, progress_percent = 5, progress_message = 'Processing', started_at = NOW(), locked_at = NOW()
WHERE id = ? AND status = ?"
);
if (!$stmt) {
return null;
}
$processing = self::STATUS_PROCESSING;
$queued = self::STATUS_QUEUED;
$stmt->bind_param('sis', $processing, $job_id, $queued);
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
if ($affected < 1) {
return null;
}
return $this->getJobById($job_id);
}
/**
* @throws Exception
*/
private function executeJob(array $job): array
{
$payload = (array)($job['payload'] ?? []);
$transfer_type = (string)($job['transfer_type'] ?? '');
$requested_by = (int)($payload['requested_by'] ?? ($job['created_by'] ?? 0));
$this->updateProgress((int)$job['id'], 40, 'Validating job payload');
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT => $this->executeOrderDraftExport($job, $payload, $requested_by),
self::TYPE_ORDER_INVOICE_EXPORT => $this->executeOrderInvoiceExport($job, $payload, $requested_by),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->executeCollectedInvoiceExport($job, $payload, $requested_by),
default => throw new Exception('Unsupported transfer type: ' . $transfer_type),
};
}
/**
* @throws Exception
*/
private function executeOrderDraftExport(array $job, array $payload, int $requested_by): array
{
$order_id = (int)($payload['order_id'] ?? 0);
if ($order_id < 1) {
throw new Exception('order_id is required');
}
$this->updateProgress((int)$job['id'], 65, 'Exporting order draft invoice');
return $this->executor->exportOrderDraftInvoice($order_id, $requested_by);
}
/**
* @throws Exception
*/
private function executeOrderInvoiceExport(array $job, array $payload, int $requested_by): array
{
$order_id = (int)($payload['order_id'] ?? 0);
if ($order_id < 1) {
throw new Exception('order_id is required');
}
$this->updateProgress((int)$job['id'], 65, 'Exporting booked invoice');
return $this->executor->exportOrderInvoice($order_id, $requested_by);
}
/**
* @throws Exception
*/
private function executeCollectedInvoiceExport(array $job, array $payload, int $requested_by): array
{
$collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0);
if ($collected_invoice_id < 1) {
throw new Exception('collected_invoice_id is required');
}
$send_as_is = (bool)($payload['send_as_is'] ?? false);
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by);
}
private function updateProgress(int $job_id, int $percent, string $message): void
{
global $db;
$percent = max(0, min(100, $percent));
$escaped_message = $db->escape_string($message);
$sql = "UPDATE economic_transfer_queue_jobs
SET progress_percent = $percent, progress_message = '$escaped_message'
WHERE id = $job_id";
$db->query($sql);
}
private function markCompleted(int $job_id, array $result): void
{
global $db;
$result_json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($result_json === false) {
$result_json = json_encode(['result' => 'serialization_error']);
}
$escaped_result = $db->escape_string((string)$result_json);
$sql = "UPDATE economic_transfer_queue_jobs
SET status = '" . self::STATUS_COMPLETED . "',
progress_percent = 100,
progress_message = 'Completed',
result_json = '$escaped_result',
error_message = NULL,
completed_at = NOW(),
locked_at = NULL
WHERE id = $job_id";
$db->query($sql);
(new logs_o())->add(
'economic_transfer_queue',
'global',
1,
0,
'ECONOMIC_TRANSFER_JOB_COMPLETED',
'Transfer job #' . $job_id . ' completed'
);
}
private function markFailed(int $job_id, string $error_message): void
{
global $db;
$error_message = trim($error_message);
if ($error_message === '') {
$error_message = 'Unknown transfer queue error';
}
$escaped_error = $db->escape_string($error_message);
$sql = "UPDATE economic_transfer_queue_jobs
SET status = '" . self::STATUS_FAILED . "',
progress_message = 'Failed',
error_message = '$escaped_error',
attempts = attempts + 1,
completed_at = NOW(),
locked_at = NULL
WHERE id = $job_id";
$db->query($sql);
(new logs_o())->add(
'economic_transfer_queue',
'global',
3,
0,
'ECONOMIC_TRANSFER_JOB_FAILED',
'Transfer job #' . $job_id . ' failed: ' . $error_message
);
}
private function decodeJsonValue(mixed $json): mixed
{
if (!is_string($json) || trim($json) === '') {
return null;
}
$decoded = json_decode($json, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
private function normalizeJobRow(array $row): array
{
return [
'id' => (int)$row['id'],
'transfer_type' => (string)($row['transfer_type'] ?? ''),
'status' => (string)($row['status'] ?? self::STATUS_QUEUED),
'progress_percent' => (int)($row['progress_percent'] ?? 0),
'progress_message' => $row['progress_message'] ?? null,
'attempts' => (int)($row['attempts'] ?? 0),
'max_attempts' => (int)($row['max_attempts'] ?? 0),
'error_message' => $row['error_message'] ?? null,
'payload' => $this->decodeJsonValue($row['payload_json'] ?? null),
'result' => $this->decodeJsonValue($row['result_json'] ?? null),
'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
'started_at' => $row['started_at'] ?? null,
'completed_at' => $row['completed_at'] ?? null,
'next_retry_at' => $row['next_retry_at'] ?? null,
];
}
/**
* Release jobs stuck in PROCESSING due to crashes or killed workers.
*/
private function releaseStaleProcessingLocks(): void
{
global $db;
$timeout = (int)self::STALE_PROCESSING_LOCK_SECONDS;
$sql = "UPDATE economic_transfer_queue_jobs
SET status = '" . self::STATUS_QUEUED . "',
progress_message = 'Re-queued after stale processing lock',
locked_at = NULL,
started_at = NULL
WHERE status = '" . self::STATUS_PROCESSING . "'
AND locked_at IS NOT NULL
AND locked_at < (NOW() - INTERVAL $timeout SECOND)";
$db->query($sql);
}
/**
* @throws Exception
*/
private function validateTransferType(string $transfer_type): string
{
$transfer_type = strtoupper(trim($transfer_type));
if (!in_array($transfer_type, [
self::TYPE_ORDER_DRAFT_EXPORT,
self::TYPE_ORDER_INVOICE_EXPORT,
self::TYPE_COLLECTED_INVOICE_EXPORT,
], true)) {
throw new Exception('Unsupported transfer type: ' . $transfer_type);
}
return $transfer_type;
}
}
@@ -0,0 +1,47 @@
<?php
namespace classes;
/**
* Ensures additive schema for e-conomic transfer queue jobs.
*/
class economic_transfer_queue_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$db->query(
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_jobs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
transfer_type VARCHAR(64) NOT NULL,
payload_json JSON NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'QUEUED',
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
progress_message VARCHAR(255) NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
error_message TEXT NULL,
result_json JSON NULL,
created_by INT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
next_retry_at DATETIME NULL,
locked_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_economic_transfer_queue_jobs_status_created (status, created_at),
INDEX idx_economic_transfer_queue_jobs_next_retry (next_retry_at),
INDEX idx_economic_transfer_queue_jobs_transfer_type (transfer_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
+10
View File
@@ -29,6 +29,10 @@ set_time_limit(10 * 60); // 10 minutes
// Set the memory limit to 16 GB
ini_set('memory_limit', '16G');
require_once __DIR__ . '/classes/economic_transfer_executor.php';
require_once __DIR__ . '/classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/classes/economic_transfer_queue.php';
// If the first argument is 'run', switch to the second argument
if ($args[1] === 'run') {
@@ -88,6 +92,12 @@ if ($args[1] === 'run') {
case 'logSync':
require_once 'cron/SyncLogs.php';
break;
case 'economic-transfer-queue':
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running economic transfer queue worker\n";
$queue = new \classes\economic_transfer_queue();
$result = $queue->processPending(25);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
break;
case 'cron':
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
require_once 'cron/Cron.php';
+27
View File
@@ -3,6 +3,7 @@
use classes\backup_store;
use classes\economic;
use classes\economic_transfer_queue;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
@@ -25,6 +26,10 @@ use objects\logs_o;
use objects\users_o;
use routes\moduleWeatherAPIRoute;
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
if (!defined('WD')) {
exit;
}
@@ -88,6 +93,12 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'SyncEconomicInvoiceStatus',
],
'EconomicTransferQueueCron' => [
'interval' => 30, // 30 seconds
'last_run' => 0,
'next_run' => 0,
'function' => 'EconomicTransferQueueCron',
],
'SyncXLVaskModuleCron' => [
'interval' => 3600, // 1 hour
'last_run' => 0,
@@ -229,6 +240,22 @@ function SyncXLVaskModuleCron(): void
}
}
function EconomicTransferQueueCron(): void
{
try {
$queue = new economic_transfer_queue();
$result = $queue->processPending(10);
if ((int)($result['processed'] ?? 0) > 0) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] EconomicTransferQueueCron processed="
. (int)($result['processed'] ?? 0)
. " completed=" . (int)($result['completed'] ?? 0)
. " failed=" . (int)($result['failed'] ?? 0) . "\n";
}
} catch (Throwable $e) {
warn('EconomicTransferQueueCron failed: ' . $e->getMessage());
}
}
function SystemSearchCacheMaintenanceCron(): void
{
try {
+19 -5
View File
@@ -70,14 +70,28 @@ try {
*/
spl_autoload_register(function (string $class): void {
$class = ltrim($class, '\\');
$cache_key = 'autoload:' . $class;
$is_loaded = static function (string $candidate): bool {
return class_exists($candidate, false)
|| interface_exists($candidate, false)
|| trait_exists($candidate, false)
|| (function_exists('enum_exists') && enum_exists($candidate, false));
};
// Check Redis cache first
if (defined('redis')) {
try {
$cached = redis->get('autoload:' . $class);
if ($cached && is_file($cached)) {
$cached = redis->get($cache_key);
if (is_string($cached) && $cached !== '' && is_file($cached)) {
require_once $cached;
return;
if ($is_loaded($class)) {
return;
}
// Stale class mapping in cache, continue with normal lookup.
redis->delete($cache_key);
} elseif (is_string($cached) && $cached !== '') {
// Remove non-existing cached path to avoid repeated failed lookups.
redis->delete($cache_key);
}
} catch (\Throwable $e) {
// Fall back to manual search on Redis error
@@ -137,10 +151,10 @@ spl_autoload_register(function (string $class): void {
$file = $path . $suffix . '.php';
if (is_file($file)) {
require_once $file;
if (class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false) || (function_exists('enum_exists') && enum_exists($class, false))) {
if ($is_loaded($class)) {
if (defined('redis')) {
try {
redis->setEx('autoload:' . $class, $file, 86400); // Cache for 24 hours
redis->setEx($cache_key, $file, 86400); // Cache for 24 hours
} catch (\Throwable $e) {}
}
return;
@@ -226,6 +226,9 @@ class economic_invoice_draft
$total_discount = 0;
// Loop through the order items
foreach ( $order_items as $order_item ) {
if ($this->shouldSkipOrderItemLine($order_item)) {
continue;
}
// Add the order item to the draft invoice
self::addOrderItemLine($order_item, $department);
// Add the line discount to the total discount
@@ -254,6 +257,9 @@ class economic_invoice_draft
if (!isset($order_item['id'])) {
throw new Exception('The order item is not valid');
}
if ($this->shouldSkipOrderItemLine($order_item)) {
return;
}
// Get the department id
$economic_department_id = $department['economic_department_id'] ?? 0;
// Get the dimension id
@@ -309,6 +315,25 @@ class economic_invoice_draft
}
/**
* Skip line when quantity is zero/negative, or final unit price is zero.
*/
private function shouldSkipOrderItemLine(array $order_item): bool
{
$quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity'])
? (float)$order_item['quantity']
: 0.0;
if ($quantity <= 0.0) {
return true;
}
$price = isset($order_item['price']) && is_numeric($order_item['price'])
? (float)$order_item['price']
: 0.0;
return abs($price) < 0.00001;
}
/**
* Add a product line to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
@@ -428,4 +453,4 @@ class economic_invoice_draft
throw new Exception('The draft invoice data is not set');
}
}
}
}
@@ -1482,4 +1482,9 @@ class collected_order_invoices_o extends db
$row = $result->fetch_assoc();
return (int)$row['count'] === 0;
}
public function clearCachedData(): void
{
$this->objectChanged();
}
}
+41
View File
@@ -1676,4 +1676,45 @@ class orders_o extends db
}
return $orders;
}
/**
* @throws Exception
*/
public function getOrdersWithRegistrationNumberInDateRange(string $registration_number, string $from_date, string $to_date): array
{
global /** @var db $db */
$db;
// Validate the date range
$from_timestamp = strtotime($from_date);
$to_timestamp = strtotime($to_date);
if ($from_timestamp === false || $to_timestamp === false) {
throw new Exception('Invalid date range provided');
}
if ($from_timestamp > $to_timestamp) {
throw new Exception('The start date cannot be after the end date');
}
// Prepare the SQL query to find orders with the registration number in the date range
$reg = strtoupper(trim($registration_number));
if ($reg === '') {
return [];
}
$reg = $db->escape_string($reg);
$from_date = $db->escape_string(date('Y-m-d H:i:s', $from_timestamp));
$to_date = $db->escape_string(date('Y-m-d H:i:s', $to_timestamp));
$sql = "SELECT id FROM $this->table
WHERE (UPPER(TRIM(reg_1)) = '$reg' OR UPPER(TRIM(reg_2)) = '$reg' OR UPPER(TRIM(reg_3)) = '$reg')
AND created_at BETWEEN '$from_date' AND '$to_date'
AND deleted_at IS NULL";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return []; // No orders found with the registration number in the date range
}
$orders = [];
while ($row = $result->fetch_assoc()) {
$order = new orders_o();
$order->select((int)$row['id']);
$orders[] = $order;
}
return $orders;
}
}
File diff suppressed because it is too large Load Diff
+212 -313
View File
@@ -3,14 +3,13 @@
namespace routes;
use classes\authentication;
use classes\economic_transfer_queue;
use classes\response;
use classes\router;
use economic_invoice_draft_mo;
use objects\departments_o;
use objects\economic_module_orders;
use objects\logs_o;
use objects\orders_o;
use objects\users_o;
use traits\route_t;
class economicInvoiceRoute
@@ -23,118 +22,54 @@ class economicInvoiceRoute
/** @var router $router */
$router, $response;
$this->post('/economic/invoice/draft/export', function () {
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
if ($user) {
$order_id = $response->getRequestParameter('order_id');
if (!isset($order_id)) {
$response->error('Order ID is required', 400);
}
// Validate the order ID is a number
if (!is_numeric($order_id)) {
$response->error('Order ID must be a number', 400);
}
$order = (new orders_o())->getOrderById($order_id);
// Check if the order exists
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Get the order items
$order_items = (new orders_o())->getOrderItems($order_id);
// Apply the department pricing
$order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value());
// Get the customer
$customer = (new orders_o())->getCustomerByOrderId($order_id);
// Check if the customer exists
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
// Get the customer economic number
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = (new economic_invoice_draft_mo());
// Set the customer number
$economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number);
// Set the recipient
$economic_invoice_draft->setRecipient(
$customer_economic->name ?? 'Ukendt',
$customer_economic->address ?? 'Ukendt',
$customer_economic->zip ?? 'Ukendt',
$customer_economic->city ?? 'Ukendt'
);
// Make sure there are order items
if (count($order_items) === 0) {
$response->error('No order items found', 404);
}
// Get the department
$department = (new departments_o())->getDepartmentById($order->department_id->value());
// Add the department, date, reference
$this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order);
// Add the lines to the invoice
foreach ( $order_items as $order_item ) {
// Add the order item to the invoice draft
$this->addOrderItemToInvoice(
$customer,
$order,
$order_item,
$economic_invoice_draft,
$order_item['quantity'] ?? 1);
}
// Check if the user has an open invoice draft
$hasOpenInvoiceDraft = $customer->hasOpenInvoiceDraft();
if ($hasOpenInvoiceDraft && !$customer->invoicePerOrder()) {
// Get the open invoice draft
$openInvoiceDraft = $customer->getOpenInvoiceDraft();
// Add the order to the invoice draft
$result = $this->addOrderToInvoiceDraft($openInvoiceDraft, $order, $customer, $order_items);
}
// If the customer doesn't want to be billed per order, or if there's no open invoice draft, we'll create a new one
// Create the invoice draft
if (!isset($result)) {
$result = $economic_invoice_draft->createInvoiceDraftExample();
}
// Check if the invoice draft was created, or if the lines were added (lines is an array, and should not be empty)
if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) {
// Log the error
(new logs_o())->add('economic_invoice_draft', 'global', 3, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'Failed to create economic invoice draft');
// Check if we can get the errors from the response
if (isset($result->errors)) {
$response->add_meta('economic_errors', $result->errors);
}
$response->add_meta('economic_result', $result);
// Try to parse the error message
$response->error($result->message ?? 'Failed to create economic invoice draft', 500);
}
// Add the economic invoice draft to the order
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
// Remove the existing invoice draft (if any)
if ($economic_module_orders->economic_invoice_draft_id->value() > 0) {
$economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value());
}
$economic_module_orders->economic_invoice_draft_id->set($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft());
if (!$customer->invoicePerOrder()) {
// If the customer wants to be billed per order, we'll add the order to the invoice draft
$customer->setOpenInvoiceDraft($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft());
} else {
// If the customer doesn't want to be billed per order, we'll remove the open invoice draft
$customer->unsetOpenInvoiceDraft();
}
// Return the response
(new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'Successfully exported an economic invoice draft');
$response->success($economic_module_orders->getArray());
} else {
if (!$user) {
(new logs_o())->add('economic_invoice_draft', 'global', 1, 0, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$order_id = $response->getRequestParameter('order_id');
if (!isset($order_id)) {
$response->error('Order ID is required', 400);
}
if (!is_numeric($order_id) || (int)$order_id < 1) {
$response->error('Order ID must be a positive number', 400);
}
$order = (new orders_o())->getOrderById((int)$order_id);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT,
[
'order_id' => (int)$order_id,
'requested_by' => (int)$user->id,
],
(int)$user->id
);
$job_id = (int)($job['id'] ?? 0);
if ($job_id < 1) {
$response->error('Failed to enqueue draft export job: missing queue job id in enqueue response', 500);
}
(new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT_QUEUED', 'Queued economic invoice draft export');
$response->success([
'message' => 'Economic invoice draft export queued',
'job_id' => $job_id,
'job' => $job,
], 202);
}, [
'economic_invoice_draft_export' => 'Export an economic invoice draft'
]);
$this->delete('/economic/invoice/draft/delete', function () {
global /** @var response $response */
$response;
$this->requirePermission('economic_invoice_draft_delete');
@@ -145,34 +80,25 @@ class economicInvoiceRoute
$response->error('Order ID is required', 400);
}
$order = (new orders_o())->getOrderById($order_id);
// Check if the order exists
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Make sure the order has an economic invoice draft
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
$response->error('No economic invoice draft found', 404);
}
$economic_invoice_draft = (new economic_invoice_draft_mo());
// Get the invoice draft number
$invoiceDraftId = $economic_module_orders->economic_invoice_draft_id->value();
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->invoicePerOrder()) {
// Check if the customer has an open invoice draft
if ($customer->hasOpenInvoiceDraft()) {
// Check if it's the same as the order's invoice draft
if ((int)$customer->getOpenInvoiceDraft() === (int)$invoiceDraftId) {
// Remove the open invoice draft from the customer
$customer->deleteOpenInvoiceDraft();
}
}
}
// Delete the invoice draft
$economic_invoice_draft->deleteInvoiceDraft($invoiceDraftId);
// Remove the economic invoice draft from the order
$economic_module_orders->economic_invoice_draft_id->set(null);
// Return the response
(new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_DELETE', 'Successfully deleted an economic invoice draft');
$response->success($economic_module_orders->getArray());
} else {
@@ -187,216 +113,189 @@ class economicInvoiceRoute
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
if ($user) {
$order_id = $response->getRequestParameter('order_id');
if (!isset($order_id)) {
$response->error('Order ID is required', 400);
}
$order = (new orders_o())->getOrderById($order_id);
// Check if the order exists
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Get the customer
$customer = (new orders_o())->getCustomerByOrderId($order_id);
// Check if the customer exists
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
// Make sure the order has an economic invoice draft
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
$response->error('No economic invoice draft found', 404);
}
$economic_invoice_draft = (new economic_invoice_draft_mo());
// Get the invoice draft number
$invoiceDraftId = $economic_module_orders->economic_invoice_draft_id->value();
// Make sure there's not already an invoice created
$invoiceId = $economic_module_orders->economic_invoice_id->value();
if ($invoiceId > 0) {
$response->error('An invoice has already been created, invoice ID: ' . $invoiceId, 400);
}
// Publish the invoice draft
$result = $economic_invoice_draft->publishInvoiceDraft((int)$invoiceDraftId);
// Check if the invoice was created
if (!isset($result->bookedInvoiceNumber)) {
// Log the error
(new logs_o())->add('economic_invoice', 'global', 3, $user->id, 'ECONOMIC_INVOICE_EXPORT', 'Failed to create economic invoice from draft: ' . $invoiceDraftId);
// Check if we can get the errors from the response
if (isset($result->errors)) {
$response->add_meta('economic_errors', $result->errors);
}
// Try to parse the error message
$response->error($result->message ?? 'Failed to create economic invoice', 500);
}
// Add the economic invoice to the order
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
$economic_module_orders->economic_invoice_id->set($result->bookedInvoiceNumber);
// Remove the economic invoice draft from the customer
$customer->unsetOpenInvoiceDraft();
// Return the response
(new logs_o())->add('economic_invoice', 'global', 1, $user->id, 'ECONOMIC_INVOICE_EXPORT', 'Successfully exported an economic invoice');
$response->success($economic_module_orders->getArray());
} else {
if (!$user) {
(new logs_o())->add('economic_invoice', 'global', 1, 0, 'ECONOMIC_INVOICE_EXPORT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$order_id = $response->getRequestParameter('order_id');
if (!isset($order_id)) {
$response->error('Order ID is required', 400);
}
if (!is_numeric($order_id) || (int)$order_id < 1) {
$response->error('Order ID must be a positive number', 400);
}
$order = (new orders_o())->getOrderById((int)$order_id);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
$customer = (new orders_o())->getCustomerByOrderId((int)$order_id);
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId((int)$order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
$response->error('No economic invoice draft found', 404);
}
if ((int)$economic_module_orders->economic_invoice_id->value() > 0) {
$response->error('An invoice has already been created, invoice ID: ' . (int)$economic_module_orders->economic_invoice_id->value(), 400);
}
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT,
[
'order_id' => (int)$order_id,
'requested_by' => (int)$user->id,
],
(int)$user->id
);
$job_id = (int)($job['id'] ?? 0);
if ($job_id < 1) {
$response->error('Failed to enqueue invoice export job: missing queue job id in enqueue response', 500);
}
(new logs_o())->add('economic_invoice', 'global', 1, $user->id, 'ECONOMIC_INVOICE_EXPORT_QUEUED', 'Queued economic invoice export');
$response->success([
'message' => 'Economic invoice export queued',
'job_id' => $job_id,
'job' => $job,
], 202);
}, [
'economic_invoice_export' => 'Export an economic invoice'
]);
$this->get('/economic/invoice/draft/export/status', function () {
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$job = $queue->getJobById((int)$job_id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
$response->error('Draft export queue job not found', 404);
}
$response->success($job);
}, [
'economic_invoice_draft_export' => 'Read queue status for economic draft invoice export'
]);
$this->post('/economic/invoice/draft/export/retry', function () {
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$existing_job = $queue->getJobById((int)$job_id);
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
$response->error('Draft export queue job not found', 404);
}
try {
$job = $queue->retryJob((int)$job_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success([
'message' => 'Draft export queue job retried',
'job' => $job,
]);
}, [
'economic_invoice_draft_export' => 'Retry failed queue job for economic draft invoice export'
]);
$this->get('/economic/invoice/export/status', function () {
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$job = $queue->getJobById((int)$job_id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
$response->error('Invoice export queue job not found', 404);
}
$response->success($job);
}, [
'economic_invoice_export' => 'Read queue status for economic invoice export'
]);
$this->post('/economic/invoice/export/retry', function () {
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$existing_job = $queue->getJobById((int)$job_id);
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
$response->error('Invoice export queue job not found', 404);
}
try {
$job = $queue->retryJob((int)$job_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success([
'message' => 'Invoice export queue job retried',
'job' => $job,
]);
}, [
'economic_invoice_export' => 'Retry failed queue job for economic invoice export'
]);
}
/**
* @param economic_invoice_draft_mo $economic_invoice_draft
* @param $department_name
* @param orders_o $order
* @return void
*/
function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, $department_name, orders_o $order): void
private function ensureEconomicTransferQueueIsAvailable(): void
{
// The format is:
// Truck Wash - [department name], [date], (?)Ref(erence): [reference], Reg 1: [reg_1], (?)Reg 2: [reg_2], (?)Reg 3: [reg_3]
// (?)[note]
// Example:
// 12-02-2025 13:27, Truck Wash - Administration, Ref: 123456, Reg 1: ABC123, Reg 2: DEF456, Reg 3: GHI789
// This is a note for the invoice
//
// (?) = Optional
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
$economic_invoice_draft->addLineTEXT("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
// If there's a reference, add it to the invoice
if ($order->reference->value() !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->reference->value(), "\n")) {
foreach ( explode("\n", $order->reference->value()) as $line ) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->reference->value());
}
}
// Add the registration numbers (if any)
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '')
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
if ($order->reg_3->value() !== '')
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
// Add the line to the invoice
$economic_invoice_draft->addLineTEXT($line_reg);
// If there's a note, add it to the invoice
if ($order->notes->value() !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->notes->value(), "\n")) {
foreach ( explode("\n", $order->notes->value()) as $line ) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->notes->value());
}
global $response;
if (
!class_exists('\\classes\\economic_transfer_executor')
|| !class_exists('\\classes\\economic_transfer_queue_schema_bootstrap')
|| !class_exists(economic_transfer_queue::class)
) {
$response->error('Economic transfer queue is unavailable in this deployment', 503);
}
}
/**
* @param users_o $customer
* @param orders_o $order
* @param mixed $order_item
* @param economic_invoice_draft_mo $economic_invoice_draft
* @param int $quantity
* @return void
* @throws \Exception
*/
function addOrderItemToInvoice(users_o $customer, orders_o $order, mixed $order_item, economic_invoice_draft_mo $economic_invoice_draft, int $quantity = 1): void
{
// The format is:
// [product name], (?)Ref(erence): [reference], (!?)Reg 1: [reg 1], (!?)Reg 2: [reg 2], (!?)Reg 3: [reg 3], (?)Note: [note], (?)Discount: ([order item price] - [product price]) DKK ([discount percentage] %)
// (?) = Optional
// (!) = Required if the customer requires it
// (!?) = Should be added if the customer requires it
// Example:
// Tankcleaning 4 spulehoveder, Ref: 123456, Reg 1: ABC123, Reg 2: DEF456, Reg 3: GHI789, Note: This is a note, Discount: -100 DKK (20%)
// Get the department
$department = $order->getDepartmentByOrderId($order->id);
$economic_department_id = $department['economic_department_id'];
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
// Add the line to the invoice
$economic_invoice_draft->addLine(
(string)$order_item['product']['economic_product_id'],
(string)$order_item['product']['name'],
(int)$quantity,
(int)$order_item['price'],
0, // Since we can't be specific about the discount, we set it to 0. (The API has a limit of 2 decimals, and that's not enough for our needs)
(int)$economic_department_id ?? 0,
(int)$economic_dimension_id ?? 0 // If the department is not set, we'll set it to 0
);
// Calculate the discount percentage
$discountPercentage = round((($order_item['product']['price'] - $order_item['price']) / $order_item['product']['price']) * 100, 0);
// If the price is different from the product price, add it to the line
if ($order_item['price'] !== $order_item['product']['price'])
$economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item['price'] - $order_item['product']['price']) . ' DKK (' . $discountPercentage . '%)');
// If there's a reference, add it to the line
if ($order_item['reference'] !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['reference'], "\n")) {
foreach ( explode("\n", $order_item['reference']) as $line ) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order_item['reference']);
}
}
// Add the registration numbers (if they exist, and the customer requires it)
if ($customer->doesUserHaveAttribute('requiresRegistrationNumbersInvoice')) {
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
}
$economic_invoice_draft->addLineTEXT($line_reg);
}
// If there's a note, add it to the line
if ($order_item['notes'] !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['notes'], "\n")) {
foreach ( explode("\n", $order_item['notes']) as $line ) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order_item['notes']);
}
}
}
function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object
{
$economic_invoice_draft = (new economic_invoice_draft_mo());
// Add two empty lines to the invoice, to separate the orders
$economic_invoice_draft->addLineTEXT('');
$economic_invoice_draft->addLineTEXT('');
// Add the department, date, reference
$this->addTheDepartmentDateReference($economic_invoice_draft, $order->getDepartmentByOrderId($order->id)['name'], $order);
// Add the lines to the invoice
foreach ( $order_items as $order_item ) {
// Add the order item to the invoice draft
$this->addOrderItemToInvoice($customer, $order, $order_item, $economic_invoice_draft, $order_item['quantity'] ?? 1);
}
return $economic_invoice_draft->addLinesToInvoiceDraft($economic_invoice_draft_id);
}
}
}
+340 -52
View File
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\economic;
use classes\economic_transfer_queue;
use classes\economic_v2_compare_engine;
use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service;
@@ -13,6 +14,7 @@ use Exception;
use objects\collected_order_invoices_o;
use objects\customer_fixed_pricing_o;
use objects\logs_o;
use objects\orders_o;
use objects\users_o;
use traits\route_t;
@@ -522,77 +524,75 @@ class orderInvoicesRoute
]
);
/** Collected order invoices > E-Conomic > POST */
/** Collected order invoices > E-Conomic > POST (queued) */
$this->post('/collected-invoices/economic', function () {
global $response;
self::requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'User added a collected order invoice to E-Conomic');
// Require the ID, and validate its type and length
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::type_int());
self::requireMinLength('id', 1);
self::requireMaxLength('id', 10);
// Require the ID to be above 0
self::requireMinValue((int)self::getParameter('id'), 1);
// If the send_as_is parameter is set, validate its type
$send_as_is = false; // This is false by default, when true it will send the invoice as is, without adding vehicle subscriptions or fixed pricing. This is used when a customer with fixed pricing has already been invoiced with the fixed price, and we just need to send the invoice to E-Conomic.
$send_as_is = false;
if (self::isParametersSet(['send_as_is'])) {
self::requireType((bool)self::getParameter('send_as_is'), self::type_bool());
$send_as_is = (bool)self::getParameter('send_as_is');
$send_as_is_raw = self::getParameter('send_as_is');
if (is_bool($send_as_is_raw)) {
$send_as_is = $send_as_is_raw;
} elseif (is_numeric($send_as_is_raw)) {
$send_as_is = ((int)$send_as_is_raw) === 1;
} elseif (is_string($send_as_is_raw)) {
$normalized_send_as_is = strtolower(trim($send_as_is_raw));
if (!in_array($normalized_send_as_is, ['true', 'false', '1', '0'], true)) {
$response->error('send_as_is must be a boolean', 400);
}
$send_as_is = in_array($normalized_send_as_is, ['true', '1'], true);
} else {
$response->error('send_as_is must be a boolean', 400);
}
}
// Validate the ID against the database
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
$collected_order_invoices->requireSelected();
// Check if the collected order invoice has an external ID
if ($collected_order_invoices->external_id->value() === null) {
// Check if the send_as_is parameter is set to true
if (!$send_as_is) {
// Add fixed price to the collected order invoice
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed((int)$customer_fixed_pricing_price);
} else {
// Apply vehicle subscriptions if the customer does not have fixed pricing
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
} else {
// Since we are sending the invoice as is, we need to check if the invoice has any left-over subscription / fixed price transactions
$collected_order_invoices->removeSpecialArrangements(); // Remove any left-over special arrangement transactions (subscriptions / fixed price)
// Reset the price of all items set not to be included in the invoice
//$collected_order_invoices->resetPricesOfItemsNotIncludedInInvoice();
// Set all items to be included in the invoice
$collected_order_invoices->setAllItemsToBeIncludedInInvoice(); // Set all items to be included in the invoice, since we are sending the invoice as is.
}
// Add the collected order invoice to E-Conomic
$collected_order_invoices->addToEconomic();
$response->success($collected_order_invoices->asArray());
}
// Check if the invoice has been booked
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
$response->error('Invoice has already been booked', 400);
}
// Check if the invoice draft exists in E-Conomic
if ($collected_order_invoices->isDraftExisting()) {
$response->error('Invoice draft already exists in E-Conomic', 400);
if (!$this->isEconomicTransferQueueAvailable()) {
try {
$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'Processed collected invoice transfer synchronously (queue unavailable)');
$response->success($result);
}
// Add fixed price to the collected order invoice
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed((int)$customer_fixed_pricing_price);
} else {
// Apply vehicle subscriptions if the customer does not have fixed pricing
$collected_order_invoices->addVehicleSubscriptionsTransaction();
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => (int)self::getParameter('id'),
'send_as_is' => $send_as_is,
'requested_by' => (int)$user->id,
],
(int)$user->id
);
$job_id = (int)($job['id'] ?? 0);
if ($job_id < 1) {
$response->error('Failed to enqueue collected invoice export job: missing queue job id in enqueue response', 500);
}
// Create the invoice in E-Conomic
$collected_order_invoices->addToEconomic(true);
// Return the collected order invoice
$response->success($collected_order_invoices->asArray());
(new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC_QUEUED', 'Queued collected invoice transfer to E-Conomic');
$response->success([
'message' => 'Collected invoice export queued',
'job_id' => $job_id,
'job' => $job,
], 202);
} else {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'User tried to add a collected order invoice to E-Conomic without a valid session');
$response->error('Invalid session', 400);
@@ -603,6 +603,232 @@ class orderInvoicesRoute
]
);
$this->get('/collected-invoices/economic/queue', function () {
global $response;
self::requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$statuses = [];
if (self::isParametersSet(['status'])) {
$status_raw = (string)self::getParameter('status');
$statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw))));
}
$limit = self::isParametersSet(['limit']) ? (int)self::getParameter('limit') : 50;
$offset = self::isParametersSet(['offset']) ? (int)self::getParameter('offset') : 0;
$queue = new economic_transfer_queue();
$jobs = $queue->listJobs($statuses, $limit, $offset);
$filtered = array_values(array_filter($jobs, static function (array $job): bool {
return ($job['transfer_type'] ?? null) === economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT;
}));
$response->success([
'items' => $filtered,
'count' => count($filtered),
]);
},
[
'add_collected_invoice_economic' => 'List queued collected invoice transfer jobs.'
]
);
$this->get('/collected-invoices/economic/queue/status', function () {
global $response;
self::requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
self::requireParameters(['job_id']);
self::requireType((int)self::getParameter('job_id'), self::type_int());
self::requireMinValue((int)self::getParameter('job_id'), 1);
$queue = new economic_transfer_queue();
$job = $queue->getJobById((int)self::getParameter('job_id'));
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
$response->error('Collected invoice queue job not found', 404);
}
$response->success($job);
},
[
'add_collected_invoice_economic' => 'Get queued collected invoice transfer job status.'
]
);
$this->post('/collected-invoices/economic/queue/retry', function () {
global $response;
self::requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
self::requireParameters(['job_id']);
self::requireType((int)self::getParameter('job_id'), self::type_int());
self::requireMinValue((int)self::getParameter('job_id'), 1);
$queue = new economic_transfer_queue();
$job = $queue->getJobById((int)self::getParameter('job_id'));
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
$response->error('Collected invoice queue job not found', 404);
}
try {
$retried = $queue->retryJob((int)self::getParameter('job_id'));
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success([
'message' => 'Collected invoice queue job retried',
'job' => $retried,
]);
},
[
'add_collected_invoice_economic' => 'Retry failed queued collected invoice transfer job.'
]
);
/** Collected order invoices > Move multiple > Registration numbers > POST */
$this->post('/collected-invoices/move-multiple/registration-numbers', function () {
global $response;
self::requirePermission('move_collected_invoice');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
self::requireParameters(['registration_numbers', 'target_customer_number', 'from_date', 'to_date', 'closed_at']);
$registration_numbers = self::getParameter('registration_numbers');
if (!is_array($registration_numbers) || empty($registration_numbers)) {
$response->error('registration_numbers must be a non-empty array', 400);
}
$target_customer_number = self::getParameter('target_customer_number');
self::requireType((int)$target_customer_number, self::type_int());
self::requireMinValue((int)$target_customer_number, 1);
$closed_at = self::getParameter('closed_at');
if (!!$closed_at) {
self::requireDateFormat($closed_at, self::FORMAT_DATE());
$closed_at = date('Y-m-d H:i:s', strtotime($closed_at . ' 23:59:59'));
} else {
$closed_at = null;
}
$from_date = self::getParameter('from_date');
self::requireDateFormat($from_date, self::FORMAT_DATE());
$to_date = self::getParameter('to_date');
self::requireDateFormat($to_date, self::FORMAT_DATE());
$from_date = date('Y-m-d 00:00:00', strtotime($from_date));
$to_date = date('Y-m-d 23:59:59', strtotime($to_date));
if (strtotime($from_date) > strtotime($to_date)) {
$response->error('from_date must be before or equal to to_date', 400);
}
$customer = (new users_o())->getUserByCustomerNumber((int)$target_customer_number);
$customer->requireSelected();
$collected_order_invoices = new collected_order_invoices_o();
$moved_invoices = [];
// Create a new collected order invoice for the target customer
$new_invoice_collection = $collected_order_invoices->add(
$customer->customer_number->value(),
null,
null,
null,
$closed_at
);
if (!$new_invoice_collection->exists()) {
$response->error('Failed to create new invoice collection');
}
foreach ($registration_numbers as $registration_number) {
if (!is_string($registration_number) || trim($registration_number) === '') {
$response->error('Each registration number must be a non-empty string', 400);
}
$orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange($registration_number, $from_date, $to_date);
if (empty($orders)) {
continue; // Skip if no orders found for this registration number in the date range
}
foreach ($orders as $order) {
$order->customer_id->set((int)$new_invoice_collection->customer_number->value());
$order->assignToInvoiceCollection((int)$new_invoice_collection->id);
$order->objectChanged();
$moved_invoices[] = $order->id;
}
}
$response->success([
'message' => 'Orders moved to new invoice collection',
'moved_invoices' => $moved_invoices,
'from_date' => $from_date,
'to_date' => $to_date,
'target_customer_number' => $target_customer_number,
]);
});
/** Collected order invoices > Move multiple > POST */
$this->post('/collected-invoices/move-multiple', function () {
// Used to move multiple orders, to a new collected order invoice, in one request, instead of having to move each order one by one
global $response;
self::requirePermission('move_collected_invoice');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
self::requireParameters(['order_ids', 'target_customer_number', 'closed_at']);
$order_ids = self::getParameter('order_ids');
if (!is_array($order_ids) || empty($order_ids)) {
$response->error('order_ids must be a non-empty array', 400);
}
$target_customer_number = self::getParameter('target_customer_number');
self::requireType((int)$target_customer_number, self::type_int());
self::requireMinValue((int)$target_customer_number, 1);
$closed_at = self::getParameter('closed_at');
if (!!$closed_at) {
self::requireDateFormat($closed_at, self::FORMAT_DATE());
$closed_at = date('Y-m-d H:i:s', strtotime($closed_at . ' 23:59:59'));
} else {
$closed_at = null;
}
$customer = (new users_o())->select((int)$target_customer_number);
$customer->requireSelected();
$collected_order_invoices = new collected_order_invoices_o();
$moved_invoices = [];
// Create a new collected order invoice for the target customer
$new_invoice_collection = $collected_order_invoices->add(
$customer->id,
null,
null,
null,
$closed_at
);
if (!$new_invoice_collection->exists()) {
$response->error('Failed to create new invoice collection');
}
foreach ($order_ids as $order_id) {
self::requireType((int)$order_id, self::type_int());
self::requireMinValue((int)$order_id, 1);
$order = (new orders_o())->select((int)$order_id);
$order->requireSelected();
$order->customer_id->set((int)$new_invoice_collection->customer_number->value());
$order->objectChanged();
$order->assignToInvoiceCollection((int)$new_invoice_collection->id);
$moved_invoices[] = $order->id;
}
$new_invoice_collection->clearCachedData();
$response->success([
'message' => 'Orders moved to new invoice collection',
'moved_invoices' => $moved_invoices,
]);
},
[
'move_collected_invoice' => 'Move multiple orders to a new collected order invoice. This is a superuser-only route.'
]
);
/** Collected order invoices > E-Conomic > Unlink and clear cached data > POST */
$this->post('/collected-invoices/economic/unlink', function () {
global $response;
@@ -1461,6 +1687,68 @@ class orderInvoicesRoute
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
}
/**
* Synchronous fallback when queue components are unavailable in this deployment.
* Mirrors the collected-invoice flow used by the queue executor.
* @throws Exception
*/
private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array
{
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
} else {
$collected_order_invoices->removeSpecialArrangements();
$collected_order_invoices->setAllItemsToBeIncludedInInvoice();
}
$collected_order_invoices->addToEconomic();
} else {
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
throw new Exception('Invoice has already been booked');
}
if ($collected_order_invoices->isDraftExisting()) {
throw new Exception('Invoice draft already exists in E-Conomic');
}
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
$collected_order_invoices->addToEconomic(true);
}
return $collected_order_invoices->asArray();
}
private function isEconomicTransferQueueAvailable(): bool
{
return class_exists('\\classes\\economic_transfer_executor')
&& class_exists('\\classes\\economic_transfer_queue_schema_bootstrap')
&& class_exists(economic_transfer_queue::class);
}
private function ensureEconomicTransferQueueIsAvailable(): void
{
global $response;
if (!$this->isEconomicTransferQueueAvailable()) {
$response->error('Economic transfer queue is unavailable in this deployment', 503);
}
}
/**
* @param $collected_order_invoice
* @param users_o $users
@@ -0,0 +1,10 @@
<?php
it('skips zero-cost and zero-quantity items in legacy economic draft helper', function (): void {
$content = file_get_contents(app_path('modules/economic/helpers/economic_invoice_draft.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private function shouldSkipOrderItemLine(array $order_item): bool');
expect($content)->toContain('if ($this->shouldSkipOrderItemLine($order_item))');
expect($content)->toContain('continue;');
});
@@ -0,0 +1,53 @@
<?php
app_require('classes/economic_transfer_executor.php');
use classes\economic_transfer_executor;
it('skips order items with zero quantity for e-conomic export', function (): void {
$shouldSkip = economic_transfer_executor::shouldSkipOrderItemForInvoice([
'quantity' => 0,
'price' => 199,
], 0);
expect($shouldSkip)->toBeTrue();
});
it('skips order items with zero price for e-conomic export', function (): void {
$shouldSkip = economic_transfer_executor::shouldSkipOrderItemForInvoice([
'quantity' => 2,
'price' => 0,
], 2);
expect($shouldSkip)->toBeTrue();
});
it('keeps positive quantity and price items billable for e-conomic export', function (): void {
$shouldSkip = economic_transfer_executor::shouldSkipOrderItemForInvoice([
'quantity' => 2,
'price' => 149,
], 2);
expect($shouldSkip)->toBeFalse();
});
it('skips malformed order-item payloads for e-conomic export safety', function (): void {
expect(economic_transfer_executor::shouldSkipOrderItemForInvoice(null, 1))->toBeTrue();
expect(economic_transfer_executor::shouldSkipOrderItemForInvoice('invalid', 1))->toBeTrue();
expect(economic_transfer_executor::shouldSkipOrderItemForInvoice([
'quantity' => 1,
], 1))->toBeTrue();
expect(economic_transfer_executor::shouldSkipOrderItemForInvoice([
'quantity' => 1,
'price' => 'abc',
], 1))->toBeTrue();
});
it('wires zero-cost and zero-quantity skip guard into transfer line builder', function (): void {
$content = file_get_contents(app_path('classes/economic_transfer_executor.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('if (self::shouldSkipOrderItemForInvoice($order_item, $quantity))');
expect($content)->toContain("throw new Exception('No billable order items found');");
expect($content)->toContain("throw new Exception('Order item is missing economic product id');");
});
@@ -0,0 +1,78 @@
<?php
it('guards all economic invoice queue endpoints before constructing queue service', function (): void {
$content = file_get_contents(app_path('routes/economicInvoiceRoute.php'));
expect($content)->not->toBeFalse();
$guardCount = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content);
$queueInitCount = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content);
expect($guardCount)->toBe(6);
expect($queueInitCount)->toBe(6);
$endpointPatterns = [
"/\\\$this->post\\('\\/economic\\/invoice\\/draft\\/export'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->post\\('\\/economic\\/invoice\\/export'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->get\\('\\/economic\\/invoice\\/draft\\/export\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->post\\('\\/economic\\/invoice\\/draft\\/export\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->get\\('\\/economic\\/invoice\\/export\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->post\\('\\/economic\\/invoice\\/export\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
];
foreach ($endpointPatterns as $pattern) {
expect(preg_match($pattern, (string)$content))->toBe(1);
}
});
it('guards all collected-invoice queue endpoints before constructing queue service', function (): void {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($content)->not->toBeFalse();
$guardCount = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content);
$queueInitCount = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content);
expect($guardCount)->toBe(3);
expect($queueInitCount)->toBe(4);
$endpointPatterns = [
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
"/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s",
];
foreach ($endpointPatterns as $pattern) {
expect(preg_match($pattern, (string)$content))->toBe(1);
}
expect($content)->toContain('if (!$this->isEconomicTransferQueueAvailable()) {');
expect($content)->toContain('$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is);');
expect($content)->toContain('private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array');
expect($content)->toContain('private function isEconomicTransferQueueAvailable(): bool');
});
it('uses a consistent unavailable-service contract for missing queue dependencies', function (): void {
$invoiceRouteContent = file_get_contents(app_path('routes/economicInvoiceRoute.php'));
$orderInvoicesRouteContent = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($invoiceRouteContent)->not->toBeFalse();
expect($orderInvoicesRouteContent)->not->toBeFalse();
expect($invoiceRouteContent)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void');
expect($invoiceRouteContent)->toContain("!class_exists('\\\\classes\\\\economic_transfer_executor')");
expect($invoiceRouteContent)->toContain("!class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')");
expect($invoiceRouteContent)->toContain('!class_exists(economic_transfer_queue::class)');
expect($orderInvoicesRouteContent)->toContain('private function isEconomicTransferQueueAvailable(): bool');
expect($orderInvoicesRouteContent)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')");
expect($orderInvoicesRouteContent)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')");
expect($orderInvoicesRouteContent)->toContain('class_exists(economic_transfer_queue::class)');
expect($orderInvoicesRouteContent)->toContain('if (!$this->isEconomicTransferQueueAvailable()) {');
foreach ([$invoiceRouteContent, $orderInvoicesRouteContent] as $content) {
expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void');
expect($content)->toContain("Economic transfer queue is unavailable in this deployment', 503");
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';");
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';");
}
});
@@ -0,0 +1,26 @@
<?php
it('registers economic transfer queue cron task and handler', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';");
expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';");
expect($content)->toContain("'EconomicTransferQueueCron'");
expect($content)->toContain("'function' => 'EconomicTransferQueueCron'");
expect($content)->toContain('function EconomicTransferQueueCron(): void');
expect($content)->toContain('new economic_transfer_queue()');
expect($content)->toContain('processPending(10)');
});
it('wires queue worker class loading for CLI queue action', function (): void {
$content = file_get_contents(app_path('cli.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_executor.php';");
expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_queue_schema_bootstrap.php';");
expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_queue.php';");
expect($content)->toContain("case 'economic-transfer-queue':");
expect($content)->toContain('new \\classes\\economic_transfer_queue()');
});
@@ -0,0 +1,13 @@
<?php
it('hardens transfer queue with type validation retry caps and stale lock recovery', function (): void {
$content = file_get_contents(app_path('classes/economic_transfer_queue.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private const STALE_PROCESSING_LOCK_SECONDS = 900');
expect($content)->toContain('$this->validateTransferType($transfer_type)');
expect($content)->toContain('$max_attempts = max(1, min(10, $max_attempts));');
expect($content)->toContain('Queue job reached max retry attempts');
expect($content)->toContain('AND attempts < max_attempts');
expect($content)->toContain('private function releaseStaleProcessingLocks(): void');
});
@@ -0,0 +1,51 @@
<?php
function economic_transfer_queue_openapi_content_or_skip(): string
{
$candidates = [];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$candidates = array_values(array_unique($candidates));
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
it('documents economic transfer queue paths in openapi', function (): void {
$content = economic_transfer_queue_openapi_content_or_skip();
expect($content)->toContain('/collected-invoices/economic:');
expect($content)->toContain('/collected-invoices/economic/queue:');
expect($content)->toContain('/collected-invoices/economic/queue/status:');
expect($content)->toContain('/collected-invoices/economic/queue/retry:');
expect($content)->toContain('/economic/invoice/draft/export/status:');
expect($content)->toContain('/economic/invoice/draft/export/retry:');
expect($content)->toContain('/economic/invoice/export/status:');
expect($content)->toContain('/economic/invoice/export/retry:');
});
it('documents economic transfer queue schemas in openapi', function (): void {
$content = economic_transfer_queue_openapi_content_or_skip();
expect($content)->toContain('EconomicTransferQueueStatus:');
expect($content)->toContain('EconomicTransferQueueJob:');
expect($content)->toContain('EconomicTransferQueueEnqueueResponse:');
expect($content)->toContain('EconomicTransferQueueStatusResponse:');
expect($content)->toContain('EconomicTransferQueueRetryResponse:');
expect($content)->toContain('EconomicTransferQueueListResponse:');
});
@@ -0,0 +1,44 @@
<?php
it('registers queued economic invoice endpoints in economicInvoiceRoute', function (): void {
$content = file_get_contents(app_path('routes/economicInvoiceRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void');
expect($content)->toContain("!class_exists('\\\\classes\\\\economic_transfer_executor')");
expect($content)->toContain("!class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')");
expect($content)->toContain('$this->ensureEconomicTransferQueueIsAvailable();');
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($content)->toContain('/economic/invoice/draft/export');
expect($content)->toContain('/economic/invoice/export');
expect($content)->toContain('/economic/invoice/draft/export/status');
expect($content)->toContain('/economic/invoice/draft/export/retry');
expect($content)->toContain('/economic/invoice/export/status');
expect($content)->toContain('/economic/invoice/export/retry');
expect($content)->toContain('economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT');
expect($content)->toContain('economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT');
expect($content)->toContain("'job_id' => \$job_id");
expect($content)->toContain("missing queue job id in enqueue response");
});
it('registers collected-invoice queue endpoints in orderInvoicesRoute', function (): void {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void');
expect($content)->toContain('private function isEconomicTransferQueueAvailable(): bool');
expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')");
expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')");
expect($content)->toContain('$this->ensureEconomicTransferQueueIsAvailable();');
expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';");
expect($content)->toContain('/collected-invoices/economic');
expect($content)->toContain('/collected-invoices/economic/queue');
expect($content)->toContain('/collected-invoices/economic/queue/status');
expect($content)->toContain('/collected-invoices/economic/queue/retry');
expect($content)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT');
expect($content)->toContain("'job_id' => \$job_id");
expect($content)->toContain("missing queue job id in enqueue response");
expect($content)->toContain("requirePermission('add_collected_invoice_economic')");
expect($content)->toContain('send_as_is must be a boolean');
expect($content)->toContain("in_array(\$normalized_send_as_is, ['true', '1'], true)");
});
@@ -0,0 +1,27 @@
<?php
it('defines economic transfer queue jobs schema bootstrap table and tracking columns', function (): void {
$content = file_get_contents(app_path('classes/economic_transfer_queue_schema_bootstrap.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_jobs');
expect($content)->toContain("status VARCHAR(32) NOT NULL DEFAULT 'QUEUED'");
expect($content)->toContain('progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0');
expect($content)->toContain('error_message TEXT NULL');
expect($content)->toContain('payload_json JSON NOT NULL');
expect($content)->toContain('result_json JSON NULL');
});
it('provides queue processor class constants and processing entrypoint', function (): void {
$content = file_get_contents(app_path('classes/economic_transfer_queue.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('public const STATUS_QUEUED');
expect($content)->toContain('public const STATUS_PROCESSING');
expect($content)->toContain('public const STATUS_COMPLETED');
expect($content)->toContain('public const STATUS_FAILED');
expect($content)->toContain('public const TYPE_ORDER_DRAFT_EXPORT');
expect($content)->toContain('public const TYPE_ORDER_INVOICE_EXPORT');
expect($content)->toContain('public const TYPE_COLLECTED_INVOICE_EXPORT');
expect($content)->toContain('public function processPending(int $limit = 5): array');
});
@@ -0,0 +1,127 @@
<?php
app_require('objects/orders_o.php');
use objects\orders_o;
final class OrdersRegistrationDateRangeDbResultStub
{
public int $num_rows;
/** @var array<int, array<string, mixed>> */
private array $rows;
/**
* @param array<int, array<string, mixed>> $rows
*/
public function __construct(array $rows)
{
$this->rows = array_values($rows);
$this->num_rows = count($this->rows);
}
/**
* @return array<string, mixed>|null
*/
public function fetch_assoc(): ?array
{
if ($this->rows === []) {
return null;
}
return array_shift($this->rows);
}
}
final class OrdersRegistrationDateRangeDbStub
{
public string $lastQuery = '';
public int $queryCalls = 0;
public function escape_string(string $value): string
{
return addslashes($value);
}
public function query(string $sql): OrdersRegistrationDateRangeDbResultStub
{
$this->queryCalls++;
$this->lastQuery = $sql;
return new OrdersRegistrationDateRangeDbResultStub([]);
}
}
it('applies created_at bounds directly in SQL when filtering orders by registration number', function (): void {
$dbStub = new OrdersRegistrationDateRangeDbStub();
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $hadDb ? $GLOBALS['db'] : null;
$GLOBALS['db'] = $dbStub;
try {
$orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange(
' ec21235 ',
'2025-03-01 00:00:00',
'2025-04-30 23:59:59'
);
expect($orders)->toBe([]);
expect($dbStub->queryCalls)->toBe(1);
expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_1)) = 'EC21235'");
expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_2)) = 'EC21235'");
expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_3)) = 'EC21235'");
expect($dbStub->lastQuery)->toContain("created_at BETWEEN '2025-03-01 00:00:00' AND '2025-04-30 23:59:59'");
expect($dbStub->lastQuery)->toContain('AND deleted_at IS NULL');
expect($dbStub->lastQuery)->not->toContain('SELECT id, created_at');
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('rejects an inverted date range for registration lookups', function (): void {
$dbStub = new OrdersRegistrationDateRangeDbStub();
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $hadDb ? $GLOBALS['db'] : null;
$GLOBALS['db'] = $dbStub;
try {
(new orders_o())->getOrdersWithRegistrationNumberInDateRange(
'EC21235',
'2025-04-30 23:59:59',
'2025-03-01 00:00:00'
);
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
})->throws(\Exception::class, 'The start date cannot be after the end date');
it('returns early when registration number is blank', function (): void {
$dbStub = new OrdersRegistrationDateRangeDbStub();
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $hadDb ? $GLOBALS['db'] : null;
$GLOBALS['db'] = $dbStub;
try {
$orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange(
' ',
'2025-03-01 00:00:00',
'2025-04-30 23:59:59'
);
expect($orders)->toBe([]);
expect($dbStub->queryCalls)->toBe(0);
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
@@ -0,0 +1,11 @@
<?php
it('validates cached autoload paths before returning and clears stale cache entries', function (): void {
$content = file_get_contents(app_path('index.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('$cache_key = \'autoload:\' . $class;');
expect($content)->toContain('if (is_string($cached) && $cached !== \'\' && is_file($cached)) {');
expect($content)->toContain('if ($is_loaded($class)) {');
expect($content)->toContain('redis->delete($cache_key);');
});