修复抖音收款账户与胜算月度明细差异 (#178)

This commit is contained in:
qiuxiang 2026-09-14 11:02:29 +08:00
parent 1e5c2a7e0b
commit 30ac6a39c0
8 changed files with 502 additions and 0 deletions

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\DouyinAuthoritativeBillImporter;
use Throwable;
class ImportDouyinAuthoritativeBills extends Command
{
protected $signature = 'business-mirror:import-douyin-authoritative
{path : 胜算原始明细 UTF-8 CSV} {month : 完整月份 YYYY-MM}
{--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计}';
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'));
} catch (Throwable $e) {
$this->error('胜算抖音明细未启用,保留上一版数据。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件和镜像连接。'));
return self::FAILURE;
}
$this->info('胜算抖音明细已启用,月份:' . $this->argument('month') . ',笔数:' . $result['row_count']);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
if (! $schema->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');
}
};

View File

@ -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 <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.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 |

View File

@ -0,0 +1,153 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
class DouyinAuthoritativeBillRepository
{
public function __construct(
private readonly DatabaseManager $databases,
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly StoreReceivingAlipayBillMirrorRepository $source,
) {}
public function available(): bool
{
return $this->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);
}
}

View File

@ -430,6 +430,13 @@ class StoreReceivingAlipayBillMirrorRepository
->all();
}
/** @param array<int,object> $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));

View File

@ -9,12 +9,16 @@ class StoreReceivingDouyinBillMirrorRepository
public function __construct(
private readonly StoreReceivingAlipayBillMirrorRepository $source,
private readonly ?DouyinAuthoritativeBillRepository $authoritative = null,
) {
}
/** @return array<string,mixed> */
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<int,array<string,mixed>> */
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<int,string>,bill_items:array<int,string>} */
public function filterOptions(): array
{
if ($this->authoritative?->available()) {
return $this->authoritative->filterOptions();
}
return $this->source->filterOptionsForChannel(self::SOURCE_CHANNEL);
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/** Imports a complete, explicitly reconciled month 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
{
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');
$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');
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Services\DouyinAuthoritativeBillImporter;
use Tests\TestCase;
class DouyinAuthoritativeBillTest extends TestCase
{
private array $files = [];
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror' => ['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'); }
}