From 303a0c7082cc1253445432724466ea7593b1b88c Mon Sep 17 00:00:00 2001 From: chenxi Date: Sat, 12 Sep 2026 16:24:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?WIP:=20=E6=8E=A5=E5=85=A5=E4=BA=AC=E4=B8=9C?= =?UTF-8?q?=E8=90=A5=E9=94=80=E8=B4=A6=E6=88=B7=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.47.2 From 5841a6471dfa5c6fbf6d4d6a91183422e75ae395 Mon Sep 17 00:00:00 2001 From: chenxi Date: Sat, 12 Sep 2026 17:35:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E4=BA=AC?= =?UTF-8?q?=E4=B8=9C=E8=90=A5=E9=94=80=E8=B4=A6=E6=88=B7=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 32 ++- config/upstream_mirrors.php | 19 ++ docs/上游业务镜像清单.md | 7 + .../JdPromotionAccountBalanceRepository.php | 183 +++++++++++++++ .../partials/embedded-table.blade.php | 2 +- .../MarketingAccountBillsTabsTest.php | 89 +++++++ ...dPromotionAccountBalanceRepositoryTest.php | 222 ++++++++++++++++++ 7 files changed, 544 insertions(+), 10 deletions(-) create mode 100644 modules/EcommerceFinance/Repositories/JdPromotionAccountBalanceRepository.php create mode 100644 tests/Unit/EcommerceFinance/JdPromotionAccountBalanceRepositoryTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 5e868c90..c6f6e82b 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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'); diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index a477c8eb..3f452209 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -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' => [ diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index b95baa54..0934742e 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。 ## 同步规则 diff --git a/modules/EcommerceFinance/Repositories/JdPromotionAccountBalanceRepository.php b/modules/EcommerceFinance/Repositories/JdPromotionAccountBalanceRepository.php new file mode 100644 index 00000000..b21caf9b --- /dev/null +++ b/modules/EcommerceFinance/Repositories/JdPromotionAccountBalanceRepository.php @@ -0,0 +1,183 @@ +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>,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 */ + 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); + } +} diff --git a/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php b/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php index 67b18f5d..dd8ac746 100644 --- a/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php +++ b/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php @@ -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