diff --git a/app/Console/Commands/ImportDouyinAuthoritativeBills.php b/app/Console/Commands/ImportDouyinAuthoritativeBills.php new file mode 100644 index 00000000..e5a327ce --- /dev/null +++ b/app/Console/Commands/ImportDouyinAuthoritativeBills.php @@ -0,0 +1,29 @@ +import((string) $this->argument('path'), (string) $this->argument('month'), + (int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense')); + } catch (Throwable $e) { + $this->error('胜算抖音明细未启用,保留上一版数据。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件和镜像连接。')); + return self::FAILURE; + } + $this->info('胜算抖音明细已启用,月份:' . $this->argument('month') . ',笔数:' . $result['row_count']); + return self::SUCCESS; + } +} diff --git a/database/migrations/2026_09_14_100000_create_douyin_authoritative_bill_tables.php b/database/migrations/2026_09_14_100000_create_douyin_authoritative_bill_tables.php new file mode 100644 index 00000000..0a0e5652 --- /dev/null +++ b/database/migrations/2026_09_14_100000_create_douyin_authoritative_bill_tables.php @@ -0,0 +1,50 @@ +hasTable('douyin_authoritative_periods')) { + $schema->create('douyin_authoritative_periods', function (Blueprint $table): void { + $table->date('period_start')->primary(); + $table->date('period_end'); + $table->uuid('active_generation'); + $table->string('source_filename'); + $table->char('source_sha256', 64); + $table->unsignedInteger('row_count'); + $table->decimal('income', 20, 2); + $table->decimal('expense', 20, 2); + $table->dateTime('imported_at'); + }); + } + if (! $schema->hasTable('douyin_authoritative_bills')) { + $schema->create('douyin_authoritative_bills', function (Blueprint $table): void { + $table->uuid('generation'); + $table->string('record_code', 64); + $table->string('store_name'); + $table->string('order_no', 64)->nullable(); + $table->string('bill_item'); + $table->dateTime('occurred_at'); + $table->decimal('income', 20, 2); + $table->decimal('expense', 20, 2); + $table->decimal('balance', 20, 2); + $table->json('row_data'); + $table->primary(['generation', 'record_code'], 'dy_authoritative_pk'); + $table->index(['generation', 'occurred_at', 'record_code'], 'dy_authoritative_time'); + $table->index(['generation', 'store_name', 'occurred_at'], 'dy_authoritative_shop'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror')); + $schema->dropIfExists('douyin_authoritative_bills'); + $schema->dropIfExists('douyin_authoritative_periods'); + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 4c8e7d68..645292ab 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -23,6 +23,7 @@ | 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 | | 已完成 | `operations_replica/rpt.jst_originalBillInfo` 已进入通用原始账单镜像 `ops_jst_original_bill_info`;保留当前 19 个业务字段,按 `Id` 断点全量、按 `updateTime` 重叠增量。 | | 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `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.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | diff --git a/modules/EcommerceFinance/Repositories/DouyinAuthoritativeBillRepository.php b/modules/EcommerceFinance/Repositories/DouyinAuthoritativeBillRepository.php new file mode 100644 index 00000000..eb939975 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/DouyinAuthoritativeBillRepository.php @@ -0,0 +1,153 @@ +db()->getSchemaBuilder()->hasTable('douyin_authoritative_periods') + && $this->db()->table('douyin_authoritative_periods')->exists(); + } + + public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array + { + $query = $this->query($filters); + $summary = (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first(); + $total = (int) $summary->count; + $page = max(1, $page); + $perPage = min(100, max(1, $perPage)); + $rows = $this->ordered($query, $sort, $direction)->forPage($page, $perPage)->get()->all(); + $income = round((float) $summary->income, 2); + $expense = round((float) $summary->expense, 2); + return [ + 'data' => $this->mapRows($rows), 'total' => $total, 'current_page' => $page, + 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, + 'summary' => ['count' => $total, 'income' => $income, 'expense' => $expense, 'balance' => round($income - $expense, 2)], + 'sync' => $this->mirror->state($this->datasets->get(StoreReceivingAlipayBillMirrorRepository::DATASET)) ?? [], + ]; + } + + public function exportRows(array $filters, string $sort, string $direction): iterable + { + $query = $this->ordered($this->query($filters), $sort, $direction); + // Freeze the selected generations in query() so activation cannot mix versions during export. + return $this->batches($query); + } + + private function batches(Builder $query): iterable + { + $batch = []; + foreach ($query->cursor() as $row) { + $batch[] = $row; + if (count($batch) === 500) { + yield from $this->mapRows($batch); + $batch = []; + } + } + if ($batch !== []) yield from $this->mapRows($batch); + } + + public function filterOptions(): array + { + $query = $this->query([]); + return [ + 'shops' => (clone $query)->whereNotNull('store_name')->where('store_name', '<>', '')->distinct()->orderBy('store_name')->pluck('store_name')->all(), + 'bill_items' => (clone $query)->whereNotNull('bill_item')->where('bill_item', '<>', '')->distinct()->orderBy('bill_item')->pluck('bill_item')->all(), + ]; + } + + private function query(array $filters): Builder + { + $dataset = $this->datasets->get(StoreReceivingAlipayBillMirrorRepository::DATASET); + if (! $this->mirror->ready($dataset) || ! $this->mirror->ready($this->datasets->get(StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET))) { + throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); + } + $db = $this->db(); + $periods = $db->table('douyin_authoritative_periods')->get(); + $shops = $db->table('ops_jst_shop_info')->select('shop_id')->selectRaw('MAX(shop_name) AS shop_name')->groupBy('shop_id'); + $legacy = $db->table($dataset->targetTable() . ' as b') + ->leftJoinSub($shops, 's', fn ($join) => $join->on('b.shop_id', '=', 's.shop_id')) + ->where('b.channel', StoreReceivingDouyinBillMirrorRepository::SOURCE_CHANNEL); + foreach ($periods as $period) { + $legacy->where(fn (Builder $q) => $q->where('b.fee_time', '<', $period->period_start) + ->orWhere('b.fee_time', '>=', $period->period_end)->orWhereNull('b.fee_time')); + } + $legacy->selectRaw("CAST(b.Id AS CHAR) AS record_code, s.shop_name AS store_name, + COALESCE(NULLIF(b.so_id, ''), b.p_auto_id) AS order_no, b.fee_name AS bill_item, b.fee_time AS occurred_at, + CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END AS income, + CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END AS expense, + CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) WHEN b.inout_type = '-1' THEN -ABS(COALESCE(b.amount, 0)) ELSE 0 END AS balance, + b.Id AS source_id, NULL AS row_data"); + $anchor = $db->table('douyin_authoritative_bills') + ->whereIn('generation', $periods->pluck('active_generation')->all()) + ->select(['record_code', 'store_name', 'order_no', 'bill_item', 'occurred_at', 'income', 'expense', 'balance']) + ->selectRaw('NULL AS source_id, row_data'); + // Apply selective filters before UNION ALL to avoid materializing the full account for a monthly view. + $this->filters($legacy, $filters, true); + $this->filters($anchor, $filters, false); + return $db->query()->fromSub($legacy->unionAll($anchor), 'bills'); + } + + private function filters(Builder $query, array $filters, bool $legacy): void + { + $date = $legacy ? 'b.fee_time' : 'occurred_at'; + $shop = $legacy ? 's.shop_name' : 'store_name'; + $item = $legacy ? 'b.fee_name' : 'bill_item'; + foreach (['store_name' => $shop, 'bill_item' => $item] as $key => $column) { + if (($filters[$key] ?? '') !== '') $query->where($column, 'like', '%' . $this->escape($filters[$key]) . '%'); + } + if (($filters['start'] ?? '') !== '') $query->where($date, '>=', $filters['start'] . ' 00:00:00'); + if (($filters['end'] ?? '') !== '') $query->where($date, '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); + if (($filters['keyword'] ?? '') !== '') { + $value = '%' . $this->escape($filters['keyword']) . '%'; + $columns = $legacy ? ['b.Id', 'b.auto_id', 'b.fee_name', 'b.primary_key', 'b.p_auto_id', 'b.so_id', 'b.sku_id', 'b.shop_sku_id', 'b.shop_i_id', 'b.channel'] + : ['record_code', 'order_no', 'bill_item', 'row_data']; + $query->where(function (Builder $q) use ($value, $columns): void { + foreach ($columns as $column) $q->orWhere($column, 'like', $value); + }); + } + } + + private function ordered(Builder $query, string $sort, string $direction): Builder + { + $sort = in_array($sort, ['record_code', 'occurred_at', 'income', 'expense', 'balance'], true) ? $sort : 'occurred_at'; + $direction = $direction === 'asc' ? 'asc' : 'desc'; + return $query->orderBy($sort, $direction)->orderBy('record_code', $direction); + } + + private function mapRows(array $rows): array + { + $ids = array_values(array_filter(array_map(fn ($r) => $r->source_id, $rows), fn ($id) => $id !== null)); + $legacy = []; + if ($ids !== []) { + $raw = $this->db()->table($this->datasets->get(StoreReceivingAlipayBillMirrorRepository::DATASET)->targetTable())->whereIn('Id', $ids)->get()->all(); + foreach ($this->source->mapDouyinRows($raw) as $row) $legacy[$row['id']] = $row; + } + return array_map(fn ($row) => $row->source_id === null + ? json_decode($row->row_data, true, 512, JSON_THROW_ON_ERROR) + : $legacy[(string) $row->source_id], $rows); + } + + private function db(): \Illuminate\Database\Connection + { + return $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror')); + } + + private function escape(string $value): string + { + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); + } +} diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index 7fc4a61f..3b629011 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -430,6 +430,13 @@ class StoreReceivingAlipayBillMirrorRepository ->all(); } + /** @param array $rows */ + public function mapDouyinRows(array $rows): array + { + $shops = $this->shopNames($rows); + return array_map(fn (object $row): array => $this->mapRowFor($row, $shops[(string) $row->shop_id] ?? null, 'douyin'), $rows); + } + private function mapRow(object $row, ?string $shopName): array { $amount = abs((float) ($row->amount ?? 0)); diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingDouyinBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingDouyinBillMirrorRepository.php index 4a605852..908143d8 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingDouyinBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingDouyinBillMirrorRepository.php @@ -9,12 +9,16 @@ class StoreReceivingDouyinBillMirrorRepository public function __construct( private readonly StoreReceivingAlipayBillMirrorRepository $source, + private readonly ?DouyinAuthoritativeBillRepository $authoritative = null, ) { } /** @return array */ public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array { + if ($this->authoritative?->available()) { + return $this->authoritative->paginate($filters, $sort, $direction, $page, $perPage); + } return $this->source->paginateChannel( $filters, $sort, @@ -29,6 +33,9 @@ class StoreReceivingDouyinBillMirrorRepository /** @return iterable> */ public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable { + if ($this->authoritative?->available()) { + return $this->authoritative->exportRows($filters, $sort, $direction); + } return $this->source->exportChannelRows( $filters, $sort, @@ -41,6 +48,9 @@ class StoreReceivingDouyinBillMirrorRepository /** @return array{shops:array,bill_items:array} */ public function filterOptions(): array { + if ($this->authoritative?->available()) { + return $this->authoritative->filterOptions(); + } return $this->source->filterOptionsForChannel(self::SOURCE_CHANNEL); } } diff --git a/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php b/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php new file mode 100644 index 00000000..9fd0a47d --- /dev/null +++ b/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php @@ -0,0 +1,121 @@ +addMonth()->format('Y-m-d'); + $expectedIncomeCents = $this->cents($expectedIncome); + $expectedExpenseCents = $this->cents($expectedExpense); + $file = fopen($path, 'rb'); + if ($file === false) throw new RuntimeException('无法读取胜算明细文件。'); + $db = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror')); + $generation = (string) Str::uuid(); + $count = $income = $expense = 0; + $seen = []; + $batch = []; + try { + $headers = fgetcsv($file); + if (! is_array($headers)) throw new InvalidArgumentException('胜算明细为空。'); + $headers[0] = preg_replace('/^\xEF\xBB\xBF/', '', $headers[0]); + foreach (['记录编码', '店铺', '商户主体名称', '账单项目', '动账流水号', '订单号', '结算时间', '收入', '支出'] as $header) { + if (! in_array($header, $headers, true)) throw new InvalidArgumentException('缺少胜算原始明细字段:' . $header); + } + while (($values = fgetcsv($file)) !== false) { + if ($values === [null]) continue; + if (count($values) !== count($headers)) throw new InvalidArgumentException('胜算明细列数不一致。'); + $r = array_combine($headers, $values); + $code = trim($r['记录编码']); + if (! preg_match('/^\d{1,64}$/', $code) || isset($seen[$code])) throw new InvalidArgumentException('记录编码为空、失真或重复。'); + $seen[$code] = true; + $time = $this->dateTime($r['结算时间']); + if ($time === null || $time < $start || $time >= $end || trim($r['店铺']) === '') { + throw new InvalidArgumentException('明细月份或店铺不符合导入范围。'); + } + $in = $this->cents($r['收入']); + $out = $this->cents($r['支出']); + $payload = []; + foreach ([ + 'merchant_name' => '商户主体名称', 'move_serial_no' => '动账流水号', 'order_no_2' => '订单号', + 'online_order_no' => '线上单号', 'sub_order_no' => '子订单号', 'goods_id' => '商品ID', + 'goods_name' => '商品名称', 'account_type' => '账务类型', 'move_summary' => '动账摘要', + 'business_type' => '业务类型', 'settle_account' => '结算账户', 'move_scene' => '动账场景', + 'talent_id' => '达人ID', 'talent_name' => '达人名称', + ] as $field => $label) $payload[$field] = ($r[$label] ?? '') !== '' ? $r[$label] : null; + $payload['order_placed_at'] = $this->dateTime($r['下单时间'] ?? ''); + $row = [ + 'id' => 'shengsuan:' . $code, 'record_code' => $code, 'store_name' => trim($r['店铺']), + 'finance_remark' => null, 'order_no' => $r['订单号'], 'amount' => ($in ?: $out) / 100, + 'custom_remark' => $r['自定义备注'] ?? null, 'bill_item' => $r['账单项目'], + 'occurred_at' => $time, 'income' => $in / 100, 'expense' => $out / 100, + 'balance' => ($in - $out) / 100, 'remark' => $r['备注'] ?? null, + 'modifier' => $r['修改人'] ?? null, 'created_at' => $this->dateTime($r['创建时间'] ?? ''), + 'updated_at' => $this->dateTime($r['修改时间'] ?? ''), 'payload' => $payload, + ]; + $batch[] = [ + 'generation' => $generation, 'record_code' => $code, 'store_name' => $row['store_name'], + 'order_no' => $row['order_no'], 'bill_item' => $row['bill_item'], 'occurred_at' => $time, + 'income' => $this->decimal($in), 'expense' => $this->decimal($out), 'balance' => $this->decimal($in - $out), + 'row_data' => json_encode($row, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ]; + $count++; $income += $in; $expense += $out; + if (count($batch) === 500) { + $db->table('douyin_authoritative_bills')->insert($batch); + $batch = []; + } + } + if ($count !== $expectedCount || $income !== $expectedIncomeCents || $expense !== $expectedExpenseCents) { + throw new InvalidArgumentException('明细笔数或收支合计与核对值不一致,未启用。'); + } + if ($batch !== []) $db->table('douyin_authoritative_bills')->insert($batch); + // Publish only a fully validated generation; retain previous generations for rollback. + $state = [ + 'period_start' => $start, 'period_end' => $end, 'active_generation' => $generation, + 'source_filename' => basename($path), 'source_sha256' => hash_file('sha256', $path), + '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)); + return $state; + } catch (Throwable $e) { + $db->table('douyin_authoritative_bills')->where('generation', $generation)->delete(); + throw $e; + } finally { + fclose($file); + } + } + + private function cents(string $value): int + { + if (! preg_match('/^\d{1,14}(?:\.\d{1,2})?$/', trim($value))) throw new InvalidArgumentException('收入或支出不是有效的非负金额。'); + [$whole, $fraction] = array_pad(explode('.', trim($value), 2), 2, ''); + return ((int) $whole) * 100 + (int) str_pad($fraction, 2, '0'); + } + + private function decimal(int $cents): string + { + return ($cents < 0 ? '-' : '') . intdiv(abs($cents), 100) . '.' . str_pad((string) (abs($cents) % 100), 2, '0', STR_PAD_LEFT); + } + + private function dateTime(string $value): ?string + { + if (trim($value) === '') return null; + return Carbon::parse($value)->format('Y-m-d H:i:s'); + } +} diff --git a/tests/Unit/EcommerceFinance/DouyinAuthoritativeBillTest.php b/tests/Unit/EcommerceFinance/DouyinAuthoritativeBillTest.php new file mode 100644 index 00000000..ed5887ce --- /dev/null +++ b/tests/Unit/EcommerceFinance/DouyinAuthoritativeBillTest.php @@ -0,0 +1,131 @@ + ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); + DB::purge('business_mirror'); + (require database_path('migrations/2026_09_14_100000_create_douyin_authoritative_bill_tables.php'))->up(); + (require database_path('migrations/2026_09_07_000000_create_original_bill_info_mirror_table.php'))->up(); + $schema = Schema::connection('business_mirror'); + $schema->create('ops_jst_shop_info', function (Blueprint $t): void { + $t->integer('Id'); $t->integer('shop_id'); $t->string('shop_name'); + }); + $schema->create('business_mirror_table_states', function (Blueprint $t): void { + $t->string('dataset_key'); $t->dateTime('initial_completed_at')->nullable(); $t->string('status'); + }); + foreach ([StoreReceivingAlipayBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $key) { + $this->db()->table('business_mirror_table_states')->insert(['dataset_key' => $key, 'initial_completed_at' => now(), 'status' => 'succeeded']); + } + $this->db()->table('ops_jst_shop_info')->insert(['Id' => 1, 'shop_id' => 7, 'shop_name' => '测试店']); + } + + protected function tearDown(): void + { + foreach ($this->files as $file) @unlink($file); + parent::tearDown(); + } + + public function test_month_replaces_split_bills_and_unifies_list_summary_filters_and_export(): void + { + $this->legacy(1, '2026-08-15 12:00:00', 30); + $this->legacy(2, '2026-08-15 12:00:00', 30); + $this->legacy(3, '2026-09-01 00:00:00', 9); + $rows = [$this->row('2000000000000000101', '40.12'), $this->row('2000000000000000102', '0.00', '2.03')]; + $this->import($rows, 2, '40.12', '2.03'); + $repo = app(StoreReceivingDouyinBillMirrorRepository::class); + $all = $repo->paginate([], 'occurred_at', 'asc', 1, 10); + $this->assertSame(3, $all['total']); + $this->assertSame(49.12, $all['summary']['income']); + $this->assertSame(2.03, $all['summary']['expense']); + $filters = ['start' => '2026-08-01', 'end' => '2026-08-31']; + $first = $repo->paginate($filters, 'record_code', 'asc', 1, 1); + $second = $repo->paginate($filters, 'record_code', 'asc', 2, 1); + $this->assertSame('2000000000000000101', $first['data'][0]['record_code']); + $this->assertSame('2000000000000000102', $second['data'][0]['record_code']); + $this->assertSame('测试主体', $first['data'][0]['payload']['merchant_name']); + $this->assertSame('20260815000100000000001', $first['data'][0]['payload']['move_serial_no']); + $this->assertSame('6912345678901234567', $first['data'][0]['payload']['sub_order_no']); + $this->assertSame('2026-07-31 10:00:00', $first['data'][0]['payload']['order_placed_at']); + $export = iterator_to_array($repo->exportRows($filters, 'record_code', 'asc')); + $this->assertSame([$first['data'][0], $second['data'][0]], $export); + $this->assertSame(2, $repo->paginate($filters + ['store_name' => '测试店', 'bill_item' => '订单实付'], 'income', 'desc', 1, 10)['total']); + $this->assertSame(1, $repo->paginate(['keyword' => '2000000000000000102'], 'occurred_at', 'asc', 1, 10)['total']); + $this->assertContains('测试店', $repo->filterOptions()['shops']); + $this->assertContains('订单实付', $repo->filterOptions()['bill_items']); + // A later source sync must not bring the incorrect month back. + $this->legacy(4, '2026-08-31 23:59:59', 100); + $this->assertSame(2, $repo->paginate($filters, 'occurred_at', 'asc', 1, 10)['total']); + } + + public function test_invalid_counts_totals_duplicate_ids_and_dates_do_not_publish_partial_data(): void + { + $row = $this->row('2000000000000000101', '10.00'); + $initial = $this->import([$row], 1, '10.00', '0.00'); + $cases = [ + [[$row], 2, '10.00', '0.00'], + [[$row], 1, '11.00', '0.00'], + [[$row, $row], 2, '20.00', '0.00'], + [[array_replace($row, ['结算时间' => '2026-09-01 00:00:00'])], 1, '10.00', '0.00'], + [[array_replace($row, ['收入' => '1.005'])], 1, '1.00', '0.00'], + ]; + foreach ($cases as $case) { + try { + $this->import(...$case); + $this->fail('Invalid import 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()); + } + } + } + + public function test_reimport_activates_new_generation_and_keeps_old_rows_for_rollback(): void + { + $first = $this->import([$this->row('2000000000000000101', '10.00')], 1, '10.00', '0.00'); + $second = $this->import([$this->row('2000000000000000101', '12.00')], 1, '12.00', '0.00'); + $this->assertNotSame($first['active_generation'], $second['active_generation']); + $this->assertSame(2, $this->db()->table('douyin_authoritative_bills')->count()); + $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 + { + $path = tempnam(sys_get_temp_dir(), 'dy-bills-'); + $this->files[] = $path; + $file = fopen($path, 'wb'); + 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); + } + + private function row(string $code, string $income, string $expense = '0.00'): array + { + return ['记录编码' => $code, '店铺' => '测试店', '商户主体名称' => '测试主体', '账单项目' => '订单实付', + '动账流水号' => '20260815000100000000001', '订单号' => '6912345678901234567', '子订单号' => '6912345678901234567', + '结算时间' => '2026-08-15 12:00:00', '下单时间' => '2026-07-31 10:00:00', '收入' => $income, '支出' => $expense]; + } + + private function legacy(int $id, string $time, float $amount): void + { + $this->db()->table('ops_jst_original_bill_info')->insert(['Id' => $id, 'shop_id' => 7, '_mirror_key' => hash('sha256', (string) $id), '_mirror_synced_at' => now(), + 'channel' => 'TouTiaoFXGZJ', 'fee_time' => $time, 'amount' => $amount, 'inout_type' => '1', 'fee_name' => '订单实付']); + } + + private function db(): \Illuminate\Database\Connection { return DB::connection('business_mirror'); } +}