404 lines
18 KiB
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;
|
|
}
|
|
}
|