GET */ $this->get('/collected-invoices', function () { global $response; self::requirePermission('list_collected_invoices'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES', 'User accessed the list of collected order invoices'); $collected_order_invoices = new collected_order_invoices_o(); // Check if the request contains an ID if (self::isParametersSet(['id'])) { self::requireType((int)self::getParameter('id'), self::type_int()); $collected_order_invoices->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); $response->success($collected_order_invoices->asArray()); } // Define the users $users = new users_o(); // Define the collected order invoices $tmp_collected_order_invoices = new collected_order_invoices_o(); // Return the list of collected order invoices $startTime = microtime(true); // Build a short-lived cache key to coalesce concurrent identical requests $cacheTtl = 15; // seconds $pageParam = (string)($response->getRequestParameter('page') ?? '1'); $limitParam = (string)($response->getRequestParameter('limit') ?? '1000'); $searchParam = (string)($response->getRequestParameter('search') ?? ''); $orderParam = (string)($response->getRequestParameter('order') ?? 'id:ASC'); $filtersParam = (string)($response->getRequestParameter('filters') ?? ''); $cacheKey = 'collected_invoices:list:' . md5(json_encode([ 'p' => $pageParam, 'l' => $limitParam, 's' => $searchParam, 'o' => $orderParam, 'f' => $filtersParam, ], JSON_UNESCAPED_UNICODE)); $redis = new \classes\redis(); $cachedPayload = $redis->get($cacheKey); if ($cachedPayload) { // Cached payload contains both meta and data $payload = json_decode($cachedPayload, true); if (isset($payload['meta']) && is_array($payload['meta'])) { foreach ($payload['meta'] as $k => $v) { $response->add_meta($k, $v); } } $durationMs = (int)round((microtime(true) - $startTime) * 1000); (new logs_o())->add( 'orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_TIMING_CACHE_HIT', 'Duration(ms): ' . $durationMs . ', page=' . ((int)$response->getRequestParameter('page')) . ', limit=' . ((int)$response->getRequestParameter('limit')) . ', search=' . (string)($response->getRequestParameter('search') ?? '') . ', order=' . (string)($response->getRequestParameter('order') ?? '') ); $response->success($payload['data'] ?? []); } // Cache miss: compute and cache $result = $collected_order_invoices->listObjectsWithPaginationIfSet( function ($collected_order_invoice) use ($tmp_collected_order_invoices, $users) { // Select the orders for each collected order invoice $tmp_collected_order_invoices->select((int)$collected_order_invoice['id']); return $this->getOrderInvoiceDetails($collected_order_invoice, $users, $tmp_collected_order_invoices); }, null, [], // Set the where, to where a non-deleted order is connected to the collected order invoice (new pagination_helper())->where->addCondition(pagination_condition_where::CUSTOM('EXISTS (SELECT 1 FROM orders o WHERE o.invoice_collection_id = collected_order_invoices.id AND o.deleted_at IS NULL)')) ); // Capture current pagination meta for caching $meta = [ 'pagination' => [ 'page' => (int)$pageParam, 'per_page' => (int)$limitParam, // We don't have the total directly here; the response object already has it, // but we cache the meta as provided by the client for consistency. ] ]; // Store combined payload $redis->setEx($cacheKey, json_encode([ 'data' => $result, 'meta' => $meta, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $cacheTtl); $durationMs = (int)round((microtime(true) - $startTime) * 1000); // Add timing log for performance monitoring (new logs_o())->add( 'orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_TIMING', 'Duration(ms): ' . $durationMs . ', page=' . ((int)$response->getRequestParameter('page')) . ', limit=' . ((int)$response->getRequestParameter('limit')) . ', search=' . (string)($response->getRequestParameter('search') ?? '') . ', order=' . (string)($response->getRequestParameter('order') ?? '') ); $response->success($result); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES', 'User tried to access the list of collected order invoices without a valid session'); $response->error('Invalid session', 400); } }, [ 'list_collected_invoices' => 'List ALL collected order invoices. This is a superuser-only route.' ] ); /** Collected order invoices > Compare with E-conomic > GET */ $this->get('/collected-invoices/economic/compare', function () { global $response; self::requirePermission('compare_collected_invoice_economic'); //$user = (new authentication())->get_user(); self::requireParameters(['collected_invoice_id']); self::requireType((int)self::getParameter('collected_invoice_id'), self::type_int()); $collected_invoice_id = (int)self::getParameter('collected_invoice_id'); self::requireMinValue($collected_invoice_id, 1); self::requireMaxValue($collected_invoice_id, 999999999); // Define variables $draft_id = null; // E-Conomic draft ID $booked_id = null; // E-Conomic booked invoice ID $warnings = []; // Select the collected order invoice /** @var collected_order_invoices_o $invoice */ $invoice = (new collected_order_invoices_o())->select($collected_invoice_id); $invoice->requireSelected(); // Require that the collected order invoice exists // Get the E-Conomic draft ID and booked invoice ID try { $draft_id = $invoice->getInvoiceDraftId(); } catch (Exception $e) { $warnings[] = 'Error fetching draft ID: ' . $e->getMessage(); } try { $booked_id = $invoice->getInvoiceBookedId(); } catch (Exception $e) { $warnings[] = 'Error fetching booked invoice ID: ' . $e->getMessage(); } // Define the variables for comparison $draft_total = null; $booked_total = null; // Get the total price of the draft (if it exists) if ($draft_id !== null) { try { $economic = new economic(); $draft_invoice = $economic->getInvoiceDraft((int)$draft_id); $draft_total = $draft_invoice['total_amount'] ?? null; $internal_total = $invoice->getTotalAmount(); if ($draft_total === null) { $warnings[] = 'Could not fetch total amount for draft invoice ID ' . $draft_id; } elseif (abs((float)$draft_total - (float)$internal_total) > 0.01) { $warnings[] = 'Total amount mismatch for draft invoice ID ' . $draft_id . ': E-Conomic total is ' . $draft_total . ', internal total is ' . $internal_total; } } catch (Exception $e) { $warnings[] = 'Error comparing draft total: ' . $e->getMessage(); } } // Get the total price of the booked invoice (if it exists) if ($booked_id !== null) { try { $economic = new economic(); $booked_invoice = $economic->getInvoiceBookedFromExternalId((string)$invoice->external_id->value()); /** * stdClass Object ( [bookedInvoiceNumber] => 28368 [orderNumber] => 30342 [date] => 2025-12-31 [currency] => DKK [exchangeRate] => 100 [netAmount] => 694 [netAmountInBaseCurrency] => 694 [grossAmount] => 867.5 [grossAmountInBaseCurrency] => 867.5 [vatAmount] => 173.5 [roundingAmount] => 0 [remainder] => 0 [remainderInBaseCurrency] => 0 [dueDate] => 2026-01-08 [paymentTerms] => stdClass Object ( [paymentTermsNumber] => 1 [daysOfCredit] => 8 [name] => Netto 8 dage [paymentTermsType] => net [self] => https://restapi.e-conomic.com/payment-terms/1 ) [customer] => stdClass Object ( [customerNumber] => 42493959 [self] => https://restapi.e-conomic.com/customers/42493959 ) [recipient] => stdClass Object ( [name] => Dejen Transport ApS [address] => Æblehaven 144, st [zip] => 4000 [city] => Roskilde [vatZone] => stdClass Object ( [name] => Domestic [vatZoneNumber] => 1 [enabledForCustomer] => 1 [enabledForSupplier] => 1 [self] => https://restapi.e-conomic.com/vat-zones/1 ) ) [references] => stdClass Object ( [other] => 2ced7a3f-fab2-edbd-075f-ce981867c340 ) [layout] => stdClass Object ( [layoutNumber] => 12 [self] => https://restapi.e-conomic.com/layouts/12 ) [pdf] => stdClass Object ( [download] => https://restapi.e-conomic.com/invoices/booked/28368/pdf ) [lines] => Array ( [0] => stdClass Object ( [lineNumber] => 1 [sortKey] => 1 [description] => [ 01/12/2025 00:00 PLENO #38679 ] [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) [1] => stdClass Object ( [lineNumber] => 2 [sortKey] => 2 [description] => Reference: [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) [2] => stdClass Object ( [lineNumber] => 3 [sortKey] => 3 [description] => # Vaskeabonnementer [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) [3] => stdClass Object ( [lineNumber] => 4 [sortKey] => 4 [description] => Trækker [quantity] => 2 [unitNetPrice] => 579 [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 25 [vatAmount] => 289.5 [totalNetAmount] => 1158 [product] => stdClass Object ( [productNumber] => 1 [self] => https://restapi.e-conomic.com/products/1 ) [departmentalDistribution] => stdClass Object ( [departmentalDistributionNumber] => 75 [name] => Vaskeaftaler og fastprisaftaler [barred] => [distributionType] => department [distributions] => Array ( [0] => stdClass Object ( [percentage] => 100 [department] => stdClass Object ( [departmentNumber] => 75 [self] => https://restapi.e-conomic.com/departments/75 ) ) ) [self] => https://restapi.e-conomic.com/departmental-distributions/departments/75 ) ) [4] => stdClass Object ( [lineNumber] => 5 [sortKey] => 5 [description] => Reference: [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) [5] => stdClass Object ( [lineNumber] => 6 [sortKey] => 6 [description] => # EH89254 [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) [6] => stdClass Object ( [lineNumber] => 7 [sortKey] => 7 [description] => Spot Free- Lastbil [quantity] => 2 [unitNetPrice] => 39 [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 25 [vatAmount] => 19.5 [totalNetAmount] => 78 [product] => stdClass Object ( [productNumber] => 33 [self] => https://restapi.e-conomic.com/products/33 ) [departmentalDistribution] => stdClass Object ( [departmentalDistributionNumber] => 75 [name] => Vaskeaftaler og fastprisaftaler [barred] => [distributionType] => department [distributions] => Array ( [0] => stdClass Object ( [percentage] => 100 [department] => stdClass Object ( [departmentNumber] => 75 [self] => https://restapi.e-conomic.com/departments/75 ) ) ) [self] => https://restapi.e-conomic.com/departmental-distributions/departments/75 ) ) [7] => stdClass Object ( [lineNumber] => 8 [sortKey] => 8 [description] => Reference: [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) [8] => stdClass Object ( [lineNumber] => 9 [sortKey] => 9 [description] => # EH89254 [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) [9] => stdClass Object ( [lineNumber] => 10 [sortKey] => 10 [description] => Rabat [quantity] => 1 [unitNetPrice] => -542 [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 25 [vatAmount] => -135.5 [totalNetAmount] => -542 [product] => stdClass Object ( [productNumber] => TotDiscount [self] => https://restapi.e-conomic.com/products/TotDiscount ) [departmentalDistribution] => stdClass Object ( [departmentalDistributionNumber] => 75 [name] => Vaskeaftaler og fastprisaftaler [barred] => [distributionType] => department [distributions] => Array ( [0] => stdClass Object ( [percentage] => 100 [department] => stdClass Object ( [departmentNumber] => 75 [self] => https://restapi.e-conomic.com/departments/75 ) ) ) [self] => https://restapi.e-conomic.com/departmental-distributions/departments/75 ) ) [10] => stdClass Object ( [lineNumber] => 11 [sortKey] => 11 [discountPercentage] => 0 [unitCostPrice] => 0 [vatRate] => 0 ) ) [sent] => https://restapi.e-conomic.com/invoices/booked/28368/sent [self] => https://restapi.e-conomic.com/invoices/booked/28368 ) */ $booked_total = $booked_invoice->total_amount ?? null; // Remove tax from booked total if prices are stored as tax inclusive $booked_total = $booked_total - ($booked_invoice->vat_amount ?? 0); $internal_total = $invoice->getTotalAmount(); if ($booked_total === null) { $warnings[] = 'Could not fetch total amount for booked invoice ID ' . $booked_id; } elseif (abs((float)$booked_total - (float)$internal_total) > 0.01) { $warnings[] = 'Total amount mismatch for booked invoice ID ' . $booked_id . ': E-Conomic total is ' . $booked_total . ', internal total is ' . $internal_total; } } catch (Exception $e) { $warnings[] = 'Error comparing booked total: ' . $e->getMessage(); } } // Determine HTTP status // 404 = Not found (in either draft or booked) // 200 = OK (Found one, and matches internal total) // 409 = Conflict (Both found but do not match) $httpStatus = match (true) { $draft_id === null && $booked_id === null => 404, ($draft_total !== null && $internal_total === (float)$draft_total) || ($booked_total !== null && $internal_total === (float)$booked_total) => 200, default => 409, }; // Return the comparison result $response->success([ 'collected_invoice_id' => $collected_invoice_id, 'draft_id' => $draft_id, 'booked_id' => $booked_id, 'warnings' => $warnings, 'draft_total' => $draft_total, 'booked_total' => $booked_total, 'difference' => ((float)($draft_total ?? $booked_total) - (float)$invoice->getTotalAmount()), 'internal_total' => $internal_total, //'order_ids' => $invoice->getOrderIds(), ], $httpStatus); }, [ 'compare_collected_invoice_economic' => 'Compare collected order invoices with E-Conomic. This is a superuser-only route.' ] ); /** Collected order invoices > E-conomic V2 details > GET */ $this->get('/collected-invoices/economic/v2/details', function () { global $response; self::requirePermission('view_collected_invoice_economic_v2_details'); $collected_invoice_id = $this->requireCollectedInvoiceId(); $payload = $this->buildEconomicV2DetailsPayload($collected_invoice_id); $response->success($payload); }, [ 'view_collected_invoice_economic_v2_details' => 'View normalized internal/draft/booked e-conomic invoice details (V2).', ] ); /** Collected order invoices > E-conomic PDF > GET */ $this->get('/collected-invoices/economic/pdf', function () { global $response; self::requirePermission('download_collected_invoice_economic_pdf'); $collected_invoice_id = $this->requireCollectedInvoiceId(); self::requireParameters(['type']); $type = strtolower((string)self::getParameter('type')); if (!in_array($type, ['draft', 'booked'], true)) { $response->error('type must be either draft or booked', 400); } $payload = $this->downloadCollectedEconomicInvoicePdf($collected_invoice_id, $type); $response->success($payload); }, [ 'download_collected_invoice_economic_pdf' => 'Download draft/booked e-conomic PDF for a collected order invoice.', ] ); /** Collected order invoices > E-conomic V2 compare > GET */ $this->get('/collected-invoices/economic/v2/compare', function () { global $response; self::requirePermission('compare_collected_invoice_economic_v2'); $collected_invoice_id = $this->requireCollectedInvoiceId(); $details = $this->buildEconomicV2DetailsPayload($collected_invoice_id); $comparison = economic_v2_compare_engine::compare( $details['internal']['normalized'], $details['draft']['exists'] ? $details['draft']['normalized'] : null, $details['booked']['exists'] ? $details['booked']['normalized'] : null ); $response->success([ 'collected_invoice_id' => $collected_invoice_id, 'details' => $details, 'comparison' => $comparison, 'warnings' => array_values(array_unique(array_merge( (array)($details['warnings'] ?? []), (array)($comparison['warnings'] ?? []) ))), ]); }, [ 'compare_collected_invoice_economic_v2' => 'Compare normalized internal invoice with draft/booked e-conomic targets (V2).', ] ); /** Collected order invoices > E-conomic V2 compare bulk > POST */ $this->post('/collected-invoices/economic/v2/compare/bulk', function () { global $response; self::requirePermission('compare_collected_invoice_economic_v2_bulk'); self::requireParameters(['collected_invoice_ids']); $collected_invoice_ids = self::getParameter('collected_invoice_ids'); if (!is_array($collected_invoice_ids)) { $response->error('collected_invoice_ids must be an array', 400); } $normalized_ids = array_values(array_unique(array_filter(array_map(static function ($id) { return (int)$id; }, $collected_invoice_ids), static function ($id) { return $id > 0; }))); if (empty($normalized_ids)) { $response->error('collected_invoice_ids must contain at least one positive integer', 400); } if (count($normalized_ids) > 200) { $response->error('Maximum 200 collected_invoice_ids per bulk compare request', 400); } $results = []; $errors = []; foreach ($normalized_ids as $collected_invoice_id) { try { $details = $this->buildEconomicV2DetailsPayload((int)$collected_invoice_id); $comparison = economic_v2_compare_engine::compare( $details['internal']['normalized'], $details['draft']['exists'] ? $details['draft']['normalized'] : null, $details['booked']['exists'] ? $details['booked']['normalized'] : null ); $results[] = [ 'collected_invoice_id' => (int)$collected_invoice_id, 'details' => $details, 'comparison' => $comparison, 'warnings' => array_values(array_unique(array_merge( (array)($details['warnings'] ?? []), (array)($comparison['warnings'] ?? []) ))), ]; } catch (Exception $e) { $errors[] = [ 'collected_invoice_id' => (int)$collected_invoice_id, 'error' => $e->getMessage(), ]; } } $response->success([ 'requested' => count($normalized_ids), 'compared' => count($results), 'failed' => count($errors), 'results' => $results, 'errors' => $errors, ]); }, [ 'compare_collected_invoice_economic_v2_bulk' => 'Compare multiple collected invoices against draft/booked e-conomic targets (V2).', ] ); /** Collected order invoices > E-conomic V2 revenue statistics > GET */ $this->get('/collected-invoices/economic/v2/revenue-statistics', function () { global $response; self::requirePermission('view_collected_invoice_economic_v2_revenue_statistics'); $dateFrom = (string)(self::fromRequest('dateFrom') ?? date('Y-m-01')); $dateTo = (string)(self::fromRequest('dateTo') ?? date('Y-m-d')); self::requireDateFormat($dateFrom, self::FORMAT_DATE()); self::requireDateFormat($dateTo, self::FORMAT_DATE()); if (strtotime($dateFrom) > strtotime($dateTo)) { $response->error('dateFrom must be before or equal to dateTo', 400); } $barred = strtolower(trim((string)(self::fromRequest('barred') ?? 'all'))); self::requireInArray($barred, ['all', 'barred', 'active']); $currency = self::fromRequest('currency'); $currency = ($currency !== null && trim($currency) !== '') ? strtoupper(trim($currency)) : null; if ($currency !== null && !preg_match('/^[A-Z]{3}$/', $currency)) { $response->error('currency must be a 3-letter ISO code (e.g. DKK)', 400); } $max_pages = (int)(self::fromRequest('max_pages') ?? 10); self::requireMinValue($max_pages, 1); self::requireMaxValue($max_pages, 200); $payload = (new economic_v2_revenue_statistics_service())->getBookedRevenueStatistics([ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'customer_numbers' => $this->parseIntegerListParameter('customer_numbers'), 'department_numbers' => $this->parseIntegerListParameter('department_numbers'), 'currency' => $currency, 'barred' => $barred, 'max_pages' => $max_pages, ]); $response->success($payload); }, [ 'view_collected_invoice_economic_v2_revenue_statistics' => 'View aggregated booked revenue statistics from e-conomic (V2), including barred-customer filtering.', ] ); /** Collected order invoices > Ready to invoice > GET */ $this->get('/collected-invoices/ready-to-invoice', function () { global $response; self::requirePermission('list_collected_invoices'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_READY_TO_INVOICE', 'User accessed the list of collected order invoices ready to invoice'); $collected_order_invoices = new collected_order_invoices_o(); // Define the users $users = new users_o(); // Define the collected order invoices $tmp_collected_order_invoices = new collected_order_invoices_o(); // Return the list of collected order invoices $response->success($collected_order_invoices->listObjectsWithPaginationIfSet( function ($collected_order_invoice) use ($tmp_collected_order_invoices, $users) { // Select the orders for each collected order invoice $tmp_collected_order_invoices->select((int)$collected_order_invoice['id']); return $this->getOrderInvoiceDetails($collected_order_invoice, $users, $tmp_collected_order_invoices); }, $collected_order_invoices->forceRestrictFilters( [ // This makes sure that the user can only see orders from the departments they explicitly have access to 'customer_number' => $collected_order_invoices->listCustomersWithIndividualOrderInvoicing(), ] ) )); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES_READY_TO_INVOICE', 'User tried to access the list of collected order invoices ready to invoice without a valid session'); $response->error('Invalid session', 400); } }, [ 'list_collected_invoices' => 'List ALL collected order invoices. This is a superuser-only route.' ] ); /** Collected order invoices > POST */ $this->post('/collected-invoices', function () { global $response; self::requirePermission('add_collected_invoice'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE', 'User added a collected order invoice'); // Require the customer number, and validate its type and length self::requireParameters(['customer_number']); self::requireType((int)self::getParameter('customer_number'), self::type_int()); self::requireMinLength('customer_number', 1); self::requireMaxLength('customer_number', 10); // Require the customer number to be above 0 self::requireMinValue((int)self::getParameter('customer_number'), 1); // Validate the customer number against the database $customer = (new users_o())->select((int)self::getParameter('customer_number')); $customer->requireSelected(); // Define the variables $name = null; $notes = null; $processor = null; $closed_at = null; // Check if the name is set if (self::isParametersSet(['name'])) { self::requireType((string)self::getParameter('name'), self::type_string()); self::requireMinLength('name', 1); self::requireMaxLength('name', 255); $name = (string)self::getParameter('name'); } // Check if the notes are set if (self::isParametersSet(['notes'])) { self::requireType((string)self::getParameter('notes'), self::type_string()); $notes = (string)self::getParameter('notes'); } // Check if the processor is set if (self::isParametersSet(['processor'])) { self::requireType((int)self::getParameter('processor'), self::type_int()); $processor = (int)self::getParameter('processor'); } // Check if the closed_at is set if (self::isParametersSet(['closed_at'])) { // Check if the closed date is set, and validate its type if (!!self::getParameter('closed_at')) { self::requireType((string)self::getParameter('closed_at'), self::type_string()); $closed_at = (string)self::getParameter('closed_at'); // Check if the closed date is valid if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $closed_at)) { $response->error('Invalid closed date format', 400); } // Set the time to 00:00:01z $closed_at = date('Y-m-d H:i:s', strtotime($closed_at . ' 00:00:01')); } } // Add the collected order invoice $collected_order_invoices = new collected_order_invoices_o(); $collected_order_invoices->add( $customer->id, $name, $notes, $processor, $closed_at ?: null, ); $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE', 'User tried to add a collected order invoice without a valid session'); $response->error('Invalid session', 400); } }, [ 'add_collected_invoice' => 'Add a collected order invoice. This is a superuser-only route.' ] ); /** Collected order invoices > Move to customer > POST */ $this->post('/collected-invoices/move-to-customer', function () { global $response; self::requirePermission('move_collected_invoice_customer'); $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'MOVE_COLLECTED_INVOICE_CUSTOMER', 'User tried to move a collected order invoice without a valid session'); $response->error('Invalid session', 400); } self::requireParameters(['id', 'customer_number']); self::requireType((int)self::getParameter('id'), self::type_int()); self::requireType((int)self::getParameter('customer_number'), self::type_int()); self::requireMinValue((int)self::getParameter('id'), 1); self::requireMinValue((int)self::getParameter('customer_number'), 1); self::requireMaxValue((int)self::getParameter('customer_number'), 999999999); $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); try { $move_result = $collected_order_invoices->moveToCustomer((int)self::getParameter('customer_number')); } catch (\Throwable $e) { $response->error($e->getMessage(), 400); } (new logs_o())->add( 'orderInvoices', 'global', 1, $user->id, 'MOVE_COLLECTED_INVOICE_CUSTOMER', 'User moved collected order invoice #' . (int)$collected_order_invoices->id . ' to customer #' . (int)self::getParameter('customer_number') ); $response->add_meta('move', $move_result); $response->success($collected_order_invoices->asArray()); }, [ 'move_collected_invoice_customer' => 'Move a collected order invoice and its orders to another customer. This is a superuser-only route.' ] ); /** Collected order invoices > Split > POST */ $this->post('/collected-invoices/split', function () { global $response; self::requirePermission('split_collected_invoice'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'SPLIT_COLLECTED_INVOICE', 'User split a collected order invoice'); // Require the ID, and validate its type and length self::requireParameters(['id']); self::requireType((int)self::getParameter('id'), self::type_int()); self::requireMinValue((int)self::getParameter('id'), 1); // Require the ID to be above 0 self::requireMinValue((int)self::getParameter('id'), 1); // Validate the ID against the database $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); // Split the collected order invoice $collected_order_invoices->split(); // Return the collected order invoice $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'SPLIT_COLLECTED_INVOICE', 'User tried to split a collected order invoice without a valid session'); $response->error('Invalid session', 400); } }, [ 'split_collected_invoice' => 'Split a collected order invoice. This is a superuser-only route.' ] ); /** Collected order invoices > Split by month > POST */ $this->post('/collected-invoices/split-by-month', function () { global $response, $db; self::requirePermission('split_collected_invoice'); $user = (new authentication())->get_user(); if ($user) { self::requireParameters(['dateFrom', 'dateTo']); try { $date_range = invoicing_period_utils::normalizeDateRange( (string)self::getParameter('dateFrom'), (string)self::getParameter('dateTo') ); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } $preview = false; if (self::isParametersSet(['preview'])) { $preview_raw = self::getParameter('preview'); if (is_bool($preview_raw)) { $preview = $preview_raw; } elseif (is_numeric($preview_raw)) { $preview = ((int)$preview_raw) === 1; } elseif (is_string($preview_raw)) { $normalized_preview = strtolower(trim($preview_raw)); if (!in_array($normalized_preview, ['true', 'false', '1', '0'], true)) { $response->error('preview must be a boolean', 400); } $preview = in_array($normalized_preview, ['true', '1'], true); } else { $response->error('preview must be a boolean', 400); } } (new logs_o())->add( 'orderInvoices', 'global', 1, $user->id, $preview ? 'PREVIEW_SPLIT_COLLECTED_INVOICE_BY_MONTH' : 'SPLIT_COLLECTED_INVOICE_BY_MONTH', $preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month' ); $invoice_collection_ids = []; if (self::isParametersSet(['invoice_collection_ids'])) { $invoice_collection_ids_raw = self::getParameter('invoice_collection_ids'); if (!is_array($invoice_collection_ids_raw)) { $response->error('invoice_collection_ids must be an array', 400); } foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) { if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) { $response->error('invoice_collection_ids must contain only positive integer ids', 400); } $invoice_collection_id = (int)$invoice_collection_id_raw; if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) { $response->error('invoice_collection_ids must contain only positive integer ids', 400); } $invoice_collection_ids[] = $invoice_collection_id; } $invoice_collection_ids = array_values(array_unique($invoice_collection_ids)); if (empty($invoice_collection_ids)) { $response->error('invoice_collection_ids must contain at least one id', 400); } } else { $date_from = $db->escape_string($date_range['dateFrom']); $date_to = $db->escape_string($date_range['dateTo']); $sql = "SELECT DISTINCT invoice_collection_id FROM orders WHERE created_at BETWEEN '$date_from' AND '$date_to' AND invoice_collection_id IS NOT NULL AND invoice_collection_id > 0 AND deleted_at IS NULL"; $query_result = $db->query($sql); while ($row = $query_result->fetch_assoc()) { $invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0); if ($invoice_collection_id > 0) { $invoice_collection_ids[] = $invoice_collection_id; } } } $items = []; $changed = []; $skipped = []; foreach ( array_values(array_unique($invoice_collection_ids)) as $invoice_collection_id ) { try { $collected_order_invoices = (new collected_order_invoices_o())->select($invoice_collection_id); $collected_order_invoices->requireSelected(); $split_result = $preview ? $collected_order_invoices->previewSplitByOrderMonth() : $collected_order_invoices->splitByOrderMonth(); $item = [ 'invoice_collection_id' => $invoice_collection_id, ...$split_result, ]; if (($split_result['status'] ?? '') === 'changed') { $changed[] = $item; } else { $skipped[] = $item; } $items[] = $item; } catch (\Throwable $e) { $item = [ 'status' => 'skipped', 'invoice_collection_id' => $invoice_collection_id, 'preview' => $preview, 'reason' => 'not_splittable', 'message' => $e->getMessage(), ]; $skipped[] = $item; $items[] = $item; } } $response->success([ 'message' => $preview ? 'Collected invoice monthly split preview completed' : 'Collected invoice monthly split completed', 'preview' => $preview, 'dateFrom' => $date_range['dateFrom'], 'dateTo' => $date_range['dateTo'], 'processed_count' => count($items), 'changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped, 'items' => $items, ]); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'SPLIT_COLLECTED_INVOICE_BY_MONTH', 'User tried to split collected order invoices by month without a valid session'); $response->error('Invalid session', 400); } }, [ 'split_collected_invoice' => 'Split collected order invoices by order month. This is a superuser-only route.' ] ); /** Collected order invoices > Bulk action preview > POST */ $this->post('/collected-invoices/bulk-actions/preview', function () { global $response; $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'PREVIEW_COLLECTED_INVOICE_BULK_ACTION', 'User tried to preview a collected invoice bulk action without a valid session'); $response->error('Invalid session', 400); } self::requireParameters(['action', 'invoice_collection_ids']); $action = (string)self::getParameter('action'); $this->requireCollectedInvoiceBulkActionPermission($action); $invoice_collection_ids = self::getParameter('invoice_collection_ids'); if (!is_array($invoice_collection_ids)) { $response->error('invoice_collection_ids must be an array', 400); } $options = self::isParametersSet(['options']) ? self::getParameter('options') : []; if (!is_array($options)) { $response->error('options must be an object', 400); } $locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da'; $customer_number = self::isParametersSet(['customer_number']) ? (int)self::getParameter('customer_number') : null; $snapshot_revision = self::isParametersSet(['snapshot_revision']) ? (string)self::getParameter('snapshot_revision') : null; $require_snapshot = $snapshot_revision !== null || $customer_number !== null; try { $preview = (new invoice_collection_bulk_action_service())->preview( $action, $invoice_collection_ids, $options, $locale, (int)$user->id, $customer_number, $snapshot_revision, $require_snapshot ); } catch (invoice_collection_bulk_action_conflict $e) { $response->error($e->getMessage(), 409); } catch (\Throwable $e) { $response->error($e->getMessage(), 400); } (new logs_o())->add( 'orderInvoices', 'global', 1, $user->id, 'PREVIEW_COLLECTED_INVOICE_BULK_ACTION', 'User previewed collected invoice bulk action ' . $action . ' for ' . count($invoice_collection_ids) . ' invoice collections' ); $response->success($preview); }, [ 'reset_collected_invoice_economic' => 'Preview collected invoice bulk cleanup and price reset actions. This is a superuser-only route.', 'move_collected_invoice' => 'Preview merging selected collected invoices. This is a superuser-only route.', 'split_collected_invoice' => 'Preview splitting selected collected invoices by order month. This is a superuser-only route.', 'add_collected_invoice_economic' => 'Preview queueing selected collected invoices for E-Conomic. This is a superuser-only route.', ] ); /** Collected order invoices > Bulk action apply > POST */ $this->post('/collected-invoices/bulk-actions/apply', function () { global $response; $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'APPLY_COLLECTED_INVOICE_BULK_ACTION', 'User tried to apply a collected invoice bulk action without a valid session'); $response->error('Invalid session', 400); } self::requireParameters(['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text']); $action = (string)self::getParameter('action'); $this->requireCollectedInvoiceBulkActionPermission($action); $invoice_collection_ids = self::getParameter('invoice_collection_ids'); if (!is_array($invoice_collection_ids)) { $response->error('invoice_collection_ids must be an array', 400); } $options = self::isParametersSet(['options']) ? self::getParameter('options') : []; if (!is_array($options)) { $response->error('options must be an object', 400); } $locale = self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da'; $customer_number = self::isParametersSet(['customer_number']) ? (int)self::getParameter('customer_number') : null; $snapshot_revision = self::isParametersSet(['snapshot_revision']) ? (string)self::getParameter('snapshot_revision') : null; try { $result = (new invoice_collection_bulk_action_service())->apply( (string)self::getParameter('preview_id'), $action, $invoice_collection_ids, $options, (string)self::getParameter('confirmation_text'), (int)$user->id, $locale, $customer_number, $snapshot_revision ); } catch (invoice_collection_bulk_action_conflict $e) { $response->error($e->getMessage(), 409); } catch (invoice_collection_bulk_action_validation $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } $response->success($result); }, [ 'reset_collected_invoice_economic' => 'Apply collected invoice bulk cleanup and price reset actions after confirmation. This is a superuser-only route.', 'move_collected_invoice' => 'Apply merging selected collected invoices after confirmation. This is a superuser-only route.', 'split_collected_invoice' => 'Apply splitting selected collected invoices by order month after confirmation. This is a superuser-only route.', 'add_collected_invoice_economic' => 'Apply queueing selected collected invoices for E-Conomic after confirmation. This is a superuser-only route.', ] ); $this->post('/superuser/invoicing/period/tree-actions/preview', function () { global $response; self::requirePermission('superuser_invoicing_period'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } if (!InvoicingPeriodRoute::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)) { $response->error('Invoice-period object tree is not enabled.', 403); } self::requireParameters([ 'action', 'invoice_collection_ids', 'customer_number', 'snapshot_revision', ]); $action = (string)self::getParameter('action'); $this->requireCollectedInvoiceBulkActionPermission($action); $invoiceCollectionIds = self::getParameter('invoice_collection_ids'); if (!is_array($invoiceCollectionIds)) { $response->error('invoice_collection_ids must be an array', 400); } $options = self::isParametersSet(['options']) ? self::getParameter('options') : []; if (!is_array($options)) { $response->error('options must be an object', 400); } try { $preview = (new invoice_collection_bulk_action_service())->preview( $action, $invoiceCollectionIds, $options, self::isParametersSet(['locale']) ? (string)self::getParameter('locale') : 'da', (int)$user->id, (int)self::getParameter('customer_number'), (string)self::getParameter('snapshot_revision'), true ); $response->success($preview); } catch (invoice_collection_bulk_action_conflict $e) { $response->error($e->getMessage(), 409); } catch (invoice_collection_bulk_action_validation $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'superuser_invoicing_period' => 'Preview a selected-customer invoice-period tree action.', 'reset_collected_invoice_economic' => 'Preview invoice collection cleanup and price reset actions.', 'move_collected_invoice' => 'Preview invoice collection merge actions.', 'split_collected_invoice' => 'Preview invoice collection monthly split actions.', 'add_collected_invoice_economic' => 'Preview invoice collection e-conomic queue actions.', ] ); $this->post('/superuser/invoicing/period/tree-actions/apply', function () { global $response; self::requirePermission('superuser_invoicing_period'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } if (!InvoicingPeriodRoute::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)) { $response->error('Invoice-period object tree is not enabled.', 403); } self::requireParameters(['preview_id', 'confirmation_text']); $service = new invoice_collection_bulk_action_service(); try { $this->requireCollectedInvoiceBulkActionPermission( $service->cachedPreviewAction((string)self::getParameter('preview_id')) ); $result = $service->applyCachedPreview( (string)self::getParameter('preview_id'), (string)self::getParameter('confirmation_text'), (int)$user->id ); $response->success($result); } catch (invoice_collection_bulk_action_conflict $e) { $response->error($e->getMessage(), 409); } catch (invoice_collection_bulk_action_validation $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'superuser_invoicing_period' => 'Apply a cached selected-customer invoice-period tree action.', 'reset_collected_invoice_economic' => 'Apply invoice collection cleanup and price reset actions.', 'move_collected_invoice' => 'Apply invoice collection merge actions.', 'split_collected_invoice' => 'Apply invoice collection monthly split actions.', 'add_collected_invoice_economic' => 'Apply invoice collection e-conomic queue actions.', ] ); /** 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) { self::requireParameters(['id']); self::requireType((int)self::getParameter('id'), self::type_int()); self::requireMinLength('id', 1); self::requireMaxLength('id', 10); self::requireMinValue((int)self::getParameter('id'), 1); $send_as_is = false; if (self::isParametersSet(['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); } } $collection_export_lock = order_payment_lock::tryAcquireInvoiceCollection((int)self::getParameter('id')); if ($collection_export_lock === null) { $response->error('Invoice collection is currently being changed or paid. Try again.', 409); } $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); try { invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices); } catch (\Throwable $e) { $response->error($e->getMessage(), 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, (int)$user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC_FALLBACK', 'Processed collected invoice transfer synchronously because queue dependencies are unavailable' ); $response->success([ 'message' => 'Collected invoice export processed synchronously', 'mode' => 'synchronous_fallback', 'result' => $result, ]); } $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); } (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); } }, [ 'add_collected_invoice_economic' => 'Add a collected order invoice to E-Conomic. This is a superuser-only route.' ] ); $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 = $this->parseCollectedInvoiceQueueStatuses(); ['limit' => $limit, 'offset' => $offset] = $this->parseCollectedInvoiceQueuePagination(); $queue = new economic_transfer_queue(); $jobs = $queue->listJobsForCreatedBy( $statuses, $limit, $offset, economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, (int)$user->id ); $total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses, (int)$user->id); $has_more = ($offset + count($jobs)) < $total_jobs; $response->success([ 'items' => $this->withCollectedInvoiceQueueDetailsSummaryList($jobs), 'count' => count($jobs), 'total' => $total_jobs, 'limit' => $limit, 'offset' => $offset, 'has_more' => $has_more, ]); }, [ '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(); $job_id = $this->requireCollectedInvoiceQueueJobId(); $job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id); $response->success($this->withCollectedInvoiceQueueDetailsSummary($job)); }, [ 'add_collected_invoice_economic' => 'Get queued collected invoice transfer job status.' ] ); $this->get('/collected-invoices/economic/queue/monitor', function () { global $response; self::requirePermission('add_collected_invoice_economic'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } $this->ensureEconomicTransferQueueIsAvailable(); $limit = $this->parseCollectedInvoiceQueueMonitorLimit(); $queue = new economic_transfer_queue(); $response->success($this->buildCollectedInvoiceQueueMonitorPayload( $queue, (int)$user->id, $limit )); }, [ 'add_collected_invoice_economic' => 'Monitor visible collected invoice transfer queue jobs.' ] ); $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(); $job_id = $this->requireCollectedInvoiceQueueJobId(); $job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id, true); if ((int)($job['attempts'] ?? 0) >= (int)($job['max_attempts'] ?? 1)) { $response->error('Collected invoice queue job reached max retry attempts', 409); } try { $queue = new economic_transfer_queue(); $retried = $queue->retryJobForUser($job_id, (int)$user->id); } catch (\Throwable $e) { $message = trim((string)$e->getMessage()); $status_code = $this->resolveCollectedInvoiceQueueRetryErrorStatus($message); $response->error('Failed to retry collected invoice queue job: ' . $message, $status_code); } $response->success([ 'message' => 'Collected invoice queue job retried', 'job' => $this->withCollectedInvoiceQueueDetailsSummary($retried), ]); }, [ 'add_collected_invoice_economic' => 'Retry failed queued collected invoice transfer job.' ] ); $this->post('/collected-invoices/economic/queue/dismiss', function () { global $response; self::requirePermission('add_collected_invoice_economic'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } $this->ensureEconomicTransferQueueIsAvailable(); $job_id = $this->requireCollectedInvoiceQueueJobId(); $job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id); $status = strtoupper((string)($job['status'] ?? '')); if (!in_array($status, [ economic_transfer_queue::STATUS_COMPLETED, economic_transfer_queue::STATUS_FAILED, ], true)) { $response->error('Only completed or failed collected invoice queue jobs can be cleared', 409); } try { $queue = new economic_transfer_queue(); $dismissed = $queue->dismissTerminalJobForUser($job_id, (int)$user->id); } catch (\Throwable $e) { $response->error('Failed to clear collected invoice queue job: ' . $e->getMessage(), 400); } $response->success([ 'message' => 'Collected invoice queue job cleared', 'job' => $this->withCollectedInvoiceQueueDetailsSummary($dismissed), ]); }, [ 'add_collected_invoice_economic' => 'Clear one completed or failed queued collected invoice transfer job for the current user.' ] ); $this->post('/collected-invoices/economic/queue/dismiss-terminal', function () { global $response; self::requirePermission('add_collected_invoice_economic'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } $this->ensureEconomicTransferQueueIsAvailable(); $queue = new economic_transfer_queue(); $dismissed_count = $queue->dismissTerminalJobsForUser( (int)$user->id, economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT ); $response->success([ 'message' => 'Completed and failed collected invoice queue jobs cleared', 'dismissed_count' => $dismissed_count, ]); }, [ 'add_collected_invoice_economic' => 'Clear all visible completed or failed queued collected invoice transfer jobs for the current user.' ] ); $this->post('/collected-invoices/economic/queue/run', function () { global $response; self::requirePermission('add_collected_invoice_economic'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } $this->ensureEconomicTransferQueueIsAvailable(); $limit = 10; if (self::isParametersSet(['limit'])) { $limit_raw = self::getParameter('limit'); if (!is_numeric($limit_raw)) { $response->error('limit must be a positive integer', 400); } $limit = (int)$limit_raw; } $limit = max(1, min(10, $limit)); $queue = new economic_transfer_queue(); $batch = $this->runCollectedInvoiceQueueBatch($queue, $limit); $result = (array)($batch['result'] ?? []); $response->success([ 'message' => (bool)($batch['fallback'] ?? false) ? 'Collected invoice queue batch processed using compatibility fallback' : 'Collected invoice queue batch processed', 'processed' => (int)($result['processed'] ?? 0), 'completed' => (int)($result['completed'] ?? 0), 'failed' => (int)($result['failed'] ?? 0), 'jobs' => array_values(array_map('intval', (array)($result['jobs'] ?? []))), 'limit' => $limit, 'transfer_type' => economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, 'fallback' => (bool)($batch['fallback'] ?? false), ]); }, [ 'add_collected_invoice_economic' => 'Run one queued collected invoice transfer batch immediately.' ] ); /** 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->assignToInvoiceCollection( (int)$new_invoice_collection->id, true, (int)$new_invoice_collection->customer_number->value() ); $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->assignToInvoiceCollection( (int)$new_invoice_collection->id, true, (int)$new_invoice_collection->customer_number->value() ); $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; self::requirePermission('unlink_collected_invoice_economic'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'UNLINK_COLLECTED_INVOICE_ECONOMIC', 'User unlinked a collected order invoice from 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); // Validate the ID against the database $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); // Unlink the collected order invoice from E-Conomic $collected_order_invoices->unlinkFromEconomic(); // Return the collected order invoice $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'UNLINK_COLLECTED_INVOICE_ECONOMIC', 'User tried to unlink a collected order invoice from E-Conomic without a valid session'); $response->error('Invalid session', 400); } }, [ 'unlink_collected_invoice_economic' => 'Unlink a collected order invoice from E-Conomic. This is a superuser-only route. This will NOT delete the invoice in E-Conomic, but will clear the cached data in the system.' ] ); /** Collected order invoices > E-Conomic > Remove special arrangements, and set all items to be included in the invoice > POST */ $this->post('/collected-invoices/remove-special-arrangements', function () { global $response; self::requirePermission('reset_collected_invoice_economic'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'RESET_COLLECTED_INVOICE_ECONOMIC', 'User reset a collected order invoice in 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); // Validate the ID against the database $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); // Remove special arrangements, and set all items to be included in the invoice $collected_order_invoices->removeSpecialArrangements(); // Remove any left-over special arrangement transactions (subscriptions / fixed price) //$collected_order_invoices->resetPricesOfItemsNotIncludedInInvoice(); // Reset the price of all items set not to be included in the invoice $collected_order_invoices->setAllItemsToBeIncludedInInvoice(); // Set all items to be included in the invoice $collected_order_invoices->objectChanged(); // Return the collected order invoice $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'RESET_COLLECTED_INVOICE_ECONOMIC', 'User tried to reset a collected order invoice in E-Conomic without a valid session'); $response->error('Invalid session', 400); } }, [ 'reset_collected_invoice_economic' => 'Reset a collected order invoice in E-Conomic. This is a superuser-only route. This will NOT delete the invoice in E-Conomic, but will remove any special arrangement transactions (subscriptions / fixed price), and set all items to be included in the invoice.' ] ); /** Collected order invoices > E-Conomic > Reset prices of items not included in the invoice > POST */ $this->post('/collected-invoices/reset-prices-of-items-not-included-in-invoice', function () { global $response; self::requirePermission('reset_collected_invoice_economic'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'RESET_COLLECTED_INVOICE_ECONOMIC', 'User reset a collected order invoice in 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); // Validate the ID against the database $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); // Reset prices of items not included in the invoice $collected_order_invoices->resetPricesOfItemsNotIncludedInInvoice(); // Reset the price of all items set not to be included in the invoice $collected_order_invoices->objectChanged(); // Return the collected order invoice $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'RESET_COLLECTED_INVOICE_ECONOMIC', 'User tried to reset a collected order invoice in E-Conomic without a valid session'); $response->error('Invalid session', 400); } }, [ 'reset_collected_invoice_economic' => 'Reset a collected order invoice in E-Conomic. This is a superuser-only route. This will NOT delete the invoice in E-Conomic, but will reset the prices of all items set not to be included in the invoice.' ] ); /** Collected order invoices > Stripe > BOOK > POST */ $this->post('/collected-invoices/stripe/book', function () { global $response; self::requirePermission('add_collected_invoice_stripe'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_STRIPE', 'User added a collected order invoice to Stripe'); // 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); // Validate the ID against the database $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); try { $this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); } catch (\Throwable $e) { $response->error($e->getMessage(), 400); } // Require the external ID to be set if ($collected_order_invoices->external_id->value() === null) { $response->error('Transaction has not been created in Stripe', 400); } // Require the booked invoice ID to be not set if ($collected_order_invoices->booked_invoice_id->value() !== null) { $response->error('Invoice has already been booked', 400); } if (!$this->isEconomicTransferQueueAvailable()) { try { $result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, false); } catch (\Throwable $e) { $response->error($e->getMessage(), 400); } (new logs_o())->add( 'orderInvoices', 'global', 1, (int)$user->id, 'ADD_COLLECTED_INVOICE_STRIPE_FALLBACK', 'Processed Stripe collected invoice export synchronously because queue dependencies are unavailable' ); $response->success([ 'message' => 'Stripe collected invoice export processed synchronously', 'mode' => 'synchronous_fallback', 'result' => $result, ]); } $queue = new economic_transfer_queue(); $job = $queue->enqueue( economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, [ 'collected_invoice_id' => (int)self::getParameter('id'), 'requested_by' => (int)$user->id, ], (int)$user->id ); $job_id = (int)($job['id'] ?? 0); if ($job_id < 1) { $response->error('Failed to enqueue stripe collected invoice export job: missing queue job id in enqueue response', 500); } (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_STRIPE_QUEUED', 'Queued Stripe collected invoice export to E-Conomic'); $response->success([ 'message' => 'Stripe collected invoice export queued', 'job_id' => $job_id, 'job' => $job, ], 202); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_STRIPE', 'User tried to add a collected order invoice to Stripe without a valid session'); $response->error('Invalid session', 400); } }, [ 'add_collected_invoice_stripe' => 'Add a collected order invoice to Stripe. This is a superuser-only route.' ] ); /** Collected order invoices > Vehicle subscriptions > POST */ $this->post('/collected-invoices/vehicle-subscriptions', function () { global $response; self::requirePermission('add_collected_invoice_vehicle_subscriptions'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_VEHICLE_SUBSCRIPTIONS', 'User added a collected order invoice for vehicle subscriptions'); // 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); // Validate the ID against the database $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); // Add the collected order invoice to E-Conomic $collected_order_invoices->addVehicleSubscriptionsTransaction(); // Return the collected order invoice $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_VEHICLE_SUBSCRIPTIONS', 'User tried to add a collected order invoice for vehicle subscriptions without a valid session'); $response->error('Invalid session', 400); } }, [ 'add_collected_invoice_vehicle_subscriptions' => 'Add a collected order invoice for vehicle subscriptions. This is a superuser-only route.' ] ); /** Collected order invoices > Vehicle subscriptions > POST */ $this->post('/collected-invoices/fixed-price', function () { global $response; self::requirePermission('add_collected_invoice_fixed_price'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_FIXED_PRICE', 'User added a collected order invoice fixed price modifications'); // 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); // 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 customer has a fixed price $customer = new users_o(); $customer->getUserByCustomerNumber((int)$collected_order_invoices->customer_number->value()); $customer_fixed_pricing_o = new customer_fixed_pricing_o(); if (!$customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$customer->customer_number->value())) { $response->error('Customer does not have fixed pricing', 400); } $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$customer->customer_number->value()); $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); // Add the collected order invoice to E-Conomic $collected_order_invoices->overridePricesFixed((int)$customer_fixed_pricing_price); // Return the collected order invoice $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_FIXED_PRICE', 'User tried to add a collected order invoice fixed price modifications without a valid session'); $response->error('Invalid session', 400); } }, [ 'add_collected_invoice_fixed_price' => 'Add a collected order invoice fixed price modifications. This is a superuser-only route.' ] ); /** Collected order invoices > Vehicle subscriptions > POST */ $this->post('/collected-invoices/vehicle-subscriptions/custom', function () { global $response; self::requirePermission('add_collected_invoice_vehicle_subscriptions'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_VEHICLE_SUBSCRIPTIONS', 'User added a collected order invoice for vehicle subscriptions'); // Require the ID, and validate its type and length self::requireParameters(['customer_number', 'month', 'year']); self::requireType((int)self::getParameter('customer_number'), self::type_int()); self::requireMinLength('customer_number', 1); self::requireMaxLength('customer_number', 10); self::requireMinValue((int)self::getParameter('customer_number'), 1); $customer = (new users_o())->getUserByCustomerNumber((int)self::getParameter('customer_number')); $customer->requireSelected(); // Require the month to be between 1 and 12 self::requireType((int)self::getParameter('month'), self::type_int()); self::requireMinLength('month', 1); self::requireMaxLength('month', 2); self::requireMinValue((int)self::getParameter('month'), 1); self::requireMaxValue((int)self::getParameter('month'), 12); // Require the year to be in the past 2 years self::requireType((int)self::getParameter('year'), self::type_int()); self::requireMinLength('year', 1); self::requireMaxLength('year', 4); self::requireMinValue((int)self::getParameter('year'), date('Y') - 2); self::requireMaxValue((int)self::getParameter('year'), date('Y')); // Create the collected order invoice $collected_order_invoices = new collected_order_invoices_o(); $collected_order_invoices->add( (int)self::getParameter('customer_number'), ); // Require the collected order invoice to be selected $collected_order_invoices->requireSelected(); // Get the month and year from the request $month = (int)self::getParameter('month'); $year = (int)self::getParameter('year'); // Add a leading zero to the month if it's less than 10 $month_with_prefix_if_applicable = str_pad($month, 2, '0', STR_PAD_LEFT); // Add a leading zero to the year if it's less than 4 digits $year_with_prefix_if_applicable = str_pad($year, 4, '0', STR_PAD_LEFT); // Generate the timestamp for the selected month and year, with the first day, and first second $timestamp_selected = mktime(0, 0, 1, $month_with_prefix_if_applicable, 1, $year_with_prefix_if_applicable); // MySQL requires the date to be in the format YYYY-MM-DD HH:MM:SS $timestamp_selected = date('Y-m-d H:i:s', $timestamp_selected); // Set the date to the first second of the date specified $collected_order_invoices->created_at->set($timestamp_selected); // Add the collected order invoice to E-Conomic $collected_order_invoices->addVehicleSubscriptionsTransaction(); // This // Close the collected order invoice $collected_order_invoices->closed_at->set($timestamp_selected); // Update the collected order invoice $collected_order_invoices->objectChanged(); // Return the collected order invoice $response->success($collected_order_invoices->asArray()); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_VEHICLE_SUBSCRIPTIONS', 'User tried to add a collected order invoice for vehicle subscriptions without a valid session'); $response->error('Invalid session', 400); } }, [ 'add_collected_invoice_vehicle_subscriptions' => 'Add a collected order invoice for vehicle subscriptions. This is a superuser-only route.' ] ); /** Collected order invoices > Open > GET Customers */ $this->get('/collected-invoices/customers', function () { global $response; self::requirePermission('list_collected_invoices'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_CUSTOMERS', 'User accessed the list of collected order invoices customers'); // Define the users $users_o = new users_o(); $tmp_customer_numbers = $users_o->getCustomerNumbersWithAttributes([ 'invoiceAllOrdersIndividually' ]); // Filter out the customers that do not have any open invoices $customer_numbers = []; //print_r($tmp_customer_numbers); foreach ( $tmp_customer_numbers as $customer_number ) { // This is the customers open invoices, this might contain invoices that's empty, or only contains deleted orders. $tmp_customer_open_invoice_ids = []; // This is the net amount of the total transactions for the customer $tmp_total_net_amount = 0; // This is the number of invoices, that's not empty. $tmp_customer_active_invoice_ids = []; $collected_order_invoices = new collected_order_invoices_o(); $customer_open_invoices = $collected_order_invoices->listObjectsWithPaginationIfSet( function ($collected_order_invoice) use ($tmp_total_net_amount, $users_o, $collected_order_invoices) { // Select the orders for each collected order invoice $collected_order_invoices->select((int)$collected_order_invoice['id']); // Return the details return $this->getOrderInvoiceDetails($collected_order_invoice, $users_o, $collected_order_invoices); }, $collected_order_invoices->forceRestrictFilters([ 'customer_number' => $customer_number, 'closed_at' => 'null', ]) ); //print_r($customer_open_invoices); // Check if the customer has any open invoices if (count($customer_open_invoices) > 0) { $tmp_customer_orders = 0; // Get the total net amount of the open invoices foreach ( $customer_open_invoices as $customer_open_invoice ) { // Check if the invoice is empty if (count($customer_open_invoice['orders']) === 0) { continue; } // Add the invoice collected order invoice ID to the list (if it's not already in the list) if (!in_array($customer_open_invoice['id'], $tmp_customer_open_invoice_ids)) { $tmp_customer_active_invoice_ids[] = $customer_open_invoice['id']; } // Update the total net amount, and the number of orders $tmp_customer_open_invoice_ids[] = $customer_open_invoice['id']; $tmp_total_net_amount += $customer_open_invoice['total_net_amount']; $tmp_customer_orders += count($customer_open_invoice['orders']); } // If the open invoices are not empty, add the customer to the list if (empty($tmp_customer_orders)) { continue; } $tmp_user = (new users_o())->getUserByCustomerNumber($customer_number); $customer_numbers[] = array( 'customer_number' => $customer_number, 'customer_name' => $users_o->getCustomerName($customer_number), 'user_id' => $tmp_user->id, 'open_invoices' => $customer_open_invoices, 'active_invoices' => $tmp_customer_active_invoice_ids, 'orders' => $tmp_customer_orders, 'total_net_amount' => $tmp_total_net_amount, ); } } // Return the list of customers $response->success($customer_numbers); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES_CUSTOMERS', 'User tried to access the list of collected order invoices customers without a valid session'); $response->error('Invalid session', 400); } }, [ 'list_collected_invoices_customers' => 'List ALL collected order invoices customers. This is a superuser-only route.' ] ); $this->get('/collected-invoices/customers/invoicePerOrder', function () { global $response; self::requirePermission('list_collected_invoices'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_CUSTOMERS_INVOICE_PER_ORDER', 'User accessed the list of collected order invoices customers with invoice per order'); // Define the users $users_o = new users_o(); $users_o->setView('customers_invoice_count_per_order'); $result = $users_o ->setSearchableFields([ 'customer_number', 'customer_name', 'active_invoices', 'closed_invoices', ]) ->listObjectsWithPaginationIfSet( function ($collected_order_invoice) use ($users_o) { return [ 'customer_number' => (int)$collected_order_invoice['customer_number'], 'display_name' => (string)$users_o->getCustomerName((int)$collected_order_invoice['customer_number']), 'active_invoices' => (int)$collected_order_invoice['active_invoices'], 'closed_invoices' => (int)$collected_order_invoice['closed_invoices'], ]; }, $users_o->forceRestrictFilters( [ //'active_invoices' => 'NOT ZERO', ] ) ); // Return the list of customers $response->success($result); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES_CUSTOMERS_INVOICE_PER_ORDER', 'User tried to access the list of collected order invoices customers with invoice per order without a valid session'); $response->error('Invalid session', 400); } }, [ 'list_collected_invoices_customers_invoice_per_order' => 'List ALL collected order invoices customers with invoice per order. This is a superuser-only route.' ] ); $this->get('/collected-invoices/customers/invoicePerMonth', function () { global $response; self::requirePermission('list_collected_invoices'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_CUSTOMERS_INVOICE_PER_MONTH', 'User accessed the list of collected order invoices customers with invoice per month'); // Define the users $users_o = new users_o(); $users_o->setView('customers_invoice_count_per_month'); $result = $users_o ->setSearchableFields([ 'customer_number', 'customer_name', 'active_invoices', 'closed_invoices', ]) ->listObjectsWithPaginationIfSet( function ($collected_order_invoice) use ($users_o) { return [ 'customer_number' => (int)$collected_order_invoice['customer_number'], 'display_name' => (string)$users_o->getCustomerName((int)$collected_order_invoice['customer_number']), 'active_invoices' => (int)$collected_order_invoice['active_invoices'], 'closed_invoices' => (int)$collected_order_invoice['closed_invoices'], ]; }, $users_o->forceRestrictFilters( [ //'active_invoices' => 'NOT ZERO', ] ) ); // Return the list of customers $response->success($result); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES_CUSTOMERS_INVOICE_PER_MONTH', 'User tried to access the list of collected order invoices customers with invoice per month without a valid session'); $response->error('Invalid session', 400); } }, [ 'list_collected_invoices_customers_invoice_per_month' => 'List ALL collected order invoices customers with invoice per month. This is a superuser-only route.' ] ); $this->post('/collected-invoices/customers/invoiceTotals', function () { // This is a superuser-only route global $response; self::requirePermission('list_collected_invoices'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_CUSTOMERS_INVOICE_TOTALS', 'User accessed the list of collected order invoices customers with invoice totals'); // Require the customer_numbers parameter self::requireParameters(['customer_numbers']); // Print the customer_numbers parameter $customer_numbers = self::getParameter('customer_numbers'); // validate the customer_numbers parameter if (!is_array($customer_numbers)) { $response->error('customer_numbers must be an array', 400); } // Validate the customer_numbers parameter foreach ( $customer_numbers as $customer_number ) { self::requireType((int)$customer_number, self::type_int()); if (!is_numeric($customer_number)) { $response->error('customer_numbers must be an array of integers', 400); } if (!$customer_number || $customer_number < 1) { $response->error('customer_numbers must be an array of integers greater than 0', 400); } } // Now we can safely use the customer_numbers parameter // Define the result array $result = []; // Loop through the customer_numbers array, and get the invoice totals for each customer foreach ( $customer_numbers as $customer_number ) { // Define the invoices array $tmp_invoices = [ 'closed_invoices' => [], 'open_invoices' => [], ]; // Define the total net amount $tmp_total_net_amount = [ 'closed_invoices' => 0, 'open_invoices' => 0, ]; // Define the collected order invoices $collected_order_invoices = new collected_order_invoices_o(); // Get the collected order invoices for the customer $collected_order_invoices->setView('invoices_with_completed_orders'); $tmp_invoice_collections = $collected_order_invoices->listObjectsWithPaginationIfSet( function ($collected_order_invoice) use ($tmp_invoices, $customer_number, $collected_order_invoices) { // Select the orders for each collected order invoice $collected_order_invoices->select((int)$collected_order_invoice['id']); // Get the details for the collected order invoice return $this->getOrderInvoiceDetails($collected_order_invoice, new users_o(), $collected_order_invoices); }, $collected_order_invoices->forceRestrictFilters([ 'customer_number' => $customer_number, ]) ); // Loop through the collected order invoices, and get the total net amount foreach ( $tmp_invoice_collections as $tmp_invoice_collection ) { // Update the total net amount if ($tmp_invoice_collection['closed_at'] !== null) { $tmp_total_net_amount['closed_invoices'] += $tmp_invoice_collection['total_net_amount']; // Add the closed invoice to the closed invoices array $tmp_invoices['closed_invoices'][] = $tmp_invoice_collection; } else { $tmp_total_net_amount['open_invoices'] += $tmp_invoice_collection['total_net_amount']; // Add the open invoice to the open invoices array $tmp_invoices['open_invoices'][] = $tmp_invoice_collection; } } $tmp_user = (new users_o())->getUserByCustomerNumber($customer_number); // Add the customer to the result array $result[$customer_number] = [ 'customer_number' => (int)$customer_number, 'customer_name' => (string)$tmp_user->display_name->value(), 'user_id' => (int)$tmp_user->id, 'invoices' => $tmp_invoices, 'total_net_amount' => $tmp_total_net_amount, ]; } // Return the list of customers $response->success($result); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES_CUSTOMERS_INVOICE_TOTALS', 'User tried to access the list of collected order invoices customers with invoice totals without a valid session'); $response->error('Invalid session', 400); } }); $this->get('/collected-invoices/economic/overview', function () { global $response; self::requirePermission('list_collected_invoices_economic_overview'); $user = (new authentication())->get_user(); if ($user) { // Get the economic module $economic = new economic(); // Get the collected order invoices $collected_order_invoices = new collected_order_invoices_o(); /** * Get the total invoices using E-conomic as the processor, with the given restrictions * @param $collected_order_invoices collected_order_invoices_o * @param $restrictions array * @param $view string The MySQL view to use for the query * @param $page int The page number to return * @param $limit int The number of results to return per page * @return int */ function getTotalInvoices(collected_order_invoices_o $collected_order_invoices, array $restrictions = [], string $view = 'invoices_with_completed_orders', int $page = 1, int $limit = 100000): int { // Add the economic processor to the restrictions $restrictions['processor'] = 1; // E-conomic return count( $collected_order_invoices->getTotalInvoices( $collected_order_invoices, $restrictions, $view, $page, $limit, 1 ) ); } (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'LIST_COLLECTED_INVOICES_ECONOMIC_OVERVIEW', 'User accessed the list of collected order invoices economic overview'); // Define the result array $result = [ 'total' => getTotalInvoices($collected_order_invoices), 'paid' => 0, 'unpaid' => 0, 'overdue' => 0, 'booked' => getTotalInvoices( $collected_order_invoices, [ 'booked_invoice_id' => 'NOT NULL', 'external_id' => 'NOT NULL', ], 'invoices_with_completed_orders' ), 'draft' => getTotalInvoices( $collected_order_invoices, [ 'booked_invoice_id' => null, 'error_message' => null, 'external_id' => 'NOT NULL', ], 'invoices_with_completed_orders' ), 'sent' => 0, 'error' => getTotalInvoices( $collected_order_invoices, [ 'booked_invoice_id' => null, 'external_id' => 'NOT NULL', 'error_message' => 'NOT NULL', ], 'invoices_with_completed_orders' ), ]; // Return the list of customers $response->success($result); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES_ECONOMIC_OVERVIEW', 'User tried to access the list of collected order invoices economic overview without a valid session'); $response->error('Invalid session', 400); } }, [ 'list_collected_invoices_economic_overview' => 'List ALL collected order invoices overview. This is a superuser-only route.' ] ); $this->post('/collected-invoices/economic/run/check-drafts', function () { global $response; self::requirePermission('module_economic_run_check_drafts'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'RUN_CHECK_DRAFTS', 'User ran the check drafts'); // Get the economic module $economic = new economic(); try { // Run the check drafts $economic->getTasks()->runCheckDrafts(); } catch (Exception $e) { // If the task fails, log the error and return an error response (new logs_o())->add('orderInvoices', 'global', 0, 0, 'RUN_CHECK_DRAFTS', 'User tried to run the check drafts, but it failed: ' . $e->getMessage()); $response->error('Failed to run the check drafts: ' . $e->getMessage(), 500); } // Return the result $response->success('Check drafts completed successfully'); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'RUN_CHECK_DRAFTS', 'User tried to run the check drafts without a valid session'); $response->error('Invalid session', 400); } }, [ 'module_economic_run_check_drafts' => 'Run the check drafts. This is a superuser-only route.' ] ); $this->post('/collected-invoices/economic/run/check-errors', function () { global $response; self::requirePermission('module_economic_run_check_errors'); $user = (new authentication())->get_user(); if ($user) { (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'RUN_CHECK_ERRORS', 'User ran the check errors'); // Get the economic module $economic = new economic(); try { // Run the check errors $economic->getTasks()->runCheckErrors(); } catch (Exception $e) { // If the task fails, log the error and return an error response (new logs_o())->add('orderInvoices', 'global', 0, 0, 'RUN_CHECK_ERRORS', 'User tried to run the check errors, but it failed: ' . $e->getMessage()); $response->error('Failed to run the check errors: ' . $e->getMessage(), 500); } // Return the result $response->success('Check errors completed successfully'); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'RUN_CHECK_ERRORS', 'User tried to run the check errors without a valid session'); $response->error('Invalid session', 400); } }, [ 'module_economic_run_check_errors' => 'Run the check errors. This is a superuser-only route.' ] ); } private function requireCollectedInvoiceId(): int { self::requireParameters(['collected_invoice_id']); self::requireType((int)self::getParameter('collected_invoice_id'), self::type_int()); $collected_invoice_id = (int)self::getParameter('collected_invoice_id'); self::requireMinValue($collected_invoice_id, 1); self::requireMaxValue($collected_invoice_id, 999999999); return $collected_invoice_id; } /** * Build normalized V2 details for a collected invoice and available e-conomic targets. * @throws Exception */ private function buildEconomicV2DetailsPayload(int $collected_invoice_id): array { $warnings = []; $invoice = (new collected_order_invoices_o())->select($collected_invoice_id); $invoice->requireSelected(); $this->requireCollectedInvoiceContextAccess($invoice); $draft_id = null; $booked_id = null; $draft_raw = null; $booked_raw = null; $customer = [ 'internal_customer_number' => (int)$invoice->customer_number->value() > 0 ? (int)$invoice->customer_number->value() : null, 'draft_customer_number' => null, 'booked_customer_number' => null, 'exists' => false, 'name' => null, 'barred' => null, ]; $economic = new economic(); $persisted_booked_id = (int)$invoice->booked_invoice_id->value(); if ($persisted_booked_id > 0) { // A booked invoice replaces its draft in e-conomic. Avoid reporting that expected // transition as a missing-draft warning. $booked_id = $persisted_booked_id; } else { $draft_error = null; try { $draft_id = $invoice->getInvoiceDraftId(); } catch (Exception $e) { $draft_error = $e; } // Older rows may have an external ID but no persisted booked ID. Only probe the // booked endpoint when the draft is gone, then persist through getInvoiceBookedId(). if ($draft_id === null) { try { $booked_id = $invoice->getInvoiceBookedId(); } catch (Exception $e) { if ($draft_error !== null) { $warnings[] = 'Draft id unavailable: ' . $draft_error->getMessage(); } $warnings[] = 'Booked id unavailable: ' . $e->getMessage(); } } } if ($draft_id !== null) { try { $draft_raw = $economic->invoices->draft->get((int)$draft_id); } catch (Exception $e) { $warnings[] = 'Unable to fetch draft invoice ' . (int)$draft_id . ': ' . $e->getMessage(); } } if ($booked_id !== null) { try { $booked_raw = $economic->invoices->booked->getFromId((int)$booked_id); } catch (Exception $e) { $warnings[] = 'Unable to fetch booked invoice ' . (int)$booked_id . ': ' . $e->getMessage(); } } $customer['draft_customer_number'] = $this->extractEconomicCustomerNumber($draft_raw); $customer['booked_customer_number'] = $this->extractEconomicCustomerNumber($booked_raw); if ( $customer['internal_customer_number'] !== null && $customer['draft_customer_number'] !== null && (int)$customer['internal_customer_number'] !== (int)$customer['draft_customer_number'] ) { $warnings[] = 'Draft invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', draft=' . (int)$customer['draft_customer_number']; } if ( $customer['internal_customer_number'] !== null && $customer['booked_customer_number'] !== null && (int)$customer['internal_customer_number'] !== (int)$customer['booked_customer_number'] ) { $warnings[] = 'Booked invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', booked=' . (int)$customer['booked_customer_number']; } if ($customer['internal_customer_number'] !== null) { try { $economic_customer_raw = $economic->customers->customers->get((int)$customer['internal_customer_number']); if (isset($economic_customer_raw->customerNumber)) { $customer['exists'] = true; $customer['name'] = isset($economic_customer_raw->name) ? (string)$economic_customer_raw->name : null; $customer['barred'] = isset($economic_customer_raw->barred) ? (bool)$economic_customer_raw->barred : null; if ($customer['barred'] === true) { $warnings[] = 'The e-conomic customer is barred.'; } } else { $warnings[] = 'Unable to resolve e-conomic customer ' . (int)$customer['internal_customer_number'] . '.'; } } catch (\Throwable $e) { $warnings[] = 'Failed to fetch e-conomic customer ' . (int)$customer['internal_customer_number'] . ': ' . $e->getMessage(); } } $internal_normalized = economic_v2_line_normalizer::normalizeInternalCollectedInvoice($invoice); $draft_normalized = $draft_raw !== null ? economic_v2_line_normalizer::normalizeDraftInvoice($draft_raw) : null; $booked_normalized = $booked_raw !== null ? economic_v2_line_normalizer::normalizeBookedInvoice($booked_raw) : null; $economic_state = $booked_id !== null ? 'booked' : ($draft_id !== null ? 'draft' : 'none'); $available_pdf_type = $economic_state === 'none' ? null : $economic_state; return [ 'collected_invoice_id' => $collected_invoice_id, 'external_id' => (string)$invoice->external_id->value(), 'order_ids' => array_values(array_map(static function ($row) { return (int)($row['id'] ?? 0); }, $invoice->getOrderIds())), 'economic' => [ 'draft_id' => $draft_id !== null ? (int)$draft_id : null, 'booked_id' => $booked_id !== null ? (int)$booked_id : null, 'state' => $economic_state, 'available_pdf_type' => $available_pdf_type, ], 'customer' => $customer, 'internal' => [ 'normalized' => $internal_normalized, ], 'draft' => [ 'exists' => $draft_raw !== null, 'raw' => $this->toPlainArray($draft_raw), 'normalized' => $draft_normalized, ], 'booked' => [ 'exists' => $booked_raw !== null, 'raw' => $this->toPlainArray($booked_raw), 'normalized' => $booked_normalized, ], 'warnings' => array_values(array_unique(array_merge( $warnings, (array)($internal_normalized['warnings'] ?? []), (array)($draft_normalized['warnings'] ?? []), (array)($booked_normalized['warnings'] ?? []) ))), ]; } private function downloadCollectedEconomicInvoicePdf(int $collected_invoice_id, string $type): array { global $response; $invoice = (new collected_order_invoices_o())->select($collected_invoice_id); $invoice->requireSelected(); $this->requireCollectedInvoiceContextAccess($invoice); $resolved_type = $type; $economic_invoice_id = null; $persisted_booked_id = (int)$invoice->booked_invoice_id->value(); if ($persisted_booked_id > 0) { $resolved_type = 'booked'; $economic_invoice_id = $persisted_booked_id; } else { try { $economic_invoice_id = $type === 'draft' ? $invoice->getInvoiceDraftId() : $invoice->getInvoiceBookedId(); } catch (Exception $e) { // A legacy booked row can have only the former draft external ID persisted. // Fall back to the booked target when a requested draft no longer exists. if ($type === 'draft') { try { $economic_invoice_id = $invoice->getInvoiceBookedId(); $resolved_type = 'booked'; } catch (Exception $bookedException) { $response->error('No e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); } } else { $response->error('No booked e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); } } } $economic_invoice_id = (int)$economic_invoice_id; if ($economic_invoice_id <= 0) { $response->error('No ' . $resolved_type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); } $economic = new economic(); $invoice_path_file = $resolved_type === 'draft' ? $economic->invoices->pdf->getDraft($economic_invoice_id) : $economic->invoices->pdf->getBooked($economic_invoice_id); $store_key_id = 'economic_' . $resolved_type . '_' . $economic_invoice_id; $invoice_store = new invoice_store(); try { $invoice_store->uploadFile('invoice_' . $store_key_id . '.pdf', $invoice_path_file); } finally { if (is_string($invoice_path_file) && file_exists($invoice_path_file)) { unlink($invoice_path_file); } } $user = (new authentication())->get_user(); (new logs_o())->add( 'orderInvoices', 'global', 1, $user ? (int)$user->id : 0, 'DOWNLOAD_COLLECTED_ECONOMIC_INVOICE_PDF', 'Downloaded ' . $type . ' e-conomic invoice PDF for collected invoice ' . $collected_invoice_id ); return [ 'collected_invoice_id' => $collected_invoice_id, 'type' => $resolved_type, 'economic_invoice_id' => $economic_invoice_id, 'url' => $invoice_store->getInvoiceDownloadUrl($store_key_id), ]; } private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void { global $response; if ($this->hasPermission('superuser')) { return; } $invoice_customer_number = (int)$invoice->customer_number->value(); if ($invoice_customer_number > 0 && $this->isOwnCustomerContext($invoice_customer_number)) { return; } if ($this->hasAccessToAllCollectedInvoiceDepartments((int)$invoice->id)) { return; } $response->error('Permission denied for requested collected invoice.', 403); } private function hasAccessToAllCollectedInvoiceDepartments(int $collected_invoice_id): bool { $orders = new orders_o(); $order_departments = $orders->getFieldsWhere( [ 'invoice_collection_id' => $collected_invoice_id, 'deleted_at' => null, ], ['department_id'] ); $department_ids = array_values(array_unique(array_filter(array_map(static function (array $order): int { return (int)($order['department_id'] ?? 0); }, $order_departments), static function (int $department_id): bool { return $department_id > 0; }))); if (empty($department_ids)) { return false; } foreach ($department_ids as $department_id) { if (!$this->hasDepartmentAccess((string)$department_id)) { return false; } } return true; } private function extractEconomicCustomerNumber(mixed $invoice_raw): ?int { if ($invoice_raw === null) { return null; } $data = is_array($invoice_raw) ? $invoice_raw : $this->toPlainArray($invoice_raw); $value = $data['customer']['customerNumber'] ?? $data['customer']['customer_number'] ?? $data['customerNumber'] ?? $data['customer_number'] ?? null; if ($value === null) { return null; } $customer_number = (int)$value; return $customer_number > 0 ? $customer_number : null; } private function parseIntegerListParameter(string $parameter): array { if (!self::isParametersSet([$parameter])) { return []; } $raw = self::getParameter($parameter); $values = []; if (is_array($raw)) { $values = $raw; } elseif (is_string($raw)) { $values = explode(',', $raw); } elseif (is_numeric($raw)) { $values = [$raw]; } $normalized = []; foreach ($values as $value) { $int_value = (int)$value; if ($int_value > 0) { $normalized[$int_value] = true; } } return array_values(array_map('intval', array_keys($normalized))); } private function toPlainArray(mixed $value): mixed { if ($value === null || is_scalar($value)) { return $value; } return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true); } /** * Synchronous fallback when queue components are unavailable in this deployment. * @throws Exception */ private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array { $this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); 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); } } private function parseCollectedInvoiceQueueStatuses(): array { global $response; if (!self::isParametersSet(['status'])) { return []; } $status_raw = (string)self::getParameter('status'); $statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw)))); if ($statuses === []) { return []; } $allowed = [ economic_transfer_queue::STATUS_QUEUED, economic_transfer_queue::STATUS_PROCESSING, economic_transfer_queue::STATUS_COMPLETED, economic_transfer_queue::STATUS_FAILED, ]; $normalized = []; foreach ($statuses as $status) { $status = strtoupper($status); if (!in_array($status, $allowed, true)) { $response->error('status must contain only: ' . implode(', ', $allowed), 400); } $normalized[] = $status; } return array_values(array_unique($normalized)); } private function parseCollectedInvoiceQueuePagination(): array { global $response; $limit = 50; if (self::isParametersSet(['limit'])) { $limit_raw = self::getParameter('limit'); if (!is_numeric($limit_raw)) { $response->error('limit must be between 1 and 500', 400); } $limit = (int)$limit_raw; if ($limit < 1 || $limit > 500) { $response->error('limit must be between 1 and 500', 400); } } $offset = 0; if (self::isParametersSet(['offset'])) { $offset_raw = self::getParameter('offset'); if (!is_numeric($offset_raw)) { $response->error('offset must be at least 0', 400); } $offset = (int)$offset_raw; if ($offset < 0) { $response->error('offset must be at least 0', 400); } } return [ 'limit' => $limit, 'offset' => $offset, ]; } private function parseCollectedInvoiceQueueMonitorLimit(): int { global $response; $limit = 50; if (self::isParametersSet(['limit'])) { $limit_raw = self::getParameter('limit'); if (!is_numeric($limit_raw)) { $response->error('limit must be between 1 and 100', 400); } $limit = (int)$limit_raw; if ($limit < 1 || $limit > 100) { $response->error('limit must be between 1 and 100', 400); } } return $limit; } private function requireCollectedInvoiceQueueJobId(): int { global $response; if (!self::isParametersSet(['job_id'])) { $response->error('job_id is required', 400); } $job_id = self::getParameter('job_id'); if (!is_numeric($job_id) || (int)$job_id < 1) { $response->error('job_id must be a positive integer', 400); } return (int)$job_id; } private function requireCollectedInvoiceQueueJobById(int $job_id, int $created_by, bool $mustBeFailed = false): array { global $response; $queue = new economic_transfer_queue(); $job = $queue->getJobByIdForUser($job_id, $created_by); if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) { $response->error('Collected invoice queue job not found', 404); } if ($mustBeFailed && (string)($job['status'] ?? '') !== economic_transfer_queue::STATUS_FAILED) { $response->error('Collected invoice queue job can only be retried when status is FAILED', 409); } return $job; } private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int { $normalized = strtolower(trim($message)); if ($normalized === '') { return 400; } if (str_contains($normalized, 'not found')) { return 404; } $is_conflict = str_contains($normalized, 'only failed jobs can be retried') || str_contains($normalized, 'can only be retried when status is failed') || str_contains($normalized, 'max retry attempts') || str_contains($normalized, 'failed to retry queue job'); return $is_conflict ? 409 : 400; } private function runCollectedInvoiceQueueBatch(economic_transfer_queue $queue, int $limit): array { if (method_exists($queue, 'processPendingByTransferType')) { return [ 'result' => $queue->processPendingByTransferType( economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, $limit ), 'fallback' => false, ]; } return [ 'result' => $queue->processPending($limit), 'fallback' => true, ]; } private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array { $jobs = $queue->listMonitorJobsForUser( $user_id, $limit, economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT ); $jobs = $this->withCollectedInvoiceQueueDetailsSummaryList($jobs); $counts = [ 'queued' => 0, 'in_progress' => 0, 'failed' => 0, 'completed' => 0, 'total' => count($jobs), ]; $progress_sum = 0; foreach ($jobs as $job) { $status = strtoupper((string)($job['status'] ?? '')); $job_progress = max(0, min(100, (int)($job['progress_percent'] ?? 0))); if ($status === economic_transfer_queue::STATUS_QUEUED) { $counts['queued']++; $progress_sum += 0; continue; } if ($status === economic_transfer_queue::STATUS_PROCESSING) { $counts['in_progress']++; $progress_sum += $job_progress; continue; } if ($status === economic_transfer_queue::STATUS_FAILED) { $counts['failed']++; $progress_sum += 100; continue; } if ($status === economic_transfer_queue::STATUS_COMPLETED) { $counts['completed']++; $progress_sum += 100; } } return [ 'jobs' => $jobs, 'counts' => $counts, 'progress_percent' => $counts['total'] > 0 ? (int)round($progress_sum / $counts['total']) : 0, 'limit' => $limit, ]; } private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses, int $created_by): int { global $db; $created_by = max(0, $created_by); if ($created_by < 1) { return 0; } if (method_exists($queue, 'countJobsForCreatedBy')) { return max(0, (int)$queue->countJobsForCreatedBy( $statuses, economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, $created_by )); } $conditions = [ "transfer_type = '" . $db->escape_string(economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) . "'", 'created_by = ' . $created_by, ]; if ($statuses !== []) { $escaped_statuses = array_map(static function (string $status) use ($db): string { return "'" . $db->escape_string(strtoupper(trim($status))) . "'"; }, $statuses); $conditions[] = 'status IN (' . implode(',', $escaped_statuses) . ')'; } $sql = 'SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs WHERE ' . implode(' AND ', $conditions); $result = $db->query($sql); if (!$result instanceof \mysqli_result) { return 0; } $row = $result->fetch_assoc(); if (!is_array($row) || !isset($row['total'])) { return 0; } return max(0, (int)$row['total']); } private function withCollectedInvoiceQueueDetailsSummary(array $job): array { $job['details_summary'] = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary($job); $collected_invoice_id = (int)($job['details_summary']['target']['collected_invoice_id'] ?? 0); if ($collected_invoice_id > 0) { $job['details_summary']['customer'] = $this->resolveCollectedInvoiceQueueCustomerSummary( $job['details_summary']['customer'] ?? [], $collected_invoice_id ); } return $job; } private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array { return array_values(array_map(function (array $job): array { return $this->withCollectedInvoiceQueueDetailsSummary($job); }, $jobs)); } private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array { global $db; $customer_number = isset($customer['customer_number']) && is_numeric($customer['customer_number']) ? (int)$customer['customer_number'] : null; $customer_name = is_string($customer['name'] ?? null) && trim((string)$customer['name']) !== '' ? trim((string)$customer['name']) : null; if ($customer_number !== null && $customer_name !== null) { return [ 'customer_number' => $customer_number, 'name' => $customer_name, ]; } $collected_invoice_id = max(0, $collected_invoice_id); if ($collected_invoice_id < 1) { return [ 'customer_number' => $customer_number, 'name' => $customer_name, ]; } $sql = "SELECT coi.customer_number, coi.name AS invoice_name, u.display_name FROM collected_order_invoices coi LEFT JOIN users u ON u.customer_number = coi.customer_number WHERE coi.id = $collected_invoice_id LIMIT 1"; $result = $db->query($sql); if (!$result instanceof \mysqli_result) { return [ 'customer_number' => $customer_number, 'name' => $customer_name, ]; } $row = $result->fetch_assoc(); if (!is_array($row)) { return [ 'customer_number' => $customer_number, 'name' => $customer_name, ]; } $resolved_customer_number = isset($row['customer_number']) && is_numeric($row['customer_number']) ? (int)$row['customer_number'] : $customer_number; $display_name = trim((string)($row['display_name'] ?? '')); $invoice_name = trim((string)($row['invoice_name'] ?? '')); $resolved_name = $customer_name; if ($resolved_name === null && $display_name !== '' && strtolower($display_name) !== 'unnamed') { $resolved_name = $display_name; } if ($resolved_name === null && $invoice_name !== '') { $resolved_name = $invoice_name; } return [ 'customer_number' => $resolved_customer_number, 'name' => $resolved_name, ]; } /** * @throws Exception */ private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void { invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices); } private function requireCollectedInvoiceBulkActionPermission(string $action): void { $permission = match ($action) { invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES, invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES => 'reset_collected_invoice_economic', invoice_collection_bulk_action_service::ACTION_MERGE => 'move_collected_invoice', invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH => 'split_collected_invoice', invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC => 'add_collected_invoice_economic', default => throw new Exception('Invalid invoice collection bulk action.'), }; self::requirePermission($permission); } /** * @param $collected_order_invoice * @param users_o $users * @param collected_order_invoices_o $tmp_collected_order_invoices * @return array * @throws Exception */ function getOrderInvoiceDetails($collected_order_invoice, users_o $users, collected_order_invoices_o $tmp_collected_order_invoices): array { return [ 'id' => (int)$collected_order_invoice['id'], 'name' => (string)$collected_order_invoice['name'], 'notes' => (string)$collected_order_invoice['notes'], 'customer_number' => (int)$collected_order_invoice['customer_number'], 'customer_name' => (string)$users->getCustomerName((int)$collected_order_invoice['customer_number']), 'processor' => $collected_order_invoice['processor'] ? (int)$collected_order_invoice['processor'] : null, 'external_id' => (string)$collected_order_invoice['external_id'], 'closed_at' => $collected_order_invoice['closed_at'] ? (string)$collected_order_invoice['closed_at'] : null, 'updated_at' => (string)$collected_order_invoice['updated_at'], 'created_at' => (string)$collected_order_invoice['created_at'], 'orders' => $tmp_collected_order_invoices->getOrders(), 'total_net_amount' => (float)$tmp_collected_order_invoices->getTotalAmount(), ]; } }