得物账单纳入退款及其他费用渠道 #190
@ -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',
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
| 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 |
|
||||
| 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative <CSV路径> <YYYY-MM> --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` 的工作平台价格记录混为同一来源。
|
||||
|
||||
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -19,7 +19,7 @@ interface BusinessMirrorDataset
|
||||
|
||||
public function watermarkColumn(): ?string;
|
||||
|
||||
/** @return array<string,scalar> */
|
||||
/** @return array<string,scalar|list<scalar>> */
|
||||
public function fixedFilters(): array;
|
||||
|
||||
/** @return list<list<string>> */
|
||||
|
||||
@ -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<int,string>,bill_items:array<int,string>} */
|
||||
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<string,scalar> $fixedFilters */
|
||||
private function applyChannel(Builder $query, ?string $channel, array $fixedFilters): void
|
||||
/** @param array<string,scalar|list<scalar>> $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<string,scalar> $fixedFilters */
|
||||
private function channelCoversDataset(?string $channel, array $fixedFilters): bool
|
||||
/** @param array<string,scalar|list<scalar>> $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,
|
||||
|
||||
@ -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<int,string>,bill_items:array<int,string>} */
|
||||
public function filterOptions(): array
|
||||
{
|
||||
return $this->source->filterOptionsForChannel(self::SOURCE_CHANNEL, self::DATASET);
|
||||
return $this->source->filterOptionsForChannel(self::SOURCE_CHANNELS, self::DATASET);
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ class BusinessMirrorDatasetRegistry
|
||||
return $this->definitions()[$key]['columns'] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<string,scalar> */
|
||||
/** @return array<string,scalar|list<scalar>> */
|
||||
public function fixedFilters(string $key): array
|
||||
{
|
||||
$definitions = $this->definitions();
|
||||
@ -145,7 +145,7 @@ class BusinessMirrorDatasetRegistry
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<string,scalar> */
|
||||
/** @return array<string,scalar|list<scalar>> */
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<string,mixed> */
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user