修复业务镜像增量同步与营销账单导出 #56

Merged
WuYongJie merged 1 commits from hotfix/WuYongJie-business-mirror-incremental-export into main 2026-08-14 20:15:35 +08:00
4 changed files with 31 additions and 33 deletions

View File

@ -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);

View File

@ -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<mixed>} */
private function keyset(array $columns, array $cursor): array
{

View File

@ -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

View File

@ -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']);