diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 1325eb65..cc839d6a 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -436,8 +436,8 @@ return [ ['fee_name'], ], ], - // 聚水潭得物货款账户:只复制主账单渠道,保证金、跨境和推广流水 - // 由其他账户域处理,不混入店铺收款账户页面。 + // 聚水潭得物货款账户:复制主账单、退货退款和其他费用扣减渠道; + // 保证金及其他账户域流水不混入店铺收款账户页面。 'jiansun.dbo.jst_originalBillInfo.dewu_receiving' => [ 'source_key' => 'jiansun', 'schema' => 'dbo', @@ -446,7 +446,7 @@ return [ 'keys' => ['Id'], 'watermark' => 'updateTime', 'fixed_filters' => [ - 'channel' => 'Dewu', + 'channel' => ['Dewu', 'DewuTK', 'DewuKJ'], ], 'columns' => [ 'Id', 'shop_id', 'channel', 'fee_time', 'del_flag', 'primary_key', diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 8f58b741..285132de 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -25,7 +25,7 @@ | 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 | | 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative --count=<核对笔数> --income=<收入> --expense=<支出>`,只接受完整月份和精确核对值,保留旧版本、文件摘要及原镜像供回滚。生产执行前备份状态与受影响月份镜像,并使用 `/run/caiwu-upstream-sync.lock`。实际财务数据只放私有目录,不进入 Git。 | | 已接入待首次同步 | 店铺收款账单的拼多多账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = PinduoduoImport` 的独立切片镜像 `js_jst_pdd_receiving_bill_rows`;不使用慢一天的 `operations_replica` 副本或营销账户 `pdd_spread_bills`。 | -| 已接入待首次同步 | 店铺收款账单的得物账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = Dewu` 的独立切片镜像 `js_jst_dewu_receiving_bill_rows`;`Dewubzj`、`DewuKJ`、`DewuTK` 保留给保证金、跨境或推广等其他账户域,不混入得物收款账户。 | +| 已接入待首次同步 | 店铺收款账单的得物账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel IN (Dewu, DewuTK, DewuKJ)` 的独立切片镜像 `js_jst_dewu_receiving_bill_rows`;主账单、退货退款订单和扣减其他费用明细统一展示,`Dewubzj` 保证金继续排除。 | | 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 JSON/XLSX 及多平台“平台资金账户余额”导出,进入 `jushuitan_shop_balance_snapshots`;已导入 2026-09-11 的淘系、拼多多和抖音共 164 条余额。页面按店铺名称精确匹配并强制使用平台消歧,未匹配和多匹配不会显示为零。 | | 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `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` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | @@ -74,6 +74,7 @@ | 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` | | 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) | | 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) | +| 得物货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel IN (Dewu, DewuTK, DewuKJ)`,镜像 `js_jst_dewu_receiving_bill_rows`;排除 `Dewubzj`) | ## JSDataMiddlePlatform 中台库(js_data_middle_platform) @@ -96,8 +97,9 @@ 9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。 10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。 11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。 -12. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 -13. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。 +12. 得物货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.dewu_receiving` 数据集。同步器必须以参数绑定固定 `channel IN (Dewu, DewuTK, DewuKJ)`,页面 Repository 对列表、分页、筛选、汇总和导出使用相同渠道范围,并保留原始渠道代码用于审计;`Dewubzj` 保证金不得混入。 +13. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 +14. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。 工作平台采购价格首次全量需在备份和负责人授权后单独执行: diff --git a/modules/EcommerceFinance/AccountBillColumns.php b/modules/EcommerceFinance/AccountBillColumns.php index 9c4ef75d..568bc088 100644 --- a/modules/EcommerceFinance/AccountBillColumns.php +++ b/modules/EcommerceFinance/AccountBillColumns.php @@ -194,6 +194,7 @@ class AccountBillColumns ['商品货号', 'payload.goods_no', 'num'], ['数量', 'payload.quantity', 'num'], ['渠道', 'payload.channel', 'text'], + ['源渠道代码', 'payload.source_channel', 'text'], ['业务类型', 'payload.business_type', 'text'], ['收入金额', 'income', 'money_in'], ['支出金额', 'expense', 'money_out'], diff --git a/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php b/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php index 77ca082c..de3b1fb2 100644 --- a/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php +++ b/modules/EcommerceFinance/Contracts/BusinessMirrorDataset.php @@ -19,7 +19,7 @@ interface BusinessMirrorDataset public function watermarkColumn(): ?string; - /** @return array */ + /** @return array> */ public function fixedFilters(): array; /** @return list> */ diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index de4dcffc..8e0c68f3 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -42,7 +42,7 @@ class StoreReceivingAlipayBillMirrorRepository string $direction, int $page, int $perPage, - ?string $channel, + string|array|null $channel, string $mapping = 'taobao', string $datasetKey = self::DATASET, bool $excludeNonTaobaoPlatforms = false, @@ -124,7 +124,7 @@ class StoreReceivingAlipayBillMirrorRepository array $filters, string $sort = 'occurred_at', string $direction = 'asc', - ?string $channel = null, + string|array|null $channel = null, string $mapping = 'taobao', string $datasetKey = self::DATASET, bool $excludeNonTaobaoPlatforms = false, @@ -157,7 +157,7 @@ class StoreReceivingAlipayBillMirrorRepository } /** @return array{shops:array,bill_items:array} */ - public function filterOptionsForChannel(string $channel, string $datasetKey = self::DATASET): array + public function filterOptionsForChannel(string|array $channel, string $datasetKey = self::DATASET): array { $dataset = $this->datasets->get($datasetKey); if (! $this->mirror->ready($dataset) @@ -258,7 +258,7 @@ class StoreReceivingAlipayBillMirrorRepository int $page, int $perPage, array $filters, - ?string $channel, + string|array|null $channel, string $targetTable, ): array { if ($channel === null || ($filters['bill_item'] ?? '') === '') { @@ -298,7 +298,7 @@ class StoreReceivingAlipayBillMirrorRepository private function summary( Builder $billQuery, array $filters, - ?string $channel = null, + string|array|null $channel = null, ): array { if ($channel === null && $this->dailySummarySupports($filters)) { @@ -402,12 +402,24 @@ class StoreReceivingAlipayBillMirrorRepository } } - /** @param array $fixedFilters */ - private function applyChannel(Builder $query, ?string $channel, array $fixedFilters): void + /** @param array> $fixedFilters */ + private function applyChannel(Builder $query, string|array|null $channel, array $fixedFilters): void { - if ($this->channelCoversDataset($channel, $fixedFilters)) return; + if ($channel === null || $channel === '') return; + if (is_string($channel)) { + if (! $this->channelCoversDataset($channel, $fixedFilters)) { + $query->where('b.channel', $channel); + } + return; + } - $query->where('b.channel', $channel); + $channels = array_values($channel); + if ($channels === []) { + $query->whereRaw('1 = 0'); + return; + } + + $query->whereIn('b.channel', $channels); } private function applyTaobaoPlatformScope(Builder $query): void @@ -423,17 +435,22 @@ class StoreReceivingAlipayBillMirrorRepository $query->whereNotIn('b.shop_id', $excludedShopIds); } - /** @param array $fixedFilters */ - private function channelCoversDataset(?string $channel, array $fixedFilters): bool + /** @param array> $fixedFilters */ + private function channelCoversDataset(string|array|null $channel, array $fixedFilters): bool { if ($channel === null || $channel === '') { return true; } foreach ($fixedFilters as $column => $value) { - if (strcasecmp($column, 'channel') === 0 && (string) $value === $channel) { - return true; - } + if (strcasecmp($column, 'channel') !== 0) continue; + + $requested = is_array($channel) ? array_map('strval', $channel) : [(string) $channel]; + $configured = is_array($value) ? array_map('strval', $value) : [(string) $value]; + sort($requested); + sort($configured); + + return $requested === $configured; } return false; @@ -625,7 +642,13 @@ class StoreReceivingAlipayBillMirrorRepository 'goods_name' => null, 'goods_no' => $goodsNo, 'quantity' => null, - 'channel' => $row->channel, + 'channel' => match ((string) $row->channel) { + 'Dewu' => '得物', + 'DewuTK' => '退货退款订单', + 'DewuKJ' => '扣减其他费用明细', + default => (string) $row->channel, + }, + 'source_channel' => $row->channel, 'business_type' => $this->directionName($row->inout_type), 'online_real_store' => $shopName, 'order_created_at' => null, diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingDewuBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingDewuBillMirrorRepository.php index 1af74db8..b1d8aab3 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingDewuBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingDewuBillMirrorRepository.php @@ -6,7 +6,7 @@ namespace Modules\EcommerceFinance\Repositories; class StoreReceivingDewuBillMirrorRepository { public const DATASET = 'jiansun.dbo.jst_originalBillInfo.dewu_receiving'; - public const SOURCE_CHANNEL = 'Dewu'; + public const SOURCE_CHANNELS = ['Dewu', 'DewuTK', 'DewuKJ']; public function __construct( private readonly StoreReceivingAlipayBillMirrorRepository $source, @@ -22,7 +22,7 @@ class StoreReceivingDewuBillMirrorRepository $direction, $page, $perPage, - self::SOURCE_CHANNEL, + self::SOURCE_CHANNELS, 'dewu', self::DATASET, ); @@ -35,7 +35,7 @@ class StoreReceivingDewuBillMirrorRepository $filters, $sort, $direction, - self::SOURCE_CHANNEL, + self::SOURCE_CHANNELS, 'dewu', self::DATASET, ); @@ -44,6 +44,6 @@ class StoreReceivingDewuBillMirrorRepository /** @return array{shops:array,bill_items:array} */ public function filterOptions(): array { - return $this->source->filterOptionsForChannel(self::SOURCE_CHANNEL, self::DATASET); + return $this->source->filterOptionsForChannel(self::SOURCE_CHANNELS, self::DATASET); } } diff --git a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php index ec9f9c8e..3ea66e29 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php @@ -71,7 +71,7 @@ class BusinessMirrorDatasetRegistry return $this->definitions()[$key]['columns'] ?? null; } - /** @return array */ + /** @return array> */ public function fixedFilters(string $key): array { $definitions = $this->definitions(); @@ -145,7 +145,7 @@ class BusinessMirrorDatasetRegistry }; } - /** @return array */ + /** @return array> */ private function normalizeFixedFilters(string $key, array $filters): array { $normalized = []; @@ -157,7 +157,14 @@ class BusinessMirrorDatasetRegistry if ($column === '' || mb_strlen($column) > 128 || str_contains($column, "\0") || isset($seen[$canonical])) { throw new InvalidArgumentException('业务镜像数据集包含无效的固定过滤字段:' . $key); } - if (! is_scalar($value)) { + if (is_array($value)) { + if (! array_is_list($value) || $value === [] || collect($value)->contains( + static fn (mixed $item): bool => ! is_scalar($item) + )) { + throw new InvalidArgumentException('业务镜像数据集包含无效的固定过滤值:' . $key); + } + $value = array_values($value); + } elseif (! is_scalar($value)) { throw new InvalidArgumentException('业务镜像数据集包含无效的固定过滤值:' . $key); } diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php index bb1a0441..4c06be98 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php @@ -121,8 +121,13 @@ class BusinessMirrorSynchronizer $bindings = []; foreach ($dataset->fixedFilters() as $column => $value) { - $where[] = $this->quote($column) . ' = ?'; - $bindings[] = $value; + if (is_array($value)) { + $where[] = $this->quote($column) . ' IN (' . implode(', ', array_fill(0, count($value), '?')) . ')'; + array_push($bindings, ...$value); + } else { + $where[] = $this->quote($column) . ' = ?'; + $bindings[] = $value; + } } if ($mode === 'incremental' && $dataset->watermarkColumn() !== null) { diff --git a/tests/Feature/Business/DewuReceivingBillsTest.php b/tests/Feature/Business/DewuReceivingBillsTest.php index 4f7de9fc..f5ad4f15 100644 --- a/tests/Feature/Business/DewuReceivingBillsTest.php +++ b/tests/Feature/Business/DewuReceivingBillsTest.php @@ -36,8 +36,23 @@ class DewuReceivingBillsTest extends FeatureTestCase 'expense' => 0.0, 'payload' => [ 'online_order_no' => '110211311661075313', - 'channel' => 'Dewu', + 'channel' => '得物', + 'source_channel' => 'Dewu', ], + ], [ + 'id' => '14654481', + 'record_code' => '2000000001248775180', + 'store_name' => '得物-测试店', + 'income' => 0.0, + 'expense' => 18.07, + 'payload' => ['channel' => '退货退款订单', 'source_channel' => 'DewuTK'], + ], [ + 'id' => '14654482', + 'record_code' => '2000000001248775181', + 'store_name' => '得物-测试店', + 'income' => 0.0, + 'expense' => 3.50, + 'payload' => ['channel' => '扣减其他费用明细', 'source_channel' => 'DewuKJ'], ]], 'summary' => ['income' => 21.0, 'expense' => 0.0, 'balance' => 21.0, 'count' => 41], ]); @@ -52,7 +67,12 @@ class DewuReceivingBillsTest extends FeatureTestCase 'limit' => 20, ])) ->assertOk() - ->assertJsonPath('data.0.payload.channel', 'Dewu') + ->assertJsonPath('data.0.payload.channel', '得物') + ->assertJsonPath('data.0.payload.source_channel', 'Dewu') + ->assertJsonPath('data.1.payload.channel', '退货退款订单') + ->assertJsonPath('data.1.payload.source_channel', 'DewuTK') + ->assertJsonPath('data.2.payload.channel', '扣减其他费用明细') + ->assertJsonPath('data.2.payload.source_channel', 'DewuKJ') ->assertJsonPath('columns.title', '得物账户') ->assertJsonPath('summary.count', 41); } @@ -70,7 +90,7 @@ class DewuReceivingBillsTest extends FeatureTestCase )->andReturn([[ 'record_code' => '2000000001248775179', 'store_name' => '得物-A店', - 'payload' => ['channel' => 'Dewu'], + 'payload' => ['channel' => '退货退款订单', 'source_channel' => 'DewuTK'], ]]); $this->app->instance(StoreReceivingDewuBillMirrorRepository::class, $repository); @@ -88,15 +108,15 @@ class DewuReceivingBillsTest extends FeatureTestCase 'store_name' => '得物-A店', 'sort' => 'record_code', 'dir' => 'asc', - 'columns' => 'payload.channel,record_code,store_name', + 'columns' => 'payload.channel,payload.source_channel,record_code,store_name', ])); $response->assertOk() ->assertHeader('content-type', 'text/csv; charset=UTF-8') ->assertDownload('dewu_account_bills.csv'); $csv = $response->streamedContent(); - $this->assertStringContainsString('渠道,记录编码,店铺', $csv); - $this->assertStringContainsString('Dewu,"=""2000000001248775179""",得物-A店', $csv); + $this->assertStringContainsString('渠道,源渠道代码,记录编码,店铺', $csv); + $this->assertStringContainsString('退货退款订单,DewuTK,"=""2000000001248775179""",得物-A店', $csv); } public function test_unready_mirror_returns_a_clear_retryable_response(): void diff --git a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php index a92cb530..64c883b3 100644 --- a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php @@ -11,6 +11,7 @@ use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager; use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer; use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery; use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class BusinessMirrorSynchronizerTest extends TestCase @@ -176,7 +177,40 @@ class BusinessMirrorSynchronizerTest extends TestCase ); } - public function test_registry_rejects_non_scalar_fixed_filter_values(): void + public function test_fixed_filter_lists_are_validated_and_bound_to_every_source_batch(): void + { + DB::table('upstream_schema_columns')->insert($this->column('channel', 5, 'nvarchar', 50)); + $datasets = (array) config('upstream_mirrors.datasets', []); + $datasets['test.dbo.records.dewu'] = [ + 'source_key' => 'test', + 'schema' => 'dbo', + 'object' => 'records', + 'target' => 'test_dewu_records', + 'keys' => ['Id'], + 'watermark' => 'updateTime', + 'fixed_filters' => ['channel' => ['Dewu', 'DewuTK', 'DewuKJ']], + ]; + config(['upstream_mirrors.datasets' => $datasets]); + $this->query->batches = [[[ + 'Id' => 1, + 'updateTime' => '2026-08-14 10:00:00', + 'displayName' => '得物退款记录', + 'businessDate' => '2026-08-14', + 'channel' => 'DewuTK', + ]]]; + + $this->synchronizer->sync('test.dbo.records.dewu', true, 10, 1); + + $this->assertStringContainsString('WHERE ([channel] IN (?, ?, ?))', $this->query->calls[0]['sql']); + $this->assertSame(['Dewu', 'DewuTK', 'DewuKJ'], $this->query->calls[0]['bindings']); + $this->assertSame( + ['channel' => ['Dewu', 'DewuTK', 'DewuKJ']], + (new BusinessMirrorDatasetRegistry())->fixedFilters('test.dbo.records.dewu'), + ); + } + + #[DataProvider('invalidFixedFilterLists')] + public function test_registry_rejects_invalid_fixed_filter_lists(mixed $value): void { $datasets = (array) config('upstream_mirrors.datasets', []); $datasets['test.dbo.invalid_filter'] = [ @@ -185,7 +219,7 @@ class BusinessMirrorSynchronizerTest extends TestCase 'object' => 'records', 'target' => 'test_invalid_filter', 'keys' => ['Id'], - 'fixed_filters' => ['channel' => ['PinduoduoImport']], + 'fixed_filters' => ['channel' => $value], ]; config(['upstream_mirrors.datasets' => $datasets]); @@ -193,6 +227,16 @@ class BusinessMirrorSynchronizerTest extends TestCase (new BusinessMirrorDatasetRegistry())->get('test.dbo.invalid_filter'); } + public static function invalidFixedFilterLists(): array + { + return [ + 'empty list' => [[]], + 'associative array' => [['main' => 'Dewu']], + 'nested list' => [[['Dewu']]], + 'non-scalar item' => [[null]], + ]; + } + public function test_incremental_keyset_keeps_watermark_out_of_pagination_cursor(): void { $this->query->batches = [[ @@ -488,7 +532,7 @@ class BusinessMirrorSynchronizerTest extends TestCase $this->assertSame('jiansun', $dewu->sourceKey()); $this->assertSame('jst_originalBillInfo', $dewu->sourceObject()); $this->assertSame('js_jst_dewu_receiving_bill_rows', $dewu->targetTable()); - $this->assertSame(['channel' => 'Dewu'], $dewu->fixedFilters()); + $this->assertSame(['channel' => ['Dewu', 'DewuTK', 'DewuKJ']], $dewu->fixedFilters()); } /** @return array */ diff --git a/tests/Unit/EcommerceFinance/StoreReceivingDewuBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReceivingDewuBillMirrorRepositoryTest.php index e97f98c8..6d70e649 100644 --- a/tests/Unit/EcommerceFinance/StoreReceivingDewuBillMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReceivingDewuBillMirrorRepositoryTest.php @@ -29,7 +29,7 @@ class StoreReceivingDewuBillMirrorRepositoryTest extends TestCase 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_originalBillInfo', 'target' => 'js_jst_dewu_receiving_bill_rows', 'keys' => ['Id'], - 'fixed_filters' => ['channel' => StoreReceivingDewuBillMirrorRepository::SOURCE_CHANNEL], + 'fixed_filters' => ['channel' => StoreReceivingDewuBillMirrorRepository::SOURCE_CHANNELS], ], StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET => [ 'source_key' => 'operations_replica', 'schema' => 'rpt', @@ -72,30 +72,35 @@ class StoreReceivingDewuBillMirrorRepositoryTest extends TestCase $this->markReady(); } - public function test_it_reads_only_the_dewu_dataset_and_maps_available_source_fields(): void + public function test_it_reads_all_three_dewu_channels_and_excludes_deposit_rows(): void { DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([ ['Id' => 10, 'shop_id' => 7, 'shop_name' => '得物-测试店'], ['Id' => 11, 'shop_id' => 8, 'shop_name' => '其他店'], ]); DB::connection('business_mirror')->table('js_jst_dewu_receiving_bill_rows')->insert([ - $this->row(1, 7, '2026-09-13 18:48:12', '1', 21.00, '交易收入'), - $this->row(2, 7, '2026-09-13 19:00:00', '-1', 18.07, '平台服务费'), + $this->row(1, 7, '2026-09-13 18:48:12', '1', 21.00, '交易收入', 'Dewu'), + $this->row(2, 7, '2026-09-13 19:00:00', '-1', 18.07, '退款', 'DewuTK'), + $this->row(3, 7, '2026-09-13 19:30:00', '-1', 3.50, '其他费用', 'DewuKJ'), + $this->row(4, 8, '2026-09-13 20:00:00', '1', 100.00, '保证金充值', 'Dewubzj'), ]); $result = $this->repository->paginate([], 'occurred_at', 'desc', 1, 10); + $rowsBySource = collect($result['data'])->keyBy('payload.source_channel'); - $this->assertSame(2, $result['total']); - $this->assertSame('200000000000000002', $result['data'][0]['record_code']); - $this->assertSame('得物-测试店', $result['data'][0]['store_name']); - $this->assertSame('110211-ORDER-2', $result['data'][0]['order_no']); - $this->assertSame('Dewu', $result['data'][0]['payload']['channel']); - $this->assertSame('支出', $result['data'][0]['payload']['business_type']); - $this->assertSame('SKU-2', $result['data'][0]['payload']['goods_no']); - $this->assertSame('聚水潭', $result['data'][0]['modifier']); + $this->assertSame(3, $result['total']); + $this->assertSame(['DewuKJ', 'DewuTK', 'Dewu'], collect($result['data'])->pluck('payload.source_channel')->all()); + $this->assertSame('得物', $rowsBySource['Dewu']['payload']['channel']); + $this->assertSame('退货退款订单', $rowsBySource['DewuTK']['payload']['channel']); + $this->assertSame('扣减其他费用明细', $rowsBySource['DewuKJ']['payload']['channel']); + $this->assertSame('得物-测试店', $rowsBySource['DewuTK']['store_name']); + $this->assertSame('110211-ORDER-2', $rowsBySource['DewuTK']['order_no']); + $this->assertSame('支出', $rowsBySource['DewuTK']['payload']['business_type']); + $this->assertSame('SKU-2', $rowsBySource['DewuTK']['payload']['goods_no']); + $this->assertSame('聚水潭', $rowsBySource['DewuTK']['modifier']); $this->assertSame(21.0, $result['summary']['income']); - $this->assertSame(18.07, $result['summary']['expense']); - $this->assertSame(2.93, $result['summary']['balance']); + $this->assertSame(21.57, $result['summary']['expense']); + $this->assertSame(-0.57, $result['summary']['balance']); } public function test_filters_options_export_and_exact_total_use_the_fixed_channel_slice(): void @@ -105,13 +110,15 @@ class StoreReceivingDewuBillMirrorRepositoryTest extends TestCase ['Id' => 11, 'shop_id' => 8, 'shop_name' => '得物-B店'], ]); DB::connection('business_mirror')->table('js_jst_dewu_receiving_bill_rows')->insert([ - $this->row(1, 7, '2026-08-12 09:00:00', '1', 22.80, '交易收入'), - $this->row(2, 8, '2026-08-13 09:00:00', '-1', 0.14, '平台服务费'), + $this->row(1, 7, '2026-08-12 09:00:00', '1', 22.80, '交易收入', 'Dewu'), + $this->row(2, 8, '2026-08-13 09:00:00', '-1', 0.14, '退货退款', 'DewuTK'), + $this->row(3, 8, '2026-08-14 09:00:00', '-1', 1.26, '扣减其他费用', 'DewuKJ'), + $this->row(4, 8, '2026-08-15 09:00:00', '-1', 8.88, '保证金扣减', 'Dewubzj'), ]); $connection = DB::connection('business_mirror'); $connection->table('business_mirror_table_states') ->where('dataset_key', StoreReceivingDewuBillMirrorRepository::DATASET) - ->update(['mirror_row_count' => 2, 'mirror_row_count_is_exact' => true]); + ->update(['mirror_row_count' => 3, 'mirror_row_count_is_exact' => true]); $connection->enableQueryLog(); $connection->flushQueryLog(); @@ -124,24 +131,26 @@ class StoreReceivingDewuBillMirrorRepositoryTest extends TestCase 'keyword' => '110211-ORDER-1', ], 'record_code', 'asc', 1, 10); $options = $this->repository->filterOptions(); - $export = iterator_to_array($this->repository->exportRows(['bill_item' => '平台服务费'])); + $export = iterator_to_array($this->repository->exportRows(['bill_item' => '扣减其他费用'])); - $this->assertSame(2, $unfiltered['total']); + $this->assertSame(3, $unfiltered['total']); $this->assertSame(1, $filtered['total']); $this->assertSame('200000000000000001', $filtered['data'][0]['record_code']); $this->assertSame(['得物-A店', '得物-B店'], $options['shops']); - $this->assertSame(['交易收入', '平台服务费'], $options['bill_items']); + $this->assertSame(['交易收入', '扣减其他费用', '退货退款'], $options['bill_items']); $this->assertCount(1, $export); - $this->assertSame('200000000000000002', $export[0]['record_code']); + $this->assertSame('200000000000000003', $export[0]['record_code']); + $this->assertSame('扣减其他费用明细', $export[0]['payload']['channel']); + $this->assertSame('DewuKJ', $export[0]['payload']['source_channel']); $targetQueries = array_filter( $connection->getQueryLog(), static fn (array $query): bool => str_contains($query['query'], 'js_jst_dewu_receiving_bill_rows'), ); $this->assertNotEmpty($targetQueries); - $this->assertCount(0, array_filter( + $this->assertNotEmpty(array_filter( $targetQueries, - static fn (array $query): bool => str_contains($query['query'], '"b"."channel" = ?'), + static fn (array $query): bool => str_contains($query['query'], '"b"."channel" in (?, ?, ?)'), )); $connection->disableQueryLog(); } @@ -165,11 +174,12 @@ class StoreReceivingDewuBillMirrorRepositoryTest extends TestCase string $direction, float $amount, string $feeName, + string $channel, ): array { return [ 'Id' => $id, 'shop_id' => $shopId, - 'channel' => 'Dewu', + 'channel' => $channel, 'primary_key' => 'KEY-' . $id, 'fee_name' => $feeName, 'inout_type' => $direction,