From 515c98bff8521fba4741a7bec3362ce413259b38 Mon Sep 17 00:00:00 2001 From: chenxi Date: Tue, 8 Sep 2026 19:46:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=98=E7=B3=BB=E6=94=B6=E6=AC=BE?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E5=88=87=E6=8D=A2=E7=BB=8F=E8=90=A5=E5=88=86?= =?UTF-8?q?=E6=9E=90=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/上游业务镜像清单.md | 6 +- .../Http/Controllers/AlipayBills.php | 34 +++ ...oreReceivingAlipayBillMirrorRepository.php | 267 +++++++++++++----- .../components/business/TaobaoAlipayBills.vue | 44 ++- resources/assets/js/views/business/pages.js | 7 +- routes/admin.php | 2 + tests/Feature/Business/PagesTest.php | 31 ++ ...eceivingAlipayBillMirrorRepositoryTest.php | 182 +++++++----- .../Unit/View/TaobaoAlipayBillsTableTest.php | 3 + 9 files changed, 412 insertions(+), 164 deletions(-) diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 30956928..bd2f4262 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -20,8 +20,8 @@ | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | | 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 | | 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 | -| 已登记,待首次同步 | `operations_replica/rpt.jst_originalBillInfo` 登记为通用原始账单镜像 `ops_jst_original_bill_info`;保留当前 19 个业务字段,按 `Id` 断点全量、按 `updateTime` 重叠增量。部署迁移只创建空表,不自动启动约 1400 万行的首次同步。 | -| 已登记,待首次同步 | 店铺收款账单的淘系支付宝账户已登记 `jiansun.dbo.alipay_Bill_Detail`,页面按共享镜像分页、筛选和汇总;首次全量完成前显示同步中状态。 | +| 已完成 | `operations_replica/rpt.jst_originalBillInfo` 已进入通用原始账单镜像 `ops_jst_original_bill_info`;保留当前 19 个业务字段,按 `Id` 断点全量、按 `updateTime` 重叠增量。 | +| 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 | ## 经营分析副本库(operations_replica) @@ -52,7 +52,7 @@ | 业务范围 | 上游对象 | | --- | --- | -| 店铺收款账单 | `dbo.alipay_Bill_Detail` | +| 店铺收款账单旧来源(已停用) | `dbo.alipay_Bill_Detail` | | 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` | | 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` | | 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` | diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 10fa1b90..7cdcf6bc 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -57,6 +57,40 @@ class AlipayBills extends Controller } } + public function export(Request $request, StoreReceivingAlipayBillMirrorRepository $repository) + { + $this->authorizeStoreReceivingBills(); + $filters = $request->validate([ + 'store_name' => ['nullable', 'string', 'max:255'], + 'alipay_account' => ['nullable', 'string', 'max:128'], + 'bill_item' => ['nullable', 'string', 'max:255'], + 'business_type' => ['nullable', 'string', 'max:255'], + 'start' => ['nullable', 'date_format:Y-m-d'], + 'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'], + 'keyword' => ['nullable', 'string', 'max:255'], + ]); + + try { + $rows = $repository->exportRows(array_map(static fn (mixed $value): string => trim((string) $value), $filters)); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') { + throw $exception; + } + + return response()->json(['message' => '淘系支付宝账单共享业务镜像正在首次同步,完成后将自动显示。'], 503); + } + + return response()->streamDownload(function () use ($rows): void { + $csv = fopen('php://output', 'w'); + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, ['记录编码', '原始店铺', '平台渠道', '账单项目', '账务订单号', '业务流水号', '商户订单号', '业务基础订单号', '商品编码', '发生时间', '收入(元)', '支出(元)', '收支净额(元)', '收支类型', '线上单号', '业务描述', '业务账单来源', '创建时间', '修改时间']); + foreach ($rows as $row) { + fputcsv($csv, [$row['record_code'], $row['store_name'], $row['alipay_account'], $row['bill_item'], $row['account_order_no'], $row['business_serial_no'], $row['merchant_order_no'], $row['business_base_order_no'], $row['goods_name'], $row['occurred_at'], $row['income'], $row['expense'], $row['balance'], $row['business_type'], $row['online_order_no'], $row['business_description'], $row['business_bill_source'], $row['created_at'], $row['updated_at']]); + } + fclose($csv); + }, 'taobao_alipay_bills.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } + public function show(AlipayBill $bill) { $this->authorizeStoreReceivingBills(); diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index 5f83275b..b9ae8095 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -3,13 +3,15 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use RuntimeException; -/** 店铺收款账单·淘系支付宝账户只读共享业务镜像。 */ +/** 店铺收款账单·淘系支付宝账户只读经营分析副本镜像。 */ class StoreReceivingAlipayBillMirrorRepository { - public const DATASET = 'jiansun.dbo.alipay_Bill_Detail'; + public const DATASET = 'operations_replica.rpt.jst_originalBillInfo'; + public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo'; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, @@ -20,7 +22,8 @@ class StoreReceivingAlipayBillMirrorRepository public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array { $dataset = $this->datasets->get(self::DATASET); - if (! $this->mirror->ready($dataset)) { + if (! $this->mirror->ready($dataset) + || ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) { throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); } @@ -28,33 +31,32 @@ class StoreReceivingAlipayBillMirrorRepository $perPage = min(max($perPage, 1), 100); $direction = $direction === 'asc' ? 'asc' : 'desc'; $sortColumn = [ - 'record_code' => 'Id', - 'occurred_at' => 'happenTime', - 'income' => 'inAmount', - 'expense' => 'outAmount', - 'balance' => 'accountAmount', - ][$sort] ?? 'happenTime'; + 'record_code' => 'b.Id', + 'occurred_at' => 'b.fee_time', + 'income' => 'b.amount', + 'expense' => 'b.amount', + 'balance' => 'b.amount', + ][$sort] ?? 'b.fee_time'; - $query = $this->mirror->connection()->table($dataset->targetTable()); + $query = $this->billQuery(); $this->applyFilters($query, $filters); $total = (clone $query)->count(); - $summaryRow = (clone $query)->selectRaw( - 'COALESCE(SUM(CASE WHEN inAmount > 0 THEN inAmount ELSE 0 END), 0) AS income, ' - . 'COALESCE(SUM(ABS(COALESCE(outAmount, 0))), 0) AS expense, COUNT(*) AS aggregate_count' - )->first(); - $latestBalance = (clone $query) - ->orderByDesc('happenTime') - ->orderByDesc('Id') - ->value('accountAmount'); + $summary = $this->summary($query, $filters); - $rows = $query + $billRows = $query + ->select('b.*') ->orderBy($sortColumn, $direction) - ->orderBy('Id', $direction) + ->orderBy('b.Id', $direction) ->forPage($page, $perPage) ->get() - ->map(fn (object $row): array => $this->mapRow($row)) + ->all(); + $shopNames = $this->shopNames($billRows); + $rows = collect($billRows) + ->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null)) ->all(); $state = $this->mirror->state($dataset) ?? []; + $income = $summary['income']; + $expense = $summary['expense']; return [ 'data' => $rows, @@ -63,10 +65,10 @@ class StoreReceivingAlipayBillMirrorRepository 'last_page' => max((int) ceil($total / $perPage), 1), 'per_page' => $perPage, 'summary' => [ - 'income' => (float) ($summaryRow->income ?? 0), - 'expense' => (float) ($summaryRow->expense ?? 0), - 'balance' => (float) ($latestBalance ?? 0), - 'count' => (int) ($summaryRow->aggregate_count ?? 0), + 'income' => $income, + 'expense' => $expense, + 'balance' => $income - $expense, + 'count' => $total, ], 'sync' => [ 'status' => $state['status'] ?? 'unknown', @@ -75,75 +77,210 @@ class StoreReceivingAlipayBillMirrorRepository ]; } + /** @return iterable> */ + public function exportRows(array $filters): iterable + { + $dataset = $this->datasets->get(self::DATASET); + if (! $this->mirror->ready($dataset) + || ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) { + throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); + } + + $query = $this->billQuery(); + $this->applyFilters($query, $filters); + $shopNames = $this->allShopNames(); + + return $query->select('b.*') + ->orderBy('b.fee_time')->orderBy('b.Id')->cursor() + ->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null)); + } + + private function billQuery(): Builder + { + return $this->mirror->connection()->table('ops_jst_original_bill_info as b'); + } + + /** @return array{income:float,expense:float} */ + private function summary(Builder $billQuery, array $filters): array + { + if ($this->dailySummarySupports($filters)) { + $summary = $this->dailySummary($filters); + if ($summary !== null) { + return $summary; + } + } + + $row = (clone $billQuery)->selectRaw( + "COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, " + . "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense" + )->first(); + + return [ + 'income' => round((float) ($row->income ?? 0), 2), + 'expense' => round((float) ($row->expense ?? 0), 2), + ]; + } + + private function dailySummarySupports(array $filters): bool + { + return ($filters['alipay_account'] ?? '') === '' + && ($filters['bill_item'] ?? '') === '' + && ($filters['keyword'] ?? '') === ''; + } + + /** @return array{income:float,expense:float}|null */ + private function dailySummary(array $filters): ?array + { + $connection = $this->mirror->connection(); + $schema = Schema::connection($connection->getName()); + if (! $schema->hasTable('store_report_import_states') || ! $schema->hasTable('store_report_daily_rows')) { + return null; + } + + $state = $connection->table('store_report_import_states') + ->where('dataset_key', 'store-report.daily-sales')->first(); + $generation = trim((string) ($state->active_generation ?? '')); + if ($generation === '' || empty($state->initial_completed_at)) { + return null; + } + + $query = $connection->table('store_report_daily_rows')->where('generation', $generation); + if (($filters['store_name'] ?? '') !== '') { + $query->where('store_name', $filters['store_name']); + } + if (($filters['start'] ?? '') !== '') { + $query->where('occurred_on', '>=', $filters['start']); + } + if (($filters['end'] ?? '') !== '') { + $query->where('occurred_on', '<=', $filters['end']); + } + $row = $query->selectRaw('COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first(); + $income = round((float) ($row->income ?? 0), 2); + $expense = round((float) ($row->expense ?? 0), 2); + + return match ($this->directionCode((string) ($filters['business_type'] ?? ''))) { + '1' => ['income' => $income, 'expense' => 0.0], + '-1' => ['income' => 0.0, 'expense' => $expense], + default => ['income' => $income, 'expense' => $expense], + }; + } + private function applyFilters(Builder $query, array $filters): void { if (($filters['store_name'] ?? '') !== '') { - $query->where('shopName', $filters['store_name']); + $shopIds = $this->mirror->connection()->table('ops_jst_shop_info') + ->where('shop_name', $filters['store_name']) + ->pluck('shop_id') + ->filter(static fn (mixed $value): bool => $value !== null && $value !== '') + ->all(); + if ($shopIds === []) { + $query->whereRaw('1 = 0'); + } else { + $query->whereIn('b.shop_id', $shopIds); + } } if (($filters['alipay_account'] ?? '') !== '') { - $value = '%' . $this->escapeLike((string) $filters['alipay_account']) . '%'; - $query->where(function (Builder $query) use ($value): void { - $query->where('appId', 'like', $value) - ->orWhere('account', 'like', $value); - }); + $query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['alipay_account']) . '%'); } if (($filters['bill_item'] ?? '') !== '') { - $query->where('tradeChannel', $filters['bill_item']); + $query->where('b.fee_name', $filters['bill_item']); } if (($filters['business_type'] ?? '') !== '') { - $query->where('businessType', $filters['business_type']); + $query->where('b.inout_type', $this->directionCode((string) $filters['business_type'])); } if (($filters['start'] ?? '') !== '') { - $query->where('happenTime', '>=', $filters['start'] . ' 00:00:00'); + $query->where('b.fee_time', '>=', $filters['start'] . ' 00:00:00'); } if (($filters['end'] ?? '') !== '') { - $query->where('happenTime', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); + $query->where('b.fee_time', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); } if (($filters['keyword'] ?? '') !== '') { $value = '%' . $this->escapeLike((string) $filters['keyword']) . '%'; $query->where(function (Builder $query) use ($value): void { - $query->where('commodityName', 'like', $value) - ->orWhere('accountNum', 'like', $value) - ->orWhere('businessNum', 'like', $value) - ->orWhere('merchantOrder', 'like', $value) - ->orWhere('storeNum', 'like', $value) - ->orWhere('remark', 'like', $value); + foreach (['b.fee_name', 'b.primary_key', 'b.p_auto_id', 'b.so_id', 'b.sku_id', 'b.shop_sku_id', 'b.shop_i_id', 'b.channel'] as $column) { + $query->orWhere($column, 'like', $value); + } }); } } - private function mapRow(object $row): array + /** @param array $rows @return array */ + private function shopNames(array $rows): array { + $shopIds = collect($rows)->pluck('shop_id')->filter()->unique()->values()->all(); + if ($shopIds === []) { + return []; + } + + return $this->mirror->connection()->table('ops_jst_shop_info') + ->whereIn('shop_id', $shopIds)->pluck('shop_name', 'shop_id') + ->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name]) + ->all(); + } + + /** @return array */ + private function allShopNames(): array + { + return $this->mirror->connection()->table('ops_jst_shop_info') + ->whereNotNull('shop_id')->pluck('shop_name', 'shop_id') + ->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name]) + ->all(); + } + + private function mapRow(object $row, ?string $shopName): array + { + $amount = abs((float) ($row->amount ?? 0)); + $income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0; + $expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0; + return [ 'id' => (string) $row->Id, 'record_code' => (string) $row->Id, - 'store_name' => $row->shopName, - 'custom_remark' => $row->buyerRemark, - 'bill_item' => $row->tradeChannel, - 'alipay_account' => $row->appId ?: $row->account, - 'account_order_no' => $row->accountNum, - 'business_serial_no' => $row->businessNum, - 'merchant_order_no' => $row->merchantOrder, - 'business_base_order_no' => $row->busiBasicNum, - 'goods_name' => $row->commodityName, - 'counterparty_account' => $row->oppAccount, - 'occurred_at' => $this->dateTime($row->happenTime), - 'income' => max((float) ($row->inAmount ?? 0), 0), - 'expense' => abs((float) ($row->outAmount ?? 0)), - 'balance' => (float) ($row->accountAmount ?? 0), - 'business_type' => $row->businessType, - 'remark' => $row->remark, - 'online_order_no' => $row->storeNum, - 'online_real_store' => $row->shopName, - 'business_description' => $row->busiDescription, - 'business_bill_source' => $row->busiBillSource, - 'business_order_no' => $row->busiNum, - 'created_at' => '', - 'updated_at' => '', + 'store_name' => $shopName, + 'custom_remark' => null, + 'bill_item' => $row->fee_name, + 'alipay_account' => $row->channel, + 'account_order_no' => $row->primary_key, + 'business_serial_no' => $row->auto_id, + 'merchant_order_no' => $row->p_auto_id, + 'business_base_order_no' => $row->so_id, + 'goods_name' => $row->sku_id ?: $row->shop_sku_id, + 'counterparty_account' => null, + 'occurred_at' => $this->dateTime($row->fee_time), + 'income' => $income, + 'expense' => $expense, + 'balance' => $income - $expense, + 'business_type' => $this->directionName($row->inout_type), + 'remark' => null, + 'online_order_no' => $row->so_id, + 'online_real_store' => $shopName, + 'business_description' => $row->combine_type, + 'business_bill_source' => $row->channel, + 'business_order_no' => $row->p_auto_id, + 'created_at' => $this->dateTime($row->createTime), + 'updated_at' => $this->dateTime($row->updateTime), 'modifier' => '', ]; } + private function directionCode(string $value): string + { + return match ($value) { + '收入', 'IN', 'in' => '1', + '支出', 'OUT', 'out' => '-1', + default => $value, + }; + } + + private function directionName(mixed $value): string + { + return match ((string) $value) { + '1' => '收入', + '-1' => '支出', + default => (string) ($value ?? ''), + }; + } + private function dateTime(mixed $value): string { return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value)); diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index 09823b67..6b5d9c6a 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -13,9 +13,9 @@
按当前筛选
-
期末余额(最新)
+
收支净额
{{ money(summary.balance) }}
-
最新一条记录
+
收入合计 - 支出合计
交易笔数
@@ -31,30 +31,19 @@
- - + +
- +
- +
@@ -65,12 +54,13 @@
- - + +
+
@@ -97,17 +87,17 @@ 原始店铺 自定义备注 账单项目 - 支付宝账号 + 平台渠道 账务订单号 业务流水号 商户订单号 业务基础订单号 - 商品名称 + 商品编码 对方账号 发生时间 ⇅ 收入 ⇅ 支出 ⇅ - 余额 ⇅ + 收支净额 ⇅ 业务类型 备注 线上单号 @@ -188,6 +178,7 @@ export default { props: { apiBase: { type: String, required: true }, + exportBase: { type: String, required: true }, }, data() { @@ -283,6 +274,11 @@ export default { this.fetch(); }, + exportRows() { + const params = new URLSearchParams(this.filters); + window.location.href = `${this.exportBase}?${params.toString()}`; + }, + toggleSort(key) { if (this.sort.key === key) { this.sort.dir = this.sort.dir === 'asc' ? 'desc' : 'asc'; diff --git a/resources/assets/js/views/business/pages.js b/resources/assets/js/views/business/pages.js index 3ca5bc28..ddd4f876 100644 --- a/resources/assets/js/views/business/pages.js +++ b/resources/assets/js/views/business/pages.js @@ -4,7 +4,7 @@ * 路由:/{company_id}/workspace/{section}/{page}/{tab} * * 店铺收款账户账单(store-receiving-bills)下各 tab: - * - taobao-alipay → 独立实现 TaobaoAlipayBills(表 ec_alipay_bills) + * - taobao-alipay → 独立实现 TaobaoAlipayBills(经营分析副本共享镜像) * - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / douyin / dewu * → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分,列配置由后端下发) */ @@ -43,7 +43,10 @@ function resolveComponent(context) { : `/ecommerce-finance/account-bills/type/${encodeURIComponent(context.tab)}`; // 店铺收款账户账单 › 淘系支付宝账户(独立实现) if (context.page === 'store-receiving-bills' && context.tab === 'taobao-alipay') { - return { Component: TaobaoAlipayBills, props: { apiBase: alipayBase } }; + const exportBase = context.companyId + ? `/${context.companyId}/ecommerce-finance/alipay-bills/export` + : '/ecommerce-finance/alipay-bills/export'; + return { Component: TaobaoAlipayBills, props: { apiBase: alipayBase, exportBase } }; } // 店铺收款账户账单 › 其它账户类型(通用实现) diff --git a/routes/admin.php b/routes/admin.php index 5f4f661a..0f044fba 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -18,6 +18,8 @@ Route::post('store-account-passwords/{account}/reveal', 'Business\StoreAccountPa Route::get('ecommerce-finance/alipay-bills', [AlipayBills::class, 'index']) ->name('ecommerce-finance.alipay-bills.index'); +Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export']) + ->name('ecommerce-finance.alipay-bills.export'); Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show']) ->name('ecommerce-finance.alipay-bills.show'); diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 26b1f90c..d8d037ca 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -117,6 +117,37 @@ class PagesTest extends FeatureTestCase ->assertSee('public/js/business/pages.min.js', false); } + public function testTaobaoAlipayBillsExportUsesTheMirrorRepository() + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class); + $repository->shouldReceive('exportRows')->once()->with(Mockery::on( + static fn (array $filters): bool => ($filters['store_name'] ?? '') === '天猫-经营店' + && ($filters['start'] ?? '') === '2026-08-01' + && ($filters['end'] ?? '') === '2026-08-31' + ))->andReturn([[ + 'record_code' => '1', 'store_name' => '天猫-经营店', 'alipay_account' => 'CommonBillTGCPay', + 'bill_item' => '交易佣金', 'account_order_no' => 'KEY-1', 'business_serial_no' => '1001', + 'merchant_order_no' => 'P-1', 'business_base_order_no' => 'ORDER-1', 'goods_name' => 'SKU-1', + 'occurred_at' => '2026-08-01 09:00:00', 'income' => 100.25, 'expense' => 0, + 'balance' => 100.25, 'business_type' => '收入', 'online_order_no' => 'ORDER-1', + 'business_description' => 'double', 'business_bill_source' => 'CommonBillTGCPay', + 'created_at' => '2026-08-02 09:00:00', 'updated_at' => '2026-08-02 10:00:00', + ]]); + $this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository); + + $this->loginAs() + ->get(route('ecommerce-finance.alipay-bills.export', [ + 'store_name' => '天猫-经营店', 'start' => '2026-08-01', 'end' => '2026-08-31', + ])) + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8') + ->assertDownload('taobao_alipay_bills.csv'); + } + public function testSupplierPayablesPageRendersItsSpecificContent() { $this->user->attachPermission( diff --git a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php index 7e0163e1..f5b6dfae 100644 --- a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php @@ -21,125 +21,167 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase config([ 'database.connections.business_mirror' => [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', 'upstream_mirrors.datasets' => [ StoreReceivingAlipayBillMirrorRepository::DATASET => [ - 'source_key' => 'jiansun', - 'schema' => 'dbo', - 'object' => 'alipay_Bill_Detail', - 'target' => 'js_alipay_bill_detail', + 'source_key' => 'operations_replica', 'schema' => 'rpt', + 'object' => 'jst_originalBillInfo', 'target' => 'ops_jst_original_bill_info', 'keys' => ['Id'], ], + StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET => [ + 'source_key' => 'operations_replica', 'schema' => 'rpt', + 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], + ], ], ]); DB::purge('business_mirror'); - - Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void { + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { $table->string('dataset_key')->primary(); $table->dateTime('initial_completed_at')->nullable(); $table->string('status')->nullable(); $table->dateTime('last_succeeded_at')->nullable(); }); - Schema::connection('business_mirror')->create('js_alipay_bill_detail', function (Blueprint $table): void { + $schema->create('ops_jst_original_bill_info', function (Blueprint $table): void { $table->integer('Id'); - foreach ([ - 'accountNum', 'businessNum', 'merchantOrder', 'commodityName', 'oppAccount', - 'tradeChannel', 'businessType', 'remark', 'shopName', 'busiBillSource', - 'busiDescription', 'busiNum', 'busiBasicNum', 'buyerRemark', 'storeNum', - 'appId', 'account', 'bindingType', - ] as $column) { + $table->integer('shop_id')->nullable(); + foreach (['channel', 'primary_key', 'fee_name', 'inout_type', 'p_auto_id', 'shop_i_id', 'shop_sku_id', 'sku_id', 'so_id', 'combine_type'] as $column) { $table->string($column)->nullable(); } - $table->dateTime('happenTime')->nullable(); - $table->date('date')->nullable(); - $table->decimal('inAmount', 20, 6)->nullable(); - $table->decimal('outAmount', 20, 6)->nullable(); - $table->decimal('accountAmount', 20, 6)->nullable(); + $table->dateTime('fee_time')->nullable(); + $table->dateTime('createTime')->nullable(); + $table->dateTime('updateTime')->nullable(); + $table->bigInteger('auto_id')->nullable(); + $table->decimal('amount', 20, 6)->nullable(); + }); + $schema->create('ops_jst_shop_info', function (Blueprint $table): void { + $table->integer('Id'); + $table->integer('shop_id')->nullable(); + $table->string('shop_name')->nullable(); }); $this->repository = new StoreReceivingAlipayBillMirrorRepository( - new BusinessMirrorDatasetRegistry(), - new GenericBusinessMirrorRepository(app('db')), + new BusinessMirrorDatasetRegistry(), new GenericBusinessMirrorRepository(app('db')), ); } - public function test_it_returns_filtered_pages_and_summary_using_attachment_field_names(): void + public function test_it_reads_operations_replica_rows_and_maps_shop_and_flow_fields(): void { $this->markReady(); - DB::connection('business_mirror')->table('js_alipay_bill_detail')->insert([ - $this->row(1, '天猫-测试店', '2026-08-01 09:00:00', 100.25, 0, 500.25), - $this->row(2, '天猫-测试店', '2026-08-02 09:00:00', 0, -20.10, 480.15), - $this->row(3, '其他店铺', '2026-08-03 09:00:00', 88, 0, 88), + DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([ + ['Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店'], + ['Id' => 11, 'shop_id' => 8, 'shop_name' => '淘宝-其他店'], + ]); + DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([ + $this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25), + $this->row(2, 7, '2026-08-02 09:00:00', '-1', 20.10), + $this->row(3, 8, '2026-08-03 09:00:00', '1', 88), ]); $result = $this->repository->paginate( - ['store_name' => '天猫-测试店', 'start' => '2026-08-01', 'end' => '2026-08-31'], - 'occurred_at', - 'desc', - 2, - 1, + ['store_name' => '天猫-经营店', 'start' => '2026-08-01', 'end' => '2026-08-31'], + 'occurred_at', 'desc', 1, 10, ); $this->assertSame(2, $result['total']); - $this->assertSame(2, $result['current_page']); - $this->assertSame('1', $result['data'][0]['record_code']); - $this->assertSame('2088000000000000', $result['data'][0]['alipay_account']); + $this->assertSame('2', $result['data'][0]['record_code']); + $this->assertSame('天猫-经营店', $result['data'][0]['store_name']); + $this->assertSame('支出', $result['data'][0]['business_type']); + $this->assertSame('CommonBillTGCPay', $result['data'][0]['alipay_account']); $this->assertSame(100.25, $result['summary']['income']); $this->assertSame(20.10, $result['summary']['expense']); - $this->assertSame(480.15, $result['summary']['balance']); + $this->assertSame(80.15, $result['summary']['balance']); $this->assertSame(2, $result['summary']['count']); - $this->assertSame('2026-09-07 09:00:00', $result['sync']['last_succeeded_at']); } public function test_it_rejects_an_unready_mirror_instead_of_querying_upstream_from_http(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('store_receiving_alipay_bill_mirror_not_ready'); - $this->repository->paginate([], 'occurred_at', 'desc', 1, 10); } + public function test_export_uses_the_same_filters_and_mapping_as_the_page(): void + { + $this->markReady(); + DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([ + 'Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店', + ]); + DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert([ + $this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25), + $this->row(2, 7, '2026-08-02 09:00:00', '-1', 20.10), + ]); + + $rows = iterator_to_array($this->repository->exportRows([ + 'business_type' => '支出', 'alipay_account' => 'CommonBill', 'keyword' => 'SKU-2', + ])); + + $this->assertCount(1, $rows); + $this->assertSame('2', $rows[0]['record_code']); + $this->assertSame(20.10, $rows[0]['expense']); + } + + public function test_common_filters_use_the_existing_daily_summary_acceleration(): void + { + $this->markReady(); + DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([ + 'Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店', + ]); + DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert( + $this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25), + ); + Schema::connection('business_mirror')->create('store_report_import_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('active_generation')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + }); + Schema::connection('business_mirror')->create('store_report_daily_rows', function (Blueprint $table): void { + $table->string('generation'); + $table->string('store_name'); + $table->date('occurred_on'); + $table->decimal('income', 20, 4); + $table->decimal('expense', 20, 4); + }); + DB::connection('business_mirror')->table('store_report_import_states')->insert([ + 'dataset_key' => 'store-report.daily-sales', 'active_generation' => 'daily-v1', + 'initial_completed_at' => '2026-09-01 09:00:00', + ]); + DB::connection('business_mirror')->table('store_report_daily_rows')->insert([ + 'generation' => 'daily-v1', 'store_name' => '天猫-经营店', 'occurred_on' => '2026-08-01', + 'income' => 100.25, 'expense' => 20.10, + ]); + + $result = $this->repository->paginate([ + 'store_name' => '天猫-经营店', 'start' => '2026-08-01', 'end' => '2026-08-01', + ], 'occurred_at', 'desc', 1, 10); + + $this->assertSame(['income' => 100.25, 'expense' => 20.10, 'balance' => 80.15, 'count' => 1], $result['summary']); + } + private function markReady(): void { - DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ - 'dataset_key' => StoreReceivingAlipayBillMirrorRepository::DATASET, - 'initial_completed_at' => '2026-09-07 09:00:00', - 'status' => 'succeeded', - 'last_succeeded_at' => '2026-09-07 09:00:00', - ]); + foreach ([StoreReceivingAlipayBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $dataset) { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => $dataset, + 'initial_completed_at' => '2026-09-07 09:00:00', + 'status' => 'succeeded', + 'last_succeeded_at' => '2026-09-07 09:00:00', + ]); + } } - private function row(int $id, string $shop, string $time, float $income, float $expense, float $balance): array + private function row(int $id, int $shopId, string $time, string $direction, float $amount): array { return [ - 'Id' => $id, - 'accountNum' => 'ACCOUNT-' . $id, - 'businessNum' => 'BUSINESS-' . $id, - 'merchantOrder' => 'MERCHANT-' . $id, - 'commodityName' => '商品' . $id, - 'happenTime' => $time, - 'oppAccount' => '对方账号', - 'inAmount' => $income, - 'outAmount' => $expense, - 'accountAmount' => $balance, - 'tradeChannel' => '交易收款', - 'businessType' => '交易付款', - 'remark' => '备注', - 'shopName' => $shop, - 'date' => substr($time, 0, 10), - 'busiBillSource' => '支付宝', - 'busiDescription' => '业务说明', - 'busiNum' => 'BILL-' . $id, - 'busiBasicNum' => 'BASE-' . $id, - 'buyerRemark' => '自定义备注', - 'storeNum' => 'ORDER-' . $id, - 'appId' => '2088000000000000', - 'account' => 'account@example.test', - 'bindingType' => '淘系', + 'Id' => $id, 'shop_id' => $shopId, 'channel' => 'CommonBillTGCPay', + 'primary_key' => 'KEY-' . $id, 'fee_name' => '交易佣金', 'inout_type' => $direction, + 'p_auto_id' => 'P-' . $id, 'shop_i_id' => 'STYLE-' . $id, + 'shop_sku_id' => 'SHOP-SKU-' . $id, 'sku_id' => 'SKU-' . $id, + 'so_id' => 'ORDER-' . $id, 'combine_type' => 'double', + 'fee_time' => $time, 'createTime' => '2026-08-04 09:00:00', + 'updateTime' => '2026-08-04 10:00:00', 'auto_id' => 1000 + $id, 'amount' => $amount, ]; } } diff --git a/tests/Unit/View/TaobaoAlipayBillsTableTest.php b/tests/Unit/View/TaobaoAlipayBillsTableTest.php index 917eb89b..62e54c09 100644 --- a/tests/Unit/View/TaobaoAlipayBillsTableTest.php +++ b/tests/Unit/View/TaobaoAlipayBillsTableTest.php @@ -18,6 +18,9 @@ class TaobaoAlipayBillsTableTest extends TestCase $this->assertStringContainsString('assertStringContainsString('v-if="error" class="tb-error"', $source); $this->assertStringContainsString('e.response.data.message', $source); + $this->assertStringContainsString('', $source); + $this->assertStringContainsString('@click="exportRows">导出', $source); + $this->assertStringContainsString('收支净额', $source); $this->assertStringNotContainsString('财务备注', $source); $this->assertStringNotContainsString('订单号', $source); $this->assertStringNotContainsString('金额', $source); -- 2.47.2