按月滚动计算阿里巴巴账户余额 #160
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user