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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user