From e6340163ddbfb4330cd7e84bbb7c9e7c2de97779 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 14 Aug 2026 20:12:09 +0800 Subject: [PATCH] fix: stabilize mirror incrementals and balance exports --- .../MarketingAccountBillMirrorRepository.php | 46 ++++++------------- .../Services/BusinessMirrorSynchronizer.php | 12 ++++- .../BusinessMirrorSynchronizerTest.php | 4 ++ ...rketingAccountBillMirrorRepositoryTest.php | 2 + 4 files changed, 31 insertions(+), 33 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php index d76d5dfb..b59b5bb2 100644 --- a/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php @@ -484,27 +484,29 @@ class MarketingAccountBillMirrorRepository $currentMonth = $driver === 'sqlite' ? "date('now', 'start of month')" : "DATE_FORMAT(CURRENT_DATE, '%Y-%m-01')"; - $nextMonth = $driver === 'sqlite' - ? "date(m.month_start, '+1 month')" - : 'DATE_ADD(m.month_start, INTERVAL 1 MONTH)'; $lastDay = $driver === 'sqlite' ? "date(m.month_start, '+1 month', '-1 day')" : "DATE_FORMAT(LAST_DAY(m.month_start), '%Y-%m-%d')"; $table = $this->dataset($datasetKey)->targetTable(); - $groups = $this->connection->table($table . ' AS src') + $ranked = $this->connection->table($table . ' AS src') ->whereNotNull('src.shopName') ->whereNotNull('src.' . $dateField) ->whereRaw("src.$dateField < $currentMonth") ->when($storeName !== null && $storeName !== '', fn (Builder $query) => $query->where('src.shopName', $storeName)) - ->when($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1, function (Builder $query) use ($dateField, $month): void { - $start = $month . '-01'; - $end = date('Y-m-d', strtotime($start . ' +1 month')); - $query->where('src.' . $dateField, '>=', $start)->where('src.' . $dateField, '<', $end); - }) - ->selectRaw("src.shopName, $monthStart AS month_start, COALESCE(SUM($incomeExpression), 0) AS $incomeAlias, COALESCE(SUM($expenseExpression), 0) AS $expenseAlias") - ->groupBy('src.shopName') - ->groupByRaw($monthStart); + ->selectRaw("src.shopName, src.$dateField AS entry_time, src.id AS entry_id, src.balance AS entry_balance, $monthStart AS month_start") + ->selectRaw("$incomeExpression AS income_value, $expenseExpression AS expense_value") + ->selectRaw("LAG(src.balance) OVER (PARTITION BY src.shopName ORDER BY src.$dateField, src.id) AS opening_balance") + ->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shopName, $monthStart ORDER BY src.$dateField, src.id) AS month_first_rank") + ->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shopName, $monthStart ORDER BY src.$dateField DESC, src.id DESC) AS month_last_rank"); + + $groups = $this->connection->query()->fromSub($ranked, 'ranked') + ->when($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1, fn (Builder $query) => $query->where('month_start', $month . '-01')) + ->selectRaw("shopName, month_start, COALESCE(SUM(income_value), 0) AS $incomeAlias, COALESCE(SUM(expense_value), 0) AS $expenseAlias") + ->selectRaw("MAX(CASE WHEN month_first_rank = 1 THEN opening_balance END) AS $openingAlias") + ->selectRaw("MAX(CASE WHEN month_last_rank = 1 THEN entry_balance END) AS $closingAlias") + ->groupBy('shopName') + ->groupBy('month_start'); $total = $this->connection->query()->fromSub(clone $groups, 'monthly_count')->count(); if ($total === 0) { @@ -512,25 +514,7 @@ class MarketingAccountBillMirrorRepository } $query = $this->connection->query()->fromSub($groups, 'm') - ->selectRaw("m.shopName, $lastDay AS balance_date, m.$incomeAlias, m.$expenseAlias") - ->selectSub(function (Builder $query) use ($table, $dateField): void { - $query->from($table . ' AS opening_entry') - ->select('opening_entry.balance') - ->whereColumn('opening_entry.shopName', 'm.shopName') - ->whereColumn('opening_entry.' . $dateField, '<', 'm.month_start') - ->orderByDesc('opening_entry.' . $dateField) - ->orderByDesc('opening_entry.id') - ->limit(1); - }, $openingAlias) - ->selectSub(function (Builder $query) use ($table, $dateField, $nextMonth): void { - $query->from($table . ' AS closing_entry') - ->select('closing_entry.balance') - ->whereColumn('closing_entry.shopName', 'm.shopName') - ->whereRaw("closing_entry.$dateField < $nextMonth") - ->orderByDesc('closing_entry.' . $dateField) - ->orderByDesc('closing_entry.id') - ->limit(1); - }, $closingAlias) + ->selectRaw("m.shopName, $lastDay AS balance_date, m.$incomeAlias, m.$expenseAlias, m.$openingAlias, m.$closingAlias") ->orderByDesc('m.month_start') ->orderBy('m.shopName') ->forPage($page, $pageSize); diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php index 8ef379b4..80802317 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php @@ -114,8 +114,8 @@ class BusinessMirrorSynchronizer if ($mode === 'incremental' && $dataset->watermarkColumn() !== null) { $watermark = $this->quote($dataset->watermarkColumn()); $where[] = $watermark . ' >= ? AND ' . $watermark . ' < ?'; - $bindings[] = $windowStart?->format('Y-m-d H:i:s.u'); - $bindings[] = $windowEnd?->format('Y-m-d H:i:s.u'); + $bindings[] = $this->sqlServerDateTime($windowStart); + $bindings[] = $this->sqlServerDateTime($windowEnd); } elseif ($mode === 'incremental' && $cursor === null && $highWatermark !== []) { [$predicate, $values] = $this->keyset($dataset->keyColumns(), $highWatermark); $where[] = $predicate; @@ -137,6 +137,14 @@ class BusinessMirrorSynchronizer return $this->queries->forSource($dataset->sourceKey())->select($sql, $bindings); } + private function sqlServerDateTime(?Carbon $value): ?string + { + // ISO 8601 with millisecond precision is accepted consistently by SQL Server + // through FreeTDS. Six-digit fractions were interpreted as an invalid date by + // some upstream datetime columns and broke every incremental run. + return $value?->format('Y-m-d\TH:i:s.v'); + } + /** @return array{0:string,1:list} */ private function keyset(array $columns, array $cursor): array { diff --git a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php index be3455b2..29925c62 100644 --- a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php @@ -129,6 +129,10 @@ class BusinessMirrorSynchronizerTest extends TestCase $this->assertStringContainsString('[updateTime] >= ? AND [updateTime] < ?', $this->query->calls[0]['sql']); $this->assertStringContainsString('ORDER BY [updateTime], [Id]', $this->query->calls[0]['sql']); $this->assertCount(2, $this->query->calls[0]['bindings']); + foreach ($this->query->calls[0]['bindings'] as $binding) { + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/', $binding); + $this->assertStringNotContainsString(' ', $binding); + } } public function test_incremental_sync_refuses_to_guess_before_first_full_sync(): void diff --git a/tests/Unit/EcommerceFinance/MarketingAccountBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/MarketingAccountBillMirrorRepositoryTest.php index 856b269f..609ae15b 100644 --- a/tests/Unit/EcommerceFinance/MarketingAccountBillMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/MarketingAccountBillMirrorRepositoryTest.php @@ -95,12 +95,14 @@ class MarketingAccountBillMirrorRepositoryTest extends TestCase $this->assertSame(50.0, $taobaoPage2['rows'][0]['amount']); $this->assertSame(['镜像店铺'], $taobao->storeOptions()); $this->assertSame(30.0, $taobaoSummary['net']); + $this->assertSame(100.0, $taobaoMonthly['rows'][0]['opening_balance']); $this->assertSame(130.0, $taobaoMonthly['rows'][0]['closing_balance']); $this->assertSame(0.0, $taobaoMonthly['rows'][0]['balance_difference']); $this->assertSame(2, $pddPage2['total']); $this->assertSame(50.0, $pddPage2['rows'][0]['amount']); $this->assertSame(30.0, $pddSummary['net']); + $this->assertSame(100.0, $pddMonthly['rows'][0]['opening_balance']); $this->assertSame(130.0, $pddMonthly['rows'][0]['closing_balance']); $this->assertSame(0.0, $pddMonthly['rows'][0]['balance_difference']); -- 2.47.2