按月滚动计算阿里巴巴账户余额 #160

Merged
chenxi merged 1 commits from feature/chenxi-alibaba-balance-rollforward into main 2026-09-11 13:50:13 +08:00
2 changed files with 321 additions and 39 deletions

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
@ -11,10 +12,18 @@ class AlibabaAccountBalanceRepository
{
public const TABLE = 'alibaba_account_balance_snapshots';
private const BILL_TABLE = 'js_alibaba_bill';
private const CUTOFF_DATE = '2026-06-30';
private const FIRST_CALCULATED_MONTH = '2026-07';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
public function __construct(
DatabaseManager $database,
private readonly MarketingAccountBillMirrorRepository $billMirror,
) {
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
@ -22,7 +31,7 @@ class AlibabaAccountBalanceRepository
public function usesBusinessMirror(): bool
{
return Schema::connection($this->connection->getName())->hasTable(self::TABLE);
return $this->hasTable(self::TABLE);
}
/** @return string[] */
@ -32,14 +41,30 @@ class AlibabaAccountBalanceRepository
return [];
}
return $this->connection->table(self::TABLE)
$stores = $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();
if ($this->billMirrorReady()) {
$billStores = $this->connection->table(self::BILL_TABLE)
->where('tradeDay', '>=', self::FIRST_CALCULATED_MONTH . '-01')
->whereNotNull('shopName')
->where('shopName', '<>', '')
->distinct()
->pluck('shopName')
->map(static fn (mixed $value): string => (string) $value)
->all();
$stores = array_merge($stores, $billStores);
}
$stores = array_values(array_unique($stores));
sort($stores, SORT_NATURAL);
return $stores;
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
@ -53,52 +78,191 @@ class AlibabaAccountBalanceRepository
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();
$month = $this->normalizeMonth($month);
$rows = [];
if ($total === 0) {
return ['rows' => [], 'total' => 0];
if ($month === null || $month < self::FIRST_CALCULATED_MONTH) {
$rows = $this->historicalRows($storeName, $month);
}
$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();
if (($month === null || $month >= self::FIRST_CALCULATED_MONTH)
&& $this->billMirrorReady()) {
$rows = array_merge($rows, $this->calculatedRows($storeName, $month));
}
return ['rows' => $rows, 'total' => $total];
usort($rows, static function (array $left, array $right): int {
$dateOrder = strcmp((string) $right['date'], (string) $left['date']);
return $dateOrder !== 0
? $dateOrder
: strnatcmp((string) $left['store_name'], (string) $right['store_name']);
});
$page = max($page, 1);
$pageSize = min(max($pageSize, 1), 5000);
return [
'rows' => array_slice($rows, ($page - 1) * $pageSize, $pageSize),
'total' => count($rows),
];
}
private function applyFilters(Builder $query, ?string $storeName, ?string $month): void
/** @return array<int,array<string,mixed>> */
private function historicalRows(?string $storeName, ?string $month): array
{
$query = $this->connection->table(self::TABLE)
->where('balance_date', '<=', self::CUTOFF_DATE);
$this->applyHistoricalFilters($query, $storeName, $month);
return $query->get([
'balance_date', 'store_name', 'opening_balance', 'income_total',
'expense_total', 'closing_balance', 'system_closing_balance',
'balance_difference', 'difference_reason', 'verification_status', 'notes',
])->map(fn (object $row): array => $this->mapHistoricalRow($row))->all();
}
/** @return array<int,array<string,mixed>> */
private function calculatedRows(?string $storeName, ?string $month): array
{
$currentMonth = CarbonImmutable::now()->startOfMonth();
if ($month !== null && $month >= $currentMonth->format('Y-m')) {
return [];
}
$query = $this->connection->table(self::BILL_TABLE)
->where('tradeDay', '>=', self::FIRST_CALCULATED_MONTH . '-01')
->where('tradeDay', '<', $currentMonth->toDateString())
->whereNotNull('shopName')
->where('shopName', '<>', '');
if ($storeName !== null && $storeName !== '') {
$query->where('shopName', $storeName);
}
if ($month !== null) {
$query->where('tradeDay', '<', CarbonImmutable::createFromFormat('!Y-m', $month)
->addMonth()->toDateString());
}
$monthExpression = $this->monthExpression('tradeDay');
$aggregates = $query
->selectRaw($monthExpression . ' AS balance_month, shopName')
->selectRaw('COALESCE(SUM(tradeAMTIn), 0) AS income_total')
->selectRaw('COALESCE(SUM(tradeAMTOut), 0) AS expense_total')
->groupByRaw($monthExpression . ', shopName')
->orderBy('shopName')
->orderBy('balance_month')
->get();
if ($aggregates->isEmpty()) {
return [];
}
$stores = $aggregates->pluck('shopName')
->map(static fn (mixed $value): string => (string) $value)
->unique()
->values()
->all();
$balances = $this->connection->table(self::TABLE)
->where('balance_date', self::CUTOFF_DATE)
->whereIn('store_name', $stores)
->pluck('closing_balance', 'store_name')
->map(static fn (mixed $value): ?float => $value === null ? null : (float) $value)
->all();
$rows = [];
foreach ($aggregates as $aggregate) {
$store = (string) $aggregate->shopName;
$balanceMonth = (string) $aggregate->balance_month;
$openingBalance = array_key_exists($store, $balances) ? $balances[$store] : null;
$income = round((float) $aggregate->income_total, 2);
$expense = round((float) $aggregate->expense_total, 2);
$closingBalance = $openingBalance === null
? null
: round($openingBalance + $income - $expense, 2);
$balances[$store] = $closingBalance;
if ($month !== null && $balanceMonth !== $month) {
continue;
}
$difference = $closingBalance === null ? null : round(-$closingBalance, 2);
$isVerified = $difference !== null && abs($difference) < 0.005;
$reason = $openingBalance === null
? '缺少上月期末余额'
: ($isVerified ? '' : sprintf('系统期末余额与计算期末余额相差 %.2f 元', abs($difference)));
$rows[] = [
'date' => CarbonImmutable::createFromFormat('!Y-m', $balanceMonth)->endOfMonth()->toDateString(),
'store_name' => $store,
'opening_balance' => $openingBalance,
'income_total' => $income,
'expense_total' => $expense,
'closing_balance' => $closingBalance,
'system_closing_balance' => 0.0,
'balance_difference' => $difference,
'difference_reason' => $reason,
'verification_status' => $isVerified ? '已核对' : '待核对',
'notes' => '',
];
}
return $rows;
}
/** @return array<string,mixed> */
private function mapHistoricalRow(object $row): array
{
return [
'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 ?? ''),
];
}
private function applyHistoricalFilters(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) {
if ($month !== null) {
$nextMonth = CarbonImmutable::createFromFormat('!Y-m', $month)->addMonth()->toDateString();
$query->where('balance_date', '>=', $month . '-01')
->where('balance_date', '<', date('Y-m-d', strtotime($month . '-01 +1 month')));
->where('balance_date', '<', $nextMonth);
}
}
private function normalizeMonth(?string $month): ?string
{
return $month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1
? $month
: null;
}
private function monthExpression(string $column): string
{
return $this->connection->getDriverName() === 'sqlite'
? "strftime('%Y-%m', {$column})"
: "DATE_FORMAT({$column}, '%Y-%m')";
}
private function hasTable(string $table): bool
{
return Schema::connection($this->connection->getName())->hasTable($table);
}
private function billMirrorReady(): bool
{
return $this->hasTable(self::BILL_TABLE)
&& $this->billMirror->ready(MarketingAccountBillMirrorRepository::ALIBABA);
}
}

View File

@ -2,10 +2,12 @@
namespace Tests\Unit\EcommerceFinance;
use Carbon\CarbonImmutable;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository;
use Modules\EcommerceFinance\Services\AlibabaAccountBalanceWorkbookImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@ -29,6 +31,13 @@ class AlibabaAccountBalanceRepositoryTest extends TestCase
]);
DB::purge('business_mirror');
$this->createTable();
$this->createBillTable();
$billMirror = \Mockery::mock(MarketingAccountBillMirrorRepository::class);
$billMirror->shouldReceive('ready')
->with(MarketingAccountBillMirrorRepository::ALIBABA)
->andReturnTrue();
$this->app->instance(MarketingAccountBillMirrorRepository::class, $billMirror);
CarbonImmutable::setTestNow('2026-09-11 12:00:00');
$this->workbookPath = sys_get_temp_dir() . '/alibaba-account-balance-' . uniqid('', true) . '.xlsx';
}
@ -38,6 +47,7 @@ class AlibabaAccountBalanceRepositoryTest extends TestCase
unlink($this->workbookPath);
}
CarbonImmutable::setTestNow();
DB::purge('business_mirror');
parent::tearDown();
}
@ -81,6 +91,103 @@ class AlibabaAccountBalanceRepositoryTest extends TestCase
app(AlibabaAccountBalanceWorkbookImporter::class)->import($this->workbookPath);
}
public function testItCalculatesCompletedMonthsFromTheJuneSnapshotAndMonthlyBills(): void
{
$this->writeWorkbook([
['2026-06-30', '阿里店甲', null, null, null, 100, null, null, null, null, '历史值'],
]);
app(AlibabaAccountBalanceWorkbookImporter::class)->import($this->workbookPath);
DB::connection('business_mirror')->table('js_alibaba_bill')->insert([
['id' => 1, 'shopName' => '阿里店甲', 'tradeDay' => '2026-07-05', 'tradeAMTIn' => 30, 'tradeAMTOut' => 5],
['id' => 2, 'shopName' => '阿里店甲', 'tradeDay' => '2026-07-20', 'tradeAMTIn' => 20, 'tradeAMTOut' => 15],
['id' => 3, 'shopName' => '阿里店甲', 'tradeDay' => '2026-08-02', 'tradeAMTIn' => 10, 'tradeAMTOut' => 40],
]);
$repository = app(AlibabaAccountBalanceRepository::class);
$july = $repository->paginateMonthlyBalances('阿里店甲', '2026-07');
$august = $repository->paginateMonthlyBalances('阿里店甲', '2026-08');
$this->assertSame(100.0, $july['rows'][0]['opening_balance']);
$this->assertSame(50.0, $july['rows'][0]['income_total']);
$this->assertSame(20.0, $july['rows'][0]['expense_total']);
$this->assertSame(130.0, $july['rows'][0]['closing_balance']);
$this->assertSame(0.0, $july['rows'][0]['system_closing_balance']);
$this->assertSame(-130.0, $july['rows'][0]['balance_difference']);
$this->assertSame('待核对', $july['rows'][0]['verification_status']);
$this->assertSame('系统期末余额与计算期末余额相差 130.00 元', $july['rows'][0]['difference_reason']);
$this->assertSame('2026-08-31', $august['rows'][0]['date']);
$this->assertSame(130.0, $august['rows'][0]['opening_balance']);
$this->assertSame(100.0, $august['rows'][0]['closing_balance']);
$this->assertSame(-100.0, $august['rows'][0]['balance_difference']);
}
public function testItKeepsHistoricalRowsUnchangedAndCarriesAcrossMonthsWithoutTransactions(): void
{
$this->writeWorkbook([
['2026-06-30', '阿里店乙', null, null, null, 88.88, null, null, '历史原因', '', '历史备注'],
]);
app(AlibabaAccountBalanceWorkbookImporter::class)->import($this->workbookPath);
DB::connection('business_mirror')->table('js_alibaba_bill')->insert([
['id' => 1, 'shopName' => '阿里店乙', 'tradeDay' => '2026-08-12', 'tradeAMTIn' => 11.12, 'tradeAMTOut' => 0],
]);
$repository = app(AlibabaAccountBalanceRepository::class);
$historical = $repository->paginateMonthlyBalances('阿里店乙', '2026-06');
$august = $repository->paginateMonthlyBalances('阿里店乙', '2026-08');
$this->assertNull($historical['rows'][0]['opening_balance']);
$this->assertSame(88.88, $historical['rows'][0]['closing_balance']);
$this->assertNull($historical['rows'][0]['system_closing_balance']);
$this->assertSame('历史原因', $historical['rows'][0]['difference_reason']);
$this->assertSame('历史备注', $historical['rows'][0]['notes']);
$this->assertSame(88.88, $august['rows'][0]['opening_balance']);
$this->assertSame(100.0, $august['rows'][0]['closing_balance']);
}
public function testItExplainsMissingOpeningBalancesAndMarksZeroDifferencesVerified(): void
{
$this->writeWorkbook([
['2026-06-30', '有期初店铺', null, null, null, 10, null, null, null, null, null],
]);
app(AlibabaAccountBalanceWorkbookImporter::class)->import($this->workbookPath);
DB::connection('business_mirror')->table('js_alibaba_bill')->insert([
['id' => 1, 'shopName' => '无期初店铺', 'tradeDay' => '2026-07-10', 'tradeAMTIn' => 12, 'tradeAMTOut' => 2],
['id' => 2, 'shopName' => '有期初店铺', 'tradeDay' => '2026-07-10', 'tradeAMTIn' => 5, 'tradeAMTOut' => 15],
]);
$repository = app(AlibabaAccountBalanceRepository::class);
$missing = $repository->paginateMonthlyBalances('无期初店铺', '2026-07')['rows'][0];
$verified = $repository->paginateMonthlyBalances('有期初店铺', '2026-07')['rows'][0];
$this->assertNull($missing['opening_balance']);
$this->assertNull($missing['closing_balance']);
$this->assertNull($missing['balance_difference']);
$this->assertSame(0.0, $missing['system_closing_balance']);
$this->assertSame('缺少上月期末余额', $missing['difference_reason']);
$this->assertSame('待核对', $missing['verification_status']);
$this->assertSame(0.0, $verified['closing_balance']);
$this->assertSame(0.0, $verified['balance_difference']);
$this->assertSame('', $verified['difference_reason']);
$this->assertSame('已核对', $verified['verification_status']);
}
public function testItDoesNotExposeTheCurrentOrFutureMonth(): void
{
$this->writeWorkbook([
['2026-06-30', '阿里店甲', null, null, null, 100, null, null, null, null, null],
]);
app(AlibabaAccountBalanceWorkbookImporter::class)->import($this->workbookPath);
DB::connection('business_mirror')->table('js_alibaba_bill')->insert([
['id' => 1, 'shopName' => '阿里店甲', 'tradeDay' => '2026-09-01', 'tradeAMTIn' => 20, 'tradeAMTOut' => 0],
]);
$repository = app(AlibabaAccountBalanceRepository::class);
$this->assertSame(['rows' => [], 'total' => 0], $repository->paginateMonthlyBalances(null, '2026-09'));
$this->assertSame(['rows' => [], 'total' => 0], $repository->paginateMonthlyBalances(null, '2026-10'));
}
/** @param array<int,array<int,mixed>> $rows */
private function writeWorkbook(array $rows): void
{
@ -117,4 +224,15 @@ class AlibabaAccountBalanceRepositoryTest extends TestCase
$table->unique(['balance_date', 'store_name']);
});
}
private function createBillTable(): void
{
Schema::connection('business_mirror')->create('js_alibaba_bill', function (Blueprint $table): void {
$table->unsignedBigInteger('id')->primary();
$table->string('shopName')->nullable();
$table->dateTime('tradeDay')->nullable();
$table->decimal('tradeAMTIn', 20, 6)->nullable();
$table->decimal('tradeAMTOut', 20, 6)->nullable();
});
}
}