CaiWuXiTong/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php

404 lines
18 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class JushuitanShopBalanceRepositoryTest extends TestCase
{
/** @var list<string> */
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<string,mixed> $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<list<mixed>> $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;
}
}