576 lines
20 KiB
PHP
576 lines
20 KiB
PHP
<?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';
|
||
}
|
||
}
|