WIP: 接入京东营销账户余额 #171

Closed
chenxi wants to merge 3 commits from feature/chenxi-jd-promotion-account-balance into main
7 changed files with 544 additions and 10 deletions

View File

@ -33,6 +33,7 @@ use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepo
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
@ -1315,7 +1316,7 @@ class Pages extends Controller
'pending' => false,
];
if (! in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true)) {
if (! in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true)) {
return $content;
}
@ -1331,23 +1332,28 @@ class Pages extends Controller
];
$isTaobao = $tab === 'taobao-alipay';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$cache = $isAlibaba ? null : $this->upstreamCaches->forSource('jiansun');
$cache = ($isAlibaba || $isJd) ? null : $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步,请稍后重试。');
}
$resolver = fn () => $repository->paginateMonthlyBalances(
$filters['store_name'],
$filters['month'],
$page,
$pageSize,
);
$result = ($usingMirror || $isAlibaba)
$result = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $cache->remember(
$scope,
@ -1362,14 +1368,14 @@ class Pages extends Controller
$result = ['rows' => [], 'total' => 0];
} catch (Throwable $exception) {
report($exception);
$platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : '拼多多');
$platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : ($isJd ? '京东' : '拼多多'));
$content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。';
$result = ['rows' => [], 'total' => 0];
}
try {
$resolver = fn () => $repository->storeOptions();
$storeOptions = ($usingMirror || $isAlibaba)
$storeOptions = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $cache->remember(
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
@ -1415,7 +1421,7 @@ class Pages extends Controller
}
abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404);
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true), 404);
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true), 404);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
@ -1426,9 +1432,11 @@ class Pages extends Controller
];
$isTaobao = $tab === 'taobao-alipay';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
@ -1441,8 +1449,12 @@ class Pages extends Controller
], static fn (mixed $value): bool => $value !== '');
try {
$usingMirror = $repository->usesBusinessMirror();
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步。');
}
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
$result = ($repository->usesBusinessMirror() || $isAlibaba)
$result = ($usingMirror || $isAlibaba || $isJd)
? $resolver()
: $this->upstreamCaches->forSource('jiansun')->remember(
$scope,
@ -1451,7 +1463,9 @@ class Pages extends Controller
);
} catch (ServiceUnavailableHttpException) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。');
->with('error', $isJd
? '京东账户余额共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'
: '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。');
} catch (Throwable $exception) {
report($exception);
@ -1460,7 +1474,7 @@ class Pages extends Controller
}
$rows = $result['rows'] ?? [];
$filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : 'pinduoduo');
$filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : ($isJd ? 'jd' : 'pinduoduo'));
return response()->streamDownload(function () use ($rows): void {
$csv = fopen('php://output', 'w');

View File

@ -255,6 +255,25 @@ return [
],
],
// JSDataMiddlePlatform 中台库:京东京准通账单流水。京东营销账户余额页按
// show_date 归属月份,并以 settle_datetime + id 确定月末最后一条余额。
'js_data_middle_platform.dbo.jd_jzt_finance_records' => [
'source_key' => 'js_data_middle_platform',
'schema' => 'dbo',
'object' => 'jd_jzt_finance_records',
'target' => 'jsm_jd_jzt_finance_records',
'keys' => ['id'],
'watermark' => 'updated_at',
'columns' => [
'id', 'data_month', 'shop_name', 'in_out_type', 'in_out_type_name',
'amount', 'balance_amount', 'settle_datetime', 'show_date', 'updated_at',
],
'indexes' => [
['show_date', 'settle_datetime', 'id'],
['shop_name(128)', 'show_date', 'settle_datetime', 'id'],
],
],
// 聚水潭聚工单:协同工单列表。只复制工单查询需要的非敏感字段,
// 正式 API 从共享镜像关联“仓内未发”类型,不在请求中直连上游。
'operations_replica.rpt.work_order_main' => [

View File

@ -23,6 +23,7 @@
| 已完成 | `operations_replica/rpt.jst_originalBillInfo` 已进入通用原始账单镜像 `ops_jst_original_bill_info`;保留当前 19 个业务字段,按 `Id` 断点全量、按 `updateTime` 重叠增量。 |
| 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 |
| 已接入待首次同步 | 店铺收款账单的拼多多账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = PinduoduoImport` 的独立切片镜像 `js_jst_pdd_receiving_bill_rows`;不使用慢一天的 `operations_replica` 副本或营销账户 `pdd_spread_bills`。 |
| 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `js_data_middle_platform.dbo.jd_jzt_finance_records` 镜像 `jsm_jd_jzt_finance_records`;按 `id` 稳定断点、`updated_at` 重叠增量,页面不直连上游。 |
| 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 |
## 经营分析副本库(operations_replica)
@ -68,6 +69,12 @@
| 聚水潭小额打款工单 | `dbo.jst_workOrderInfo`(稳定键 `Id`、增量字段 `updateTime`)与 `dbo.jst_small_payment_order_map`(稳定键 `woNo`、增量字段 `updated_at`) |
| 拼多多货款账户账单 | `dbo.jst_originalBillInfo`(固定 `channel = PinduoduoImport`,镜像 `js_jst_pdd_receiving_bill_rows`) |
## JSDataMiddlePlatform 中台库(js_data_middle_platform)
| 业务范围 | 上游对象 | 镜像目标 |
| --- | --- | --- |
| 京东京准通账户余额 | `dbo.jd_jzt_finance_records` | `jsm_jd_jzt_finance_records` |
其中 `fys_supplierGoods_sttlement_sale` 当前约 280 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。
## 同步规则

View File

@ -0,0 +1,183 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
class JdPromotionAccountBalanceRepository
{
public const DATASET_KEY = 'js_data_middle_platform.dbo.jd_jzt_finance_records';
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function usesBusinessMirror(): bool
{
try {
return $this->mirror->ready($this->dataset());
} catch (Throwable $exception) {
report($exception);
return false;
}
}
/** @return string[] */
public function storeOptions(): array
{
$this->ensureReady();
return $this->connection->table($this->dataset()->targetTable())
->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->distinct()
->orderBy('shop_name')
->pluck('shop_name')
->map(static fn (mixed $value): string => (string) $value)
->all();
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateMonthlyBalances(
?string $storeName = null,
?string $month = null,
int $page = 1,
int $pageSize = 25,
): array {
$this->ensureReady();
$month = $this->normalizeMonth($month);
$page = max($page, 1);
$pageSize = min(max($pageSize, 1), 5000);
$driver = $this->connection->getDriverName();
$monthStart = $driver === 'sqlite'
? "date(src.show_date, 'start of month')"
: "DATE_FORMAT(src.show_date, '%Y-%m-01')";
$currentMonth = CarbonImmutable::now()->startOfMonth()->toDateString();
$entryTime = 'COALESCE(src.settle_datetime, src.show_date)';
$ranked = $this->connection->table($this->dataset()->targetTable() . ' AS src')
->whereNotNull('src.shop_name')
->where('src.shop_name', '<>', '')
->whereNotNull('src.show_date')
->where('src.show_date', '<', $currentMonth)
->when($storeName !== null && $storeName !== '', fn (Builder $query) => $query->where('src.shop_name', $storeName))
->selectRaw("src.shop_name, src.show_date AS entry_date, src.balance_amount AS entry_balance, $monthStart AS month_start")
->selectRaw("CASE WHEN src.in_out_type = 'IN' OR src.in_out_type_name = '入账' THEN src.amount ELSE 0 END AS income_value")
->selectRaw("CASE WHEN src.in_out_type = 'OUT' OR src.in_out_type_name = '出账' THEN src.amount ELSE 0 END AS expense_value")
->selectRaw("LAG(src.balance_amount) OVER (PARTITION BY src.shop_name ORDER BY src.show_date, $entryTime, src.id) AS previous_balance")
->selectRaw("LAG(src.show_date) OVER (PARTITION BY src.shop_name ORDER BY src.show_date, $entryTime, src.id) AS previous_date")
->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shop_name, $monthStart ORDER BY src.show_date, $entryTime, src.id) AS month_first_rank")
->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shop_name, $monthStart ORDER BY src.show_date DESC, $entryTime DESC, src.id DESC) AS month_last_rank");
$groups = $this->connection->query()->fromSub($ranked, 'ranked')
->when($month !== null, fn (Builder $query) => $query->where('month_start', $month . '-01'))
->selectRaw('shop_name, month_start, COALESCE(SUM(income_value), 0) AS income_total, COALESCE(SUM(expense_value), 0) AS expense_total')
->selectRaw('MAX(CASE WHEN month_first_rank = 1 THEN previous_balance END) AS opening_balance')
->selectRaw('MAX(CASE WHEN month_first_rank = 1 THEN previous_date END) AS opening_source_date')
->selectRaw('MAX(CASE WHEN month_last_rank = 1 THEN entry_balance END) AS system_closing_balance')
->selectRaw('MAX(CASE WHEN month_last_rank = 1 THEN entry_date END) AS closing_source_date')
->groupBy('shop_name')
->groupBy('month_start');
$total = $this->connection->query()->fromSub(clone $groups, 'monthly_count')->count();
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
$rows = $this->connection->query()->fromSub($groups, 'monthly_balances')
->orderByDesc('month_start')
->orderBy('shop_name')
->forPage($page, $pageSize)
->get()
->map(fn (object $row): array => $this->normalizeRow($row))
->all();
return ['rows' => $rows, 'total' => $total];
}
/** @return array<string,mixed> */
private function normalizeRow(object $row): array
{
$monthStart = CarbonImmutable::parse((string) $row->month_start)->startOfMonth();
$expectedOpeningDate = $monthStart->subMonth()->endOfMonth()->toDateString();
$expectedClosingDate = $monthStart->endOfMonth()->toDateString();
$openingSourceDate = $row->opening_source_date === null
? null
: CarbonImmutable::parse((string) $row->opening_source_date)->toDateString();
$closingSourceDate = $row->closing_source_date === null
? null
: CarbonImmutable::parse((string) $row->closing_source_date)->toDateString();
$openingBalance = $openingSourceDate === $expectedOpeningDate
&& $row->opening_balance !== null
? round((float) $row->opening_balance, 2)
: null;
$income = round((float) $row->income_total, 2);
$expense = round((float) $row->expense_total, 2);
$systemClosing = $closingSourceDate !== $expectedClosingDate || $row->system_closing_balance === null
? null
: round((float) $row->system_closing_balance, 2);
$closing = $openingBalance === null ? null : round($openingBalance + $income - $expense, 2);
$difference = $closing === null || $systemClosing === null
? null
: round($systemClosing - $closing, 2);
$verified = $difference !== null && abs($difference) < 0.005;
if ($openingBalance === null && $systemClosing === null) {
$reason = '缺少期初余额和系统期末余额';
} elseif ($openingBalance === null) {
$reason = '缺少上月期末余额';
} elseif ($systemClosing === null) {
$reason = '缺少本月系统期末余额';
} elseif ($verified) {
$reason = '';
} else {
$reason = sprintf('系统期末余额与计算期末余额相差 %.2f 元', abs($difference));
}
return [
'date' => $expectedClosingDate,
'store_name' => (string) $row->shop_name,
'opening_balance' => $openingBalance,
'income_total' => $income,
'expense_total' => $expense,
'closing_balance' => $closing,
'system_closing_balance' => $systemClosing,
'balance_difference' => $difference,
'difference_reason' => $reason,
'verification_status' => $verified ? '已核对' : '待核对',
'notes' => '',
];
}
private function normalizeMonth(?string $month): ?string
{
return $month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1
? $month
: null;
}
private function ensureReady(): void
{
if (! $this->usesBusinessMirror()) {
throw new RuntimeException('京东账户余额共享业务镜像尚未完成同步。');
}
}
private function dataset(): BusinessMirrorDataset
{
return $this->datasets->get(self::DATASET_KEY);
}
}

View File

@ -4,7 +4,7 @@
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
];
$balances = $balances ?? collect();
$supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'taobao-wechat', 'pinduoduo'], true);
$supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true);
@endphp
<style>

View File

@ -7,6 +7,7 @@ use Mockery;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
@ -203,6 +204,94 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
$this->assertStringContainsString('4142.96', $response->streamedContent());
}
public function testJdAccountBalanceUsesTheSharedBalanceTableAndCurrentFilters(): void
{
$this->allowMarketingAccountBalancePage();
$repository = Mockery::mock(JdPromotionAccountBalanceRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('paginateMonthlyBalances')
->once()
->with('京东测试店', '2026-06', 1, 25)
->andReturn(['rows' => [[
'date' => '2026-06-30',
'store_name' => '京东测试店',
'opening_balance' => 1000.0,
'income_total' => 500.0,
'expense_total' => 200.0,
'closing_balance' => 1300.0,
'system_closing_balance' => 1300.0,
'balance_difference' => 0.0,
'difference_reason' => '',
'verification_status' => '已核对',
'notes' => '',
]], 'total' => 1]);
$repository->shouldReceive('storeOptions')->once()->andReturn(['京东测试店']);
$this->app->instance(JdPromotionAccountBalanceRepository::class, $repository);
$parameters = [
'section' => 'account-balance-data',
'page' => 'marketing-account-balance',
'tab' => 'jd-wallet',
'store_name' => '京东测试店',
'month' => '2026-06',
];
$this->loginAs()
->get(route('business.pages.show', $parameters))
->assertOk()
->assertSeeText('京东测试店')
->assertSeeText('期初余额')
->assertSeeText('本期收入')
->assertSeeText('本期支出')
->assertSeeText('期末余额')
->assertSeeText('系统期末余额')
->assertSeeText('余额差异')
->assertSeeText('差异原因')
->assertSeeText('核对状态')
->assertSeeText('1,300.00')
->assertSeeText('已核对')
->assertSee(e(route('business.pages.export', $parameters)), false)
->assertDontSeeText('该平台余额数据表尚未配置');
}
public function testJdAccountBalanceExportsTheCurrentFilters(): void
{
$this->allowMarketingAccountBalancePage();
$repository = Mockery::mock(JdPromotionAccountBalanceRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('paginateMonthlyBalances')
->once()
->with('京东测试店', '2026-06', 1, 5000)
->andReturn(['rows' => [[
'date' => '2026-06-30',
'store_name' => '京东测试店',
'opening_balance' => 1000.0,
'income_total' => 500.0,
'expense_total' => 200.0,
'closing_balance' => 1300.0,
'system_closing_balance' => 1300.0,
'balance_difference' => 0.0,
'difference_reason' => '',
'verification_status' => '已核对',
'notes' => '',
]], 'total' => 1]);
$this->app->instance(JdPromotionAccountBalanceRepository::class, $repository);
$response = $this->loginAs()->get(route('business.pages.export', [
'section' => 'account-balance-data',
'page' => 'marketing-account-balance',
'tab' => 'jd-wallet',
'store_name' => '京东测试店',
'month' => '2026-06',
]));
$response->assertOk()->assertDownload('jd_account_balances_2026-06.csv');
$content = $response->streamedContent();
$this->assertStringContainsString('京东测试店', $content);
$this->assertStringContainsString('2026-06-30', $content);
$this->assertStringContainsString('1300', $content);
}
public function testMarketingAccountBillsOnlyRenamesTheRequestedTabs(): void
{
$this->allowMarketingAccountBillsPage();

View File

@ -0,0 +1,222 @@
<?php
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\JdPromotionAccountBalanceRepository;
use RuntimeException;
use Tests\TestCase;
class JdPromotionAccountBalanceRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
CarbonImmutable::setTestNow('2026-09-12 12:00:00');
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
JdPromotionAccountBalanceRepository::DATASET_KEY => [
'source_key' => 'js_data_middle_platform',
'schema' => 'dbo',
'object' => 'jd_jzt_finance_records',
'target' => 'fixture_jd_jzt_finance_records',
'keys' => ['id'],
'watermark' => 'updated_at',
],
],
]);
DB::purge('business_mirror');
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
Schema::connection('business_mirror')->create('fixture_jd_jzt_finance_records', function (Blueprint $table): void {
$table->integer('id');
$table->string('data_month')->nullable();
$table->string('shop_name')->nullable();
$table->string('in_out_type')->nullable();
$table->string('in_out_type_name')->nullable();
$table->decimal('amount', 18, 2)->nullable();
$table->decimal('balance_amount', 18, 2)->nullable();
$table->dateTime('settle_datetime')->nullable();
$table->date('show_date')->nullable();
$table->dateTime('updated_at')->nullable();
});
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
DB::purge('business_mirror');
parent::tearDown();
}
public function test_it_calculates_completed_month_balances_from_jd_jzt_rows(): void
{
$this->markReady();
DB::connection('business_mirror')->table('fixture_jd_jzt_finance_records')->insert([
$this->row(1, '京东A店', '2026-05-31', '2026-05-31 23:59:00', 'IN', '入账', 1000, 1000),
$this->row(2, '京东A店', '2026-06-01', '2026-06-01 09:00:00', 'IN', '入账', 500, 1500),
$this->row(3, '京东A店', '2026-06-30', '2026-06-30 10:00:00', 'OUT', '出账', 100, 1400),
$this->row(4, '京东A店', '2026-06-30', '2026-06-30 20:00:00', 'OUT', '出账', 100, 1300),
$this->row(5, '京东B店', '2026-06-30', '2026-06-30 18:00:00', 'OUT', '出账', 25, 75),
$this->row(6, '京东A店', '2026-09-01', '2026-09-01 09:00:00', 'IN', '入账', 10, 1310),
]);
$repository = app(JdPromotionAccountBalanceRepository::class);
$result = $repository->paginateMonthlyBalances(null, '2026-06', 1, 25);
$this->assertTrue($repository->usesBusinessMirror());
$this->assertSame(['京东A店', '京东B店'], $repository->storeOptions());
$this->assertSame(2, $result['total']);
$this->assertSame([
'date' => '2026-06-30',
'store_name' => '京东A店',
'opening_balance' => 1000.0,
'income_total' => 500.0,
'expense_total' => 200.0,
'closing_balance' => 1300.0,
'system_closing_balance' => 1300.0,
'balance_difference' => 0.0,
'difference_reason' => '',
'verification_status' => '已核对',
'notes' => '',
], $result['rows'][0]);
$this->assertNull($result['rows'][1]['opening_balance']);
$this->assertNull($result['rows'][1]['closing_balance']);
$this->assertSame('缺少上月期末余额', $result['rows'][1]['difference_reason']);
$this->assertSame('待核对', $result['rows'][1]['verification_status']);
}
public function test_store_and_month_filters_share_the_same_monthly_result(): void
{
$this->markReady();
DB::connection('business_mirror')->table('fixture_jd_jzt_finance_records')->insert([
$this->row(1, '京东A店', '2026-05-31', '2026-05-31 23:59:00', 'IN', '入账', 100, 100),
$this->row(2, '京东A店', '2026-06-30', '2026-06-30 23:59:00', 'IN', '入账', 20, 120),
$this->row(3, '京东B店', '2026-06-30', '2026-06-30 23:59:00', 'IN', '入账', 30, 30),
]);
$result = app(JdPromotionAccountBalanceRepository::class)
->paginateMonthlyBalances('京东A店', '2026-06', 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('京东A店', $result['rows'][0]['store_name']);
$this->assertSame(100.0, $result['rows'][0]['opening_balance']);
$this->assertSame(20.0, $result['rows'][0]['income_total']);
}
public function test_it_does_not_treat_non_month_end_balances_as_month_end_balances(): void
{
$this->markReady();
DB::connection('business_mirror')->table('fixture_jd_jzt_finance_records')->insert([
$this->row(1, '京东A店', '2026-05-30', '2026-05-30 23:59:00', 'IN', '入账', 100, 100),
$this->row(2, '京东A店', '2026-06-01', '2026-06-01 09:00:00', 'IN', '入账', 20, 120),
$this->row(3, '京东A店', '2026-06-29', '2026-06-29 23:59:00', 'OUT', '出账', 5, 115),
]);
$row = app(JdPromotionAccountBalanceRepository::class)
->paginateMonthlyBalances('京东A店', '2026-06', 1, 25)['rows'][0];
$this->assertNull($row['opening_balance']);
$this->assertNull($row['system_closing_balance']);
$this->assertNull($row['closing_balance']);
$this->assertNull($row['balance_difference']);
$this->assertSame('缺少期初余额和系统期末余额', $row['difference_reason']);
$this->assertSame('待核对', $row['verification_status']);
}
public function test_it_identifies_a_missing_current_month_end_balance(): void
{
$this->markReady();
DB::connection('business_mirror')->table('fixture_jd_jzt_finance_records')->insert([
$this->row(1, '京东A店', '2026-05-31', '2026-05-31 23:59:00', 'IN', '入账', 100, 100),
$this->row(2, '京东A店', '2026-06-01', '2026-06-01 09:00:00', 'IN', '入账', 20, 120),
$this->row(3, '京东A店', '2026-06-29', '2026-06-29 23:59:00', 'OUT', '出账', 5, 115),
]);
$row = app(JdPromotionAccountBalanceRepository::class)
->paginateMonthlyBalances('京东A店', '2026-06', 1, 25)['rows'][0];
$this->assertSame(100.0, $row['opening_balance']);
$this->assertSame(115.0, $row['closing_balance']);
$this->assertNull($row['system_closing_balance']);
$this->assertNull($row['balance_difference']);
$this->assertSame('缺少本月系统期末余额', $row['difference_reason']);
$this->assertSame('待核对', $row['verification_status']);
}
public function test_it_explains_a_non_zero_balance_difference(): void
{
$this->markReady();
DB::connection('business_mirror')->table('fixture_jd_jzt_finance_records')->insert([
$this->row(1, '京东A店', '2026-05-31', '2026-05-31 23:59:00', 'IN', '入账', 100, 100),
$this->row(2, '京东A店', '2026-06-30', '2026-06-30 23:59:00', 'IN', '入账', 20, 125),
]);
$row = app(JdPromotionAccountBalanceRepository::class)
->paginateMonthlyBalances('京东A店', '2026-06', 1, 25)['rows'][0];
$this->assertSame(120.0, $row['closing_balance']);
$this->assertSame(125.0, $row['system_closing_balance']);
$this->assertSame(5.0, $row['balance_difference']);
$this->assertSame('系统期末余额与计算期末余额相差 5.00 元', $row['difference_reason']);
$this->assertSame('待核对', $row['verification_status']);
}
public function test_it_rejects_an_incomplete_shared_mirror(): void
{
$repository = app(JdPromotionAccountBalanceRepository::class);
$this->assertFalse($repository->usesBusinessMirror());
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('京东账户余额共享业务镜像尚未完成同步。');
$repository->paginateMonthlyBalances(null, '2026-06', 1, 25);
}
private function markReady(): void
{
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => JdPromotionAccountBalanceRepository::DATASET_KEY,
'initial_completed_at' => '2026-09-12 10:00:00',
]);
}
/** @return array<string,mixed> */
private function row(
int $id,
string $shop,
string $date,
string $settledAt,
string $type,
string $typeName,
float $amount,
float $balance,
): array {
return [
'id' => $id,
'data_month' => substr($date, 0, 7),
'shop_name' => $shop,
'in_out_type' => $type,
'in_out_type_name' => $typeName,
'amount' => $amount,
'balance_amount' => $balance,
'settle_datetime' => $settledAt,
'show_date' => $date,
'updated_at' => $settledAt,
];
}
}