From 9a550ed334b35500458be26eb95a9368b01b8799 Mon Sep 17 00:00:00 2001 From: chenxi Date: Fri, 11 Sep 2026 13:44:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E6=9C=88=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=98=BF=E9=87=8C=E5=B7=B4=E5=B7=B4=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AlibabaAccountBalanceRepository.php | 242 +++++++++++++++--- .../AlibabaAccountBalanceRepositoryTest.php | 118 +++++++++ 2 files changed, 321 insertions(+), 39 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php b/modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php index e17ea696..b0c3eca4 100644 --- a/modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php +++ b/modules/EcommerceFinance/Repositories/AlibabaAccountBalanceRepository.php @@ -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>,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> */ + 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> */ + 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 */ + 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); + } } diff --git a/tests/Unit/EcommerceFinance/AlibabaAccountBalanceRepositoryTest.php b/tests/Unit/EcommerceFinance/AlibabaAccountBalanceRepositoryTest.php index 2e1e2d05..00d734f4 100644 --- a/tests/Unit/EcommerceFinance/AlibabaAccountBalanceRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/AlibabaAccountBalanceRepositoryTest.php @@ -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> $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(); + }); + } } -- 2.47.2