diff --git a/database/migrations/2026_11_02_160000_add_ai_cloud_oa_process_account_mapping.php b/database/migrations/2026_11_02_160000_add_ai_cloud_oa_process_account_mapping.php new file mode 100644 index 00000000..03d1ae7d --- /dev/null +++ b/database/migrations/2026_11_02_160000_add_ai_cloud_oa_process_account_mapping.php @@ -0,0 +1,74 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('oa_process_account_mapping')) { + return; + } + + $mapping = json_decode( + (string) file_get_contents(base_path('resources/data/oa_process_account_mapping.json')), + true, + 512, + JSON_THROW_ON_ERROR, + ); + $codes = array_column($mapping, 'account_code'); + if ($mapping === [] || count($codes) !== count(array_unique($codes))) { + throw new \RuntimeException('流程科目映射快照为空或科目编码重复。'); + } + + $database = $schema->getConnection(); + $now = now(); + foreach (array_chunk($mapping, 100) as $chunk) { + $database->table('oa_process_account_mapping')->upsert( + array_map(static fn (array $row): array => [ + 'account_code' => (string) $row['account_code'], + 'account_name' => (string) $row['account_name'], + 'expense_category' => (string) $row['expense_category'], + 'second_level' => $row['second_level'] + ?? ((string) $row['expense_category'] . '(未细分)'), + 'third_level' => $row['third_level'] ?? '未细分', + 'in_fee_ratio' => (bool) $row['in_fee_ratio'], + 'allocation' => $row['allocation'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ], $chunk), + ['account_code'], + [ + 'account_name', + 'expense_category', + 'second_level', + 'third_level', + 'in_fee_ratio', + 'allocation', + 'updated_at', + ], + ); + } + + $database->table('oa_process_account_mapping') + ->whereNotIn('account_code', $codes) + ->delete(); + } + + public function down(): void + { + // Confirmed business mapping is kept during rollback. + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index aa21418f..b6511da9 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -34,7 +34,7 @@ | 本次接入 | 普通商品资料的工作平台采购价格接入 `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 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 | -| 本次接入 | 财务经营报表 › 管理费用使用 `oa_process_account_mapping` 保存流程科目映射;当前快照包含原工作簿 191 个科目和补充的 `5601.11.01 货代费`、`1221.07 种菜备用金`,共 193 个科目,其中 37 个进费比科目和 125 个分摊规则。页面按打款时间提供整月选择并默认展示上个月 OA 流程中审批状态为“已完成”、涉及付款且出纳已打款的明细,不涉及付款或出纳未打款的数据不进入汇总,按费用大类和二级汇总进费比/不进费比/未映射金额,点击二级查看三级汇总与 9 列明细模板。审批完成时间写入 `ding_process_detail_mirror.approval_finished_at`,页面月份筛选使用 `ding_process_detail_mirror.paid_at`;钉钉副本把费用明细表保存为未命名字段时,按配置中的金额列识别包含该列的未命名表格组件,避免只抓到付款明细而漏掉费用科目和金额。明细行没有科目时,使用同一审批表单表头的 `科目编码 / 科目名称` 回填;行级科目始终优先。 | +| 本次接入 | 财务经营报表 › 管理费用使用 `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/resources/data/oa_process_account_mapping.json b/resources/data/oa_process_account_mapping.json index da56a6fd..fdddc062 100644 --- a/resources/data/oa_process_account_mapping.json +++ b/resources/data/oa_process_account_mapping.json @@ -1928,5 +1928,15 @@ "in_fee_ratio": true, "allocation": null, "source_row": 194 + }, + { + "account_code": "5602.11.03.19", + "account_name": "AI阿里云软件费", + "expense_category": "管理费用", + "second_level": "技术服务费", + "third_level": "AI算力软件", + "in_fee_ratio": false, + "allocation": "分摊", + "source_row": 195 } ] diff --git a/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php b/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php index ebd9bf52..2cdf9fc5 100644 --- a/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php +++ b/tests/Unit/EcommerceFinance/ManagementExpensesPageDataTest.php @@ -130,10 +130,10 @@ class ManagementExpensesPageDataTest extends TestCase JSON_THROW_ON_ERROR, ); - $this->assertCount(193, $mapping); - $this->assertCount(193, array_unique(array_column($mapping, 'account_code'))); + $this->assertCount(194, $mapping); + $this->assertCount(194, array_unique(array_column($mapping, 'account_code'))); $this->assertSame(37, count(array_filter($mapping, static fn (array $row): bool => $row['in_fee_ratio']))); - $this->assertSame(125, count(array_filter($mapping, static fn (array $row): bool => $row['allocation'] !== null))); + $this->assertSame(126, count(array_filter($mapping, static fn (array $row): bool => $row['allocation'] !== null))); $mappingByCode = array_column($mapping, null, 'account_code'); $this->assertSame('往来', $mappingByCode['2202.01']['expense_category']); $this->assertSame('往来', $mappingByCode['2202.01']['second_level']); @@ -147,6 +147,11 @@ class ManagementExpensesPageDataTest extends TestCase $this->assertSame('推广费用', $mappingByCode['1221.07']['second_level']); $this->assertSame('空包费', $mappingByCode['1221.07']['third_level']); $this->assertTrue($mappingByCode['1221.07']['in_fee_ratio']); + $this->assertSame('管理费用', $mappingByCode['5602.11.03.19']['expense_category']); + $this->assertSame('技术服务费', $mappingByCode['5602.11.03.19']['second_level']); + $this->assertSame('AI算力软件', $mappingByCode['5602.11.03.19']['third_level']); + $this->assertFalse($mappingByCode['5602.11.03.19']['in_fee_ratio']); + $this->assertSame('分摊', $mappingByCode['5602.11.03.19']['allocation']); $this->assertNotContains('报销', array_column($mapping, 'expense_category')); $this->assertNotContains('订单退款', array_column($mapping, 'expense_category')); $this->assertContains('分摊', array_column($mapping, 'allocation'));