From 9da9bb128f7812f7776270396926030b0188a702 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 27 Oct 2026 14:00:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E5=87=BA=E7=BA=B3=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=B1=95=E7=A4=BA=E6=B5=81=E7=A8=8B=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r_status_to_ding_process_detail_mirror.php | 51 ++++++ docs/上游业务镜像清单.md | 4 +- .../Http/Controllers/ProcessDetails.php | 5 +- .../DingProcessDetailMirrorRepository.php | 40 ++++- .../DingProcessDetailMirrorSynchronizer.php | 161 ++++++++++++++++-- .../DingTalkProcessInstanceClient.php | 34 +++- .../process-details-content.blade.php | 21 ++- .../Business/ProcessDetailsPageTest.php | 28 ++- .../DingProcessDetailMirrorRepositoryTest.php | 69 ++++---- ...ingProcessDetailMirrorSynchronizerTest.php | 120 ++++++++++++- .../ProcessDetailsPageDataTest.php | 26 ++- 11 files changed, 480 insertions(+), 79 deletions(-) create mode 100644 database/migrations/2026_10_27_130000_add_cashier_status_to_ding_process_detail_mirror.php diff --git a/database/migrations/2026_10_27_130000_add_cashier_status_to_ding_process_detail_mirror.php b/database/migrations/2026_10_27_130000_add_cashier_status_to_ding_process_detail_mirror.php new file mode 100644 index 00000000..1bb01458 --- /dev/null +++ b/database/migrations/2026_10_27_130000_add_cashier_status_to_ding_process_detail_mirror.php @@ -0,0 +1,51 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('ding_process_detail_mirror') + && ! $schema->hasColumn('ding_process_detail_mirror', 'cashier_status')) { + $schema->table('ding_process_detail_mirror', function (Blueprint $table): void { + $table->string('cashier_status', 32)->nullable()->after('payment_source_state'); + $table->dateTime('cashier_checked_at')->nullable()->after('cashier_status'); + $table->index( + ['source_scope', 'status', 'cashier_checked_at'], + 'ding_process_cashier_refresh_index', + ); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('ding_process_detail_mirror') + && $schema->hasColumn('ding_process_detail_mirror', 'cashier_status')) { + $schema->table('ding_process_detail_mirror', function (Blueprint $table): void { + $table->dropIndex('ding_process_cashier_refresh_index'); + $table->dropColumn(['cashier_status', 'cashier_checked_at']); + }); + } + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 6b1fbc45..f6c3e0f6 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -35,9 +35,9 @@ | 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 | | 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror` 与 `ding_process_payment_mirror`。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 | -流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `scripts/install-ding-process-sync-cron.ps1` 安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。页面按审批单费用合计和独立付款事实展示“付款明细待同步 / 未付款 / 部分付款 / 已付款 / 不适用”:只有源表单明确存在付款表或付款合计字段但金额为零时才标“未付款”,付款表单缺失时必须显示“付款明细待同步”,不能把上游尚未返回的数据误报为未付款。审批状态不能代替付款状态。 +流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `scripts/install-ding-process-sync-cron.ps1` 安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。 -`operations_replica` 缺失付款组件时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances` 补取官方最新表单。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的明细,只补缺失或空的付款字段。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。 +`operations_replica` 缺失付款组件或进行中流程需要刷新出纳节点时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances`。官方接口同时返回表单、`tasks.taskGroupName` 和 `operationRecords.showName`,用于识别“出纳打款”节点及退回/待处理/已通过状态。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的业务明细,只补缺失或空的付款字段,并独立保存“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”,`cashier_checked_at` 用于记录近期已检查时间。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失或需要刷新出纳状态的流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。 抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。 diff --git a/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php index a3f6a798..bdb0f0f4 100644 --- a/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php +++ b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php @@ -95,7 +95,7 @@ class ProcessDetails extends Controller return response()->streamDownload(function () use ($filters): void { $stream = fopen('php://output', 'wb'); fwrite($stream, "\xEF\xBB\xBF"); - fputcsv($stream, [...DingProcessDetailMirrorImporter::HEADERS, '付款状态']); + fputcsv($stream, [...DingProcessDetailMirrorImporter::HEADERS, '付款状态', '付款差额']); $number = 0; foreach ($this->repository->exportRows($filters) as $row) { @@ -117,6 +117,9 @@ class ProcessDetails extends Controller (string) ($row['payment_method'] ?? ''), (string) ($row['paid_at'] ?? ''), (string) ($row['payment_state'] ?? ''), + isset($row['payment_difference']) + ? number_format((float) $row['payment_difference'], 2, '.', '') + : '', ]); } diff --git a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php index c8f4553e..d1d42991 100644 --- a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php @@ -15,7 +15,15 @@ class DingProcessDetailMirrorRepository public const DATASET_PREFIX = 'ding.process_details.'; - public const PAYMENT_STATES = ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用']; + public const PAYMENT_STATES = [ + '出纳状态待同步', + '出纳未开始', + '出纳待打款', + '出纳退回', + '出纳已打款', + '流程终止', + '不涉及付款', + ]; public const LEGACY_PAYMENT_STATE = '未确认'; @@ -23,6 +31,8 @@ class DingProcessDetailMirrorRepository private ?bool $paymentSourceStateColumn = null; + private ?bool $cashierStatusColumn = null; + public function __construct(DatabaseManager $database) { $this->connection = $database->connection( @@ -233,12 +243,14 @@ class DingProcessDetailMirrorRepository }) ->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, ? as payment_state', + 'NULL as payment_fact_amount, NULL as payment_difference, ? as payment_state', [self::LEGACY_PAYMENT_STATE], ); } @@ -300,15 +312,18 @@ class DingProcessDetailMirrorRepository $sourceState = $this->paymentSourceStateColumnAvailable() ? 'ding_process_detail_mirror.payment_source_state' : "'missing'"; + $cashierState = $this->cashierStatusColumnAvailable() + ? 'ding_process_detail_mirror.cashier_status' + : "'pending_sync'"; return "CASE - WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 - AND payment_totals.payment_amount + 0.01 - < COALESCE(detail_totals.approval_amount, 0) THEN '部分付款' - WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 THEN '已付款' - WHEN {$sourceState} = 'not_applicable' THEN '不适用' - WHEN {$sourceState} IN ('present', 'api') THEN '未付款' - ELSE '付款明细待同步' + WHEN {$sourceState} = 'not_applicable' THEN '不涉及付款' + WHEN {$cashierState} = 'completed' THEN '出纳已打款' + WHEN {$cashierState} = 'pending' THEN '出纳待打款' + WHEN {$cashierState} = 'returned' THEN '出纳退回' + WHEN {$cashierState} = 'not_started' THEN '出纳未开始' + WHEN {$cashierState} = 'terminated' THEN '流程终止' + ELSE '出纳状态待同步' END"; } @@ -319,6 +334,13 @@ class DingProcessDetailMirrorRepository )->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|null $state */ private function stateIsStale(?array $state, int $staleAfterMinutes): bool { diff --git a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php index 5f11a08b..015c42b3 100644 --- a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php @@ -40,6 +40,9 @@ class DingProcessDetailMirrorSynchronizer private bool $approvalApiAvailable = true; + /** @var array> */ + private array $approvalSnapshots = []; + public function __construct( DatabaseManager $database, private readonly UpstreamReadOnlyQueryFactory $queries, @@ -65,6 +68,7 @@ class DingProcessDetailMirrorSynchronizer { $this->approvalApiAttempts = 0; $this->approvalApiAvailable = true; + $this->approvalSnapshots = []; $scope = $this->scope(); $state = $this->state(); $structuredState = $this->structuredState($state); @@ -173,6 +177,7 @@ class DingProcessDetailMirrorSynchronizer $this->approvalApiAttempts = 0; $this->approvalApiAvailable = true; + $this->approvalSnapshots = []; $scope = $this->scope(); $state = $this->state(); if (! $this->structuredState($state)) { @@ -444,6 +449,10 @@ class DingProcessDetailMirrorSynchronizer )) { return []; } + $cashierRefreshEnabled = Schema::connection($this->connection->getName())->hasColumn( + 'ding_process_detail_mirror', + 'cashier_checked_at', + ); $remainingAttempts = self::APPROVAL_API_FALLBACK_LIMIT - $this->approvalApiAttempts; if (! $this->approvalApiAvailable || $remainingAttempts <= 0) { return []; @@ -452,7 +461,18 @@ class DingProcessDetailMirrorSynchronizer $base = $this->connection->table('ding_process_detail_mirror') ->where('source_scope', $this->scope()) ->whereIn('status', ['审批中', '已完成']) - ->where('payment_source_state', 'missing') + ->where(function ($query) use ($cashierRefreshEnabled): void { + $query->where('payment_source_state', 'missing'); + if ($cashierRefreshEnabled) { + $query->orWhere(function ($cashier): void { + $cashier->where('status', '审批中') + ->where(function ($checked): void { + $checked->whereNull('cashier_checked_at') + ->orWhere('cashier_checked_at', '<', now()->subMinutes(15)); + }); + }); + } + }) ->whereNotNull('process_instance_id') ->where('process_instance_id', '!=', ''); $excluded = array_fill_keys($excludedProcessInstanceIds, true); @@ -655,7 +675,7 @@ class DingProcessDetailMirrorSynchronizer } $workflows = $query->select( - 'SELECT w.processInstanceId, w.processCode, w.businessId, w.title, w.status, ' + 'SELECT w.processInstanceId, w.processCode, w.businessId, w.title, w.status, w.result, ' . 'w.originatorUserId, w.originatorDeptName, w.joinAt, w.updateAt, w.finishTime, ' . "{$cursorExpression} AS sync_cursor " . 'FROM rpt.ding_Workflow w ' @@ -849,8 +869,18 @@ class DingProcessDetailMirrorSynchronizer $status = $this->status((string) ($workflow['status'] ?? '')); $businessMonth = $this->businessMonth($businessId, (string) ($workflow['joinAt'] ?? '')); $paymentSourceState = $this->paymentSourceState($process, $fields); - if ($this->needsApprovalApiFallback($process, $fields, $paymentSourceState)) { - $apiFields = $this->approvalApiFields($workflow); + $needsPaymentApi = $this->needsApprovalApiFallback( + $process, + $fields, + $paymentSourceState, + ); + $needsCashierApi = $this->needsCashierApi($process, $workflow); + $approvalSnapshot = null; + if ($needsPaymentApi || $needsCashierApi) { + $approvalSnapshot = $this->approvalApiSnapshot($workflow); + } + if ($needsPaymentApi && $approvalSnapshot !== null) { + $apiFields = (array) ($approvalSnapshot['form_values'] ?? []); if ($apiFields !== []) { foreach ($apiFields as $fieldName => $fieldValue) { if (! array_key_exists($fieldName, $fields) @@ -863,6 +893,8 @@ class DingProcessDetailMirrorSynchronizer : $this->paymentSourceState($process, $fields); } } + $cashierStatus = $this->cashierStatus($process, $workflow, $approvalSnapshot); + $cashierCheckedAt = $cashierStatus === 'pending_sync' ? null : $now; [$workflowDetails, $workflowAmountCents, $detailTableName] = $this->detailRows( $workflow, @@ -876,6 +908,8 @@ class DingProcessDetailMirrorSynchronizer $status, $businessMonth, $paymentSourceState, + $cashierStatus, + $cashierCheckedAt, ); $this->validateAmountTotal( $businessId, @@ -911,6 +945,8 @@ class DingProcessDetailMirrorSynchronizer $status, $businessMonth, $paymentSourceState, + $cashierStatus, + $cashierCheckedAt, ); } @@ -954,6 +990,8 @@ class DingProcessDetailMirrorSynchronizer string $status, string $businessMonth, string $paymentSourceState, + string $cashierStatus, + ?Carbon $cashierCheckedAt, ): array { $tableName = trim((string) ($process['detail_table'] ?? '')); $rows = $tableName !== '' ? $this->tableRows($fields[$tableName] ?? null, $workflow, $tableName, $issues) : []; @@ -986,6 +1024,8 @@ class DingProcessDetailMirrorSynchronizer $status, $businessMonth, $paymentSourceState, + $cashierStatus, + $cashierCheckedAt, ); } @@ -1170,33 +1210,122 @@ class DingProcessDetailMirrorSynchronizer * @param array $workflow * @return array */ - private function approvalApiFields(array $workflow): array + private function approvalApiSnapshot(array $workflow): ?array { + $processInstanceId = trim((string) ($workflow['processInstanceId'] ?? '')); + if ($processInstanceId === '') { + return null; + } + if (isset($this->approvalSnapshots[$processInstanceId])) { + return $this->approvalSnapshots[$processInstanceId]; + } if (! $this->approvalClient->isConfigured() || ! $this->approvalApiAvailable || $this->approvalApiAttempts >= self::APPROVAL_API_FALLBACK_LIMIT) { - return []; - } - - $processInstanceId = trim((string) ($workflow['processInstanceId'] ?? '')); - if ($processInstanceId === '') { - return []; + return null; } $this->approvalApiAttempts++; try { - return $this->approvalClient->formValues($processInstanceId); + return $this->approvalSnapshots[$processInstanceId] = + $this->approvalClient->processSnapshot($processInstanceId); } catch (Throwable $exception) { $this->approvalApiAvailable = false; - Log::warning('钉钉官方审批接口补取流程表单失败。', [ + Log::warning('钉钉官方审批接口读取流程状态失败。', [ 'process_instance_id' => $processInstanceId, 'exception' => $exception::class, ]); - return []; + return null; } } + /** + * @param array $process + * @param array $workflow + */ + private function needsCashierApi(array $process, array $workflow): bool + { + return $this->paymentSourceFieldNames($process) !== [] + && strtoupper(trim((string) ($workflow['status'] ?? ''))) === 'RUNNING'; + } + + /** + * @param array $process + * @param array $workflow + * @param array|null $snapshot + */ + private function cashierStatus(array $process, array $workflow, ?array $snapshot): string + { + if ($this->paymentSourceFieldNames($process) === []) { + return 'not_applicable'; + } + + $workflowStatus = strtoupper(trim((string) ($workflow['status'] ?? ''))); + $workflowResult = strtoupper(trim((string) ($workflow['result'] ?? ''))); + if (in_array($workflowStatus, ['TERMINATED', 'CANCELED', 'CANCELLED', 'REVOKED'], true) + || in_array($workflowResult, ['REFUSE', 'REJECT', 'REJECTED'], true)) { + return 'terminated'; + } + if ($workflowStatus === 'COMPLETED') { + return 'completed'; + } + if ($workflowStatus !== 'RUNNING' || $snapshot === null) { + return 'pending_sync'; + } + + $events = []; + foreach ((array) ($snapshot['tasks'] ?? []) as $task) { + if (! is_array($task) + || ! $this->isCashierLabel((string) ($task['taskGroupName'] ?? ''))) { + continue; + } + $status = strtoupper(trim((string) ($task['status'] ?? ''))); + $result = strtoupper(trim((string) ($task['result'] ?? ''))); + $time = trim((string) ($task['finishTime'] ?? $task['createTime'] ?? '')); + if ($status === 'COMPLETED' && $result === 'AGREE') { + $events[] = ['time' => $time, 'state' => 'completed', 'priority' => 2]; + } elseif ($status === 'RUNNING') { + $events[] = ['time' => $time, 'state' => 'pending', 'priority' => 1]; + } elseif (in_array($result, ['REDIRECTED', 'REFUSE', 'REJECT'], true) + || $status === 'CANCELED') { + $events[] = ['time' => $time, 'state' => 'returned', 'priority' => 3]; + } + } + + foreach ((array) ($snapshot['operation_records'] ?? []) as $operation) { + if (! is_array($operation) + || ! $this->isCashierLabel((string) ($operation['showName'] ?? ''))) { + continue; + } + $type = strtoupper(trim((string) ($operation['type'] ?? ''))); + $result = strtoupper(trim((string) ($operation['result'] ?? ''))); + $time = trim((string) ($operation['date'] ?? '')); + if (str_starts_with($type, 'REDIRECT') + || in_array($result, ['REDIRECTED', 'REFUSE', 'REJECT'], true)) { + $events[] = ['time' => $time, 'state' => 'returned', 'priority' => 3]; + } elseif ($type === 'EXECUTE_TASK_NORMAL' && $result === 'AGREE') { + $events[] = ['time' => $time, 'state' => 'completed', 'priority' => 2]; + } + } + + if ($events === []) { + return 'not_started'; + } + usort($events, static function (array $left, array $right): int { + return [$left['time'], $left['priority']] <=> [$right['time'], $right['priority']]; + }); + + return (string) ($events[array_key_last($events)]['state'] ?? 'not_started'); + } + + private function isCashierLabel(string $label): bool + { + return str_contains($label, '出纳') && ( + str_contains($label, '打款') || str_contains($label, '付款') + ); + } + /** * @param array $process * @param array $apiFields @@ -1321,6 +1450,8 @@ class DingProcessDetailMirrorSynchronizer string $status, string $businessMonth, string $paymentSourceState, + string $cashierStatus, + ?Carbon $cashierCheckedAt, ): array { return [ 'business_id' => mb_substr(trim((string) ($workflow['businessId'] ?? '')), 0, 32), @@ -1334,6 +1465,8 @@ class DingProcessDetailMirrorSynchronizer 'originator_name' => mb_substr($originator, 0, 100), 'status' => mb_substr($status, 0, 20), 'payment_source_state' => mb_substr($paymentSourceState, 0, 24), + 'cashier_status' => mb_substr($cashierStatus, 0, 32), + 'cashier_checked_at' => $cashierCheckedAt, 'document_type' => mb_substr($documentType, 0, 100), 'purpose' => $purpose, 'account_code' => mb_substr($accountCode, 0, 64), diff --git a/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php b/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php index 2f48fb54..1d7de1ae 100644 --- a/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php +++ b/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php @@ -16,6 +16,9 @@ class DingTalkProcessInstanceClient private int $accessTokenExpiresAt = 0; + /** @var array> */ + private array $snapshots = []; + public function isConfigured(): bool { return trim((string) config('dingtalk.client_id', '')) !== '' @@ -28,11 +31,28 @@ class DingTalkProcessInstanceClient * @return array */ public function formValues(string $processInstanceId): array + { + return $this->processSnapshot($processInstanceId)['form_values']; + } + + /** + * @return array{ + * form_values:array, + * tasks:list>, + * operation_records:list>, + * status:?string, + * result:?string + * } + */ + public function processSnapshot(string $processInstanceId): array { $processInstanceId = trim($processInstanceId); if ($processInstanceId === '') { throw new RuntimeException('DingTalk process instance ID is required.'); } + if (isset($this->snapshots[$processInstanceId])) { + return $this->snapshots[$processInstanceId]; + } try { $response = Http::acceptJson() @@ -73,7 +93,19 @@ class DingTalkProcessInstanceClient } } - return $values; + return $this->snapshots[$processInstanceId] = [ + 'form_values' => $values, + 'tasks' => array_values(array_filter( + (array) ($result['tasks'] ?? []), + 'is_array', + )), + 'operation_records' => array_values(array_filter( + (array) ($result['operationRecords'] ?? []), + 'is_array', + )), + 'status' => isset($result['status']) ? (string) $result['status'] : null, + 'result' => isset($result['result']) ? (string) $result['result'] : null, + ]; } private function appAccessToken(): string diff --git a/resources/views/business/pages/financial-operations/process-details-content.blade.php b/resources/views/business/pages/financial-operations/process-details-content.blade.php index cd7c5666..a8ac6126 100644 --- a/resources/views/business/pages/financial-operations/process-details-content.blade.php +++ b/resources/views/business/pages/financial-operations/process-details-content.blade.php @@ -3,10 +3,10 @@ $mirrorStale = $mirrorStale ?? ($pageData['mirrorStale'] ?? false); $mirrorStaleAfterMinutes = $mirrorStaleAfterMinutes ?? ($pageData['mirrorStaleAfterMinutes'] ?? 15); $paymentStateClass = static fn (string $state): string => match ($state) { - '已付款' => 'is-success', - '部分付款' => 'is-warning', - '付款明细待同步', '未确认' => 'is-warning', - '未付款', '不适用' => 'is-neutral', + '出纳已打款' => 'is-success', + '出纳待打款', '出纳退回' => 'is-warning', + '出纳状态待同步', '未确认' => 'is-warning', + '出纳未开始', '流程终止', '不涉及付款' => 'is-neutral', default => 'is-neutral', }; @endphp @@ -273,6 +273,11 @@ font-variant-numeric: tabular-nums; } + .process-details-table .is-difference { + color: var(--vip-danger, #B80000); + font-weight: 600; + } + .process-details-table .center { text-align: center; } @@ -612,8 +617,8 @@

金额合计按审批费用明细汇总,付款合计按结构化付款明细汇总,同一笔付款不会因关联多条费用而重复计算。 - 付款状态按每张审批单的费用合计与独立付款事实判断,不能仅用审批状态推断是否已付款。 - “付款明细待同步”表示上游表单尚未返回付款明细,不能据此认定未付款。 + 付款状态按钉钉“出纳打款”节点判断;付款差额按“费用合计 − 已付款合计”单独计算,不参与付款状态判断。 + “出纳状态待同步”表示尚未取得出纳节点任务,不能据此推断已付款或未付款。 当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。 企业级全量数据待领导开通 OA 权限后切换。

@@ -637,6 +642,7 @@ 银行科目编码 银行科目名称 付款金额 + 付款差额 付款方式 打款时间 @@ -662,12 +668,13 @@ {{ $text($row->bank_account_code) }} {{ $text($row->bank_account_name) }} {{ $money($row->bank_amount) }} + {{ $money($row->payment_difference) }} {{ $text($row->payment_method) }} {{ $text($row->paid_at) }} @empty - + 当前筛选条件没有流程明细。可以调整月份、单据类型、状态或关键词后重试。 diff --git a/tests/Feature/Business/ProcessDetailsPageTest.php b/tests/Feature/Business/ProcessDetailsPageTest.php index 9748db9f..ef95bef4 100644 --- a/tests/Feature/Business/ProcessDetailsPageTest.php +++ b/tests/Feature/Business/ProcessDetailsPageTest.php @@ -22,8 +22,9 @@ class ProcessDetailsPageTest extends FeatureTestCase 'line_no' => 1, 'originator_name' => '张三', 'status' => '已完成', - 'payment_state' => '未付款', + 'payment_state' => '出纳已打款', 'payment_fact_amount' => 0, + 'payment_difference' => 0.20, 'document_type' => '公摊日常报销', 'purpose' => '办公用品采购', 'account_code' => '5602.04', @@ -52,7 +53,15 @@ class ProcessDetailsPageTest extends FeatureTestCase $repository->shouldReceive('filterOptions')->once()->andReturn([ 'document_types' => ['公摊日常报销'], 'statuses' => ['已完成'], - 'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], + 'payment_states' => [ + '出纳状态待同步', + '出纳未开始', + '出纳待打款', + '出纳退回', + '出纳已打款', + '流程终止', + '不涉及付款', + ], ]); $this->app->instance(DingProcessDetailMirrorRepository::class, $repository); @@ -76,9 +85,11 @@ class ProcessDetailsPageTest extends FeatureTestCase ->assertSee('name="payment_state"', false) ->assertSee('审批状态') ->assertSee('付款状态') - ->assertSee('未付款') - ->assertSee('付款明细待同步') + ->assertSee('出纳已打款') + ->assertSee('出纳状态待同步') ->assertSee('付款金额') + ->assertSee('付款差额') + ->assertSee('0.20') ->assertSee('付款合计') ->assertDontSee('银行付款合计') ->assertSee('流程数据已超过 15 分钟未成功同步') @@ -119,7 +130,8 @@ class ProcessDetailsPageTest extends FeatureTestCase 'business_id' => '202601011200000000001', 'originator_name' => '张三', 'status' => '已完成', - 'payment_state' => '未付款', + 'payment_state' => '出纳已打款', + 'payment_difference' => 0.20, 'document_type' => '公摊日常报销', 'purpose' => '办公用品采购', 'line_no' => 1, @@ -137,7 +149,7 @@ class ProcessDetailsPageTest extends FeatureTestCase $response = $this->loginAs()->get(route('business.process-details.export', [ 'status' => '已完成', - 'payment_state' => '未付款', + 'payment_state' => '出纳已打款', ])); $response->assertOk(); @@ -147,6 +159,8 @@ class ProcessDetailsPageTest extends FeatureTestCase $this->assertStringContainsString('张三', $content); $this->assertStringContainsString('管理费用_办公费', $content); $this->assertStringContainsString('付款状态', $content); - $this->assertStringContainsString('未付款', $content); + $this->assertStringContainsString('出纳已打款', $content); + $this->assertStringContainsString('付款差额', $content); + $this->assertStringContainsString('0.20', $content); } } diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php index 884fd3cb..cfd94eb9 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php @@ -44,10 +44,9 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase $this->assertEqualsWithDelta(650.5, $result['summary']['amount_total'], 0.001); $this->assertEqualsWithDelta(350.5, $result['summary']['bank_amount_total'], 0.001); $this->assertSame('personal_permission', $result['scope']); - $this->assertSame( - ['已付款', '部分付款'], - collect($result['data'])->pluck('payment_state')->unique()->sort()->values()->all(), - ); + $paymentStates = collect($result['data'])->pluck('payment_state')->unique()->all(); + $this->assertContains('出纳已打款', $paymentStates); + $this->assertContains('出纳待打款', $paymentStates); $options = $this->repository->filterOptions(); $this->assertArrayNotHasKey('months', $options); @@ -55,10 +54,15 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase $this->assertContains('应付申请流程', $options['document_types']); $this->assertContains('已完成', $options['statuses']); $this->assertContains('审批中', $options['statuses']); - $this->assertSame( - ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], - $options['payment_states'], - ); + $this->assertSame([ + '出纳状态待同步', + '出纳未开始', + '出纳待打款', + '出纳退回', + '出纳已打款', + '流程终止', + '不涉及付款', + ], $options['payment_states']); } public function test_paginate_applies_paid_date_status_and_keyword_filters(): void @@ -96,52 +100,50 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase ); $this->assertSame( 2, - $this->repository->paginate(['payment_state' => '已付款'], 1, 50)['total'], + $this->repository->paginate(['payment_state' => '出纳已打款'], 1, 50)['total'], ); $this->assertSame( 2, - $this->repository->paginate(['payment_state' => '部分付款'], 1, 50)['total'], + $this->repository->paginate(['payment_state' => '出纳待打款'], 1, 50)['total'], ); } - public function test_missing_payment_source_is_not_reported_as_unpaid(): void + public function test_pending_cashier_status_is_not_inferred_from_payment_amount(): void { - DB::connection('business_mirror') - ->table('ding_process_payment_mirror') - ->where('business_id', '202601011200000000001') - ->delete(); DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->where('business_id', '202601011200000000001') - ->update(['payment_source_state' => 'missing']); + ->update(['cashier_status' => 'pending_sync', 'cashier_checked_at' => null]); $result = $this->repository->paginate( - ['payment_state' => '付款明细待同步'], + ['payment_state' => '出纳状态待同步'], 1, 50, ); $this->assertSame(2, $result['total']); $this->assertSame( - ['付款明细待同步'], + ['出纳状态待同步'], collect($result['data'])->pluck('payment_state')->unique()->values()->all(), ); } - public function test_present_empty_payment_table_is_reported_as_unpaid(): void + public function test_payment_difference_is_separate_from_cashier_status(): void { DB::connection('business_mirror') ->table('ding_process_payment_mirror') ->where('business_id', '202602031200000000002') ->delete(); - $result = $this->repository->paginate(['payment_state' => '未付款'], 1, 50); + $result = $this->repository->paginate( + ['business_id' => '202602031200000000002'], + 1, + 50, + ); $this->assertSame(2, $result['total']); - $this->assertSame( - ['未付款'], - collect($result['data'])->pluck('payment_state')->unique()->values()->all(), - ); + $this->assertEqualsWithDelta(500.0, (float) $result['data'][0]->payment_difference, 0.001); + $this->assertSame('出纳待打款', $result['data'][0]->payment_state); } public function test_process_without_payment_fields_is_not_applicable(): void @@ -153,13 +155,16 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->where('business_id', '202601011200000000001') - ->update(['payment_source_state' => 'not_applicable']); + ->update([ + 'payment_source_state' => 'not_applicable', + 'cashier_status' => 'not_applicable', + ]); - $result = $this->repository->paginate(['payment_state' => '不适用'], 1, 50); + $result = $this->repository->paginate(['payment_state' => '不涉及付款'], 1, 50); $this->assertSame(2, $result['total']); $this->assertSame( - ['不适用'], + ['不涉及付款'], collect($result['data'])->pluck('payment_state')->unique()->values()->all(), ); } @@ -173,11 +178,11 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->where('business_id', '202602031200000000002') - ->update(['payment_source_state' => 'api']); + ->update(['payment_source_state' => 'api', 'cashier_status' => 'completed']); - $result = $this->repository->paginate(['payment_state' => '未付款'], 1, 50); + $result = $this->repository->paginate(['payment_state' => '出纳已打款'], 1, 50); - $this->assertSame(2, $result['total']); + $this->assertSame(4, $result['total']); $this->assertSame('api', (string) $result['data'][0]->payment_source_state); } @@ -274,6 +279,8 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase 'originator_name' => $originator, 'status' => $status, 'payment_source_state' => 'present', + 'cashier_status' => $status === '已完成' ? 'completed' : 'pending', + 'cashier_checked_at' => $now, 'document_type' => $documentType, 'purpose' => $purpose, 'account_code' => '5602.04', @@ -328,6 +335,8 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase $table->string('originator_name', 100)->nullable()->index(); $table->string('status', 20)->nullable()->index(); $table->string('payment_source_state', 24)->nullable(); + $table->string('cashier_status', 32)->nullable(); + $table->dateTime('cashier_checked_at')->nullable(); $table->string('document_type', 100)->nullable()->index(); $table->text('purpose')->nullable(); $table->string('account_code', 64)->nullable(); diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php index 2d63744a..988b74fd 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php @@ -109,6 +109,76 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase $this->assertEqualsWithDelta(1114.57, $summary['bank_amount_total'], 0.001); } + public function test_completed_workflow_uses_cashier_status_and_keeps_difference_separate(): void + { + $this->query->workflows = [[ + 'processInstanceId' => 'process-cashier-completed', + 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', + 'businessId' => '202606031318000506330', + 'title' => '苏木提交的线上代发业务报销', + 'status' => 'COMPLETED', + 'result' => 'AGREE', + 'originatorUserId' => 'user-1', + 'joinAt' => '2026-06-03 13:18:00', + ]]; + $this->query->scheme = [ + '收款费用明细' => json_encode([[ + 'rowValue' => [ + ['label' => '代发订单金额', 'value' => '100.00'], + ], + ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + '收款合计' => 100, + '付款明细' => json_encode([[ + 'rowValue' => [ + ['label' => '对应序号', 'value' => '1'], + ['label' => '付款金额', 'value' => '99.80'], + ['label' => '付款方式', 'value' => '银行卡'], + ['label' => '打款时间', 'value' => '2026-06-03 00:00'], + ], + ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ]; + + $this->synchronizer->sync(true); + + $row = app(DingProcessDetailMirrorRepository::class) + ->paginate(['business_id' => '202606031318000506330'], 1, 50)['data'][0]; + $this->assertSame('completed', $row->cashier_status); + $this->assertSame('出纳已打款', $row->payment_state); + $this->assertEqualsWithDelta(0.20, (float) $row->payment_difference, 0.001); + } + + public function test_running_workflow_detects_cashier_returned_from_official_tasks(): void + { + $this->query->workflows = [[ + 'processInstanceId' => 'process-cashier-returned', + 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', + 'businessId' => '202610221534000124202', + 'title' => '武松提交的线上代发业务报销', + 'status' => 'RUNNING', + 'result' => 'NONE', + 'originatorUserId' => 'user-1', + 'joinAt' => '2026-10-22 15:34:00', + ]]; + $this->query->scheme = [ + '收款费用明细' => json_encode([[ + 'rowValue' => [ + ['label' => '代发订单金额', 'value' => '750.00'], + ], + ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + '收款合计' => 750, + '付款明细' => '[]', + ]; + $this->approvalClient->cashierStatusByInstance['process-cashier-returned'] = 'returned'; + + $this->synchronizer->sync(true); + + $row = app(DingProcessDetailMirrorRepository::class) + ->paginate(['business_id' => '202610221534000124202'], 1, 50)['data'][0]; + $this->assertSame('returned', $row->cashier_status); + $this->assertSame('出纳退回', $row->payment_state); + $this->assertEqualsWithDelta(750.0, (float) $row->payment_difference, 0.001); + } + public function test_validation_failure_keeps_the_previous_published_rows(): void { $this->query->workflows = [[ @@ -178,8 +248,9 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase $this->assertNull($row->amount); $this->assertSame('审批中', $row->status); $this->assertSame('missing', $row->payment_source_state); + $this->assertSame('not_started', $row->cashier_status); $page = app(DingProcessDetailMirrorRepository::class)->paginate( - ['payment_state' => '付款明细待同步'], + ['payment_state' => '出纳未开始'], 1, 50, ); @@ -508,6 +579,8 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase $table->string('originator_name', 100)->nullable(); $table->string('status', 20)->nullable(); $table->string('payment_source_state', 24)->nullable(); + $table->string('cashier_status', 32)->nullable(); + $table->dateTime('cashier_checked_at')->nullable(); $table->string('document_type', 100)->nullable(); $table->text('purpose')->nullable(); $table->string('account_code', 64)->nullable(); @@ -635,6 +708,9 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient /** @var array> */ public array $valuesByInstance = []; + /** @var array */ + public array $cashierStatusByInstance = []; + /** @var list */ public array $calls = []; @@ -645,8 +721,46 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient public function formValues(string $processInstanceId): array { - $this->calls[] = $processInstanceId; + return $this->processSnapshot($processInstanceId)['form_values']; + } - return $this->valuesByInstance[$processInstanceId] ?? []; + public function processSnapshot(string $processInstanceId): array + { + $this->calls[] = $processInstanceId; + $cashierStatus = $this->cashierStatusByInstance[$processInstanceId] ?? 'not_started'; + $tasks = []; + $operations = []; + if ($cashierStatus === 'completed') { + $tasks[] = [ + 'taskGroupName' => '出纳打款', + 'status' => 'COMPLETED', + 'result' => 'AGREE', + 'createTime' => '2026-06-03T15:34Z', + 'finishTime' => '2026-06-03T16:42Z', + ]; + } elseif ($cashierStatus === 'pending') { + $tasks[] = [ + 'taskGroupName' => '出纳打款', + 'status' => 'RUNNING', + 'result' => 'NONE', + 'createTime' => '2026-06-03T15:34Z', + 'finishTime' => '', + ]; + } elseif ($cashierStatus === 'returned') { + $operations[] = [ + 'showName' => '出纳打款', + 'type' => 'REDIRECT_PROCESS', + 'result' => 'NONE', + 'date' => '2026-06-03T16:55Z', + ]; + } + + return [ + 'form_values' => $this->valuesByInstance[$processInstanceId] ?? [], + 'tasks' => $tasks, + 'operation_records' => $operations, + 'status' => null, + 'result' => null, + ]; } } diff --git a/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php b/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php index b59eb505..c74c19bd 100644 --- a/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php +++ b/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php @@ -46,7 +46,7 @@ class ProcessDetailsPageDataTest extends TestCase ->method('paginate') ->with($this->callback(static fn (array $filters): bool => $filters['paid_date'] === '2026-10-24' - && $filters['payment_state'] === '未付款' + && $filters['payment_state'] === '出纳已打款' ), 2, 50) ->willReturn([ 'data' => [$row], @@ -68,13 +68,21 @@ class ProcessDetailsPageDataTest extends TestCase ->willReturn([ 'document_types' => ['种菜核销/种菜报销'], 'statuses' => ['审批中'], - 'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], + 'payment_states' => [ + '出纳状态待同步', + '出纳未开始', + '出纳待打款', + '出纳退回', + '出纳已打款', + '流程终止', + '不涉及付款', + ], ]); $data = (new ProcessDetailsPageData($repository))->page(Request::create( '/1/workspace/financial-operations/process-details', 'GET', - ['paid_date' => '2026-10-24', 'payment_state' => '未付款', 'page' => 2], + ['paid_date' => '2026-10-24', 'payment_state' => '出纳已打款', 'page' => 2], )); $this->assertInstanceOf(LengthAwarePaginator::class, $data['processRows']); @@ -85,9 +93,17 @@ class ProcessDetailsPageDataTest extends TestCase $this->assertSame('succeeded', $data['mirrorStatus']); $this->assertSame(11331, $data['summary']['rows']); $this->assertSame('2026-10-24', $data['filters']['paid_date']); - $this->assertSame('未付款', $data['filters']['payment_state']); + $this->assertSame('出纳已打款', $data['filters']['payment_state']); $this->assertSame( - ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], + [ + '出纳状态待同步', + '出纳未开始', + '出纳待打款', + '出纳退回', + '出纳已打款', + '流程终止', + '不涉及付款', + ], $data['paymentStates'], ); $this->assertTrue($data['mirrorStale']); -- 2.47.2