*/ private array $temporaryFiles = []; protected function setUp(): void { parent::setUp(); DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->delete(); } protected function tearDown(): void { foreach ($this->temporaryFiles as $path) { if (is_file($path)) { unlink($path); } } parent::tearDown(); } public function testItImportsTheRequestedJsonDateAndUsesPlatformToDisambiguateStores(): void { $path = $this->jsonFile([ 'data' => [ 'isSuccess' => true, 'fldHeads' => [ ['title' => '店铺', 'fld' => 'shop_name'], ['title' => '支付宝账号', 'fld' => 'platform_shop_id'], ['title' => '09-11', 'fld' => 'balance_20260911'], ['title' => '09-10', 'fld' => 'balance_20260910'], ], 'dataList' => [ ['shop_name' => '天猫-同名店', 'platform_shop_id' => 'TM-1', 'balance_20260911' => '19,895.01', 'balance_20260910' => 1], ['shop_name' => '淘宝-同名店', 'platform_shop_id' => 'TB-1', 'balance_20260911' => 425, 'balance_20260910' => 2], ['shop_name' => '支付宝-唯一店', 'platform_shop_id' => 'ALI-1', 'balance_20260911' => 10334.09, 'balance_20260910' => 3], ['shop_name' => '拼多多-跨平台店', 'platform_shop_id' => 'PDD-1', 'balance_20260911' => 999, 'balance_20260910' => 4], ], ], ]); $result = app(JushuitanShopBalanceImporter::class)->import($path, '2026-09-11'); $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ (object) ['id' => 1, 'store_platform' => '天猫', 'shop_name' => '同名店', 'erp_store_name' => 'ERP同名店'], (object) ['id' => 2, 'store_platform' => '淘宝', 'shop_name' => '同名店', 'erp_store_name' => 'ERP同名店'], (object) ['id' => 3, 'store_platform' => '京东', 'shop_name' => '唯一店', 'erp_store_name' => 'ERP唯一店'], (object) ['id' => 4, 'store_platform' => '拼多多', 'shop_name' => '不存在', 'erp_store_name' => 'ERP不存在'], (object) ['id' => 5, 'store_platform' => '京东', 'shop_name' => '跨平台店', 'erp_store_name' => 'ERP跨平台店'], ], '2026-09-11'); $this->assertSame('2026-09-11', $result['balance_date']); $this->assertSame(4, $result['total']); $this->assertSame(4, app(JushuitanShopBalanceRepository::class)->countForDate('2026-09-11')); $this->assertSame(19895.01, $matches[1]['balance']); $this->assertSame(425.0, $matches[2]['balance']); $this->assertSame('unmatched', $matches[3]['status']); $this->assertSame('unmatched', $matches[4]['status']); $this->assertSame('unmatched', $matches[5]['status']); } public function testItKeepsMultipleUnresolvedAccountsAmbiguous(): void { $path = $this->jsonFile([ 'isSuccess' => true, 'fldHeads' => [ ['title' => '店铺', 'fld' => 'shop_name'], ['title' => '支付宝账号', 'fld' => 'platform_shop_id'], ['title' => '09-11', 'fld' => 'balance'], ], 'dataList' => [ ['shop_name' => '天猫-重复店', 'platform_shop_id' => 'A-1', 'balance' => 10], ['shop_name' => '天猫-重复店', 'platform_shop_id' => 'A-2', 'balance' => 20], ], ]); app(JushuitanShopBalanceImporter::class)->import($path, '2026-09-11'); $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ (object) ['id' => 10, 'store_platform' => '天猫', 'shop_name' => '重复店', 'erp_store_name' => '重复店'], ], '2026-09-11'); $this->assertSame('ambiguous', $matches[10]['status']); $this->assertNull($matches[10]['balance']); } public function testItInfers1688PlatformWhenTheExportedStoreHasNoPlatformPrefix(): void { DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ 'balance_date' => '2026-09-11', 'source_scope' => 'alipay', 'source_platform' => null, 'store_name' => '新佰广1688', 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName('新佰广1688'), 'platform_account_id' => '2088180857774924', 'balance' => 44501.18, 'source_row_key' => hash('sha256', '新佰广1688'), 'source_file_name' => 'fixture.xlsx', 'source_sha256' => str_repeat('b', 64), 'imported_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ (object) ['id' => 30, 'store_platform' => '阿里巴巴', 'shop_name' => '新佰广1688', 'erp_store_name' => '新佰广1688'], ], '2026-09-11'); $this->assertSame('matched', $matches[30]['status']); $this->assertSame(44501.18, $matches[30]['balance']); $this->assertSame('1688', JushuitanShopBalanceRepository::sourcePlatform('新佰广1688')); $this->assertSame('淘宝', JushuitanShopBalanceRepository::sourcePlatform('淘宝-1688家具店')); } public function testItReadsThePreviousYearDateFromAnXlsxExportOnNewYearsDay(): void { $spreadsheet = new Spreadsheet(); $spreadsheet->getActiveSheet()->fromArray([ ['店铺', '支付宝账号', '12-31'], ['天猫-跨年店', 'TM-2026', 88.88], ]); $path = sys_get_temp_dir() . '/jushuitan-shop-balance-' . uniqid('', true) . '.xlsx'; (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->temporaryFiles[] = $path; $result = app(JushuitanShopBalanceImporter::class)->import($path, '2026-12-31'); $this->assertSame('2026-12-31', $result['balance_date']); $this->assertSame(1, $result['total']); $this->assertSame(88.88, (float) DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->value('balance')); } public function testItImportsPlatformFundBalancesFromTheActualClosingBalanceColumn(): void { $spreadsheet = new Spreadsheet(); $spreadsheet->getActiveSheet()->fromArray([ ['平台', '来源', '店铺', '期初余额', '本期收入', '本期支出', '期末余额', '平台实际期末余额', '状态'], ['拼多多', '拼多多货款账户', '拼多多-多平台测试店', 0, 0, 0, 0, 34499.28, '空'], ]); $path = sys_get_temp_dir() . '/jushuitan-platform-balance-' . uniqid('', true) . '.xlsx'; (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->temporaryFiles[] = $path; $result = app(JushuitanShopBalanceImporter::class)->import( $path, '2026-09-11', 'pinduoduo', ); $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ (object) ['id' => 20, 'store_platform' => '拼多多', 'shop_name' => '多平台测试店', 'erp_store_name' => 'ERP测试店'], ], '2026-09-11'); $this->assertSame(1, $result['total']); $this->assertSame(34499.28, $matches[20]['balance']); $this->assertSame('拼多多', $matches[20]['source_platform']); } public function testItReplacesTheSameDateAndSourceScopeOnRefresh(): void { $firstPath = $this->jsonFile([ 'isSuccess' => true, 'fldHeads' => [ ['title' => '店铺', 'fld' => 'shop_name'], ['title' => '09-12', 'fld' => 'balance'], ], 'dataList' => [ ['shop_name' => '拼多多-每日刷新店', 'balance' => 100], ['shop_name' => '拼多多-应被移除店', 'balance' => 200], ], ]); $secondPath = $this->jsonFile([ 'isSuccess' => true, 'fldHeads' => [ ['title' => '店铺', 'fld' => 'shop_name'], ['title' => '09-12', 'fld' => 'balance'], ], 'dataList' => [ ['shop_name' => '拼多多-每日刷新店', 'balance' => 350.25], ], ]); app(JushuitanShopBalanceImporter::class)->import($firstPath, '2026-09-12', 'pinduoduo'); $result = app(JushuitanShopBalanceImporter::class)->import($secondPath, '2026-09-12', 'pinduoduo'); $rows = DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', '2026-09-12') ->where('source_scope', 'pinduoduo') ->get(['store_name', 'balance']); $this->assertSame(2, $result['replaced']); $this->assertCount(1, $rows); $this->assertSame('拼多多-每日刷新店', $rows->first()->store_name); $this->assertSame(350.25, (float) $rows->first()->balance); } public function testItImportsTheLatestJdWalletBalanceAtTheRequestedCutoff(): void { $path = $this->xlsxFile([ ['记录编码', '店铺', '账户名称', '费用结算时间', '账户余额'], ['90000000000000000001', '京东-同名钱包店', '京东账户', '2026-09-13 22:00:00', 10], ['90000000000000000002', '京东-同名钱包店', '京东账户', '2026-09-14 22:00:00', 20], ['100000000000000000003', '京东-同名钱包店', '京东账户', '2026-09-14 22:00:00', 25.5], ['100000000000000000004', '京东-同名钱包店', '京东账户', '2026-09-15 00:00:00', 999], ['200000000000000000001', '京喜-京喜钱包店', '京喜账户', '2026-09-14 16:00:00', 30.75], ['300000000000000000001', '京喜小店-制造店', '京喜小店账户', '2026-09-14 17:00:00', 40.25], ]); $result = app(JushuitanShopBalanceImporter::class)->import( $path, '2026-09-14', 'jd-wallet', ); $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ (object) ['id' => 40, 'store_platform' => '京东', 'shop_name' => '同名钱包店', 'erp_store_name' => 'ERP京东店'], (object) ['id' => 41, 'store_platform' => '京喜', 'shop_name' => '京喜钱包店', 'erp_store_name' => 'ERP京喜店'], (object) ['id' => 42, 'store_platform' => '京喜', 'shop_name' => '制造店', 'erp_store_name' => 'ERP京喜小店'], ], '2026-09-14'); $this->assertSame(3, $result['total']); $this->assertSame(25.5, $matches[40]['balance']); $this->assertSame('京东', $matches[40]['source_platform']); $this->assertSame(30.75, $matches[41]['balance']); $this->assertSame('京喜', $matches[41]['source_platform']); $this->assertSame(40.25, $matches[42]['balance']); $this->assertSame('京喜小店', $matches[42]['source_platform']); } public function testJdWalletValidationFailureKeepsThePreviousSnapshot(): void { $validPath = $this->xlsxFile([ ['记录编码', '店铺', '账户名称', '费用结算时间', '账户余额'], ['1', '京东-保留旧余额店', '京东账户', '2026-09-14 12:00:00', 88.88], ]); $invalidPath = $this->xlsxFile([ ['记录编码', '店铺', '账户名称', '账户余额'], ['2', '京东-保留旧余额店', '京东账户', 99.99], ]); app(JushuitanShopBalanceImporter::class)->import($validPath, '2026-09-14', 'jd-wallet'); try { app(JushuitanShopBalanceImporter::class)->import($invalidPath, '2026-09-14', 'jd-wallet'); $this->fail('缺少费用结算时间的京东钱包文件不应被导入。'); } catch (\InvalidArgumentException $exception) { $this->assertStringContainsString('费用结算时间', $exception->getMessage()); } $row = DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', '2026-09-14') ->where('source_scope', 'jd-wallet') ->sole(); $this->assertSame('京东-保留旧余额店', $row->store_name); $this->assertSame(88.88, (float) $row->balance); } public function testJdWalletRowsAfterTheCutoffKeepThePreviousSnapshot(): void { $validPath = $this->xlsxFile([ ['记录编码', '店铺', '费用结算时间', '账户余额'], ['1', '京东-截止日前店铺', '2026-09-14 23:59:59', 66.66], ]); $futurePath = $this->xlsxFile([ ['记录编码', '店铺', '费用结算时间', '账户余额'], ['2', '京东-截止日前店铺', '2026-09-15 00:00:00', 77.77], ]); app(JushuitanShopBalanceImporter::class)->import($validPath, '2026-09-14', 'jd-wallet'); try { app(JushuitanShopBalanceImporter::class)->import($futurePath, '2026-09-14', 'jd-wallet'); $this->fail('截止日期后没有可用数据时不应替换旧快照。'); } catch (\InvalidArgumentException $exception) { $this->assertStringContainsString('已保留原有快照', $exception->getMessage()); } $this->assertSame(66.66, (float) DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', '2026-09-14') ->where('source_scope', 'jd-wallet') ->value('balance')); } public function testItKeepsHistoricalSnapshotsAndSelectsTheLatestBalanceDate(): void { Carbon::setTestNow('2026-10-20 10:00:00'); try { $this->insertBalanceSnapshot('2026-09-01', '2026-09-02 10:00:00', '历史店'); $this->insertBalanceSnapshot('2026-10-18', '2026-10-19 10:00:00', '最新店'); $this->insertBalanceSnapshot('2026-10-25', '2026-10-25 10:00:00', '未来店'); $repository = app(JushuitanShopBalanceRepository::class); $this->assertSame('2026-10-18', $repository->latestBalanceDate()); $this->assertSame('2026-09-01', $repository->earliestBalanceDate()); $this->assertSame(['2026-10-18', '2026-09-01'], $repository->availableDates()); $this->assertSame(1, $repository->countForDate('2026-09-01')); $matches = $repository->matchesForStores([ (object) ['id' => 70, 'store_platform' => '天猫', 'shop_name' => '历史店', 'erp_store_name' => '历史店'], ], '2026-09-01'); $this->assertSame('matched', $matches[70]['status']); $this->assertSame(100.0, $matches[70]['balance']); } finally { Carbon::setTestNow(); } } public function testImportingANewDateKeepsOlderSnapshots(): void { Carbon::setTestNow('2026-10-20 10:00:00'); try { $this->insertBalanceSnapshot('2026-09-01', '2026-09-02 12:00:00', '旧导入店', 'daily'); $path = $this->jsonFile([ 'isSuccess' => true, 'fldHeads' => [ ['title' => '店铺', 'fld' => 'shop_name'], ['title' => '10-19', 'fld' => 'balance'], ], 'dataList' => [ ['shop_name' => '拼多多-今日导入店', 'balance' => 123.45], ], ]); $result = app(JushuitanShopBalanceImporter::class)->import($path, '2026-10-19', 'daily'); $this->assertSame(1, $result['total']); $this->assertArrayNotHasKey('pruned', $result); $this->assertTrue(DB::connection('business_mirror') ->table(JushuitanShopBalanceRepository::TABLE) ->where('store_name', '旧导入店') ->exists()); $this->assertSame(1, app(JushuitanShopBalanceRepository::class)->countForDate('2026-09-01')); } finally { Carbon::setTestNow(); } } private function insertBalanceSnapshot( string $balanceDate, string $importedAt, string $storeName, string $sourceScope = 'all', ): void { DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ 'balance_date' => $balanceDate, 'source_scope' => $sourceScope, 'source_platform' => '天猫', 'store_name' => $storeName, 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName), 'platform_account_id' => 'retention-' . $balanceDate, 'balance' => 100, 'source_row_key' => hash('sha256', $balanceDate . '|' . $sourceScope . '|' . $storeName), 'source_file_name' => 'retention-fixture.json', 'source_sha256' => str_repeat('c', 64), 'imported_at' => Carbon::parse($importedAt), 'created_at' => Carbon::parse($importedAt), 'updated_at' => Carbon::parse($importedAt), ]); } /** @param array $payload */ private function jsonFile(array $payload): string { $path = sys_get_temp_dir() . '/jushuitan-shop-balance-' . uniqid('', true) . '.json'; file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $this->temporaryFiles[] = $path; return $path; } /** @param list> $rows */ private function xlsxFile(array $rows): string { $spreadsheet = new Spreadsheet(); $spreadsheet->getActiveSheet()->fromArray($rows); $path = sys_get_temp_dir() . '/jushuitan-shop-balance-' . uniqid('', true) . '.xlsx'; (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->temporaryFiles[] = $path; return $path; } }