CaiWuXiTong/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php
yueyang a67d458310 feat: 增加OA已等待区间筛选 (#793)
以已等待时长替代进入时间日期筛选,支持不足1小时至2天以上区间。
2026-10-30 16:58:18 +08:00

576 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Support\OaProcessTime;
use RuntimeException;
class DingProcessDetailMirrorRepository
{
public const DEFAULT_SCOPE = 'personal_permission';
public const DATASET_PREFIX = 'ding.process_details.';
public const PAYMENT_STATES = [
'待付款',
'已付款',
'不涉及付款',
];
public const WAITING_BUCKET_LABELS = [
'lt_1h' => '不足1小时',
'1_12h' => '1–12小时',
'12_24h' => '12–24小时',
'1_2d' => '1–2天',
'gte_2d' => '2天以上',
];
public const LEGACY_PAYMENT_STATE = '未确认';
public const LEGACY_PAYMENT_STATE_MAP = [
'付款待核实' => '待付款',
'未确认' => '待付款',
'出纳状态待同步' => '待付款',
'出纳未开始' => '待付款',
'出纳待打款' => '待付款',
'出纳退回' => '待付款',
'出纳已打款' => '已付款',
'流程终止' => '不涉及付款',
];
public const LEGACY_STATUS_MAP = [
'已终止' => '已撤销',
'已撤回' => '已撤销',
];
private readonly ConnectionInterface $connection;
private ?bool $paymentSourceStateColumn = null;
private ?bool $cashierStatusColumn = null;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
public function scope(): string
{
$scope = trim((string) config(
'upstream_mirrors.ding_process_details.scope',
self::DEFAULT_SCOPE,
));
return $scope !== '' ? $scope : self::DEFAULT_SCOPE;
}
public static function normalizePaymentStateFilter(mixed $value): string
{
$paymentState = trim((string) $value);
$paymentState = self::LEGACY_PAYMENT_STATE_MAP[$paymentState] ?? $paymentState;
return in_array($paymentState, self::PAYMENT_STATES, true) ? $paymentState : '';
}
public static function normalizeStatusFilter(mixed $value): string
{
$status = trim((string) $value);
return self::LEGACY_STATUS_MAP[$status] ?? $status;
}
public static function normalizeWaitingBucketFilter(mixed $value): string
{
$bucket = trim((string) $value);
return array_key_exists($bucket, self::WAITING_BUCKET_LABELS) ? $bucket : '';
}
public function datasetKey(): string
{
return self::DATASET_PREFIX . $this->scope();
}
public function ready(): bool
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('ding_process_detail_mirror')
|| ! $schema->hasTable('business_mirror_table_states')) {
return false;
}
$state = $this->state();
return $state !== null && ($state['initial_completed_at'] ?? null) !== null;
}
/** @return array<string,mixed>|null */
public function state(): ?array
{
if (! Schema::connection($this->connection->getName())->hasTable('business_mirror_table_states')) {
return null;
}
$row = $this->connection->table('business_mirror_table_states')
->where('dataset_key', $this->datasetKey())
->first();
return $row === null ? null : (array) $row;
}
/**
* @param array<string,string> $filters
* @return array{
* data:list<object>,
* total:int,
* summary:array{rows:int,approvals:int,amount_total:float,bank_amount_total:float},
* status:string,
* last_synced_at:?string,
* sync_stale:bool,
* sync_stale_after_minutes:int,
* scope:string
* }
*/
public function paginate(array $filters, int $page, int $perPage): array
{
if (! $this->ready()) {
throw new RuntimeException('ding_process_detail_mirror_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 200);
$query = $this->filteredQuery($filters);
$total = (clone $query)->count();
$bankAmountTotal = $this->structuredPaymentsEnabled()
? (float) ($this->connection->table('ding_process_payment_mirror')
->where('source_scope', $this->scope())
->whereIn(
'business_id',
(clone $query)->select('ding_process_detail_mirror.business_id'),
)
->sum('amount') ?? 0)
: (float) ((clone $query)->sum('ding_process_detail_mirror.bank_amount') ?? 0);
$summary = [
'rows' => $total,
'approvals' => (clone $query)->distinct()->count('ding_process_detail_mirror.business_id'),
'amount_total' => (float) ((clone $query)->sum('ding_process_detail_mirror.amount') ?? 0),
'bank_amount_total' => $bankAmountTotal,
];
$data = $query
->orderByDesc('ding_process_detail_mirror.business_month')
->orderByDesc('ding_process_detail_mirror.business_id')
->orderBy('ding_process_detail_mirror.line_no')
->forPage($page, $perPage)
->get()
->all();
$state = $this->state();
$staleAfterMinutes = max(
15,
(int) config('upstream_mirrors.ding_process_details.sync_interval_minutes', 5) * 3,
);
return [
'data' => $data,
'total' => $total,
'summary' => $summary,
'status' => (string) ($state['status'] ?? 'unknown'),
'last_synced_at' => isset($state['last_succeeded_at'])
? (string) $state['last_succeeded_at']
: null,
'sync_stale' => $this->stateIsStale($state, $staleAfterMinutes),
'sync_stale_after_minutes' => $staleAfterMinutes,
'scope' => $this->scope(),
];
}
/**
* @param array<string,string> $filters
* @return array{
* document_types:list<string>,
* statuses:list<string>,
* payment_states:list<string>,
* current_approvers:list<string>
* }
*/
public function filterOptions(array $filters = []): array
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) {
return [
'document_types' => [],
'statuses' => [],
'payment_states' => [],
'current_approvers' => [],
];
}
$base = $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $this->scope());
return [
'document_types' => (clone $base)
->whereNotNull('document_type')
->where('document_type', '!=', '')
->distinct()
->orderBy('document_type')
->pluck('document_type')
->map(static fn (mixed $value): string => (string) $value)
->all(),
'statuses' => (clone $base)
->whereNotNull('status')
->where('status', '!=', '')
->distinct()
->orderBy('status')
->pluck('status')
->map(static fn (mixed $value): string => (string) $value)
->all(),
'payment_states' => $this->paymentStatesAvailableFor($filters),
'current_approvers' => $this->currentApproverOptions(),
];
}
/** @return list<string> */
private function currentApproverOptions(): array
{
if (! Schema::connection($this->connection->getName())->hasColumn(
'ding_process_detail_mirror',
'current_approver_name',
)) {
return [];
}
$values = $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $this->scope())
->whereNotNull('current_approver_name')
->where('current_approver_name', '!=', '')
->distinct()
->pluck('current_approver_name');
$names = [];
foreach ($values as $value) {
foreach (preg_split(
'/\s*[、,,;;|]\s*/u',
(string) $value,
-1,
PREG_SPLIT_NO_EMPTY,
) ?: [] as $name) {
$name = trim($name);
if ($name !== '') {
$names[$name] = true;
}
}
}
$names = array_keys($names);
sort($names, SORT_NATURAL | SORT_FLAG_CASE);
return $names;
}
/** @param array<string,string> $filters */
public function exportCount(array $filters): int
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) {
return 0;
}
return (int) $this->filteredQuery($filters)->count();
}
/** @param list<string> $businessIds @return list<string> */
public function activeBusinessIds(array $businessIds): array
{
$businessIds = array_values(array_unique(array_filter(array_map(
static fn (mixed $businessId): string => mb_substr(trim((string) $businessId), 0, 32),
$businessIds,
))));
if ($businessIds === [] || ! $this->ready()) {
return [];
}
return $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $this->scope())
->where('status', '审批中')
->whereIn('business_id', array_slice($businessIds, 0, 50))
->distinct()
->orderBy('business_id')
->pluck('business_id')
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->values()
->all();
}
/**
* @param array<string,string> $filters
* @return \Generator<int,array<string,mixed>>
*/
public function exportRows(array $filters): \Generator
{
$query = $this->filteredQuery($filters)
->orderByDesc('business_month')
->orderByDesc('business_id')
->orderBy('line_no');
foreach ($query->cursor() as $row) {
yield (array) $row;
}
}
/** @param array<string,string> $filters */
private function filteredQuery(array $filters): Builder
{
$query = $this->connection->table('ding_process_detail_mirror')
->select('ding_process_detail_mirror.*');
if ($this->structuredPaymentsEnabled()) {
$detailTotals = $this->connection->table('ding_process_detail_mirror')
->select('business_id')
->selectRaw('SUM(COALESCE(amount, 0)) as approval_amount')
->where('source_scope', $this->scope())
->groupBy('business_id');
$paymentTotals = $this->connection->table('ding_process_payment_mirror')
->select('business_id')
->selectRaw('SUM(COALESCE(amount, 0)) as payment_amount')
->where('source_scope', $this->scope())
->groupBy('business_id');
$query
->leftJoinSub($detailTotals, 'detail_totals', function ($join): void {
$join->on(
'detail_totals.business_id',
'=',
'ding_process_detail_mirror.business_id',
);
})
->leftJoinSub($paymentTotals, 'payment_totals', function ($join): void {
$join->on(
'payment_totals.business_id',
'=',
'ding_process_detail_mirror.business_id',
);
})
->selectRaw(
'COALESCE(payment_totals.payment_amount, 0) as payment_fact_amount, '
. 'COALESCE(detail_totals.approval_amount, 0) '
. '- COALESCE(payment_totals.payment_amount, 0) as payment_difference, '
. $this->paymentStateExpression() . ' as payment_state',
);
} else {
$query
->selectRaw(
'NULL as payment_fact_amount, NULL as payment_difference, '
. $this->paymentStateExpression() . ' as payment_state',
);
}
return $this->applyFilters($query, $filters);
}
/** @param array<string,string> $filters */
private function applyFilters(Builder $query, array $filters): Builder
{
$query->where('ding_process_detail_mirror.source_scope', $this->scope());
$paidStart = $this->filterDate($filters['paid_start'] ?? '');
$paidEnd = $this->filterDate($filters['paid_end'] ?? '');
if ($paidStart === '' && $paidEnd === '') {
$paidStart = $this->filterDate($filters['paid_date'] ?? '');
$paidEnd = $paidStart;
}
if ($paidStart !== '' && $paidEnd !== '' && $paidStart > $paidEnd) {
[$paidStart, $paidEnd] = [$paidEnd, $paidStart];
}
if ($paidStart !== '') {
$query->where('ding_process_detail_mirror.paid_at', '>=', $paidStart);
}
if ($paidEnd !== '') {
$query->where(
'ding_process_detail_mirror.paid_at',
'<',
Carbon::createFromFormat('Y-m-d', $paidEnd)->addDay()->toDateString(),
);
}
$waitingBucket = self::normalizeWaitingBucketFilter($filters['waiting_bucket'] ?? '');
if ($waitingBucket !== '') {
$now = OaProcessTime::now();
$column = 'ding_process_detail_mirror.current_task_started_at';
$query
->whereNotNull($column)
->where($column, '<=', $now->format('Y-m-d H:i:s'));
if ($waitingBucket === 'lt_1h') {
$query->where($column, '>', $now->subHour()->format('Y-m-d H:i:s'));
} elseif ($waitingBucket === '1_12h') {
$query
->where($column, '>', $now->subHours(12)->format('Y-m-d H:i:s'))
->where($column, '<=', $now->subHour()->format('Y-m-d H:i:s'));
} elseif ($waitingBucket === '12_24h') {
$query
->where($column, '>', $now->subHours(24)->format('Y-m-d H:i:s'))
->where($column, '<=', $now->subHours(12)->format('Y-m-d H:i:s'));
} elseif ($waitingBucket === '1_2d') {
$query
->where($column, '>', $now->subHours(48)->format('Y-m-d H:i:s'))
->where($column, '<=', $now->subHours(24)->format('Y-m-d H:i:s'));
} else {
$query->where($column, '<=', $now->subHours(48)->format('Y-m-d H:i:s'));
}
}
foreach ([
'document_type' => 'document_type',
'status' => 'status',
] as $filter => $column) {
$value = trim((string) ($filters[$filter] ?? ''));
if ($filter === 'status') {
$value = self::normalizeStatusFilter($value);
}
if ($value !== '') {
$query->where('ding_process_detail_mirror.' . $column, $value);
}
}
foreach ([
'business_id' => 'business_id',
'originator_name' => 'originator_name',
'current_approver_name' => 'current_approver_name',
'keyword' => 'purpose',
] as $filter => $column) {
$value = mb_substr(trim((string) ($filters[$filter] ?? '')), 0, 100);
if ($value !== '') {
$query->where('ding_process_detail_mirror.' . $column, 'like', '%' . $value . '%');
}
}
$paymentState = self::normalizePaymentStateFilter($filters['payment_state'] ?? '');
if ($paymentState !== '') {
$query->whereRaw(
'(' . $this->paymentStateExpression() . ') = ?',
[$paymentState],
);
}
return $query;
}
private function paymentStateExpression(): string
{
$sourceState = $this->paymentSourceStateColumnAvailable()
? 'ding_process_detail_mirror.payment_source_state'
: "'missing'";
$cashierState = $this->cashierStatusColumnAvailable()
? 'ding_process_detail_mirror.cashier_status'
: "'pending_sync'";
$workflowStatus = 'ding_process_detail_mirror.status';
$paymentFacts = $this->structuredPaymentsEnabled()
? "EXISTS (
SELECT 1
FROM ding_process_payment_mirror AS payment_check
WHERE payment_check.business_id = ding_process_detail_mirror.business_id
AND payment_check.source_scope = ding_process_detail_mirror.source_scope
)"
: '0 = 1';
return "CASE
WHEN {$sourceState} = 'not_applicable'
OR {$cashierState} = 'terminated'
OR (({$cashierState} IS NULL OR {$cashierState} = '')
AND {$workflowStatus} IN ('已撤销', '已终止'))
THEN '不涉及付款'
WHEN {$cashierState} = 'completed' THEN '已付款'
WHEN {$workflowStatus} = '已完成'
AND {$sourceState} <> 'not_applicable'
THEN '已付款'
WHEN {$cashierState} IN ('pending', 'returned', 'not_started') THEN '待付款'
WHEN {$workflowStatus} = '审批中' AND ({$paymentFacts}) THEN '已付款'
ELSE '待付款'
END";
}
private function filterDate(mixed $value): string
{
$date = trim((string) $value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $parts) !== 1
|| ! checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) {
return '';
}
return $date;
}
/**
* @param array<string,string> $filters
* @return list<string>
*/
private function paymentStatesAvailableFor(array $filters): array
{
$optionFilters = $filters;
$optionFilters['payment_state'] = '';
$available = $this->applyFilters(
$this->connection->table('ding_process_detail_mirror'),
$optionFilters,
)
->selectRaw($this->paymentStateExpression() . ' as payment_state')
->groupByRaw($this->paymentStateExpression())
->pluck('payment_state')
->map(static fn (mixed $value): string => (string) $value)
->all();
return array_values(array_filter(
self::PAYMENT_STATES,
static fn (string $state): bool => in_array($state, $available, true),
));
}
private function paymentSourceStateColumnAvailable(): bool
{
return $this->paymentSourceStateColumn ??= Schema::connection(
$this->connection->getName(),
)->hasColumn('ding_process_detail_mirror', 'payment_source_state');
}
private function cashierStatusColumnAvailable(): bool
{
return $this->cashierStatusColumn ??= Schema::connection(
$this->connection->getName(),
)->hasColumn('ding_process_detail_mirror', 'cashier_status');
}
/** @param array<string,mixed>|null $state */
private function stateIsStale(?array $state, int $staleAfterMinutes): bool
{
if (($state['status'] ?? '') !== 'succeeded' || empty($state['last_succeeded_at'])) {
return true;
}
$lastSucceededAt = OaProcessTime::parse($state['last_succeeded_at']);
return $lastSucceededAt === null
|| $lastSucceededAt->lt(OaProcessTime::now()->subMinutes($staleAfterMinutes));
}
private function structuredPaymentsEnabled(): bool
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_payment_mirror')) {
return false;
}
$state = $this->state();
$cursor = $state['cursor'] ?? null;
if (is_string($cursor)) {
$cursor = json_decode($cursor, true);
}
return is_array($cursor) && ($cursor['source'] ?? '') === 'structured';
}
}