diff --git a/database/migrations/2026_11_02_190000_create_ding_process_payment_line_mirror.php b/database/migrations/2026_11_02_190000_create_ding_process_payment_line_mirror.php new file mode 100644 index 00000000..d3de3d91 --- /dev/null +++ b/database/migrations/2026_11_02_190000_create_ding_process_payment_line_mirror.php @@ -0,0 +1,123 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('ding_process_detail_mirror') + || ! $schema->hasTable('ding_process_payment_mirror')) { + return; + } + if (! $schema->hasTable('ding_process_payment_line_mirror')) { + $schema->create('ding_process_payment_line_mirror', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('source_scope', 40)->default('personal_permission'); + $table->string('business_id', 32); + $table->string('process_instance_id', 100)->nullable(); + $table->unsignedInteger('payment_no'); + $table->unsignedInteger('line_no'); + $table->string('paid_month', 7)->default(''); + $table->string('paid_at', 50)->nullable(); + $table->decimal('amount', 18, 2); + $table->string('account_code', 64)->nullable(); + $table->string('account_name', 255)->nullable(); + $table->string('payment_method', 50)->nullable(); + $table->dateTime('synced_at', 6); + $table->timestamps(); + + $table->unique( + ['source_scope', 'business_id', 'payment_no', 'line_no'], + 'ding_payment_line_payment_line_unique', + ); + $table->index( + ['source_scope', 'paid_month'], + 'ding_payment_line_scope_month_index', + ); + $table->index( + ['source_scope', 'process_instance_id'], + 'ding_payment_line_scope_process_index', + ); + $table->index( + ['source_scope', 'business_id', 'line_no'], + 'ding_payment_line_scope_business_line_index', + ); + }); + } + + $this->backfill($schema->getConnection()); + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + Schema::connection($this->connection()) + ->dropIfExists('ding_process_payment_line_mirror'); + } + + private function backfill(\Illuminate\Database\ConnectionInterface $database): void + { + $allocator = app(DingProcessPaymentLineAllocator::class); + $now = now(); + $scopes = $database->table('ding_process_payment_mirror') + ->distinct() + ->pluck('source_scope'); + + foreach ($scopes as $scopeValue) { + $scope = trim((string) $scopeValue); + if ($scope === '') { + continue; + } + + $lastId = 0; + do { + $payments = $database->table('ding_process_payment_mirror') + ->where('source_scope', $scope) + ->where('id', '>', $lastId) + ->orderBy('id') + ->limit(500) + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + if ($payments === []) { + break; + } + $lastId = (int) end($payments)['id']; + + $businessIds = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => trim((string) ($row['business_id'] ?? '')), + $payments, + )))); + $details = $database->table('ding_process_detail_mirror') + ->where('source_scope', $scope) + ->whereIn('business_id', $businessIds) + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + $rows = $allocator->allocate($details, $payments, $scope, $now); + foreach (array_chunk($rows, 500) as $chunk) { + $database->table('ding_process_payment_line_mirror')->insert($chunk); + } + } while (count($payments) === 500); + } + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index b6511da9..7e2fa230 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -33,7 +33,7 @@ | 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `js_data_middle_platform.dbo.jd_jzt_finance_records` 镜像 `jsm_jd_jzt_finance_records`;按 `id` 稳定断点、`updated_at` 重叠增量,页面不直连上游。 | | 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | | 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `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`;主表的 `originatorDeptName` 作为发起人部门单独保留。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 | +| 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror`、`ding_process_payment_mirror` 与付款按明细行分摊的 `ding_process_payment_line_mirror`;主表的 `originatorDeptName` 作为发起人部门单独保留。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。管理费用按分摊后的付款月份统计,跨月分笔付款不会把整张审批重复计入多月。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 | | 本次接入 | 财务经营报表 › 管理费用使用 `oa_process_account_mapping` 保存流程科目映射;当前快照包含原工作簿 191 个科目和补充的 `5601.11.01 货代费`、`1221.07 种菜备用金`、`5602.11.03.19 AI阿里云软件费`,共 194 个科目,其中 37 个进费比科目和 126 个分摊规则。页面按打款时间提供整月选择并默认展示上个月 OA 流程中审批状态为“已完成”、涉及付款且出纳已打款的明细,不涉及付款或出纳未打款的数据不进入汇总,按费用大类和二级汇总进费比/不进费比/未映射金额,点击二级查看三级汇总与 9 列明细模板。审批完成时间写入 `ding_process_detail_mirror.approval_finished_at`,页面月份筛选使用 `ding_process_detail_mirror.paid_at`;钉钉副本把费用明细表保存为未命名字段时,按配置中的金额列识别包含该列的未命名表格组件,避免只抓到付款明细而漏掉费用科目和金额。明细行没有科目时,使用同一审批表单表头的 `科目编码 / 科目名称` 回填;行级科目始终优先。 | 流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按实际小时展示。OA 页面每小时最多把一次当前页“审批中”的审批编号写入定向刷新队列,由每分钟检查一次的批处理命令消费;其他时间直接展示最近镜像,已完成流程不进入即时刷新链路,仍由常规增量和每周全量校准处理付款等后续变化。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。 diff --git a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php index b45607eb..f8a7ae98 100644 --- a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php @@ -44,6 +44,7 @@ class DingProcessDetailMirrorSynchronizer DatabaseManager $database, private readonly UpstreamReadOnlyQueryFactory $queries, private readonly DingTalkProcessInstanceClient $approvalClient, + private readonly DingProcessPaymentLineAllocator $paymentLineAllocator, ) { $this->connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), @@ -2261,6 +2262,12 @@ class DingProcessDetailMirrorSynchronizer Carbon $now, ): void { $previous = $this->state(); + $paymentLines = $this->paymentLineAllocator->allocate( + $details, + $payments, + $scope, + $now, + ); $details = array_map(function (array $row): array { unset($row['amount_cents']); @@ -2281,8 +2288,12 @@ class DingProcessDetailMirrorSynchronizer $metrics, $now, $previous, + $paymentLines, ): void { if ($full) { + $this->connection->table('ding_process_payment_line_mirror') + ->where('source_scope', $scope) + ->delete(); $this->connection->table('ding_process_payment_mirror') ->where('source_scope', $scope) ->delete(); @@ -2291,6 +2302,10 @@ class DingProcessDetailMirrorSynchronizer ->delete(); } elseif ($processInstanceIds !== []) { foreach (array_chunk($processInstanceIds, 500) as $chunk) { + $this->connection->table('ding_process_payment_line_mirror') + ->where('source_scope', $scope) + ->whereIn('process_instance_id', $chunk) + ->delete(); $this->connection->table('ding_process_payment_mirror') ->where('source_scope', $scope) ->whereIn('process_instance_id', $chunk) @@ -2308,6 +2323,9 @@ class DingProcessDetailMirrorSynchronizer foreach (array_chunk($payments, 500) as $chunk) { $this->connection->table('ding_process_payment_mirror')->insert($chunk); } + foreach (array_chunk($paymentLines, 500) as $chunk) { + $this->connection->table('ding_process_payment_line_mirror')->insert($chunk); + } $detailCount = (int) $this->connection->table('ding_process_detail_mirror') ->where('source_scope', $scope) @@ -2315,6 +2333,9 @@ class DingProcessDetailMirrorSynchronizer $paymentCount = (int) $this->connection->table('ding_process_payment_mirror') ->where('source_scope', $scope) ->count(); + $paymentLineCount = (int) $this->connection->table('ding_process_payment_line_mirror') + ->where('source_scope', $scope) + ->count(); $amountTotal = (float) ($this->connection->table('ding_process_detail_mirror') ->where('source_scope', $scope) ->sum('amount') ?? 0); @@ -2339,6 +2360,7 @@ class DingProcessDetailMirrorSynchronizer 'synced_payments' => $metrics['payments'], 'mirror_details' => $detailCount, 'mirror_payments' => $paymentCount, + 'mirror_payment_lines' => $paymentLineCount, 'amount_total' => round($amountTotal, 2), 'bank_amount_total' => round($bankAmountTotal, 2), 'scheme_high_watermark' => (int) ($metrics['scheme_high_watermark'] ?? 0), diff --git a/modules/EcommerceFinance/Services/DingProcessPaymentLineAllocator.php b/modules/EcommerceFinance/Services/DingProcessPaymentLineAllocator.php new file mode 100644 index 00000000..c09a63ce --- /dev/null +++ b/modules/EcommerceFinance/Services/DingProcessPaymentLineAllocator.php @@ -0,0 +1,152 @@ +> $details + * @param list> $payments + * @return list> + */ + public function allocate( + array $details, + array $payments, + string $scope, + Carbon $now, + ): array { + $detailsByBusiness = []; + foreach ($details as $detail) { + $businessId = trim((string) ($detail['business_id'] ?? '')); + $lineNo = (int) ($detail['line_no'] ?? 0); + if ($businessId === '' || $lineNo <= 0) { + continue; + } + $detailsByBusiness[$businessId][$lineNo] = [ + 'amount_cents' => $this->amountCents($detail), + ]; + } + + $rows = []; + foreach ($payments as $payment) { + $businessId = trim((string) ($payment['business_id'] ?? '')); + $paymentNo = (int) ($payment['payment_no'] ?? 0); + $amountCents = $this->amountCents($payment); + $businessDetails = $detailsByBusiness[$businessId] ?? []; + if ($businessId === '' || $paymentNo <= 0 || $amountCents === 0 || $businessDetails === []) { + continue; + } + + $lineNumbers = $this->lineNumbers( + (string) ($payment['sequence_spec'] ?? ''), + array_keys($businessDetails), + ); + if ($lineNumbers === []) { + continue; + } + + $allocated = $this->allocateCents( + $amountCents, + array_map( + static fn (int $lineNo): int => (int) $businessDetails[$lineNo]['amount_cents'], + $lineNumbers, + ), + ); + $paidAt = trim((string) ($payment['paid_at'] ?? '')); + $paidMonth = preg_match('/^\d{4}-\d{2}/', $paidAt, $matches) === 1 + ? $matches[0] + : ''; + + foreach ($lineNumbers as $index => $lineNo) { + $rows[] = [ + 'source_scope' => $scope, + 'business_id' => $businessId, + 'process_instance_id' => (string) ($payment['process_instance_id'] ?? ''), + 'payment_no' => $paymentNo, + 'line_no' => $lineNo, + 'paid_month' => $paidMonth, + 'paid_at' => $paidAt, + 'amount' => number_format($allocated[$index] / 100, 2, '.', ''), + 'account_code' => $payment['account_code'] ?? null, + 'account_name' => $payment['account_name'] ?? null, + 'payment_method' => $payment['payment_method'] ?? null, + 'synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + return $rows; + } + + /** @param array $row */ + private function amountCents(array $row): int + { + if (array_key_exists('amount_cents', $row)) { + return (int) $row['amount_cents']; + } + + return (int) round((float) ($row['amount'] ?? 0) * 100); + } + + /** + * @param list $availableLines + * @return list + */ + private function lineNumbers(string $sequenceSpec, array $availableLines): array + { + preg_match_all('/\d+/', $sequenceSpec, $matches); + $requested = array_values(array_unique(array_map('intval', $matches[0] ?? []))); + $matched = array_values(array_intersect($requested, $availableLines)); + if ($matched !== []) { + return $matched; + } + if ($requested !== []) { + return []; + } + + sort($availableLines); + + return $availableLines === [] ? [] : [$availableLines[0]]; + } + + /** + * @param list $weights + * @return list + */ + private function allocateCents(int $amountCents, array $weights): array + { + $count = count($weights); + if ($count === 0) { + return []; + } + + $totalWeight = array_sum($weights); + if ($totalWeight <= 0) { + $base = intdiv($amountCents, $count); + $allocated = array_fill(0, $count, $base); + $allocated[$count - 1] += $amountCents - ($base * $count); + + return $allocated; + } + + $allocated = []; + $used = 0; + foreach ($weights as $index => $weight) { + if ($index === $count - 1) { + $value = $amountCents - $used; + } else { + $value = intdiv($amountCents * $weight, $totalWeight); + $used += $value; + } + $allocated[] = $value; + } + + return $allocated; + } +} diff --git a/modules/EcommerceFinance/View/ManagementExpensesPageData.php b/modules/EcommerceFinance/View/ManagementExpensesPageData.php index 7cea348a..a984575f 100644 --- a/modules/EcommerceFinance/View/ManagementExpensesPageData.php +++ b/modules/EcommerceFinance/View/ManagementExpensesPageData.php @@ -159,7 +159,7 @@ class ManagementExpensesPageData ->selectRaw("{$category} AS expense_category, {$second} AS second_level") ->selectRaw($this->amountSplitExpression()) ->groupByRaw("{$category}, {$second}") - ->orderByRaw('SUM(COALESCE(d.amount, 0)) DESC') + ->orderByRaw('SUM(COALESCE(paid_line.paid_amount, 0)) DESC') ->orderByRaw("{$category}, {$second}") ->get(); @@ -190,7 +190,7 @@ class ManagementExpensesPageData ->selectRaw("{$category} AS expense_category, {$second} AS second_level, {$third} AS third_level") ->selectRaw($this->amountSplitExpression()) ->groupByRaw("{$category}, {$second}, {$third}") - ->orderByRaw('SUM(COALESCE(d.amount, 0)) DESC') + ->orderByRaw('SUM(COALESCE(paid_line.paid_amount, 0)) DESC') ->orderByRaw("{$third}") ->get(); @@ -221,16 +221,16 @@ class ManagementExpensesPageData 'd.business_id', 'd.originator_department', 'd.purpose', - 'd.amount', 'd.line_no', 'd.process_instance_id', ]) + ->selectRaw('paid_line.paid_amount AS amount') ->selectRaw($this->categoryExpression() . ' AS expense_category') ->selectRaw($this->secondLevelExpression() . ' AS second_level') ->selectRaw($this->thirdLevelExpression() . ' AS third_level') ->selectRaw($this->feeRatioExpression() . ' AS fee_ratio') ->selectRaw($this->allocationExpression() . ' AS allocation') - ->orderByDesc('d.paid_at') + ->orderByDesc('paid_line.paid_month') ->orderByDesc('d.business_id') ->orderBy('d.line_no') ->forPage($page, $perPage) @@ -280,38 +280,35 @@ class ManagementExpensesPageData private function baseQuery(array $filters): Builder { + $paidLines = $this->connection->table('ding_process_payment_line_mirror') + ->select('source_scope', 'business_id', 'line_no', 'paid_month') + ->selectRaw('SUM(amount) AS paid_amount') + ->groupBy('source_scope', 'business_id', 'line_no', 'paid_month'); + $query = $this->connection->table('ding_process_detail_mirror as d') + ->joinSub($paidLines, 'paid_line', function ($join): void { + $join + ->on('paid_line.source_scope', '=', 'd.source_scope') + ->on('paid_line.business_id', '=', 'd.business_id') + ->on('paid_line.line_no', '=', 'd.line_no'); + }) ->leftJoin( 'oa_process_account_mapping as m', 'm.account_code', '=', 'd.account_code', ) - ->where('d.cashier_status', 'completed') + ->where(function (Builder $terminated): void { + $terminated->whereNull('d.cashier_status') + ->orWhere('d.cashier_status', '<>', 'terminated'); + }) ->where(function (Builder $paymentSource): void { $paymentSource->whereNull('d.payment_source_state') ->orWhere('d.payment_source_state', '<>', 'not_applicable'); }); if (($filters['paid_month'] ?? '') !== '') { - $monthStart = \Illuminate\Support\Carbon::createFromFormat( - 'Y-m-d', - $filters['paid_month'] . '-01', - )->startOfDay(); - $monthEnd = $monthStart->copy()->addMonthNoOverflow()->toDateString(); - $query->whereExists(function (Builder $paidApproval) use ($monthStart, $monthEnd): void { - $paidApproval - ->selectRaw('1') - ->from('ding_process_detail_mirror as paid_detail') - ->whereColumn('paid_detail.business_id', 'd.business_id') - ->where('paid_detail.cashier_status', 'completed') - ->where(function (Builder $paymentSource): void { - $paymentSource->whereNull('paid_detail.payment_source_state') - ->orWhere('paid_detail.payment_source_state', '<>', 'not_applicable'); - }) - ->where('paid_detail.paid_at', '>=', $monthStart->toDateString()) - ->where('paid_detail.paid_at', '<', $monthEnd); - }); + $query->where('paid_line.paid_month', (string) $filters['paid_month']); } if (($filters['expense_category'] ?? '') !== '') { @@ -365,11 +362,11 @@ class ManagementExpensesPageData { return 'COUNT(*) AS row_count, ' . 'COUNT(DISTINCT d.business_id) AS approval_count, ' - . 'SUM(COALESCE(d.amount, 0)) AS amount_total, ' - . 'SUM(CASE WHEN m.in_fee_ratio = 1 THEN COALESCE(d.amount, 0) ELSE 0 END) AS in_fee_ratio_amount, ' + . 'SUM(COALESCE(paid_line.paid_amount, 0)) AS amount_total, ' + . 'SUM(CASE WHEN m.in_fee_ratio = 1 THEN COALESCE(paid_line.paid_amount, 0) ELSE 0 END) AS in_fee_ratio_amount, ' . 'SUM(CASE WHEN m.account_code IS NOT NULL AND m.in_fee_ratio = 0 ' - . 'THEN COALESCE(d.amount, 0) ELSE 0 END) AS non_fee_ratio_amount, ' - . 'SUM(CASE WHEN m.account_code IS NULL THEN COALESCE(d.amount, 0) ELSE 0 END) AS unmapped_amount'; + . 'THEN COALESCE(paid_line.paid_amount, 0) ELSE 0 END) AS non_fee_ratio_amount, ' + . 'SUM(CASE WHEN m.account_code IS NULL THEN COALESCE(paid_line.paid_amount, 0) ELSE 0 END) AS unmapped_amount'; } private function categoryExpression(): string diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php index d70b2508..44e2194d 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php @@ -101,6 +101,10 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase ->table('ding_process_detail_mirror')->count()); $this->assertSame(1, DB::connection('business_mirror') ->table('ding_process_payment_mirror')->count()); + $this->assertSame(14, DB::connection('business_mirror') + ->table('ding_process_payment_line_mirror')->count()); + $this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror') + ->table('ding_process_payment_line_mirror')->sum('amount'), 0.001); $this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror') ->table('ding_process_detail_mirror')->sum('bank_amount'), 0.001); $this->assertSame('present', DB::connection('business_mirror') @@ -1412,6 +1416,26 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase $table->unique(['source_scope', 'business_id', 'payment_no']); }, ); + Schema::connection('business_mirror')->create( + 'ding_process_payment_line_mirror', + function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('source_scope', 40); + $table->string('business_id', 32); + $table->string('process_instance_id', 100)->nullable(); + $table->unsignedInteger('payment_no'); + $table->unsignedInteger('line_no'); + $table->string('paid_month', 7)->default(''); + $table->string('paid_at', 50)->nullable(); + $table->decimal('amount', 18, 2); + $table->string('account_code', 64)->nullable(); + $table->string('account_name', 255)->nullable(); + $table->string('payment_method', 50)->nullable(); + $table->dateTime('synced_at', 6); + $table->timestamps(); + $table->unique(['source_scope', 'business_id', 'payment_no', 'line_no']); + }, + ); } } diff --git a/tests/Unit/EcommerceFinance/DingProcessPaymentLineAllocatorTest.php b/tests/Unit/EcommerceFinance/DingProcessPaymentLineAllocatorTest.php new file mode 100644 index 00000000..e7d1d56c --- /dev/null +++ b/tests/Unit/EcommerceFinance/DingProcessPaymentLineAllocatorTest.php @@ -0,0 +1,101 @@ +allocate( + [ + ['business_id' => 'approval-2', 'line_no' => 1, 'amount' => '100.00'], + ['business_id' => 'approval-2', 'line_no' => 2, 'amount' => '200.00'], + ], + [[ + 'business_id' => 'approval-2', + 'process_instance_id' => 'process-2', + 'payment_no' => 1, + 'sequence_spec' => '1-2', + 'amount' => '300.00', + 'paid_at' => '2026-10-15 00:00', + ]], + 'personal_permission', + Carbon::parse('2026-11-02 12:00:00'), + ); + + $this->assertSame( + [[1, 100.0], [2, 200.0]], + array_map( + static fn (array $row): array => [ + (int) $row['line_no'], + (float) $row['amount'], + ], + $rows, + ), + ); + } + + public function test_it_allocates_partial_payments_to_the_correct_months(): void + { + Carbon::setTestNow('2026-11-02 12:00:00'); + + try { + $rows = (new DingProcessPaymentLineAllocator())->allocate( + [ + ['business_id' => 'approval-1', 'line_no' => 1, 'amount' => '100.00'], + ['business_id' => 'approval-1', 'line_no' => 2, 'amount' => '50.00'], + ], + [ + [ + 'business_id' => 'approval-1', + 'process_instance_id' => 'process-1', + 'payment_no' => 1, + 'sequence_spec' => '1', + 'amount' => '40.00', + 'paid_at' => '2026-09-30 00:00', + ], + [ + 'business_id' => 'approval-1', + 'process_instance_id' => 'process-1', + 'payment_no' => 2, + 'sequence_spec' => '1', + 'amount' => '60.00', + 'paid_at' => '2026-10-15 00:00', + ], + [ + 'business_id' => 'approval-1', + 'process_instance_id' => 'process-1', + 'payment_no' => 3, + 'sequence_spec' => '2', + 'amount' => '50.00', + 'paid_at' => '2026-10-20 00:00', + ], + ], + 'personal_permission', + Carbon::parse('2026-11-02 12:00:00'), + ); + + $this->assertSame( + [ + ['2026-09', '1', 40.0], + ['2026-10', '1', 60.0], + ['2026-10', '2', 50.0], + ], + array_map( + static fn (array $row): array => [ + $row['paid_month'], + (string) $row['line_no'], + (float) $row['amount'], + ], + $rows, + ), + ); + } finally { + Carbon::setTestNow(); + } + } +} diff --git a/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php b/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php index 7dbeafad..6a372678 100644 --- a/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php +++ b/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php @@ -103,7 +103,7 @@ class ManagementExpensesPageDataTest extends TestCase array_values(ManagementExpensesPageData::DETAIL_COLUMNS), ); $this->assertSame( - ['202609251200000000008', '202610011200000000001'], + ['202610011200000000001', '202609251200000000008'], array_column($data['rows'], 'business_id'), ); $this->assertSame('不进费比', $data['rows'][0]['fee_ratio']); @@ -132,7 +132,7 @@ class ManagementExpensesPageDataTest extends TestCase 1, 10, '审批中', - 'completed', + 'pending_sync', 'present', '2026-10-20 10:00:00', '2026-10-20 00:00', @@ -143,7 +143,7 @@ class ManagementExpensesPageDataTest extends TestCase 2, 15, '审批中', - 'completed', + 'pending_sync', 'present', '2026-10-20 10:00:00', null, @@ -151,6 +151,26 @@ class ManagementExpensesPageDataTest extends TestCase DB::connection('business_mirror') ->table('ding_process_detail_mirror') ->insert([$first, $second]); + DB::connection('business_mirror') + ->table('ding_process_payment_mirror') + ->insert([ + 'business_id' => $businessId, + 'process_instance_id' => 'process-' . $businessId, + 'payment_no' => 1, + 'sequence_spec' => '1-2', + 'amount' => 25, + 'paid_at' => '2026-10-20 00:00', + 'source_scope' => 'personal_permission', + 'synced_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::connection('business_mirror') + ->table('ding_process_payment_line_mirror') + ->insert([ + $this->paymentLine($businessId, 1, 10, '2026-10-20', 1), + $this->paymentLine($businessId, 2, 15, '2026-10-20', 1), + ]); $data = $this->pageData->details(Request::create( '/1/workspace/financial-operations/management-expenses/details', @@ -168,6 +188,42 @@ class ManagementExpensesPageDataTest extends TestCase ); } + public function test_cross_month_payments_split_the_same_detail_line_by_paid_amount(): void + { + $businessId = '202609151200000000010'; + DB::connection('business_mirror') + ->table('ding_process_detail_mirror') + ->insert($this->row( + $businessId, + '5602.04', + 1, + 100, + '审批中', + 'pending_sync', + 'present', + '2026-09-15 10:00:00', + null, + )); + DB::connection('business_mirror') + ->table('ding_process_payment_line_mirror') + ->insert([ + $this->paymentLine($businessId, 1, 40, '2026-09-28', 1), + $this->paymentLine($businessId, 1, 60, '2026-10-02', 2), + ]); + + foreach (['2026-09' => 40.0, '2026-10' => 60.0] as $month => $amount) { + $data = $this->pageData->details(Request::create( + '/1/workspace/financial-operations/management-expenses/details', + 'GET', + ['business_id' => $businessId, 'paid_month' => $month], + )); + + $this->assertSame(1, $data['pagination']['total']); + $this->assertSame($amount, $data['summary']['amount_total']); + $this->assertSame($amount, $data['rows'][0]['amount']); + } + } + public function test_workbook_mapping_snapshot_is_complete_and_unique(): void { $mapping = json_decode( @@ -244,6 +300,37 @@ class ManagementExpensesPageDataTest extends TestCase $table->decimal('amount', 18, 2)->nullable(); $table->string('paid_at', 50)->nullable(); $table->dateTime('approval_finished_at')->nullable(); + $table->string('source_scope', 40)->default('personal_permission'); + $table->timestamps(); + }, + ); + Schema::connection('business_mirror')->create( + 'ding_process_payment_mirror', + function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('business_id', 32); + $table->string('process_instance_id', 100); + $table->unsignedInteger('payment_no'); + $table->string('sequence_spec', 255)->nullable(); + $table->decimal('amount', 18, 2); + $table->string('paid_at', 50)->nullable(); + $table->string('source_scope', 40)->default('personal_permission'); + $table->dateTime('synced_at', 6); + $table->timestamps(); + }, + ); + Schema::connection('business_mirror')->create( + 'ding_process_payment_line_mirror', + function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('source_scope', 40)->default('personal_permission'); + $table->string('business_id', 32); + $table->string('process_instance_id', 100)->nullable(); + $table->unsignedInteger('payment_no'); + $table->unsignedInteger('line_no'); + $table->string('paid_month', 7)->default(''); + $table->string('paid_at', 50)->nullable(); + $table->decimal('amount', 18, 2); $table->timestamps(); }, ); @@ -287,6 +374,22 @@ class ManagementExpensesPageDataTest extends TestCase $this->row('202609251200000000008', '5602.04', 1, 20, '已完成', 'completed', 'present', '2026-09-25 10:00:00', '2026-10-20'), $this->row('202610281200000000009', '5602.04', 1, 30, '已完成', 'completed', 'present', '2026-10-28 10:00:00', '2026-09-30'), ]); + DB::connection('business_mirror')->table('ding_process_payment_mirror')->insert([ + $this->payment('202610011200000000001', 100, '2026-10-10 00:00'), + $this->payment('202610021200000000002', 200, '2026-10-11 00:00'), + $this->payment('202610031200000000003', 300, '2026-10-12 00:00'), + $this->payment('202610051200000000005', 50, '2026-10-14 00:00'), + $this->payment('202609251200000000008', 20, '2026-10-20 00:00'), + $this->payment('202610281200000000009', 30, '2026-09-30 00:00'), + ]); + DB::connection('business_mirror')->table('ding_process_payment_line_mirror')->insert([ + $this->paymentLine('202610011200000000001', 1, 100, '2026-10-10', 1), + $this->paymentLine('202610021200000000002', 1, 200, '2026-10-11', 1), + $this->paymentLine('202610031200000000003', 1, 300, '2026-10-12', 1), + $this->paymentLine('202610051200000000005', 1, 50, '2026-10-14', 1), + $this->paymentLine('202609251200000000008', 1, 20, '2026-10-20', 1), + $this->paymentLine('202610281200000000009', 1, 30, '2026-09-30', 1), + ]); } /** @return array */ @@ -320,4 +423,43 @@ class ManagementExpensesPageDataTest extends TestCase 'updated_at' => now(), ]; } + + /** @return array */ + private function payment(string $businessId, float $amount, string $paidAt): array + { + return [ + 'business_id' => $businessId, + 'process_instance_id' => 'process-' . $businessId, + 'payment_no' => 1, + 'sequence_spec' => '1', + 'amount' => $amount, + 'paid_at' => $paidAt, + 'source_scope' => 'personal_permission', + 'synced_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + /** @return array */ + private function paymentLine( + string $businessId, + int $lineNo, + float $amount, + string $paidAt, + int $paymentNo, + ): array { + return [ + 'source_scope' => 'personal_permission', + 'business_id' => $businessId, + 'process_instance_id' => 'process-' . $businessId, + 'payment_no' => $paymentNo, + 'line_no' => $lineNo, + 'paid_month' => substr($paidAt, 0, 7), + 'paid_at' => $paidAt, + 'amount' => $amount, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } }