淘系支付宝账单切换经营分析副本镜像 #129

Merged
chenxi merged 2 commits from feature/chenxi-store-receiving-alipay-ops-mirror into main 2026-09-08 19:50:56 +08:00
9 changed files with 412 additions and 164 deletions

View File

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

View File

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

View File

@ -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<int,array<string,mixed>> */
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<int,object> $rows @return array<string,string> */
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<string,string> */
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));

View File

@ -13,9 +13,9 @@
<div class="tb-stat-s">按当前筛选</div>
</div>
<div class="tb-stat">
<div class="tb-stat-k">期末余额(最新)</div>
<div class="tb-stat-k">收支净额</div>
<div class="tb-stat-v">{{ money(summary.balance) }}</div>
<div class="tb-stat-s">最新一条记录</div>
<div class="tb-stat-s">收入合计 - 支出合计</div>
</div>
<div class="tb-stat">
<div class="tb-stat-k">交易笔数</div>
@ -31,30 +31,19 @@
<input v-model.trim="filters.store_name" placeholder="如 天猫-亿家达旗舰店" />
</div>
<div class="tb-field">
<label>支付宝账号</label>
<input v-model.trim="filters.alipay_account" placeholder="如 2088**********" />
<label>平台渠道</label>
<input v-model.trim="filters.alipay_account" placeholder="如 CommonBillTGCPay" />
</div>
<div class="tb-field">
<label>账单项目</label>
<select v-model="filters.bill_item">
<option value="">全部项目</option>
<option>交易收款</option>
<option>以旧换新自补平台过桥</option>
<option>退款</option>
<option>提现到银行卡</option>
<option>服务费</option>
<option>红包补贴</option>
</select>
<input v-model.trim="filters.bill_item" placeholder="如 交易佣金" />
</div>
<div class="tb-field">
<label>业务类型</label>
<label>收支类型</label>
<select v-model="filters.business_type">
<option value="">全部类型</option>
<option>交易付款</option>
<option>交易分账</option>
<option>退款</option>
<option>提现</option>
<option>手续费</option>
<option>收入</option>
<option>支出</option>
</select>
</div>
<div class="tb-field tb-field-date">
@ -65,12 +54,13 @@
</div>
</div>
<div class="tb-field tb-field-kw">
<label>关键词(商品 / 单号 / 备注)</label>
<input v-model.trim="filters.keyword" placeholder="输入商品名称、订单号或备注" />
<label>关键词(费用 / 订单 / 商品编码)</label>
<input v-model.trim="filters.keyword" placeholder="输入费用名称、订单号或商品编码" />
</div>
<div class="tb-actions">
<button class="tb-btn tb-btn-primary" @click="applyFilters">查询</button>
<button class="tb-btn" @click="resetFilters">重置</button>
<button class="tb-btn" @click="exportRows">导出</button>
</div>
</div>
@ -97,17 +87,17 @@
<th>原始店铺</th>
<th>自定义备注</th>
<th>账单项目</th>
<th>支付宝账号</th>
<th>平台渠道</th>
<th>账务订单号</th>
<th>业务流水号</th>
<th>商户订单号</th>
<th>业务基础订单号</th>
<th>商品名称</th>
<th>商品编码</th>
<th>对方账号</th>
<th @click="toggleSort('occurred_at')">发生时间 <span class="tb-sort">⇅</span></th>
<th class="tb-num" @click="toggleSort('income')">收入 <span class="tb-sort">⇅</span></th>
<th class="tb-num" @click="toggleSort('expense')">支出 <span class="tb-sort">⇅</span></th>
<th class="tb-num" @click="toggleSort('balance')">余额 <span class="tb-sort">⇅</span></th>
<th class="tb-num" @click="toggleSort('balance')">收支净额 <span class="tb-sort">⇅</span></th>
<th>业务类型</th>
<th>备注</th>
<th>线上单号</th>
@ -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';

View File

@ -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 } };
}
// 店铺收款账户账单 › 其它账户类型(通用实现)

View File

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

View File

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

View File

@ -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,
];
}
}

View File

@ -18,6 +18,9 @@ class TaobaoAlipayBillsTableTest extends TestCase
$this->assertStringContainsString('<td :colspan="25"', $source);
$this->assertStringContainsString('v-if="error" class="tb-error"', $source);
$this->assertStringContainsString('e.response.data.message', $source);
$this->assertStringContainsString('<label>平台渠道</label>', $source);
$this->assertStringContainsString('@click="exportRows">导出</button>', $source);
$this->assertStringContainsString('收支净额', $source);
$this->assertStringNotContainsString('<th>财务备注</th>', $source);
$this->assertStringNotContainsString('<th>订单号</th>', $source);
$this->assertStringNotContainsString('<th class="tb-num">金额</th>', $source);