CaiWuXiTong/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php
yueyang 26c86df915 fix: 修正OA拒绝状态并支持打款日期范围 (#704)
审批拒绝优先于流程完成;打款时间改为开始至结算日期范围。
2026-10-27 19:23:46 +08:00

434 lines
15 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 RuntimeException;
class DingProcessDetailMirrorRepository
{
public const DEFAULT_SCOPE = 'personal_permission';
public const DATASET_PREFIX = 'ding.process_details.';
public const PAYMENT_STATES = [
'待付款',
'已付款',
'付款待核实',
'不涉及付款',
];
public const LEGACY_PAYMENT_STATE = '未确认';
public const LEGACY_PAYMENT_STATE_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 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>}
*/
public function filterOptions(array $filters = []): array
{
if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) {
return ['document_types' => [], 'statuses' => [], 'payment_states' => []];
}
$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),
];
}
/** @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 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, ? 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(),
);
}
foreach ([
'document_type' => 'document_type',
'status' => 'status',
] as $filter => $column) {
$value = trim((string) ($filters[$filter] ?? ''));
if ($value !== '') {
$query->where('ding_process_detail_mirror.' . $column, $value);
}
}
foreach ([
'business_id' => 'business_id',
'originator_name' => 'originator_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';
return "CASE
WHEN {$sourceState} = 'not_applicable'
OR {$cashierState} = 'terminated'
OR (({$cashierState} IS NULL OR {$cashierState} = '') AND {$workflowStatus} = '已终止')
THEN '不涉及付款'
WHEN {$cashierState} = 'completed'
OR (({$cashierState} IS NULL OR {$cashierState} = '')
AND {$workflowStatus} = '已完成'
AND {$sourceState} <> 'not_applicable')
THEN '已付款'
WHEN {$cashierState} IN ('pending', 'returned', 'not_started') 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;
}
return Carbon::parse((string) $state['last_succeeded_at'])
->lt(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';
}
}