diff --git a/app/Console/Commands/ImportDouyinAuthoritativeBills.php b/app/Console/Commands/ImportDouyinAuthoritativeBills.php index e5a327ce..c096c2b2 100644 --- a/app/Console/Commands/ImportDouyinAuthoritativeBills.php +++ b/app/Console/Commands/ImportDouyinAuthoritativeBills.php @@ -9,21 +9,22 @@ use Throwable; class ImportDouyinAuthoritativeBills extends Command { protected $signature = 'business-mirror:import-douyin-authoritative - {path : 胜算原始明细 UTF-8 CSV} {month : 完整月份 YYYY-MM} - {--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计}'; + {path : 胜算原始明细 UTF-8 CSV} {month : 所属月份 YYYY-MM} + {--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计} + {--through= : 月初至此日期的完整数据 YYYY-MM-DD,省略时为整月}'; - protected $description = '校验并原子启用完整月份胜算抖音明细,保留旧版本用于回滚'; + protected $description = '校验并原子启用指定月份或月初至截止日的胜算抖音明细,保留旧版本用于回滚'; public function handle(DouyinAuthoritativeBillImporter $importer): int { try { $result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'), - (int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense')); + (int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through')); } catch (Throwable $e) { $this->error('胜算抖音明细未启用,保留上一版数据。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件和镜像连接。')); return self::FAILURE; } - $this->info('胜算抖音明细已启用,月份:' . $this->argument('month') . ',笔数:' . $result['row_count']); + $this->info('胜算抖音明细已启用,范围:[' . $result['period_start'] . ', ' . $result['period_end'] . '),笔数:' . $result['row_count']); return self::SUCCESS; } } diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index e672dfb8..c34c7269 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -31,6 +31,8 @@ | 本次接入 | 普通商品资料的工作平台采购价格接入 `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 条解释为真实无历史。 | +抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。 + ## 经营分析副本库(operations_replica) 店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id` diff --git a/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php b/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php index 9fd0a47d..7d8e3635 100644 --- a/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php +++ b/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php @@ -9,18 +9,26 @@ use InvalidArgumentException; use RuntimeException; use Throwable; -/** Imports a complete, explicitly reconciled month without changing upstream bills. */ +/** Imports a reconciled month-to-date range without changing upstream bills. */ class DouyinAuthoritativeBillImporter { public function __construct(private readonly DatabaseManager $databases) {} - public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense): array + public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense, ?string $through = null): array { if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) || $expectedCount < 1) { throw new InvalidArgumentException('必须指定完整月份和核对后的笔数。'); } $start = $month . '-01'; $end = Carbon::parse($start)->addMonth()->format('Y-m-d'); + if ($through !== null) { + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $through) + || ! checkdate((int) substr($through, 5, 2), (int) substr($through, 8, 2), (int) substr($through, 0, 4)) + || $through < $start || $through >= $end) { + throw new InvalidArgumentException('截止日期必须是指定月份内的有效日期。'); + } + $end = Carbon::parse($through)->addDay()->format('Y-m-d'); + } $expectedIncomeCents = $this->cents($expectedIncome); $expectedExpenseCents = $this->cents($expectedExpense); $file = fopen($path, 'rb'); @@ -91,7 +99,13 @@ class DouyinAuthoritativeBillImporter 'row_count' => $count, 'income' => $this->decimal($income), 'expense' => $this->decimal($expense), 'imported_at' => now()->format('Y-m-d H:i:s'), ]; - $db->transaction(fn () => $db->table('douyin_authoritative_periods')->updateOrInsert(['period_start' => $start], $state)); + $db->transaction(function () use ($db, $start, $end, $state): void { + $previous = $db->table('douyin_authoritative_periods')->where('period_start', $start)->lockForUpdate()->first(); + if ($previous !== null && $previous->period_end > $end) { + throw new InvalidArgumentException('截止日期早于已校准范围,请导出包含已校准日期的完整结果。'); + } + $db->table('douyin_authoritative_periods')->updateOrInsert(['period_start' => $start], $state); + }); return $state; } catch (Throwable $e) { $db->table('douyin_authoritative_bills')->where('generation', $generation)->delete(); diff --git a/tests/Unit/EcommerceFinance/DouyinAuthoritativeBillTest.php b/tests/Unit/EcommerceFinance/DouyinAuthoritativeBillTest.php index 7cb10925..cce03bf3 100644 --- a/tests/Unit/EcommerceFinance/DouyinAuthoritativeBillTest.php +++ b/tests/Unit/EcommerceFinance/DouyinAuthoritativeBillTest.php @@ -126,7 +126,41 @@ class DouyinAuthoritativeBillTest extends TestCase $this->assertSame(12.0, app(StoreReceivingDouyinBillMirrorRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10)['summary']['income']); } - private function import(array $rows, int $count, string $income, string $expense): array + public function test_cutoff_preserves_later_bills_and_can_advance_without_duplicates(): void + { + $this->legacy(1, '2026-08-21 23:59:59', 90); + $this->legacy(2, '2026-08-22 00:00:00', 9); + $firstRow = $this->row('2000000000000000101', '10.00'); + $first = $this->import([$firstRow], 1, '10.00', '0.00', '2026-08-21'); + $this->assertSame('2026-08-22', $first['period_end']); + $repo = app(StoreReceivingDouyinBillMirrorRepository::class); + $this->assertSame(19.0, $repo->paginate([], 'occurred_at', 'asc', 1, 10)['summary']['income']); + $this->assertCount(2, iterator_to_array($repo->exportRows([], 'occurred_at', 'asc'))); + $nextRow = array_replace($this->row('2000000000000000102', '12.00'), ['结算时间' => '2026-08-22 00:00:00']); + $second = $this->import([$firstRow, $nextRow], 2, '22.00', '0.00', '2026-08-22'); + $this->assertSame('2026-08-23', $second['period_end']); + $result = $repo->paginate([], 'occurred_at', 'asc', 1, 10); + $this->assertSame(2, $result['total']); + $this->assertSame(22.0, $result['summary']['income']); + $this->assertSame(3, $this->db()->table('douyin_authoritative_bills')->count()); + } + + public function test_invalid_or_regressing_cutoffs_preserve_active_generation(): void + { + $row = $this->row('2000000000000000101', '10.00'); + $initial = $this->import([$row], 1, '10.00', '0.00', '2026-08-21'); + foreach (['2026-08-20', '2026-08-14', '2026-07-31', '2026-09-01', '2026-08-32', 'invalid', ''] as $through) { + try { + $this->import([$row], 1, '10.00', '0.00', $through); + $this->fail('Invalid cutoff should fail.'); + } catch (\InvalidArgumentException) { + $this->assertSame($initial['active_generation'], $this->db()->table('douyin_authoritative_periods')->value('active_generation')); + $this->assertSame(1, $this->db()->table('douyin_authoritative_bills')->count()); + } + } + } + + private function import(array $rows, int $count, string $income, string $expense, ?string $through = null): array { $path = tempnam(sys_get_temp_dir(), 'dy-bills-'); $this->files[] = $path; @@ -134,7 +168,7 @@ class DouyinAuthoritativeBillTest extends TestCase fputcsv($file, array_keys($rows[0])); foreach ($rows as $row) fputcsv($file, array_values($row)); fclose($file); - return app(DouyinAuthoritativeBillImporter::class)->import($path, '2026-08', $count, $income, $expense); + return app(DouyinAuthoritativeBillImporter::class)->import($path, '2026-08', $count, $income, $expense, $through); } private function row(string $code, string $income, string $expense = '0.00'): array