diff --git a/services/nginx/app/classes/economic_transfer_queue.php b/services/nginx/app/classes/economic_transfer_queue.php index 89635ab3..57e3a385 100644 --- a/services/nginx/app/classes/economic_transfer_queue.php +++ b/services/nginx/app/classes/economic_transfer_queue.php @@ -43,6 +43,7 @@ class economic_transfer_queue $active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by); if ($active_job !== null) { + $this->registerJobRequester((int)($active_job['id'] ?? 0), $created_by); $target_label = $this->buildTargetLabel($transfer_type, $payload); $this->logQueueEvent( 1, @@ -75,6 +76,8 @@ class economic_transfer_queue $job_id = (int)$db->insert_id(); $stmt->close(); + $this->registerJobRequester($job_id, $created_by); + $this->logQueueEvent( 1, $created_by, @@ -145,12 +148,19 @@ class economic_transfer_queue return null; } - $stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1"); + $stmt = $db->prepare( + "SELECT q.* + FROM economic_transfer_queue_jobs q + LEFT JOIN economic_transfer_queue_job_requesters r + ON r.queue_job_id = q.id AND r.user_id = ? + WHERE q.id = ? AND (q.created_by = ? OR r.user_id = ?) + LIMIT 1" + ); if (!$stmt) { return null; } - $stmt->bind_param('ii', $job_id, $created_by); + $stmt->bind_param('iiii', $created_by, $job_id, $created_by, $created_by); if (!$stmt->execute()) { $stmt->close(); return null; @@ -179,7 +189,8 @@ class economic_transfer_queue $offset = max(0, $offset); $where = $this->buildListJobsWhereClause($statuses, $transfer_type); - $where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by; + $visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by); + $where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility; $sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset"; $result = $db->query($sql); if (!$result instanceof mysqli_result) { @@ -203,7 +214,8 @@ class economic_transfer_queue } $where = $this->buildListJobsWhereClause($statuses, $transfer_type); - $where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by; + $visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by); + $where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility; $sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where"; $result = $db->query($sql); if (!$result instanceof mysqli_result) { @@ -242,6 +254,9 @@ class economic_transfer_queue global $db; $user_id = max(0, $user_id); + if ($user_id < 1) { + return []; + } $limit = max(1, min(100, $limit)); try { $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' @@ -262,7 +277,7 @@ class economic_transfer_queue ON d.queue_job_id = q.id AND d.user_id = $user_id AND d.dismissed_status = q.status - WHERE q.created_by = $user_id + WHERE " . $this->jobVisibilitySql('q', $user_id) . " $transfer_condition AND ( q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') @@ -355,7 +370,7 @@ class economic_transfer_queue ON d.queue_job_id = q.id AND d.user_id = $user_id AND d.dismissed_status = q.status - WHERE q.created_by = $user_id + WHERE " . $this->jobVisibilitySql('q', $user_id) . " AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "') $transfer_condition AND d.queue_job_id IS NULL @@ -389,6 +404,9 @@ class economic_transfer_queue if ($existing_job === null) { throw new Exception('Queue job not found'); } + if ($created_by !== null && (int)($existing_job['created_by'] ?? 0) !== $created_by) { + throw new Exception('Only the queue job creator can retry this job'); + } if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) { throw new Exception('Only failed jobs can be retried'); } @@ -727,6 +745,38 @@ class economic_transfer_queue $db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id"); } + private function registerJobRequester(int $job_id, int $user_id): void + { + global $db; + + if ($job_id < 1 || $user_id < 1) { + return; + } + $stmt = $db->prepare( + "INSERT INTO economic_transfer_queue_job_requesters (queue_job_id, user_id, requested_at) + VALUES (?, ?, NOW()) + ON DUPLICATE KEY UPDATE requested_at = VALUES(requested_at)" + ); + if (!$stmt) { + throw new Exception('Failed to prepare queue requester registration'); + } + $stmt->bind_param('ii', $job_id, $user_id); + if (!$stmt->execute()) { + $stmt->close(); + throw new Exception('Failed to register queue requester'); + } + $stmt->close(); + } + + private function jobVisibilitySql(string $alias, int $user_id): string + { + $user_id = max(0, $user_id); + return "($alias.created_by = $user_id OR EXISTS ( + SELECT 1 FROM economic_transfer_queue_job_requesters requester + WHERE requester.queue_job_id = $alias.id AND requester.user_id = $user_id + ))"; + } + /** * Release jobs stuck in PROCESSING due to crashes or killed workers. */ @@ -843,8 +893,8 @@ class economic_transfer_queue { global $db; - $created_by = max(0, $created_by); - if ($target_value < 1 || $created_by < 1) { + // Active work is unique by transfer type and business target across all requesting users. + if ($target_value < 1) { return null; } @@ -854,7 +904,6 @@ class economic_transfer_queue WHERE transfer_type = ? AND status IN (?, ?) AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ? - AND created_by = ? ORDER BY id DESC LIMIT 1" ); @@ -864,7 +913,7 @@ class economic_transfer_queue $queued = self::STATUS_QUEUED; $processing = self::STATUS_PROCESSING; - $stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by); + $stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value); if (!$stmt->execute()) { $stmt->close(); return null; diff --git a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php index c0abfc56..8169fcff 100644 --- a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php +++ b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php @@ -54,6 +54,17 @@ class economic_transfer_queue_schema_bootstrap ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); + $db->query( + "CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_requesters ( + queue_job_id BIGINT UNSIGNED NOT NULL, + user_id INT NOT NULL, + requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (queue_job_id, user_id), + INDEX idx_economic_transfer_queue_job_requesters_user (user_id, queue_job_id), + INDEX idx_economic_transfer_queue_job_requesters_job (queue_job_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + self::$initialized = true; } } diff --git a/services/nginx/app/classes/invoice_collection_bulk_action_service.php b/services/nginx/app/classes/invoice_collection_bulk_action_service.php index 5eac93f8..f9c0b8c5 100644 --- a/services/nginx/app/classes/invoice_collection_bulk_action_service.php +++ b/services/nginx/app/classes/invoice_collection_bulk_action_service.php @@ -10,6 +10,10 @@ use objects\orders_o; use objects\products_o; use objects\users_o; +class invoice_collection_bulk_action_conflict extends Exception +{ +} + class invoice_collection_bulk_action_service { public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations'; @@ -35,6 +39,7 @@ class invoice_collection_bulk_action_service $options = $this->normalizeOptions($options); $preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale); + $preview['content_digest'] = $this->previewContentDigest($preview); $previewId = $this->previewId(); $preview['preview_id'] = $previewId; $preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options); @@ -78,10 +83,6 @@ class invoice_collection_bulk_action_service throw new Exception('Confirmation text does not match.'); } - $freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale)); - if (!empty($freshPreview['blockers'])) { - throw new Exception('Action cannot be applied while blockers are present.'); - } $lockedCollectionIds = [ ...$invoiceCollectionIds, (int)($options['target_invoice_collection_id'] ?? 0), @@ -95,6 +96,24 @@ class invoice_collection_bulk_action_service ); } + $freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale)); + $freshDigest = $this->previewContentDigest($freshPreview); + if (!hash_equals((string)($cached['preview']['content_digest'] ?? ''), $freshDigest)) { + throw new invoice_collection_bulk_action_conflict( + 'The invoice collections changed after preview. Refresh the preview before applying this action.' + ); + } + $freshPreview['content_digest'] = $freshDigest; + if (!empty($freshPreview['blockers'])) { + throw new Exception((string)($freshPreview['blockers'][0]['message'] + ?? 'Action cannot be applied while blockers are present.')); + } + + // Construct before the transaction because the queue bootstrap may run DDL on first use. + $economicQueue = $action === self::ACTION_QUEUE_ECONOMIC + ? new economic_transfer_queue() + : null; + $db->conn()->begin_transaction(); try { $result = match ($action) { @@ -102,10 +121,12 @@ class invoice_collection_bulk_action_service self::ACTION_MERGE => $this->applyMerge($freshPreview, $options), self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview), self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview), - self::ACTION_QUEUE_ECONOMIC => [ - 'queued_invoice_collection_ids' => $invoiceCollectionIds, - 'changed_count' => count($invoiceCollectionIds), - ], + self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic( + $invoiceCollectionIds, + $options, + $actorUserId, + $economicQueue + ), default => throw new Exception('Unsupported action'), }; (new logs_o())->add( @@ -354,11 +375,15 @@ class invoice_collection_bulk_action_service { $blockers = []; foreach ($collections as $collection) { - if (!empty($collection->booked_invoice_id->value())) { + try { + self::assertCollectionCanQueueEconomic($collection); + } catch (\Throwable $e) { $blockers[] = [ - 'code' => 'collection_booked', + 'code' => $e->getMessage() === economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE + ? 'draft_customer_export_blocked' + : 'collection_export_ineligible', 'invoice_collection_id' => (int)$collection->id, - 'message' => 'Invoice collection is already booked.', + 'message' => $e->getMessage(), ]; } } @@ -443,6 +468,37 @@ class invoice_collection_bulk_action_service return ['changed_count' => count($changed), 'order_item_ids' => $changed]; } + private function applyQueueEconomic( + array $invoiceCollectionIds, + array $options, + int $actorUserId, + ?economic_transfer_queue $queue + ): array + { + if ($queue === null) { + throw new Exception('E-conomic transfer queue is unavailable.'); + } + $jobs = []; + foreach ($invoiceCollectionIds as $invoiceCollectionId) { + $jobs[] = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => (int)$invoiceCollectionId, + 'send_as_is' => (bool)($options['send_as_is'] ?? false), + 'requested_by' => $actorUserId, + ], + $actorUserId + ); + } + + return [ + 'queued_invoice_collection_ids' => array_values(array_map('intval', $invoiceCollectionIds)), + 'queue_job_ids' => array_values(array_map(static fn(array $job): int => (int)($job['id'] ?? 0), $jobs)), + 'jobs' => $jobs, + 'changed_count' => count($jobs), + ]; + } + private function contentMutationBlockers(collected_order_invoices_o $collection): array { $blockers = []; @@ -522,8 +578,9 @@ class invoice_collection_bulk_action_service private function collectionSummary(collected_order_invoices_o $collection): array { + $invoiceCollectionId = (int)$collection->id; return [ - 'id' => (int)$collection->id, + 'id' => $invoiceCollectionId, 'customer_number' => (int)$collection->customer_number->value(), 'name' => (string)$collection->name->value(), 'created_at' => (string)$collection->created_at->value(), @@ -531,9 +588,77 @@ class invoice_collection_bulk_action_service 'booked_invoice_id' => $collection->booked_invoice_id->value(), 'external_id' => $collection->external_id->value(), 'order_count' => (int)$collection->getOrders(true), + 'export_content_digest' => $this->collectionExportContentDigest($invoiceCollectionId), ]; } + private function collectionExportContentDigest(int $invoiceCollectionId): string + { + global $db; + + $invoiceCollectionId = max(0, $invoiceCollectionId); + if ($invoiceCollectionId < 1) { + throw new Exception('Invoice collection id is required for preview content validation.'); + } + + $result = $db->query( + "SELECT + o.id AS order_id, + o.customer_id, + o.reference AS order_reference, + o.notes AS order_notes, + o.department_id, + o.reg_1, + o.reg_2, + o.reg_3, + o.created_at, + o.completed_at, + o.include_in_invoice AS order_include_in_invoice, + o.po, + o.safety_seal, + oi.id AS order_item_id, + oi.product_id, + oi.reference AS order_item_reference, + oi.notes AS order_item_notes, + oi.price, + oi.quantity, + oi.related_item_id, + oi.include_in_invoice AS order_item_include_in_invoice, + p.name AS product_name, + p.economic_product_id + FROM orders o + LEFT JOIN order_items oi + ON oi.order_id = o.id AND oi.deleted_at IS NULL + LEFT JOIN products p ON p.id = oi.product_id + WHERE o.invoice_collection_id = {$invoiceCollectionId} + AND o.deleted_at IS NULL + ORDER BY o.id ASC, oi.id ASC" + ); + if (!$result) { + throw new Exception('Failed to load invoice collection contents for preview validation.'); + } + + $rows = []; + while (($row = $result->fetch_assoc()) !== null) { + $rows[] = $row; + } + $encoded = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new Exception('Failed to serialize invoice collection contents for preview validation.'); + } + + return hash('sha256', $encoded); + } + + public static function assertCollectionCanQueueEconomic(collected_order_invoices_o $collection): void + { + $collection->requireSelected(); + (new economic())->assertCustomerNumberIsNotDraft((int)$collection->customer_number->value()); + if ($collection->booked_invoice_id->value() !== null) { + throw new Exception('Invoice has already been booked'); + } + } + private function normalizeAction(string $action): string { $action = trim($action); @@ -578,6 +703,18 @@ class invoice_collection_bulk_action_service if (isset($options['target_invoice_collection_id'])) { $options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id']; } + if (array_key_exists('send_as_is', $options)) { + $value = $options['send_as_is']; + if (is_bool($value)) { + $options['send_as_is'] = $value; + } elseif ((is_int($value) || is_string($value)) && in_array((string)$value, ['0', '1'], true)) { + $options['send_as_is'] = (string)$value === '1'; + } elseif (is_string($value) && in_array(strtolower(trim($value)), ['true', 'false'], true)) { + $options['send_as_is'] = strtolower(trim($value)) === 'true'; + } else { + throw new Exception('options.send_as_is must be a boolean.'); + } + } ksort($options); return $options; } @@ -591,6 +728,18 @@ class invoice_collection_bulk_action_service ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } + private function previewContentDigest(array $preview): string + { + unset( + $preview['preview_id'], + $preview['selection_hash'], + $preview['confirmation_phrase'], + $preview['content_digest'] + ); + + return hash('sha256', json_encode($preview, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + private function confirmationPhrase(string $locale): string { $language = strtolower(substr(trim($locale), 0, 2)); diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index d0f12bbc..f0bbdc5a 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -76,8 +76,11 @@ class invoice_period_flag_service $this->nullableIntSql($userId > 0 ? $userId : null) ); $db->query($sql); + $flagId = (int)$db->insert_id(); - return $this->getStoredFlag((int)$db->insert_id()); + $this->refreshManualFlagsCacheAfterMutation(); + + return $this->getStoredFlag($flagId); } public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array @@ -107,6 +110,8 @@ class invoice_period_flag_service ); $db->query($sql); + $this->refreshManualFlagsCacheAfterMutation(); + return $this->getStoredFlag($id); } @@ -227,6 +232,61 @@ class invoice_period_flag_service return $types; } + /** + * Add only aggregate active manual-flag counts for readiness derivation. + * Flag details remain absent when the caller lacks list_invoice_period_flags. + * + * @param array>> $types + * @param int[]|null $onlyCustomerNumbers + * @return array>> + */ + public function applyManualFlagCountsToPeriodTypes( + array $types, + string $dateFrom, + string $dateTo, + ?array $onlyCustomerNumbers = null + ): array { + $context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers); + $manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers); + return $this->applyManualFlagCounts($types, $manualFlags); + } + + private function applyManualFlagCounts(array $types, array $manualFlags): array + { + $flagsByCustomerNumber = []; + foreach ($manualFlags as $flag) { + $customerNumber = (int)($flag['customer_number'] ?? 0); + if ($customerNumber > 0) { + $flagsByCustomerNumber[$customerNumber][] = $flag; + } + } + + foreach ($types as $typeName => $customers) { + foreach ($customers as $index => $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $customerFlags = $this->flagsForCustomerCard( + $customer, + $flagsByCustomerNumber[$customerNumber] ?? [], + (string)$typeName + ); + $activeManualCount = count(array_filter( + $customerFlags, + static fn(array $flag): bool => ($flag['source'] ?? null) === self::SOURCE_MANUAL + && ($flag['status'] ?? self::STATUS_ACTIVE) === self::STATUS_ACTIVE + )); + $existingAutomaticCount = (int)($customer['flag_counts']['automatic'] ?? 0); + $types[$typeName][$index]['flag_counts'] = [ + 'manual' => $activeManualCount, + 'automatic' => $existingAutomaticCount, + 'total' => $activeManualCount + $existingAutomaticCount, + ]; + unset($types[$typeName][$index]['flags']); + } + } + + return $types; + } + private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array { $transactionIds = []; @@ -331,6 +391,12 @@ class invoice_period_flag_service } } + private function refreshManualFlagsCacheAfterMutation(): void + { + $this->manualFlagsInstanceCache = null; + $this->warmManualFlagsCache(); + } + private function fetchActiveManualFlagsFromDb(): array { global $db; diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 8fef737b..101b7652 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -7592,7 +7592,19 @@ paths: description: Bulk action preview created successfully content: application/json: - schema: {} + schema: + type: object + additionalProperties: true + properties: + data: + type: object + additionalProperties: true + required: [preview_id, content_digest, selection_hash, confirmation_phrase] + properties: + preview_id: {type: string} + content_digest: {type: string, pattern: '^[a-f0-9]{64}$'} + selection_hash: {type: string, pattern: '^[a-f0-9]{64}$'} + confirmation_phrase: {type: string} /collected-invoices/bulk-actions/apply: post: tags: @@ -7645,6 +7657,7 @@ paths: content: application/json: schema: {} + '409': {$ref: '#/components/responses/Conflict'} /collected-invoices/economic: post: @@ -8242,6 +8255,79 @@ paths: in: query required: true schema: {type: string, format: date} + - name: periodView + in: query + schema: + type: string + enum: [all, vehicle_subscriptions, fixed_pricing, tank_cleaning, special_arrangements, invoice_per_order, possible_duplicates] + default: all + - name: page + in: query + schema: {type: integer, minimum: 1, default: 1} + - name: limit + in: query + description: Page size from 1 through 500, or `all` for the complete selected view. + schema: + oneOf: + - {type: integer, minimum: 1, maximum: 500} + - {type: string, enum: [all]} + - name: search + in: query + description: Case-insensitive search across customer, order, collection, queue, flag, and review metadata. + schema: {type: string} + - name: flagTab + in: query + schema: + type: string + enum: [all, red, yellow, none, filters] + default: all + - name: includeRequiresAction + in: query + schema: {type: boolean, default: true} + - name: includeBooked + in: query + schema: {type: boolean, default: true} + - name: reviewState + in: query + description: Repeatable derived workflow states. Comma-separated values are also accepted; multiple values use OR semantics. + style: form + explode: true + schema: + type: array + items: {type: string, enum: [blocked, attention, queued, ready, completed]} + - name: severity + in: query + description: Repeatable derived review severities. Comma-separated values are also accepted; multiple values use OR semantics. + style: form + explode: true + schema: + type: array + items: {type: string, enum: [red, yellow, blue, green]} + - name: invoiceState + in: query + description: Repeatable invoice states present on a customer card. Comma-separated values are also accepted. + style: form + explode: true + schema: + type: array + items: {type: string, enum: [open, closed, economic_draft, economic_booked]} + - name: departmentId + in: query + description: Repeatable department IDs present on a customer card. Comma-separated values are also accepted. + style: form + explode: true + schema: + type: array + items: {type: integer, minimum: 1} + - name: sort + in: query + schema: + type: string + enum: [priority, customer_name, customer_number, total_amount] + default: customer_name + - name: direction + in: query + schema: {type: string, enum: [asc, desc], default: asc} responses: '200': description: Invoicing periods retrieved successfully @@ -20562,6 +20648,11 @@ components: $ref: '#/components/schemas/InvoicingPeriodData' meta: type: object + properties: + date_from: {type: string, format: date-time} + date_to: {type: string, format: date-time} + pagination: + $ref: '#/components/schemas/InvoicingPeriodPagination' additionalProperties: true includes: type: object @@ -20586,7 +20677,7 @@ components: InvoicingPeriodCustomer: type: object - required: [customer_number, customer_name, transactions, invoice_collections] + required: [customer_number, customer_name, transactions, invoice_collections, review] additionalProperties: true properties: customer_number: @@ -20601,6 +20692,73 @@ components: type: array items: $ref: '#/components/schemas/InvoicingPeriodInvoiceCollection' + review: + $ref: '#/components/schemas/InvoicingPeriodReview' + + InvoicingPeriodReview: + type: object + required: [state, severity, reasons, next_action, is_actionable, counts] + properties: + state: {type: string, enum: [blocked, attention, queued, ready, completed]} + severity: {type: string, enum: [red, yellow, blue, green]} + reasons: + type: array + items: + type: object + required: [code, severity, count] + properties: + code: {type: string} + severity: {type: string, enum: [red, yellow, blue, green]} + count: {type: integer, minimum: 1} + next_action: + type: string + enum: [resolve_manual_flags, resolve_collection_errors, resolve_draft, review_warnings, wait_for_export, create_invoice, none] + is_actionable: {type: boolean} + counts: + type: object + required: [active_manual_flags, active_automatic_flags, collection_errors, active_queue_jobs, booked_transactions, unbooked_transactions] + additionalProperties: + type: integer + minimum: 0 + + InvoicingPeriodPagination: + type: object + required: [page, per_page, total, total_pages, search, filters, order, facets] + properties: + page: {type: integer, minimum: 1} + per_page: + oneOf: + - {type: integer, minimum: 1, maximum: 500} + - {type: string, enum: [all]} + total: {type: integer, minimum: 0} + total_pages: {type: integer, minimum: 1} + search: {type: string} + filters: + type: object + additionalProperties: true + order: + type: object + required: [field, direction] + properties: + field: {type: string, enum: [priority, customer_name, customer_number, total_amount]} + direction: {type: string, enum: [asc, desc]} + facets: + type: object + required: [review_states, severities, invoice_states, department_ids] + properties: + review_states: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'} + severities: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'} + invoice_states: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'} + department_ids: {$ref: '#/components/schemas/InvoicingPeriodFacetValues'} + + InvoicingPeriodFacetValues: + type: array + items: + type: object + required: [value, count] + properties: + value: {type: string} + count: {type: integer, minimum: 0} InvoicingPeriodTransaction: type: object @@ -20651,6 +20809,9 @@ components: booked_invoice_id: type: integer nullable: true + error_message: + type: string + nullable: true state: type: string enum: [open, closed, economic_draft, economic_booked] diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 7c41001c..160c0e5d 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -350,8 +350,15 @@ class InvoicingPeriodRoute 'page', 'limit', 'search', + 'flagTab', 'includeRequiresAction', 'includeBooked', + 'reviewState', + 'severity', + 'invoiceState', + 'departmentId', + 'sort', + 'direction', ]; $isPaginatedRequest = false; @@ -366,7 +373,34 @@ class InvoicingPeriodRoute return null; } - return self::normalizePeriodPaginationOptions($response->getAllRequestParameters()); + $parameters = $response->getAllRequestParameters(); + foreach (['reviewState', 'severity', 'invoiceState', 'departmentId'] as $repeatableKey) { + $repeatedValues = self::getRepeatedPeriodQueryValues($repeatableKey); + if ($repeatedValues !== []) { + $parameters[$repeatableKey] = $repeatedValues; + } + } + + return self::normalizePeriodPaginationOptions($parameters); + } + + private static function getRepeatedPeriodQueryValues(string $key): array + { + $queryString = (string)($_SERVER['QUERY_STRING'] ?? ''); + if ($queryString === '') { + return []; + } + + $values = []; + foreach (explode('&', $queryString) as $part) { + [$rawName, $rawValue] = array_pad(explode('=', $part, 2), 2, ''); + $name = urldecode($rawName); + if ($name === $key || $name === $key . '[]') { + $values[] = urldecode(str_replace('+', ' ', $rawValue)); + } + } + + return $values; } private static function normalizePeriodPaginationOptions(array $parameters): array @@ -399,12 +433,37 @@ class InvoicingPeriodRoute $flagTab = 'all'; } + $allowedSortFields = ['priority', 'customer_name', 'customer_number', 'total_amount']; + $sort = trim((string)($parameters['sort'] ?? 'customer_name')); + if (!in_array($sort, $allowedSortFields, true)) { + $sort = 'customer_name'; + } + $direction = strtolower(trim((string)($parameters['direction'] ?? 'asc'))); + if (!in_array($direction, ['asc', 'desc'], true)) { + $direction = 'asc'; + } + return [ 'periodView' => $periodView, 'page' => $page, 'limit' => $limit, 'search' => trim((string)($parameters['search'] ?? '')), 'flagTab' => $flagTab, + 'reviewStates' => self::normalizePeriodFilterValues( + $parameters['reviewState'] ?? null, + ['blocked', 'attention', 'queued', 'ready', 'completed'] + ), + 'severities' => self::normalizePeriodFilterValues( + $parameters['severity'] ?? null, + ['red', 'yellow', 'blue', 'green'] + ), + 'invoiceStates' => self::normalizePeriodFilterValues( + $parameters['invoiceState'] ?? null, + ['open', 'closed', 'economic_draft', 'economic_booked'] + ), + 'departmentIds' => self::normalizePeriodIntegerFilterValues($parameters['departmentId'] ?? null), + 'sort' => $sort, + 'direction' => $direction, 'includeRequiresAction' => self::parsePeriodBooleanOption( $parameters['includeRequiresAction'] ?? null, true @@ -413,6 +472,38 @@ class InvoicingPeriodRoute ]; } + private static function normalizePeriodFilterValues(mixed $value, array $allowed): array + { + $values = is_array($value) ? $value : [$value]; + $normalized = []; + foreach ($values as $entry) { + foreach (explode(',', (string)$entry) as $candidate) { + $candidate = strtolower(trim($candidate)); + if ($candidate !== '' && in_array($candidate, $allowed, true)) { + $normalized[$candidate] = true; + } + } + } + + return array_keys($normalized); + } + + private static function normalizePeriodIntegerFilterValues(mixed $value): array + { + $values = is_array($value) ? $value : [$value]; + $normalized = []; + foreach ($values as $entry) { + foreach (explode(',', (string)$entry) as $candidate) { + $candidate = (int)trim($candidate); + if ($candidate > 0) { + $normalized[$candidate] = true; + } + } + } + + return array_map('intval', array_keys($normalized)); + } + private static function parsePeriodBooleanOption(mixed $value, bool $default): bool { if ($value === null || $value === '') { @@ -439,6 +530,7 @@ class InvoicingPeriodRoute $types = is_array($period['types'] ?? null) ? $period['types'] : []; $types = self::ensurePeriodTypeKeys($types); $types = self::enrichPeriodCustomerMetaFromTypes($types); + $types = self::enrichPeriodCustomerReview($types); $types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? '')); $types = self::filterPeriodTypesByVisibility( $types, @@ -452,10 +544,17 @@ class InvoicingPeriodRoute } $typeCounts = self::summarizePeriodTypes($types); + $facets = self::summarizePeriodReviewFacets($types[$periodView] ?? []); if (!empty($options['flagTab'])) { $types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']); } + $types = self::filterPeriodTypesByWorkflow($types, $options); + $types = self::sortPeriodTypes( + $types, + (string)($options['sort'] ?? 'customer_name'), + (string)($options['direction'] ?? 'asc') + ); $total = count($types[$periodView] ?? []); $limit = $options['limit'] ?? 100; @@ -488,15 +587,121 @@ class InvoicingPeriodRoute 'filters' => [ 'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true), 'includeBooked' => (bool)($options['includeBooked'] ?? true), + 'flagTab' => (string)($options['flagTab'] ?? 'all'), + 'reviewState' => array_values($options['reviewStates'] ?? []), + 'severity' => array_values($options['severities'] ?? []), + 'invoiceState' => array_values($options['invoiceStates'] ?? []), + 'departmentId' => array_values($options['departmentIds'] ?? []), ], 'order' => [ - 'field' => 'customer_name', - 'direction' => 'asc', + 'field' => (string)($options['sort'] ?? 'customer_name'), + 'direction' => (string)($options['direction'] ?? 'asc'), ], + 'facets' => $facets, ], ]; } + private static function enrichPeriodCustomerReview(array $types): array + { + foreach ($types as $typeName => $customers) { + foreach ((array)$customers as $index => $customer) { + if (is_array($customer)) { + $types[$typeName][$index]['review'] = self::derivePeriodCustomerReview($customer); + } + } + } + + return $types; + } + + private static function derivePeriodCustomerReview(array $customer): array + { + $flagCounts = self::getActivePeriodFlagCounts($customer); + $counts = [ + 'active_manual_flags' => $flagCounts['manual'], + 'active_automatic_flags' => $flagCounts['automatic'], + 'collection_errors' => 0, + 'active_queue_jobs' => 0, + 'booked_transactions' => 0, + 'unbooked_transactions' => 0, + ]; + $reasons = []; + + foreach ((array)($customer['invoice_collections'] ?? []) as $collection) { + if (is_array($collection) && trim((string)($collection['error_message'] ?? '')) !== '') { + $counts['collection_errors']++; + } + } + foreach ((array)($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction) || (bool)($transaction['excluded'] ?? false)) { + continue; + } + $key = (bool)($transaction['booked'] ?? false) ? 'booked_transactions' : 'unbooked_transactions'; + $counts[$key]++; + } + if ((bool)($customer['queue']['has_active_job'] ?? false)) { + $counts['active_queue_jobs'] = max( + 1, + count((array)($customer['queue']['invoice_collection_ids'] ?? [])) + ); + } + + if ($counts['active_manual_flags'] > 0) { + $reasons[] = self::periodReviewReason('manual_flags', 'red', $counts['active_manual_flags']); + } + if ($counts['collection_errors'] > 0) { + $reasons[] = self::periodReviewReason('collection_errors', 'red', $counts['collection_errors']); + } + if ((bool)($customer['draft']['is_action_blocked'] ?? false)) { + $reasons[] = self::periodReviewReason('draft_blocks_action', 'red', 1); + } + $requiresAttention = (bool)($customer['requires_action'] ?? false) + && $counts['unbooked_transactions'] === 0; + if ($requiresAttention) { + $reasons[] = self::periodReviewReason('requires_action', 'yellow', 1); + } + if ($counts['active_automatic_flags'] > 0) { + $reasons[] = self::periodReviewReason('automatic_warnings', 'yellow', $counts['active_automatic_flags']); + } + if ($counts['active_queue_jobs'] > 0) { + $reasons[] = self::periodReviewReason('export_in_progress', 'blue', $counts['active_queue_jobs']); + } + if ($counts['unbooked_transactions'] > 0) { + $reasons[] = self::periodReviewReason('unbooked_transactions', 'green', $counts['unbooked_transactions']); + } + + if ($counts['active_manual_flags'] > 0) { + [$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_manual_flags']; + } elseif ($counts['collection_errors'] > 0) { + [$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_collection_errors']; + } elseif ((bool)($customer['draft']['is_action_blocked'] ?? false)) { + [$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_draft']; + } elseif ($counts['active_automatic_flags'] > 0 || $requiresAttention) { + [$state, $severity, $nextAction] = ['attention', 'yellow', 'review_warnings']; + } elseif ($counts['active_queue_jobs'] > 0) { + [$state, $severity, $nextAction] = ['queued', 'blue', 'wait_for_export']; + } elseif ($counts['booked_transactions'] > 0 && $counts['unbooked_transactions'] === 0) { + [$state, $severity, $nextAction] = ['completed', 'green', 'none']; + } else { + [$state, $severity, $nextAction] = ['ready', 'green', 'create_invoice']; + } + + return [ + 'state' => $state, + 'severity' => $severity, + 'reasons' => $reasons, + 'next_action' => $nextAction, + 'is_actionable' => in_array($state, ['blocked', 'attention', 'ready'], true), + 'counts' => $counts, + ]; + } + + private static function periodReviewReason(string $code, string $severity, int $count): array + { + return ['code' => $code, 'severity' => $severity, 'count' => $count]; + } + private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array { if (in_array($flagTab, ['all', 'filters', ''], true)) { @@ -523,6 +728,174 @@ class InvoicingPeriodRoute return $types; } + private static function filterPeriodTypesByWorkflow(array $types, array $options): array + { + $reviewStates = array_fill_keys((array)($options['reviewStates'] ?? []), true); + $severities = array_fill_keys((array)($options['severities'] ?? []), true); + $invoiceStates = array_fill_keys((array)($options['invoiceStates'] ?? []), true); + $departmentIds = array_fill_keys(array_map('intval', (array)($options['departmentIds'] ?? [])), true); + + if ($reviewStates === [] && $severities === [] && $invoiceStates === [] && $departmentIds === []) { + return $types; + } + + foreach ($types as $typeName => $customers) { + $types[$typeName] = array_values(array_filter( + is_array($customers) ? $customers : [], + static function (array $customer) use ( + $reviewStates, + $severities, + $invoiceStates, + $departmentIds + ): bool { + if ($reviewStates !== [] && !isset($reviewStates[(string)($customer['review']['state'] ?? '')])) { + return false; + } + if ($severities !== [] && !isset($severities[(string)($customer['review']['severity'] ?? '')])) { + return false; + } + if ($invoiceStates !== [] && !self::periodCustomerMatchesInvoiceStates($customer, $invoiceStates)) { + return false; + } + if ($departmentIds !== [] && !self::periodCustomerMatchesDepartmentIds($customer, $departmentIds)) { + return false; + } + + return true; + } + )); + } + + return $types; + } + + private static function periodCustomerMatchesInvoiceStates(array $customer, array $allowed): bool + { + foreach ((array)($customer['invoice_collections'] ?? []) as $collection) { + if (is_array($collection) && isset($allowed[(string)($collection['state'] ?? '')])) { + return true; + } + } + foreach ((array)($customer['transactions'] ?? []) as $transaction) { + if (is_array($transaction) && isset($allowed[(string)($transaction['invoice_state'] ?? '')])) { + return true; + } + } + + return false; + } + + private static function periodCustomerMatchesDepartmentIds(array $customer, array $allowed): bool + { + foreach ((array)($customer['transactions'] ?? []) as $transaction) { + if (is_array($transaction) && isset($allowed[(int)($transaction['department_id'] ?? 0)])) { + return true; + } + } + + return false; + } + + private static function sortPeriodTypes(array $types, string $field, string $direction): array + { + foreach ($types as $typeName => $customers) { + $customers = is_array($customers) ? array_values($customers) : []; + usort($customers, static function (array $a, array $b) use ($field, $direction): int { + $comparison = self::comparePeriodCustomers($a, $b, $field); + if ($comparison !== 0) { + return $direction === 'desc' ? -$comparison : $comparison; + } + + return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0)); + }); + $types[$typeName] = $customers; + } + + return $types; + } + + private static function comparePeriodCustomers(array $a, array $b, string $field): int + { + if ($field === 'priority') { + $rank = ['blocked' => 0, 'attention' => 1, 'queued' => 2, 'ready' => 3, 'completed' => 4]; + return ($rank[(string)($a['review']['state'] ?? '')] ?? 99) + <=> ($rank[(string)($b['review']['state'] ?? '')] ?? 99); + } + if ($field === 'customer_number') { + return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0)); + } + if ($field === 'total_amount') { + return self::getPeriodCustomerTotalAmount($a) <=> self::getPeriodCustomerTotalAmount($b); + } + + return strnatcasecmp((string)($a['customer_name'] ?? ''), (string)($b['customer_name'] ?? '')); + } + + private static function summarizePeriodReviewFacets(array $customers): array + { + $facets = [ + 'review_states' => [], + 'severities' => [], + 'invoice_states' => [], + 'department_ids' => [], + ]; + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + self::incrementPeriodFacet($facets['review_states'], (string)($customer['review']['state'] ?? '')); + self::incrementPeriodFacet($facets['severities'], (string)($customer['review']['severity'] ?? '')); + + $customerInvoiceStates = []; + $customerDepartmentIds = []; + foreach ((array)($customer['invoice_collections'] ?? []) as $collection) { + if (is_array($collection)) { + $state = (string)($collection['state'] ?? ''); + if ($state !== '') { + $customerInvoiceStates[$state] = true; + } + } + } + foreach ((array)($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + $state = (string)($transaction['invoice_state'] ?? ''); + if ($state !== '') { + $customerInvoiceStates[$state] = true; + } + $departmentId = (int)($transaction['department_id'] ?? 0); + if ($departmentId > 0) { + $customerDepartmentIds[$departmentId] = true; + } + } + foreach (array_keys($customerInvoiceStates) as $state) { + self::incrementPeriodFacet($facets['invoice_states'], (string)$state); + } + foreach (array_keys($customerDepartmentIds) as $departmentId) { + self::incrementPeriodFacet($facets['department_ids'], (string)$departmentId); + } + } + + foreach ($facets as $name => $counts) { + ksort($counts, SORT_NATURAL); + $facets[$name] = array_map( + static fn(string|int $value, int $count): array => ['value' => (string)$value, 'count' => $count], + array_keys($counts), + array_values($counts) + ); + } + + return $facets; + } + + private static function incrementPeriodFacet(array &$counts, string $value): void + { + if ($value !== '') { + $counts[$value] = ($counts[$value] ?? 0) + 1; + } + } + private static function ensurePeriodTypeKeys(array $types): array { foreach (self::periodTypeNames() as $typeName) { @@ -637,6 +1010,10 @@ class InvoicingPeriodRoute } } + foreach (['invoice_collections', 'flags', 'review', 'queue', 'draft', 'meta', 'flag_counts'] as $field) { + self::appendPeriodSearchValues($values, $customer[$field] ?? null); + } + foreach ($values as $value) { if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) { return true; @@ -646,6 +1023,19 @@ class InvoicingPeriodRoute return false; } + private static function appendPeriodSearchValues(array &$values, mixed $value): void + { + if (is_array($value)) { + foreach ($value as $entry) { + self::appendPeriodSearchValues($values, $entry); + } + return; + } + if (is_scalar($value)) { + $values[] = $value; + } + } + private static function normalizePeriodSearchTerm(string $value): string { return mb_strtolower(trim($value), 'UTF-8'); @@ -1895,7 +2285,17 @@ class InvoicingPeriodRoute $onlyCustomerNumbers ); }, 'invoice_period_flags'); + } else { + $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { + return (new invoice_period_flag_service())->applyManualFlagCountsToPeriodTypes( + $types, + $dateFrom, + $dateTo, + $onlyCustomerNumbers + ); + }, 'invoice_period_manual_flag_counts'); } + $types = self::enrichPeriodCustomerReview($types); return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 2588e137..ff180d3c 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -11,6 +11,7 @@ use classes\economic_v2_line_normalizer; use classes\economic_v2_revenue_statistics_service; use classes\invoice_store; use classes\invoice_collection_bulk_action_service; +use classes\invoice_collection_bulk_action_conflict; use classes\invoicing_period_utils; use classes\response; use classes\router; @@ -818,6 +819,8 @@ class orderInvoicesRoute (int)$user->id, $locale ); + } catch (invoice_collection_bulk_action_conflict $e) { + $response->error($e->getMessage(), 409); } catch (\Throwable $e) { $response->error($e->getMessage(), 400); } @@ -865,15 +868,11 @@ class orderInvoicesRoute $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); try { - $this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices); + invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices); } catch (\Throwable $e) { $response->error($e->getMessage(), 400); } - 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, $send_as_is); @@ -2736,8 +2735,7 @@ class orderInvoicesRoute */ private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void { - $collected_order_invoices->requireSelected(); - (new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value()); + invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collected_order_invoices); } private function requireCollectedInvoiceBulkActionPermission(string $action): void diff --git a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php index 2b1c0398..7a008ed8 100644 --- a/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php +++ b/services/nginx/app/tests/Api/CollectedInvoiceBulkActionsApiTest.php @@ -252,3 +252,92 @@ it('merges selected invoice collections into the explicit target after confirmat expect(bulk_action_order_invoice_collection_id((int)$sourceOrder['id']))->toBe((int)$targetCollection['id']) ->and(bulk_action_order_invoice_collection_id((int)$targetOrder['id']))->toBe((int)$targetCollection['id']); }); + +it('rejects apply when export-relevant order item content changed after preview', function (): void { + api_test_covers('POST /collected-invoices/bulk-actions/preview', 'stale-export-content'); + api_test_covers('POST /collected-invoices/bulk-actions/apply', 'stale-export-content'); + + $customer = api_fixtures()->createUser(['display_name' => 'Stale Bulk Preview Customer']); + $department = api_fixtures()->createDepartment(); + $targetCollection = api_fixtures()->createInvoiceCollection(['customer_number' => $customer['customer_number']]); + $sourceCollection = api_fixtures()->createInvoiceCollection(['customer_number' => $customer['customer_number']]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $sourceCollection['id'], + ]); + $product = api_fixtures()->createProduct(['name' => 'Digest Product', 'price' => 100]); + $item = api_fixtures()->createOrderItem([ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'cashier_id' => 1, + 'price' => 100, + 'quantity' => 1, + ]); + $session = api_fixtures()->createUserSession(['move_collected_invoice']); + + $previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [ + 'action' => 'merge_collections', + 'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']], + 'options' => ['target_invoice_collection_id' => $targetCollection['id']], + 'locale' => 'en', + ], $session['headers']); + $previewResponse->assertStatus(200)->assertEnvelope()->assertSuccess(); + $preview = $previewResponse->data(); + + api_test_runtime()->db()->query('UPDATE order_items SET price = 125 WHERE id = ' . (int)$item['id']); + + api_client()->post('/collected-invoices/bulk-actions/apply', [ + 'preview_id' => $preview['preview_id'], + 'action' => 'merge_collections', + 'invoice_collection_ids' => [$sourceCollection['id'], $targetCollection['id']], + 'options' => ['target_invoice_collection_id' => $targetCollection['id']], + 'confirmation_text' => 'Confirm', + 'locale' => 'en', + ], $session['headers']) + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('The invoice collections changed after preview. Refresh the preview before applying this action.'); +}); + +it('blocks queue economic preview and apply for the configured draft customer', function (): void { + api_test_covers('POST /collected-invoices/bulk-actions/preview', 'draft-customer'); + api_test_covers('POST /collected-invoices/bulk-actions/apply', 'draft-customer'); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Bulk Draft Customer']); + $collection = api_fixtures()->createInvoiceCollection(['customer_number' => $draftCustomer['customer_number']]); + api_fixtures()->setModuleConfig( + 'economic', + 'transactionDraftCustomerNumber', + (string)$draftCustomer['customer_number'], + 'int' + ); + $session = api_fixtures()->createUserSession(['add_collected_invoice_economic']); + + $previewResponse = api_client()->post('/collected-invoices/bulk-actions/preview', [ + 'action' => 'queue_economic', + 'invoice_collection_ids' => [$collection['id']], + 'locale' => 'en', + ], $session['headers']); + $previewResponse->assertStatus(200)->assertEnvelope()->assertSuccess(); + $preview = $previewResponse->data(); + + expect($preview['blockers'][0] ?? null)->toMatchArray([ + 'code' => 'draft_customer_export_blocked', + 'invoice_collection_id' => (int)$collection['id'], + 'message' => \classes\economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE, + ]); + + api_client()->post('/collected-invoices/bulk-actions/apply', [ + 'preview_id' => $preview['preview_id'], + 'action' => 'queue_economic', + 'invoice_collection_ids' => [$collection['id']], + 'confirmation_text' => 'Confirm', + 'locale' => 'en', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\economic::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE); +}); diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php index af746b7d..168f4545 100644 --- a/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicTransferQueueIntegrationTest.php @@ -97,6 +97,11 @@ function economic_transfer_queue_integration_db(): db function economic_transfer_queue_cleanup_for_created_by(db $db, int $created_by): void { + $db->query( + "DELETE requester FROM economic_transfer_queue_job_requesters requester + LEFT JOIN economic_transfer_queue_jobs job ON job.id = requester.queue_job_id + WHERE requester.user_id = $created_by OR job.created_by = $created_by" + ); $db->query("DELETE FROM economic_transfer_queue_jobs WHERE created_by = $created_by"); } @@ -181,6 +186,43 @@ it('deduplicates active jobs per transfer target', function (): void { } }); +it('registers later deduplicated requesters for narrowly scoped queue monitoring', function (): void { + $db = economic_transfer_queue_integration_db(); + $creator = 921200 + random_int(1000, 9999); + $requester = 941200 + random_int(1000, 9999); + $unrelated = 951200 + random_int(1000, 9999); + $collected_invoice_id = 961200 + random_int(1000, 9999); + $queue = new economic_transfer_queue(new EconomicTransferQueueIntegrationStubExecutor()); + + try { + economic_transfer_queue_cleanup_for_created_by($db, $creator); + economic_transfer_queue_cleanup_for_created_by($db, $requester); + + $first = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + ['collected_invoice_id' => $collected_invoice_id, 'requested_by' => $creator], + $creator + ); + $deduplicated = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + ['collected_invoice_id' => $collected_invoice_id, 'requested_by' => $requester], + $requester + ); + + expect((int)$deduplicated['id'])->toBe((int)$first['id']) + ->and($queue->getJobByIdForUser((int)$first['id'], $requester))->not->toBeNull() + ->and(array_column($queue->listJobsForCreatedBy([], 50, 0, null, $requester), 'id')) + ->toContain((int)$first['id']) + ->and(array_column($queue->listMonitorJobsForUser($requester), 'id')) + ->toContain((int)$first['id']) + ->and($queue->getJobByIdForUser((int)$first['id'], $unrelated))->toBeNull(); + } finally { + economic_transfer_queue_cleanup_for_created_by($db, $requester); + economic_transfer_queue_cleanup_for_created_by($db, $creator); + $db->close(); + } +}); + it('processes only collected-invoice jobs and respects the manual batch limit', function (): void { $db = economic_transfer_queue_integration_db(); $created_by = 921500 + random_int(1000, 9999); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAccessScopeTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAccessScopeTest.php index 07d76eb9..271adead 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAccessScopeTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAccessScopeTest.php @@ -18,7 +18,10 @@ it('scopes economic transfer queue route reads and retries to the current user', expect($collected_invoice_route)->toContain('$queue->getJobByIdForUser($job_id, $created_by)'); expect($collected_invoice_route)->toContain('$queue->retryJobForUser($job_id, (int)$user->id)'); - expect($queue)->toContain('SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1'); - expect($queue)->toContain('WHERE q.created_by = $user_id'); - expect($queue)->toContain('AND created_by = ?'); + expect($queue)->toContain('LEFT JOIN economic_transfer_queue_job_requesters r') + ->and($queue)->toContain('q.created_by = ? OR r.user_id = ?') + ->and($queue)->toContain('private function jobVisibilitySql(string $alias, int $user_id): string') + ->and($queue)->toContain('requester.queue_job_id = $alias.id AND requester.user_id = $user_id') + ->and($queue)->toContain('$this->registerJobRequester((int)($active_job[\'id\'] ?? 0), $created_by);') + ->and($queue)->toContain('Only the queue job creator can retry this job'); }); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php index b2bb7d0f..b83c8581 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php @@ -15,6 +15,8 @@ it('defines economic transfer queue jobs schema bootstrap table and tracking col expect($content)->toContain('user_id INT NOT NULL'); expect($content)->toContain('dismissed_status VARCHAR(32) NOT NULL'); expect($content)->toContain('PRIMARY KEY (queue_job_id, user_id)'); + expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_requesters'); + expect($content)->toContain('idx_economic_transfer_queue_job_requesters_user'); }); it('provides queue processor class constants and processing entrypoint', function (): void { diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php new file mode 100644 index 00000000..9e42f663 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php @@ -0,0 +1,94 @@ +toContain('$preview[\'content_digest\'] = $this->previewContentDigest($preview);') + ->toContain('hash_equals((string)($cached[\'preview\'][\'content_digest\'] ?? \'\'), $freshDigest)') + ->toContain('throw new invoice_collection_bulk_action_conflict(') + ->and($route) + ->toContain('catch (invoice_collection_bulk_action_conflict $e)') + ->toContain('$response->error($e->getMessage(), 409);'); +}); + +it('changes the stale-preview digest when an export-relevant line changes', function (): void { + global $db; + + $previousDb = $GLOBALS['db'] ?? null; + $hadDb = array_key_exists('db', $GLOBALS); + $db = new class { + public array $rows = []; + + public function query(string $sql): object + { + expect($sql) + ->toContain('oi.price') + ->toContain('oi.quantity') + ->toContain('p.economic_product_id') + ->toContain('o.reference AS order_reference'); + return new class($this->rows) { + private int $index = 0; + + public function __construct(private readonly array $rows) + { + } + + public function fetch_assoc(): ?array + { + return $this->rows[$this->index++] ?? null; + } + }; + } + }; + + try { + $service = (new ReflectionClass(invoice_collection_bulk_action_service::class))->newInstanceWithoutConstructor(); + $method = (new ReflectionClass(invoice_collection_bulk_action_service::class)) + ->getMethod('collectionExportContentDigest'); + + $db->rows = [[ + 'order_id' => 91, + 'order_item_id' => 191, + 'product_id' => 7, + 'price' => 100, + 'quantity' => 1, + 'product_name' => 'Wash', + 'economic_product_id' => 700, + ]]; + $before = $method->invoke($service, 41); + $db->rows[0]['price'] = 125; + $after = $method->invoke($service, 41); + + expect($before)->toMatch('/^[a-f0-9]{64}$/') + ->and($after)->toMatch('/^[a-f0-9]{64}$/') + ->and($after)->not->toBe($before); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('enqueues bulk E-conomic transfers durably and deduplicates active jobs by global target', function (): void { + $service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php')); + $queue = (string)file_get_contents(app_path('classes/economic_transfer_queue.php')); + + expect($service) + ->toContain('self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic(') + ->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT') + ->toContain("'queue_job_ids'") + ->toContain('public static function assertCollectionCanQueueEconomic(') + ->toContain('(new economic())->assertCustomerNumberIsNotDraft(') + ->and($queue) + ->toContain('Active work is unique by transfer type and business target across all requesting users.') + ->toContain('CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, \'$json_path\')) AS UNSIGNED) = ?') + ->not->toContain("CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$" . "json_path')) AS UNSIGNED) = ?\n AND created_by = ?"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index ad7a1eff..5ec08ea0 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -1077,6 +1077,39 @@ it('validates supported manual flag fields by target type', function (): void { invoice_period_flag_service_invoke('normalizeField', ['order_field', 'price']); })->throws(InvalidArgumentException::class, 'Invalid order flag field.'); +it('refreshes the shared manual flag cache immediately after create and status mutations', function (): void { + $content = (string)file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect(substr_count($content, '$this->refreshManualFlagsCacheAfterMutation();'))->toBe(2) + ->and($content)->toContain('$this->manualFlagsInstanceCache = null;') + ->and($content)->toContain('$this->warmManualFlagsCache();'); +}); + +it('derives aggregate manual flag counts without returning restricted flag details', function (): void { + $types = [ + 'all' => [[ + 'customer_number' => 7701, + 'transactions' => [['id' => 8801, 'invoice_collection_id' => 9901]], + ]], + ]; + $manualFlags = [[ + 'id' => 41, + 'source' => 'manual', + 'status' => 'active', + 'target_type' => 'customer', + 'target_id' => 7701, + 'customer_number' => 7701, + ]]; + + $result = invoice_period_flag_service_invoke('applyManualFlagCounts', [$types, $manualFlags]); + + expect($result['all'][0]['flag_counts'])->toBe([ + 'manual' => 1, + 'automatic' => 0, + 'total' => 1, + ])->and($result['all'][0])->not->toHaveKey('flags'); +}); + it('wires invoice period flag routes with explicit list create and update permissions', function (): void { $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php index bcd7898e..025805c6 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php @@ -23,3 +23,19 @@ it('documents period invoice states and authenticated attachment content', funct expect($content)->toContain('enum: [draft, booked, none]'); } }); + +it('documents the exception-first period review contract and workflow queries', function (): void { + $content = (string)file_get_contents(app_path('openapi.yaml')); + + expect($content) + ->toContain('- name: reviewState') + ->toContain('- name: severity') + ->toContain('- name: invoiceState') + ->toContain('- name: departmentId') + ->toContain('enum: [priority, customer_name, customer_number, total_amount]') + ->toContain('InvoicingPeriodReview:') + ->toContain('required: [state, severity, reasons, next_action, is_actionable, counts]') + ->toContain('enum: [blocked, attention, queued, ready, completed]') + ->toContain('InvoicingPeriodPagination:') + ->toContain('required: [review_states, severities, invoice_states, department_ids]'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php index 56e1e69b..39f190e7 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php @@ -69,6 +69,7 @@ it('detects paginated period mode only when pagination parameters are present', $previousResponse = $GLOBALS['response'] ?? null; $previousGet = $_GET; $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $previousQueryString = $_SERVER['QUERY_STRING'] ?? null; $response = new response(); try { @@ -80,6 +81,22 @@ it('detects paginated period mode only when pagination parameters are present', expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull(); + $_GET['flagTab'] = 'yellow'; + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([ + 'flagTab' => 'yellow', + ]); + + unset($_GET['flagTab']); + $_SERVER['QUERY_STRING'] = 'reviewState=blocked&reviewState=ready&departmentId=4%2C8'; + $_GET['reviewState'] = 'ready'; + $_GET['departmentId'] = '4,8'; + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([ + 'reviewStates' => ['blocked', 'ready'], + 'departmentIds' => [4, 8], + ]); + + $_SERVER['QUERY_STRING'] = ''; + unset($_GET['reviewState'], $_GET['departmentId']); $_GET['page'] = '2'; expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([ 'periodView' => 'all', @@ -96,6 +113,11 @@ it('detects paginated period mode only when pagination parameters are present', } else { $_SERVER['REQUEST_METHOD'] = $previousMethod; } + if ($previousQueryString === null) { + unset($_SERVER['QUERY_STRING']); + } else { + $_SERVER['QUERY_STRING'] = $previousQueryString; + } if ($previousResponse === null) { unset($GLOBALS['response']); } else { @@ -104,6 +126,26 @@ it('detects paginated period mode only when pagination parameters are present', } }); +it('normalizes workflow filters and deterministic review sorting', function (): void { + $options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'reviewState' => ['blocked,ready', 'unknown'], + 'severity' => 'red,green,invalid', + 'invoiceState' => 'open,economic_booked', + 'departmentId' => ['4,8', '0', '-1'], + 'sort' => 'priority', + 'direction' => 'DESC', + ]]); + + expect($options)->toMatchArray([ + 'reviewStates' => ['blocked', 'ready'], + 'severities' => ['red', 'green'], + 'invoiceStates' => ['open', 'economic_booked'], + 'departmentIds' => [4, 8], + 'sort' => 'priority', + 'direction' => 'desc', + ]); +}); + it('normalizes period pagination options and clamps invalid page and limit values', function (): void { $options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ 'periodView' => 'not-a-view', @@ -121,6 +163,12 @@ it('normalizes period pagination options and clamps invalid page and limit value 'limit' => 500, 'search' => 'Nordic', 'flagTab' => 'all', + 'reviewStates' => [], + 'severities' => [], + 'invoiceStates' => [], + 'departmentIds' => [], + 'sort' => 'customer_name', + 'direction' => 'asc', 'includeRequiresAction' => false, 'includeBooked' => false, ]); @@ -465,3 +513,177 @@ it('filters period flag tabs using active customer-scoped flag counts', function 'total' => 3, ]); }); + +it('derives review workflow metadata, facets and priority order without persisted review state', function (): void { + $period = [ + 'types' => [ + 'all' => [ + invoicing_period_customer_card(5105, 'Completed', [ + invoicing_period_transaction([ + 'customer_number' => 5105, + 'booked' => true, + 'invoice_state' => 'economic_booked', + 'department_id' => 5, + ]), + ]), + invoicing_period_customer_card(5104, 'Ready', [ + invoicing_period_transaction([ + 'customer_number' => 5104, + 'invoice_state' => 'open', + 'department_id' => 4, + ]), + ]), + invoicing_period_customer_card(5103, 'Queued', [ + invoicing_period_transaction(['customer_number' => 5103, 'invoice_state' => 'closed']), + ], false, [ + 'queue' => [ + 'has_active_job' => true, + 'job_ids' => [71], + 'statuses' => ['pending'], + 'is_action_blocked' => true, + ], + ]), + invoicing_period_customer_card(5102, 'Attention', [ + invoicing_period_transaction(['customer_number' => 5102, 'invoice_state' => 'economic_draft']), + ], false, [ + 'flags' => [['source' => 'automatic', 'status' => 'active']], + ]), + invoicing_period_customer_card(5101, 'Blocked', [ + invoicing_period_transaction(['customer_number' => 5101, 'invoice_state' => 'open']), + ], false, [ + 'flags' => [['source' => 'manual', 'status' => 'active']], + 'invoice_collections' => [[ + 'id' => 9101, + 'state' => 'open', + 'external_id' => 'DRAFT-ERROR-9101', + 'error_message' => 'E-conomic rejected VAT code', + ]], + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'sort' => 'priority', + 'direction' => 'asc', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect(array_column($result['period']['types']['all'], 'customer_number')) + ->toBe([5101, 5102, 5103, 5104, 5105]); + expect($result['period']['types']['all'][0]['review'])->toMatchArray([ + 'state' => 'blocked', + 'severity' => 'red', + 'next_action' => 'resolve_manual_flags', + 'is_actionable' => true, + 'counts' => [ + 'active_manual_flags' => 1, + 'active_automatic_flags' => 0, + 'collection_errors' => 1, + 'active_queue_jobs' => 0, + 'booked_transactions' => 0, + 'unbooked_transactions' => 1, + ], + ]); + expect(array_column($result['pagination']['facets']['review_states'], 'value')) + ->toBe(['attention', 'blocked', 'completed', 'queued', 'ready']); + expect($result['pagination']['order'])->toBe(['field' => 'priority', 'direction' => 'asc']); +}); + +it('filters workflow facets and searches collection identifiers and error details', function (): void { + $period = [ + 'types' => [ + 'all' => [ + invoicing_period_customer_card(5201, 'Collection Failure', [ + invoicing_period_transaction([ + 'customer_number' => 5201, + 'invoice_state' => 'open', + 'department_id' => 12, + ]), + ], false, [ + 'invoice_collections' => [[ + 'id' => 44001, + 'invoice_collection_id' => 44001, + 'external_id' => 'EXT-REVIEW-7788', + 'booked_invoice_id' => null, + 'error_message' => 'VAT account missing', + 'state' => 'open', + ]], + ]), + invoicing_period_customer_card(5202, 'Other Customer', [ + invoicing_period_transaction([ + 'customer_number' => 5202, + 'booked' => true, + 'invoice_state' => 'economic_booked', + 'department_id' => 13, + ]), + ]), + ], + ], + ]; + + $searched = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => 'vat account missing', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + expect(array_column($searched['period']['types']['all'], 'customer_number'))->toBe([5201]); + + $filtered = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'reviewStates' => ['completed'], + 'severities' => ['green'], + 'invoiceStates' => ['economic_booked'], + 'departmentIds' => [13], + 'sort' => 'customer_number', + 'direction' => 'asc', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + expect($filtered['pagination']['total'])->toBe(1) + ->and($filtered['period']['types']['all'][0]['customer_number'])->toBe(5202) + ->and($filtered['pagination']['filters'])->toMatchArray([ + 'reviewState' => ['completed'], + 'severity' => ['green'], + 'invoiceState' => ['economic_booked'], + 'departmentId' => [13], + ]); +}); + +it('blocks review from aggregate manual counts when restricted flag details are absent', function (): void { + $period = [ + 'types' => [ + 'all' => [ + invoicing_period_customer_card(5301, 'Restricted Flag Details', [ + invoicing_period_transaction(['customer_number' => 5301]), + ], false, [ + 'flag_counts' => ['manual' => 2, 'automatic' => 0, 'total' => 2], + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['period']['types']['all'][0])->not->toHaveKey('flags') + ->and($result['period']['types']['all'][0]['review'])->toMatchArray([ + 'state' => 'blocked', + 'severity' => 'red', + 'next_action' => 'resolve_manual_flags', + ]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php index f6cb7f09..2d0a9aec 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php @@ -54,6 +54,7 @@ it('streams the main period response instead of encoding the full payload at onc it('only includes invoice period flags when the list permission is granted', function (): void { $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + $flagService = (string)file_get_contents(app_path('classes/invoice_period_flag_service.php')); expect($content)->not->toBeFalse(); $content = (string)$content; @@ -62,7 +63,9 @@ it('only includes invoice period flags when the list permission is granted', fun ->toContain("\$includeInvoicePeriodFlags = \$this->hasPermission('list_invoice_period_flags');") ->and($content)->toContain('bool $includeInvoicePeriodFlags = false') ->and($content)->toContain('if ($includeInvoicePeriodFlags) {') - ->and($content)->toContain('applyFlagsToPeriodTypes('); + ->and($content)->toContain('applyFlagsToPeriodTypes(') + ->and($content)->toContain('applyManualFlagCountsToPeriodTypes(') + ->and($flagService)->toContain("unset(\$types[\$typeName][\$index]['flags']);"); }); it('maps batched period transaction rows to the legacy transaction response shape', function (): void {