fix: support reconciled Douyin month-to-date imports

This commit is contained in:
qiuxiang 2026-09-22 10:43:51 +08:00
parent 3b1d689b26
commit 6a900eb78d
4 changed files with 61 additions and 10 deletions

View File

@ -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;
}
}

View File

@ -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`

View File

@ -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();

View File

@ -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