导入阿里巴巴账户余额快照 #159

Merged
chenxi merged 1 commits from feature/chenxi-alibaba-balance-import into main 2026-09-11 12:25:37 +08:00
8 changed files with 630 additions and 15 deletions

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AlibabaAccountBalanceWorkbookImporter;
use Throwable;
class ImportAlibabaAccountBalances extends Command
{
protected $signature = 'business:import-alibaba-account-balances
{path : 阿里巴巴账户余额 Excel 文件路径}
{--source-name= : 原始文件名,用于导入来源追溯}
{--dry-run : 只校验和统计,不写入共享业务库}';
protected $description = '校验并导入阿里巴巴账户月末余额快照';
public function handle(AlibabaAccountBalanceWorkbookImporter $importer): int
{
$path = (string) $this->argument('path');
try {
$sourceName = trim((string) $this->option('source-name'));
$result = $importer->import(
$path,
$sourceName !== '' ? $sourceName : basename($path),
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$action = $this->option('dry-run') ? '校验完成' : '导入完成';
$this->info(sprintf(
'%s:共 %d 条,新增 %d 条,更新 %d 条。',
$action,
$result['total'],
$result['created'],
$result['updated'],
));
return self::SUCCESS;
}
}

View File

@ -25,6 +25,7 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
@ -1310,7 +1311,7 @@ class Pages extends Controller
'pending' => false,
];
if (! in_array($tab, ['taobao-alipay', 'pinduoduo'], true)) {
if (! in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true)) {
return $content;
}
@ -1325,11 +1326,14 @@ class Pages extends Controller
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$repository = $isTaobao
? app(TmallTaobaoSpreadBillRepository::class)
: app(PinduoduoSpreadBillRepository::class);
$isAlibaba = $tab === 'taobao-wechat';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$cache = $this->upstreamCaches->forSource('jiansun');
$cache = $isAlibaba ? null : $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
@ -1339,7 +1343,7 @@ class Pages extends Controller
$page,
$pageSize,
);
$result = $usingMirror
$result = ($usingMirror || $isAlibaba)
? $resolver()
: $cache->remember(
$scope,
@ -1354,13 +1358,14 @@ class Pages extends Controller
$result = ['rows' => [], 'total' => 0];
} catch (Throwable $exception) {
report($exception);
$content['error'] = ($isTaobao ? '淘系' : '拼多多') . '账户余额数据暂时无法读取,请稍后重试。';
$platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : '拼多多');
$content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。';
$result = ['rows' => [], 'total' => 0];
}
try {
$resolver = fn () => $repository->storeOptions();
$storeOptions = $usingMirror
$storeOptions = ($usingMirror || $isAlibaba)
? $resolver()
: $cache->remember(
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
@ -1406,7 +1411,7 @@ class Pages extends Controller
}
abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404);
abort_unless(in_array($tab, ['taobao-alipay', 'pinduoduo'], true), 404);
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true), 404);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
@ -1416,9 +1421,12 @@ class Pages extends Controller
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$repository = $isTaobao
? app(TmallTaobaoSpreadBillRepository::class)
: app(PinduoduoSpreadBillRepository::class);
$isAlibaba = $tab === 'taobao-wechat';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$redirectParameters = array_filter([
'section' => $section,
@ -1430,7 +1438,7 @@ class Pages extends Controller
try {
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
$result = $repository->usesBusinessMirror()
$result = ($repository->usesBusinessMirror() || $isAlibaba)
? $resolver()
: $this->upstreamCaches->forSource('jiansun')->remember(
$scope,
@ -1448,6 +1456,8 @@ class Pages extends Controller
}
$rows = $result['rows'] ?? [];
$filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : 'pinduoduo');
return response()->streamDownload(function () use ($rows): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
@ -1465,7 +1475,7 @@ class Pages extends Controller
}
fclose($csv);
}, ($isTaobao ? 'taobao' : 'pinduoduo') . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
}, $filePrefix . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}

View File

@ -0,0 +1,49 @@
<?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($this->connection());
if ($schema->hasTable('alibaba_account_balance_snapshots')) {
return;
}
$schema->create('alibaba_account_balance_snapshots', function (Blueprint $table): void {
$table->id();
$table->date('balance_date');
$table->string('store_name', 255);
$table->decimal('opening_balance', 20, 6)->nullable();
$table->decimal('income_total', 20, 6)->nullable();
$table->decimal('expense_total', 20, 6)->nullable();
$table->decimal('closing_balance', 20, 6)->nullable();
$table->decimal('system_closing_balance', 20, 6)->nullable();
$table->decimal('balance_difference', 20, 6)->nullable();
$table->text('difference_reason')->nullable();
$table->string('verification_status', 64)->nullable();
$table->text('notes')->nullable();
$table->string('source_file_name', 255);
$table->char('source_sha256', 64);
$table->dateTime('imported_at', 6);
$table->timestamps(6);
$table->unique(['balance_date', 'store_name'], 'alibaba_balance_date_store_unique');
$table->index(['store_name', 'balance_date'], 'alibaba_balance_store_date_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('alibaba_account_balance_snapshots');
}
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
};

View File

@ -0,0 +1,104 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Schema;
class AlibabaAccountBalanceRepository
{
public const TABLE = 'alibaba_account_balance_snapshots';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
public function usesBusinessMirror(): bool
{
return Schema::connection($this->connection->getName())->hasTable(self::TABLE);
}
/** @return string[] */
public function storeOptions(): array
{
if (! $this->usesBusinessMirror()) {
return [];
}
return $this->connection->table(self::TABLE)
->whereNotNull('store_name')
->where('store_name', '<>', '')
->distinct()
->orderBy('store_name')
->pluck('store_name')
->map(static fn (mixed $value): string => (string) $value)
->all();
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateMonthlyBalances(
?string $storeName = null,
?string $month = null,
int $page = 1,
int $pageSize = 25,
): array {
if (! $this->usesBusinessMirror()) {
return ['rows' => [], 'total' => 0];
}
$page = max($page, 1);
$pageSize = min(max($pageSize, 1), 5000);
$query = $this->connection->table(self::TABLE);
$this->applyFilters($query, $storeName, $month);
$total = (clone $query)->count();
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
$rows = $query
->orderByDesc('balance_date')
->orderBy('store_name')
->forPage($page, $pageSize)
->get([
'balance_date', 'store_name', 'opening_balance', 'income_total',
'expense_total', 'closing_balance', 'system_closing_balance',
'balance_difference', 'difference_reason', 'verification_status', 'notes',
])
->map(static fn (object $row): array => [
'date' => (string) $row->balance_date,
'store_name' => (string) $row->store_name,
'opening_balance' => $row->opening_balance === null ? null : (float) $row->opening_balance,
'income_total' => $row->income_total === null ? null : (float) $row->income_total,
'expense_total' => $row->expense_total === null ? null : (float) $row->expense_total,
'closing_balance' => $row->closing_balance === null ? null : (float) $row->closing_balance,
'system_closing_balance' => $row->system_closing_balance === null ? null : (float) $row->system_closing_balance,
'balance_difference' => $row->balance_difference === null ? null : (float) $row->balance_difference,
'difference_reason' => (string) ($row->difference_reason ?? ''),
'verification_status' => (string) ($row->verification_status ?? ''),
'notes' => (string) ($row->notes ?? ''),
])
->all();
return ['rows' => $rows, 'total' => $total];
}
private function applyFilters(Builder $query, ?string $storeName, ?string $month): void
{
if ($storeName !== null && $storeName !== '') {
$query->where('store_name', $storeName);
}
if ($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
$query->where('balance_date', '>=', $month . '-01')
->where('balance_date', '<', date('Y-m-d', strtotime($month . '-01 +1 month')));
}
}
}

View File

@ -0,0 +1,207 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
class AlibabaAccountBalanceWorkbookImporter
{
private const HEADERS = [
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
/** @return array{created:int,updated:int,skipped:int,total:int} */
public function import(string $path, ?string $sourceFileName = null, bool $dryRun = false): array
{
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('余额文件不存在或不可读取。');
}
$rows = $this->readRows($path);
if ($rows === []) {
throw new InvalidArgumentException('余额文件中没有可导入的数据行。');
}
if (! Schema::connection($this->connection->getName())->hasTable(AlibabaAccountBalanceRepository::TABLE)) {
throw new RuntimeException('阿里巴巴余额快照表尚未创建,请先执行数据库迁移。');
}
$created = 0;
$updated = 0;
$sourceFileName = mb_substr(basename($sourceFileName ?: $path), 0, 255);
$sourceSha256 = hash_file('sha256', $path);
$now = now();
$this->connection->transaction(function () use (
$rows, $sourceFileName, $sourceSha256, $now, $dryRun, &$created, &$updated
): void {
foreach ($rows as $row) {
$identity = [
'balance_date' => $row['balance_date'],
'store_name' => $row['store_name'],
];
$exists = $this->connection->table(AlibabaAccountBalanceRepository::TABLE)
->where($identity)
->exists();
$exists ? $updated++ : $created++;
if ($dryRun) {
continue;
}
$values = $row + [
'source_file_name' => $sourceFileName,
'source_sha256' => $sourceSha256,
'imported_at' => $now,
'updated_at' => $now,
];
if ($exists) {
$this->connection->table(AlibabaAccountBalanceRepository::TABLE)
->where($identity)
->update($values);
} else {
$this->connection->table(AlibabaAccountBalanceRepository::TABLE)
->insert($values + ['created_at' => $now]);
}
}
});
return ['created' => $created, 'updated' => $updated, 'skipped' => 0, 'total' => count($rows)];
}
/** @return array<int,array<string,mixed>> */
private function readRows(string $path): array
{
$spreadsheet = IOFactory::load($path);
$worksheet = $spreadsheet->getSheetByName('阿里巴巴账户') ?: $spreadsheet->getActiveSheet();
$headerRow = $this->findHeaderRow($worksheet);
$rows = [];
$seen = [];
for ($rowNumber = $headerRow + 1; $rowNumber <= $worksheet->getHighestDataRow(); $rowNumber++) {
$values = [];
foreach (range(1, count(self::HEADERS)) as $column) {
$values[] = $worksheet->getCell([$column, $rowNumber])->getCalculatedValue();
}
if (collect($values)->every(static fn (mixed $value): bool => $value === null || trim((string) $value) === '')) {
continue;
}
$date = $this->date($values[0] ?? null, $rowNumber);
$storeName = trim((string) ($values[1] ?? ''));
if ($storeName === '') {
throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”不能为空。");
}
if (mb_strlen($storeName) > 255) {
throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”超过 255 个字符。");
}
$key = $date . "\0" . $storeName;
if (isset($seen[$key])) {
throw new InvalidArgumentException("第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和店铺名称重复。");
}
$seen[$key] = $rowNumber;
$rows[] = [
'balance_date' => $date,
'store_name' => $storeName,
'opening_balance' => $this->number($values[2] ?? null, $rowNumber, '期初余额'),
'income_total' => $this->number($values[3] ?? null, $rowNumber, '本期收入'),
'expense_total' => $this->number($values[4] ?? null, $rowNumber, '本期支出'),
'closing_balance' => $this->number($values[5] ?? null, $rowNumber, '期末余额'),
'system_closing_balance' => $this->number($values[6] ?? null, $rowNumber, '系统期末余额'),
'balance_difference' => $this->number($values[7] ?? null, $rowNumber, '余额差异'),
'difference_reason' => $this->text($values[8] ?? null),
'verification_status' => $this->text($values[9] ?? null, 64, $rowNumber, '核对状态'),
'notes' => $this->text($values[10] ?? null),
];
}
$spreadsheet->disconnectWorksheets();
return $rows;
}
private function findHeaderRow(Worksheet $worksheet): int
{
$maximum = min($worksheet->getHighestDataRow(), 20);
for ($row = 1; $row <= $maximum; $row++) {
$headers = [];
foreach (range(1, count(self::HEADERS)) as $column) {
$headers[] = trim((string) $worksheet->getCell([$column, $row])->getValue());
}
if ($headers === self::HEADERS) {
return $row;
}
}
throw new InvalidArgumentException('未找到阿里巴巴账户余额的 11 列标准表头。');
}
private function date(mixed $value, int $rowNumber): string
{
try {
$date = is_numeric($value)
? CarbonImmutable::instance(ExcelDate::excelToDateTimeObject((float) $value))
: CarbonImmutable::parse((string) $value);
} catch (\Throwable) {
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”不是有效日期。");
}
if (! $date->isLastOfMonth()) {
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”必须是月末日期。");
}
return $date->format('Y-m-d');
}
private function number(mixed $value, int $rowNumber, string $label): ?float
{
if ($value === null || trim((string) $value) === '') {
return null;
}
$normalized = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
if (! is_numeric($normalized)) {
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效金额。");
}
return round((float) $normalized, 6);
}
private function text(mixed $value, ?int $limit = null, ?int $rowNumber = null, ?string $label = null): ?string
{
if ($value === null || trim((string) $value) === '') {
return null;
}
$text = trim((string) $value);
if ($limit !== null && mb_strlen($text) > $limit) {
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”超过 {$limit} 个字符。");
}
return $text;
}
}

View File

@ -4,7 +4,7 @@
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
];
$balances = $balances ?? collect();
$supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'pinduoduo'], true);
$supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true);
@endphp
<style>

View File

@ -5,6 +5,7 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository;
@ -124,6 +125,84 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
->assertDontSeeText('该页面暂未配置数据表');
}
public function testAlibabaAccountBalanceShowsImportedSnapshotAndFilters(): void
{
$this->allowMarketingAccountBalancePage();
$repository = Mockery::mock(AlibabaAccountBalanceRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('paginateMonthlyBalances')
->once()
->with('阿里测试店', '2026-06', 1, 25)
->andReturn(['rows' => [[
'date' => '2026-06-30',
'store_name' => '阿里测试店',
'opening_balance' => null,
'income_total' => null,
'expense_total' => null,
'closing_balance' => 4142.96,
'system_closing_balance' => null,
'balance_difference' => null,
'difference_reason' => '',
'verification_status' => '',
'notes' => '',
]], 'total' => 1]);
$repository->shouldReceive('storeOptions')->once()->andReturn(['阿里测试店']);
$this->app->instance(AlibabaAccountBalanceRepository::class, $repository);
$parameters = [
'section' => 'account-balance-data',
'page' => 'marketing-account-balance',
'tab' => 'taobao-wechat',
'store_name' => '阿里测试店',
'month' => '2026-06',
];
$this->loginAs()
->get(route('business.pages.show', $parameters))
->assertOk()
->assertSeeText('阿里测试店')
->assertSeeText('4,142.96')
->assertSeeText('期末余额')
->assertSee(e(route('business.pages.export', $parameters)), false)
->assertDontSeeText('该平台余额数据表尚未配置');
}
public function testAlibabaAccountBalanceExportsCurrentFilters(): void
{
$this->allowMarketingAccountBalancePage();
$repository = Mockery::mock(AlibabaAccountBalanceRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('paginateMonthlyBalances')
->once()
->with('阿里测试店', '2026-06', 1, 5000)
->andReturn(['rows' => [[
'date' => '2026-06-30',
'store_name' => '阿里测试店',
'opening_balance' => null,
'income_total' => null,
'expense_total' => null,
'closing_balance' => 4142.96,
'system_closing_balance' => null,
'balance_difference' => null,
'difference_reason' => '',
'verification_status' => '',
'notes' => '',
]], 'total' => 1]);
$this->app->instance(AlibabaAccountBalanceRepository::class, $repository);
$response = $this->loginAs()->get(route('business.pages.export', [
'section' => 'account-balance-data',
'page' => 'marketing-account-balance',
'tab' => 'taobao-wechat',
'store_name' => '阿里测试店',
'month' => '2026-06',
]));
$response->assertOk()->assertDownload('alibaba_account_balances_2026-06.csv');
$this->assertStringContainsString('阿里测试店', $response->streamedContent());
$this->assertStringContainsString('4142.96', $response->streamedContent());
}
public function testMarketingAccountBillsOnlyRenamesTheRequestedTabs(): void
{
$this->allowMarketingAccountBillsPage();

View File

@ -0,0 +1,120 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Services\AlibabaAccountBalanceWorkbookImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class AlibabaAccountBalanceRepositoryTest extends TestCase
{
private string $workbookPath;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$this->createTable();
$this->workbookPath = sys_get_temp_dir() . '/alibaba-account-balance-' . uniqid('', true) . '.xlsx';
}
protected function tearDown(): void
{
if (isset($this->workbookPath) && is_file($this->workbookPath)) {
unlink($this->workbookPath);
}
DB::purge('business_mirror');
parent::tearDown();
}
public function testItImportsUpdatesFiltersAndPaginatesBalanceSnapshots(): void
{
$this->writeWorkbook([
['2026-06-30', '阿里店甲', null, null, null, 4142.96, null, null, null, null, null],
['2026-06-30', '阿里店乙', 100, 50, 20, 130, 130, 0, '', '已核对', '测试'],
]);
$importer = app(AlibabaAccountBalanceWorkbookImporter::class);
$first = $importer->import($this->workbookPath, '阿里巴巴余额.xlsx');
$second = $importer->import($this->workbookPath, '阿里巴巴余额.xlsx');
$this->assertSame(['created' => 2, 'updated' => 0, 'skipped' => 0, 'total' => 2], $first);
$this->assertSame(['created' => 0, 'updated' => 2, 'skipped' => 0, 'total' => 2], $second);
$this->assertSame(2, DB::connection('business_mirror')->table(AlibabaAccountBalanceRepository::TABLE)->count());
$repository = app(AlibabaAccountBalanceRepository::class);
$result = $repository->paginateMonthlyBalances('阿里店甲', '2026-06', 1, 25);
$this->assertTrue($repository->usesBusinessMirror());
$this->assertSame(['阿里店乙', '阿里店甲'], $repository->storeOptions());
$this->assertSame(1, $result['total']);
$this->assertSame('2026-06-30', $result['rows'][0]['date']);
$this->assertSame('阿里店甲', $result['rows'][0]['store_name']);
$this->assertSame(4142.96, $result['rows'][0]['closing_balance']);
$this->assertNull($result['rows'][0]['opening_balance']);
}
public function testItRejectsNonMonthEndDatesBeforeWriting(): void
{
$this->writeWorkbook([
['2026-06-29', '阿里店甲', null, null, null, 10, null, null, null, null, null],
]);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('必须是月末日期');
app(AlibabaAccountBalanceWorkbookImporter::class)->import($this->workbookPath);
}
/** @param array<int,array<int,mixed>> $rows */
private function writeWorkbook(array $rows): void
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('阿里巴巴账户');
$sheet->fromArray([[
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
], ...$rows]);
(new Xlsx($spreadsheet))->save($this->workbookPath);
$spreadsheet->disconnectWorksheets();
}
private function createTable(): void
{
Schema::connection('business_mirror')->create(AlibabaAccountBalanceRepository::TABLE, function (Blueprint $table): void {
$table->id();
$table->date('balance_date');
$table->string('store_name');
$table->decimal('opening_balance', 20, 6)->nullable();
$table->decimal('income_total', 20, 6)->nullable();
$table->decimal('expense_total', 20, 6)->nullable();
$table->decimal('closing_balance', 20, 6)->nullable();
$table->decimal('system_closing_balance', 20, 6)->nullable();
$table->decimal('balance_difference', 20, 6)->nullable();
$table->text('difference_reason')->nullable();
$table->string('verification_status')->nullable();
$table->text('notes')->nullable();
$table->string('source_file_name');
$table->char('source_sha256', 64);
$table->dateTime('imported_at');
$table->timestamps();
$table->unique(['balance_date', 'store_name']);
});
}
}