Add invoice period review workflow (#336)

Improve the superuser invoice-period review API, stale-preview protection, queue visibility, review blockers, and e-conomic eligibility.
This commit is contained in:
Jeppe B
2026-08-02 19:20:50 +02:00
committed by GitHub
parent 1e0e051775
commit c795df4aad
16 changed files with 1377 additions and 39 deletions
@@ -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,