WIP: 接入京东营销账户余额 #171
@ -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');
|
||||
|
||||
@ -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' => [
|
||||
|
||||
@ -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 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。
|
||||
|
||||
## 同步规则
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user