接入拼多多店铺收款货款账单 #164

Merged
yueyang merged 4 commits from feature/yueyang-pinduoduo-receiving-bills into main 2026-09-12 11:16:34 +08:00
17 changed files with 668 additions and 45 deletions

View File

@ -94,6 +94,9 @@ return [
'jiansun.dbo.jst_small_payment_order_map' => ['role' => 'operational', 'target_store' => 'mariadb', 'consumer' => '小额打款工单'],
// Account bills and supplier pages are bounded serving datasets today.
'jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving' => [
'role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '店铺收款账单/拼多多账户',
],
'jiansun.dbo.alipay_Bill_Detail' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '店铺收款账单/淘系支付宝账户'],
'jiansun.dbo.tmall_spread_bills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '淘宝推广账单'],
'jiansun.dbo.pdd_spread_bills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '拼多多推广账单'],

View File

@ -370,7 +370,31 @@ return [
],
],
// JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。
// JianSun 旧业务库:拼多多货款、营销账单、供应商应付、板材、封边条和库存。
// 拼多多货款账单只复制平台原始账单中的固定渠道切片,避免为该页面
// 重复同步上游 1400 万行原表,也不依赖慢一天的经营分析副本。
'jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'jst_originalBillInfo',
'target' => 'js_jst_pdd_receiving_bill_rows',
'keys' => ['Id'],
'watermark' => 'updateTime',
'fixed_filters' => [
'channel' => 'PinduoduoImport',
],
'columns' => [
'Id', 'shop_id', 'channel', 'fee_time', 'del_flag', 'primary_key',
'createTime', 'updateTime', 'auto_id', 'fee_name', 'inout_type',
'oc_amount', 'p_auto_id', 'shop_i_id', 'shop_sku_id', 'sku_id',
'so_id', 'combine_type', 'amount',
],
'indexes' => [
['Id'],
['fee_time', 'Id', 'inout_type', 'amount'],
['shop_id', 'fee_time', 'Id', 'inout_type', 'amount', 'fee_name'],
],
],
'jiansun.dbo.alipay_Bill_Detail' => [
'source_key' => 'jiansun',
'schema' => 'dbo',

View File

@ -22,6 +22,7 @@
| 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 |
| 已完成 | `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`。 |
| 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 |
## 经营分析副本库(operations_replica)
@ -65,6 +66,7 @@
| 工作平台采购价格 | `dbo.pm_priceAdjustment`(`Id` 稳定键;按 `skuCode` 匹配 `jst_sku.sku_id`,页面取当前已生效的最新 `purchasePrice` / `effectTime`) |
| 快递资料 | `dbo.wp_express`、`dbo.wp_express_expNet`、`dbo.wp_express_account`、`dbo.wp_express_extension` |
| 聚水潭小额打款工单 | `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`) |
其中 `fys_supplierGoods_sttlement_sale` 当前约 280 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。
@ -80,6 +82,7 @@
8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。
9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。
11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。
工作平台采购价格首次全量需在备份和负责人授权后单独执行:

View File

@ -19,6 +19,9 @@ interface BusinessMirrorDataset
public function watermarkColumn(): ?string;
/** @return array<string,scalar> */
public function fixedFilters(): array;
/** @return list<list<string>> */
public function indexDefinitions(): array;
}

View File

@ -8,6 +8,7 @@ use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Models\AccountBill;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use RuntimeException;
class AccountBills extends Controller
@ -27,10 +28,10 @@ class AccountBills extends Controller
abort(404, '未知的账户类型。');
}
if ($billType === 'douyin') {
if (in_array($billType, ['douyin', 'pinduoduo'], true)) {
return $request->boolean('export')
? $this->export($request, $billType)
: $this->douyinIndex($request);
: $this->mirrorIndex($request, $billType);
}
$request->validate([
@ -116,8 +117,8 @@ class AccountBills extends Controller
abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。');
if ($bill_type === 'douyin') {
return $this->exportDouyin($request);
if (in_array($bill_type, ['douyin', 'pinduoduo'], true)) {
return $this->exportMirror($request, $bill_type);
}
$filters = $request->validate([
@ -140,11 +141,13 @@ class AccountBills extends Controller
{
$this->authorizeStoreReceivingBills();
abort_unless($bill_type === 'douyin', 404, '该账户类型没有可用的筛选选项。');
abort_unless(in_array($bill_type, ['douyin', 'pinduoduo'], true), 404, '该账户类型没有可用的筛选选项。');
$label = $this->mirrorLabel($bill_type);
try {
return response()->json(
app(StoreReceivingDouyinBillMirrorRepository::class)->filterOptions()
$this->mirrorRepository($bill_type)->filterOptions()
);
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
@ -152,7 +155,7 @@ class AccountBills extends Controller
}
return response()->json([
'message' => '抖音账户账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'message' => $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'shops' => [],
'bill_items' => [],
], 503);
@ -249,16 +252,17 @@ class AccountBills extends Controller
return $query;
}
private function douyinIndex(Request $request)
private function mirrorIndex(Request $request, string $billType)
{
$filters = $this->douyinFilters($request);
$filters = $this->mirrorFilters($request);
$sort = $this->sort($request);
$direction = $this->direction($request);
$page = max((int) $request->get('page', 1), 1);
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
$label = $this->mirrorLabel($billType);
try {
$data = app(StoreReceivingDouyinBillMirrorRepository::class)->paginate(
$data = $this->mirrorRepository($billType)->paginate(
$filters,
$sort,
$direction,
@ -271,26 +275,26 @@ class AccountBills extends Controller
}
return response()->json([
'message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。',
'message' => $label . '账户账单共享业务镜像正在首次同步,完成后将自动显示。',
'data' => [],
'total' => 0,
'current_page' => 1,
'last_page' => 1,
'per_page' => $perPage,
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
'columns' => AccountBillColumns::for('douyin'),
'columns' => AccountBillColumns::for($billType),
], 503);
}
$data['columns'] = AccountBillColumns::for('douyin');
$data['columns'] = AccountBillColumns::for($billType);
return response()->json($data);
}
private function exportDouyin(Request $request)
private function exportMirror(Request $request, string $billType)
{
$filters = $this->douyinFilters($request);
$definition = AccountBillColumns::for('douyin');
$filters = $this->mirrorFilters($request);
$definition = AccountBillColumns::for($billType);
$columns = collect($definition['columns'] ?? []);
$requested = $request->query('columns', []);
if (is_string($requested)) {
@ -314,14 +318,14 @@ class AccountBills extends Controller
$sort = $this->sort($request);
$direction = $this->direction($request);
try {
$rows = app(StoreReceivingDouyinBillMirrorRepository::class)
$rows = $this->mirrorRepository($billType)
->exportRows($filters, $sort, $direction);
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
throw $exception;
}
return response()->json(['message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503);
return response()->json(['message' => $this->mirrorLabel($billType) . '账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503);
}
$headers = $selected->pluck('label')->all();
@ -336,14 +340,14 @@ class AccountBills extends Controller
)->all());
}
fclose($csv);
}, 'douyin_account_bills.csv', [
}, $billType . '_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function douyinFilters(Request $request): array
private function mirrorFilters(Request $request): array
{
$filters = $request->validate([
'store_name' => ['nullable', 'string', 'max:255'],
@ -356,6 +360,18 @@ class AccountBills extends Controller
return array_map(static fn (mixed $value): string => trim((string) $value), $filters);
}
private function mirrorRepository(string $billType): object
{
return $billType === 'pinduoduo'
? app(StoreReceivingPinduoduoBillMirrorRepository::class)
: app(StoreReceivingDouyinBillMirrorRepository::class);
}
private function mirrorLabel(string $billType): string
{
return $billType === 'pinduoduo' ? '拼多多' : '抖音';
}
private function sort(Request $request): string
{
return in_array($request->get('sort'), ['occurred_at', 'income', 'expense', 'balance', 'record_code'], true)

View File

@ -39,9 +39,10 @@ class StoreReceivingAlipayBillMirrorRepository
int $perPage,
?string $channel,
string $mapping = 'taobao',
string $datasetKey = self::DATASET,
): array
{
$dataset = $this->datasets->get(self::DATASET);
$dataset = $this->datasets->get($datasetKey);
if (! $this->mirror->ready($dataset)
|| ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) {
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
@ -51,14 +52,15 @@ class StoreReceivingAlipayBillMirrorRepository
$perPage = min(max($perPage, 1), 100);
$direction = $direction === 'asc' ? 'asc' : 'desc';
$sortColumn = [
'record_code' => 'b.Id',
'record_code' => $mapping === 'pinduoduo' ? 'b.auto_id' : 'b.Id',
'occurred_at' => 'b.fee_time',
'income' => 'b.amount',
'expense' => 'b.amount',
'balance' => 'b.amount',
][$sort] ?? 'b.fee_time';
$query = $this->billQuery();
$targetTable = $dataset->targetTable();
$query = $this->billQuery($targetTable);
$this->applyChannel($query, $channel);
$this->applyFilters($query, $filters);
$state = $this->mirror->state($dataset) ?? [];
@ -66,14 +68,14 @@ class StoreReceivingAlipayBillMirrorRepository
// that count exact in their upsert transaction, including inserts.
$hasFilters = collect($filters)->contains(static fn (mixed $value): bool => $value !== null && trim((string) $value) !== '');
$recordedCount = $state['mirror_row_count'] ?? null;
$useRecordedCount = $channel === null && ! $hasFilters
$useRecordedCount = $this->channelCoversDataset($channel, $dataset->fixedFilters()) && ! $hasFilters
&& (($state['status'] ?? null) === 'succeeded' || (bool) ($state['mirror_row_count_is_exact'] ?? false))
&& $recordedCount !== null && ctype_digit((string) $recordedCount);
$total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count();
$summary = $this->summary($query, $filters, $channel);
$billRows = $this->pageRows(
$query, $sortColumn, $direction, $page, $perPage, $filters, $channel,
$query, $sortColumn, $direction, $page, $perPage, $filters, $channel, $targetTable,
);
$shopNames = $this->shopNames($billRows);
$rows = collect($billRows)
@ -114,40 +116,42 @@ class StoreReceivingAlipayBillMirrorRepository
string $direction = 'asc',
?string $channel = null,
string $mapping = 'taobao',
string $datasetKey = self::DATASET,
): iterable
{
$dataset = $this->datasets->get(self::DATASET);
$dataset = $this->datasets->get($datasetKey);
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();
$targetTable = $dataset->targetTable();
$query = $this->billQuery($targetTable);
$this->applyChannel($query, $channel);
$this->applyFilters($query, $filters);
$shopNames = $this->allShopNames();
$direction = $direction === 'desc' ? 'desc' : 'asc';
$sortColumn = [
'record_code' => 'b.Id',
'record_code' => $mapping === 'pinduoduo' ? 'b.auto_id' : 'b.Id',
'occurred_at' => 'b.fee_time',
'income' => 'b.amount',
'expense' => 'b.amount',
'balance' => 'b.amount',
][$sort] ?? 'b.fee_time';
return $this->exportBatches($query, $sortColumn, $direction, $shopNames, $mapping);
return $this->exportBatches($query, $sortColumn, $direction, $shopNames, $mapping, $targetTable);
}
/** @return array{shops:array<int,string>,bill_items:array<int,string>} */
public function filterOptionsForChannel(string $channel): array
public function filterOptionsForChannel(string $channel, string $datasetKey = self::DATASET): array
{
$dataset = $this->datasets->get(self::DATASET);
$dataset = $this->datasets->get($datasetKey);
if (! $this->mirror->ready($dataset)
|| ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) {
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
}
$channelQuery = $this->billQuery();
$channelQuery = $this->billQuery($dataset->targetTable());
$this->applyChannel($channelQuery, $channel);
$shopIds = (clone $channelQuery)
@ -187,12 +191,12 @@ class StoreReceivingAlipayBillMirrorRepository
}
/** Read bounded pages so a full export does not sort/buffer millions of rows at once. */
private function exportBatches(Builder $query, string $sortColumn, string $direction, array $shopNames, string $mapping): iterable
private function exportBatches(Builder $query, string $sortColumn, string $direction, array $shopNames, string $mapping, string $targetTable): iterable
{
$operator = $direction === 'asc' ? '>' : '<';
$sortField = substr($sortColumn, 2);
$last = null;
$maximumId = $this->billQuery()->max('b.Id');
$maximumId = $this->billQuery($targetTable)->max('b.Id');
if ($maximumId === null) return;
do {
@ -227,9 +231,9 @@ class StoreReceivingAlipayBillMirrorRepository
} while ($rows->count() === 1000);
}
private function billQuery(): Builder
private function billQuery(string $targetTable): Builder
{
return $this->mirror->connection()->table('ops_jst_original_bill_info as b');
return $this->mirror->connection()->table($targetTable . ' as b');
}
/** @return array<int,object> */
@ -241,6 +245,7 @@ class StoreReceivingAlipayBillMirrorRepository
int $perPage,
array $filters,
?string $channel,
string $targetTable,
): array {
if ($channel === null || ($filters['bill_item'] ?? '') === '') {
return $query->select('b.*')
@ -252,7 +257,7 @@ class StoreReceivingAlipayBillMirrorRepository
}
$idQuery = clone $query;
if ($idQuery->getConnection()->getDriverName() === 'mysql') {
if ($targetTable === 'ops_jst_original_bill_info' && $idQuery->getConnection()->getDriverName() === 'mysql') {
$idQuery->forceIndex('idx_original_bill_channel_shop');
}
$ids = $idQuery->select('b.Id')
@ -265,7 +270,7 @@ class StoreReceivingAlipayBillMirrorRepository
return [];
}
$rowsById = $this->billQuery()->whereIn('b.Id', $ids)->select('b.*')->get()
$rowsById = $this->billQuery($targetTable)->whereIn('b.Id', $ids)->select('b.*')->get()
->keyBy(static fn (object $row): string => (string) $row->Id);
return collect($ids)
@ -372,7 +377,7 @@ class StoreReceivingAlipayBillMirrorRepository
if (($filters['keyword'] ?? '') !== '') {
$value = '%' . $this->escapeLike((string) $filters['keyword']) . '%';
$query->where(function (Builder $query) use ($value): void {
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) {
foreach (['b.Id', 'b.auto_id', '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);
}
});
@ -386,6 +391,22 @@ class StoreReceivingAlipayBillMirrorRepository
}
}
/** @param array<string,scalar> $fixedFilters */
private function channelCoversDataset(?string $channel, array $fixedFilters): bool
{
if ($channel === null || $channel === '') {
return true;
}
foreach ($fixedFilters as $column => $value) {
if (strcasecmp($column, 'channel') === 0 && (string) $value === $channel) {
return true;
}
}
return false;
}
/** @param array<int,object> $rows @return array<string,string> */
private function shopNames(array $rows): array
{
@ -448,6 +469,10 @@ class StoreReceivingAlipayBillMirrorRepository
/** @return array<string,mixed> */
private function mapRowFor(object $row, ?string $shopName, string $mapping): array
{
if ($mapping === 'pinduoduo') {
return $this->mapPinduoduoRow($row, $shopName);
}
if ($mapping !== 'douyin') {
return $this->mapRow($row, $shopName);
}
@ -493,6 +518,38 @@ class StoreReceivingAlipayBillMirrorRepository
];
}
/** @return array<string,mixed> */
private function mapPinduoduoRow(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->auto_id ?: $row->Id),
'store_name' => $shopName,
'finance_remark' => null,
'order_no' => $row->so_id ?: $row->p_auto_id,
'amount' => $amount,
'custom_remark' => null,
'bill_item' => $row->fee_name,
'occurred_at' => $this->dateTime($row->fee_time),
'income' => $income,
'expense' => $expense,
'balance' => $income - $expense,
'remark' => null,
'updated_at' => $this->dateTime($row->updateTime),
'modifier' => 'RPA',
'payload' => [
'merchant_order_no' => $row->so_id ?: $row->p_auto_id,
'online_order_no' => $row->so_id,
'finance_type' => $row->fee_name,
'type' => '拼多多货款账户',
],
];
}
private function directionCode(string $value): string
{
return match ($value) {

View File

@ -0,0 +1,49 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
/** 店铺收款账单·拼多多货款账户只读共享业务镜像。 */
class StoreReceivingPinduoduoBillMirrorRepository
{
public const DATASET = 'jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving';
public const SOURCE_CHANNEL = 'PinduoduoImport';
public function __construct(
private readonly StoreReceivingAlipayBillMirrorRepository $source,
) {
}
/** @return array<string,mixed> */
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
{
return $this->source->paginateChannel(
$filters,
$sort,
$direction,
$page,
$perPage,
self::SOURCE_CHANNEL,
'pinduoduo',
self::DATASET,
);
}
/** @return iterable<int,array<string,mixed>> */
public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
{
return $this->source->exportChannelRows(
$filters,
$sort,
$direction,
self::SOURCE_CHANNEL,
'pinduoduo',
self::DATASET,
);
}
/** @return array{shops:array<int,string>,bill_items:array<int,string>} */
public function filterOptions(): array
{
return $this->source->filterOptionsForChannel(self::SOURCE_CHANNEL, self::DATASET);
}
}

View File

@ -71,6 +71,19 @@ class BusinessMirrorDatasetRegistry
return $this->definitions()[$key]['columns'] ?? null;
}
/** @return array<string,scalar> */
public function fixedFilters(string $key): array
{
$definitions = $this->definitions();
$definition = (array) ($definitions[$key] ?? []);
if ($definition === []) {
throw new InvalidArgumentException('未知的业务镜像数据集:' . $key);
}
return $this->normalizeFixedFilters($key, (array) ($definition['fixed_filters'] ?? []));
}
private function make(string $key, array $definition): BusinessMirrorDataset
{
$source = trim((string) ($definition['source_key'] ?? ''));
@ -84,6 +97,7 @@ class BusinessMirrorDatasetRegistry
(array) ($definition['indexes'] ?? []),
));
$indexColumns = array_merge(...($indexes ?: [[]]));
$fixedFilters = $this->normalizeFixedFilters($key, (array) ($definition['fixed_filters'] ?? []));
foreach ([$source, $target] as $identifier) {
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier) !== 1) {
@ -105,7 +119,7 @@ class BusinessMirrorDatasetRegistry
throw new InvalidArgumentException('业务镜像数据集的增量字段不安全:' . $key);
}
return new readonly class($key, $source, $schema, $object, $target, $keys, $watermark ?: null, $indexes) implements BusinessMirrorDataset {
return new readonly class($key, $source, $schema, $object, $target, $keys, $watermark ?: null, $fixedFilters, $indexes) implements BusinessMirrorDataset {
public function __construct(
private string $key,
private string $source,
@ -114,6 +128,7 @@ class BusinessMirrorDatasetRegistry
private string $target,
private array $keys,
private ?string $watermark,
private array $fixedFilters,
private array $indexes,
) {
}
@ -125,7 +140,31 @@ class BusinessMirrorDatasetRegistry
public function targetTable(): string { return $this->target; }
public function keyColumns(): array { return $this->keys; }
public function watermarkColumn(): ?string { return $this->watermark; }
public function fixedFilters(): array { return $this->fixedFilters; }
public function indexDefinitions(): array { return $this->indexes; }
};
}
/** @return array<string,scalar> */
private function normalizeFixedFilters(string $key, array $filters): array
{
$normalized = [];
$seen = [];
foreach ($filters as $column => $value) {
$column = is_string($column) ? trim($column) : '';
$canonical = strtolower($column);
if ($column === '' || mb_strlen($column) > 128 || str_contains($column, "\0") || isset($seen[$canonical])) {
throw new InvalidArgumentException('业务镜像数据集包含无效的固定过滤字段:' . $key);
}
if (! is_scalar($value)) {
throw new InvalidArgumentException('业务镜像数据集包含无效的固定过滤值:' . $key);
}
$seen[$canonical] = true;
$normalized[$column] = $value;
}
return $normalized;
}
}

View File

@ -122,7 +122,8 @@ class BusinessMirrorSchemaManager
}
}
foreach ([...$dataset->keyColumns(), $dataset->watermarkColumn()] as $required) {
$fixedFilterColumns = array_keys($dataset->fixedFilters());
foreach ([...$dataset->keyColumns(), $dataset->watermarkColumn(), ...$fixedFilterColumns] as $required) {
if ($required !== null && ! in_array(strtolower($required), $names, true)) {
throw new RuntimeException('business_mirror_required_column_missing:' . $dataset->key());
}

View File

@ -120,6 +120,11 @@ class BusinessMirrorSynchronizer
$where = [];
$bindings = [];
foreach ($dataset->fixedFilters() as $column => $value) {
$where[] = $this->quote($column) . ' = ?';
$bindings[] = $value;
}
if ($mode === 'incremental' && $dataset->watermarkColumn() !== null) {
$watermark = $this->quote($dataset->watermarkColumn());
$where[] = $watermark . ' >= ? AND ' . $watermark . ' < ?';

View File

@ -5,14 +5,14 @@
</div>
<form class="tw-filter" @submit.prevent="applyFilters">
<label>店铺
<select v-if="isDouyin && !filterOptionsError" v-model="filters.store_name" aria-label="店铺筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters">
<select v-if="usesMirrorOptions && !filterOptionsError" v-model="filters.store_name" aria-label="店铺筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters">
<option value="">{{ filterOptionsLoading ? '店铺加载中…' : '全部店铺' }}</option>
<option v-for="shop in filterOptions.shops" :key="shop" :value="shop">{{ shop }}</option>
</select>
<input v-else v-model.trim="filters.store_name" placeholder="店铺名称" />
</label>
<label>账单项目
<select v-if="isDouyin && !filterOptionsError" v-model="filters.bill_item" aria-label="账单项目筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters">
<select v-if="usesMirrorOptions && !filterOptionsError" v-model="filters.bill_item" aria-label="账单项目筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters">
<option value="">{{ filterOptionsLoading ? '账单项目加载中…' : '全部账单项目' }}</option>
<option v-for="item in filterOptions.bill_items" :key="item" :value="item">{{ item }}</option>
</select>
@ -59,13 +59,14 @@ export default {
data(){return{loading:false,loadError:false,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
computed:{
isDouyin(){return this.billType==='douyin';},
usesMirrorOptions(){return ['douyin','pinduoduo'].indexOf(this.billType)!==-1;},
invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);},
visibleColumns(){return this.configuredColumns.filter(column=>column.visible);},
visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);},
stats(){return[{label:'收入合计',value:this.money(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.money(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:'期末余额(最新)',value:this.money(this.summary.balance),note:'最新一条记录',className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];},
rangeStart(){return this.total===0?0:(this.currentPage-1)*this.perPage+1;},rangeEnd(){return Math.min(this.currentPage*this.perPage,this.total);}
},
mounted(){if(this.isDouyin)this.fetchFilterOptions();this.fetch();},
mounted(){if(this.usesMirrorOptions)this.fetchFilterOptions();this.fetch();},
methods:{
async fetchFilterOptions(){this.filterOptionsLoading=true;this.filterOptionsError=false;try{const response=await window.axios.get(`${this.apiBase}/filter-options`);const body=response.data||{};this.filterOptions={shops:Array.isArray(body.shops)?body.shops:[],bill_items:Array.isArray(body.bill_items)?body.bill_items:[]};}catch(error){this.filterOptionsError=true;}finally{this.filterOptionsLoading=false;}},
async fetch(){const requestId=++this.requestSerial;this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...this.filters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});if(requestId!==this.requestSerial)return;const body=response.data||{};this.rows=body.data||[];this.total=body.total||0;this.currentPage=body.current_page||1;this.lastPage=body.last_page||1;this.perPage=body.per_page||this.perPage;this.summary=body.summary||{income:0,expense:0,balance:0,count:0};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}},

View File

@ -0,0 +1,142 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use RuntimeException;
use Tests\Feature\FeatureTestCase;
class PinduoduoReceivingBillsTest extends FeatureTestCase
{
public function test_endpoint_reads_pinduoduo_goods_account_mirror_with_all_filters(): void
{
$this->allowPage();
$filters = [
'store_name' => '拼多多-测试店',
'bill_item' => '交易收入',
'start' => '2026-08-12',
'end' => '2026-09-10',
'keyword' => '260909',
];
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->with(
$filters, 'occurred_at', 'desc', 2, 20,
)->andReturn([
'current_page' => 2,
'last_page' => 3,
'per_page' => 20,
'total' => 41,
'data' => [[
'id' => '14424200',
'record_code' => '380425163199',
'store_name' => '拼多多-测试店',
'income' => 91.64,
'expense' => 0.0,
'payload' => [
'merchant_order_no' => '260909-047741735284017',
'finance_type' => '交易收入',
'type' => '拼多多货款账户',
],
]],
'summary' => ['income' => 91.64, 'expense' => 0.0, 'balance' => 91.64, 'count' => 41],
]);
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$this->withExceptionHandling()
->loginAs()
->getJson(route('ecommerce-finance.account-bills.index', [
'bill_type' => 'pinduoduo',
...$filters,
'page' => 2,
'limit' => 20,
]))
->assertOk()
->assertJsonPath('data.0.payload.type', '拼多多货款账户')
->assertJsonPath('columns.title', '拼多多账户')
->assertJsonPath('summary.count', 41);
}
public function test_filter_options_and_selected_column_export_use_the_same_mirror(): void
{
$this->allowPage();
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldReceive('filterOptions')->once()->andReturn([
'shops' => ['拼多多-A店', '拼多多-B店'],
'bill_items' => ['交易收入', '技术服务费'],
]);
$repository->shouldReceive('exportRows')->once()->with(
['store_name' => '拼多多-A店'], 'record_code', 'asc',
)->andReturn([[
'record_code' => '380425163199',
'store_name' => '拼多多-A店',
'payload' => ['finance_type' => '交易收入'],
]]);
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')
->get(route('ecommerce-finance.account-bills.filter-options', ['bill_type' => 'pinduoduo']))
->assertOk()
->assertExactJson([
'shops' => ['拼多多-A店', '拼多多-B店'],
'bill_items' => ['交易收入', '技术服务费'],
]);
$response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [
'bill_type' => 'pinduoduo',
'store_name' => '拼多多-A店',
'sort' => 'record_code',
'dir' => 'asc',
'columns' => 'payload.finance_type,record_code,store_name',
]));
$response->assertOk()
->assertHeader('content-type', 'text/csv; charset=UTF-8')
->assertDownload('pinduoduo_account_bills.csv');
$csv = $response->streamedContent();
$this->assertStringContainsString('财务类型,记录编码,店铺', $csv);
$this->assertStringContainsString('交易收入,"=""380425163199""",拼多多-A店', $csv);
}
public function test_unready_mirror_returns_a_clear_retryable_response(): void
{
$this->allowPage();
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andThrow(
new RuntimeException('store_receiving_alipay_bill_mirror_not_ready')
);
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'pinduoduo']))
->assertStatus(503)
->assertJsonPath('message', '拼多多账户账单共享业务镜像正在首次同步,完成后将自动显示。');
}
public function test_invalid_date_range_is_rejected_before_reading_the_mirror(): void
{
$this->allowPage();
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldNotReceive('paginate');
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$this->withExceptionHandling()
->loginAs()
->getJson(route('ecommerce-finance.account-bills.index', [
'bill_type' => 'pinduoduo',
'start' => '2026-09-10',
'end' => '2026-08-12',
]))
->assertStatus(422);
}
private function allowPage(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
}

View File

@ -144,6 +144,55 @@ class BusinessMirrorSynchronizerTest extends TestCase
}
}
public function test_fixed_filters_are_validated_and_bound_to_every_source_batch(): void
{
DB::table('upstream_schema_columns')->insert($this->column('channel', 5, 'nvarchar', 50));
$datasets = (array) config('upstream_mirrors.datasets', []);
$datasets['test.dbo.records.pinduoduo'] = [
'source_key' => 'test',
'schema' => 'dbo',
'object' => 'records',
'target' => 'test_pinduoduo_records',
'keys' => ['Id'],
'watermark' => 'updateTime',
'fixed_filters' => ['channel' => 'PinduoduoImport'],
];
config(['upstream_mirrors.datasets' => $datasets]);
$this->query->batches = [[[
'Id' => 1,
'updateTime' => '2026-08-14 10:00:00',
'displayName' => '拼多多记录',
'businessDate' => '2026-08-14',
'channel' => 'PinduoduoImport',
]]];
$this->synchronizer->sync('test.dbo.records.pinduoduo', true, 10, 1);
$this->assertStringContainsString('WHERE ([channel] = ?)', $this->query->calls[0]['sql']);
$this->assertSame(['PinduoduoImport'], $this->query->calls[0]['bindings']);
$this->assertSame(
['channel' => 'PinduoduoImport'],
(new BusinessMirrorDatasetRegistry())->fixedFilters('test.dbo.records.pinduoduo'),
);
}
public function test_registry_rejects_non_scalar_fixed_filter_values(): void
{
$datasets = (array) config('upstream_mirrors.datasets', []);
$datasets['test.dbo.invalid_filter'] = [
'source_key' => 'test',
'schema' => 'dbo',
'object' => 'records',
'target' => 'test_invalid_filter',
'keys' => ['Id'],
'fixed_filters' => ['channel' => ['PinduoduoImport']],
];
config(['upstream_mirrors.datasets' => $datasets]);
$this->expectException(\InvalidArgumentException::class);
(new BusinessMirrorDatasetRegistry())->get('test.dbo.invalid_filter');
}
public function test_incremental_keyset_keeps_watermark_out_of_pagination_cursor(): void
{
$this->query->batches = [[
@ -428,6 +477,12 @@ class BusinessMirrorSynchronizerTest extends TestCase
$this->assertNotContains('Bulk_Price_Limit', array_map(fn ($dataset): string => $dataset->sourceObject(), $datasets));
$this->assertContains('fys_Ticketing_install_view', array_map(fn ($dataset): string => $dataset->sourceObject(), $datasets));
$this->assertContains('fys_Ticketing_install_item', array_map(fn ($dataset): string => $dataset->sourceObject(), $datasets));
$pinduoduo = $datasets['jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving'];
$this->assertSame('jiansun', $pinduoduo->sourceKey());
$this->assertSame('jst_originalBillInfo', $pinduoduo->sourceObject());
$this->assertSame('js_jst_pdd_receiving_bill_rows', $pinduoduo->targetTable());
$this->assertSame(['channel' => 'PinduoduoImport'], $pinduoduo->fixedFilters());
}
/** @return array<string,mixed> */

View File

@ -106,6 +106,7 @@ class ColumnarBusinessMirrorRepositoryTest extends TestCase
public function targetTable(): string { return 'fact_rows'; }
public function keyColumns(): array { return ['Id']; }
public function watermarkColumn(): ?string { return 'updated_at'; }
public function fixedFilters(): array { return []; }
public function indexDefinitions(): array { return []; }
};
}

View File

@ -131,6 +131,8 @@ class GenericUpstreamPipelineTest extends TestCase
['Id'],
['shop_id', 'fee_time', 'Id'],
['fee_time', 'Id'],
['channel', 'fee_time', 'Id', 'inout_type', 'amount'],
['channel', 'shop_id', 'fee_time', 'Id', 'inout_type', 'amount', 'fee_name'],
], $dataset->indexDefinitions());
$this->assertSame([
'Id', 'shop_id', 'channel', 'fee_time', 'del_flag', 'primary_key',

View File

@ -0,0 +1,198 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class StoreReceivingPinduoduoBillMirrorRepositoryTest extends TestCase
{
private StoreReceivingPinduoduoBillMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
StoreReceivingPinduoduoBillMirrorRepository::DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo',
'object' => 'jst_originalBillInfo', 'target' => 'js_jst_pdd_receiving_bill_rows',
'keys' => ['Id'],
'fixed_filters' => ['channel' => StoreReceivingPinduoduoBillMirrorRepository::SOURCE_CHANNEL],
],
StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET => [
'source_key' => 'operations_replica', 'schema' => 'rpt',
'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'],
],
],
]);
DB::purge('business_mirror');
$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();
$table->unsignedBigInteger('mirror_row_count')->nullable();
$table->boolean('mirror_row_count_is_exact')->default(false);
});
$schema->create('js_jst_pdd_receiving_bill_rows', function (Blueprint $table): void {
$table->integer('Id');
$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('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();
});
$source = new StoreReceivingAlipayBillMirrorRepository(
new BusinessMirrorDatasetRegistry(), new GenericBusinessMirrorRepository(app('db')),
);
$this->repository = new StoreReceivingPinduoduoBillMirrorRepository($source);
$this->markReady();
}
public function test_it_reads_only_pinduoduo_goods_account_rows_and_maps_source_fields(): void
{
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('js_jst_pdd_receiving_bill_rows')->insert([
$this->row(1, 7, 'PinduoduoImport', '2026-09-09 23:55:47', '1', 91.64),
$this->row(2, 7, 'PinduoduoImport', '2026-09-09 23:58:52', '-1', 0.66),
$this->row(3, 8, 'TouTiaoFXGZJ', '2026-09-09 23:59:00', '1', 999.00),
]);
$result = $this->repository->paginate([], 'occurred_at', 'desc', 1, 10);
$this->assertSame(2, $result['total']);
$this->assertSame('380000002', $result['data'][0]['record_code']);
$this->assertSame('拼多多-测试店', $result['data'][0]['store_name']);
$this->assertSame('260909-ORDER-2', $result['data'][0]['order_no']);
$this->assertSame('260909-ORDER-2', $result['data'][0]['payload']['merchant_order_no']);
$this->assertSame('技术服务费', $result['data'][0]['payload']['finance_type']);
$this->assertSame('拼多多货款账户', $result['data'][0]['payload']['type']);
$this->assertSame('RPA', $result['data'][0]['modifier']);
$this->assertSame(91.64, $result['summary']['income']);
$this->assertSame(0.66, $result['summary']['expense']);
$this->assertSame(90.98, $result['summary']['balance']);
}
public function test_filters_options_and_export_keep_the_fixed_channel(): void
{
DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([
['Id' => 10, 'shop_id' => 7, 'shop_name' => '拼多多-A店'],
['Id' => 11, 'shop_id' => 8, 'shop_name' => '拼多多-B店'],
['Id' => 12, 'shop_id' => 9, 'shop_name' => '抖音店'],
]);
DB::connection('business_mirror')->table('js_jst_pdd_receiving_bill_rows')->insert([
$this->row(1, 7, 'PinduoduoImport', '2026-08-12 09:00:00', '1', 22.80, '交易收入'),
$this->row(2, 8, 'PinduoduoImport', '2026-08-13 09:00:00', '-1', 0.14, '技术服务费'),
$this->row(3, 9, 'TouTiaoFXGZJ', '2026-08-13 09:00:00', '1', 99.00, '订单收入'),
]);
$result = $this->repository->paginate([
'store_name' => 'A店',
'bill_item' => '交易',
'start' => '2026-08-12',
'end' => '2026-08-12',
'keyword' => '380000001',
], 'record_code', 'asc', 1, 10);
$options = $this->repository->filterOptions();
$export = iterator_to_array($this->repository->exportRows(['bill_item' => '技术服务费']));
$this->assertSame(1, $result['total']);
$this->assertSame('380000001', $result['data'][0]['record_code']);
$this->assertSame(['拼多多-A店', '拼多多-B店'], $options['shops']);
$this->assertSame(['交易收入', '技术服务费'], $options['bill_items']);
$this->assertCount(1, $export);
$this->assertSame('380000002', $export[0]['record_code']);
}
public function test_unfiltered_total_uses_the_exact_mirror_count(): void
{
DB::connection('business_mirror')->table('js_jst_pdd_receiving_bill_rows')->insert([
$this->row(1, 7, 'PinduoduoImport', '2026-09-09 23:55:47', '1', 91.64),
$this->row(2, 7, 'PinduoduoImport', '2026-09-09 23:58:52', '-1', 0.66),
]);
$connection = DB::connection('business_mirror');
$connection->table('business_mirror_table_states')
->where('dataset_key', StoreReceivingPinduoduoBillMirrorRepository::DATASET)
->update(['mirror_row_count' => 2, 'mirror_row_count_is_exact' => true]);
$connection->enableQueryLog();
$connection->flushQueryLog();
$result = $this->repository->paginate([], 'occurred_at', 'desc', 1, 10);
$this->assertSame(2, $result['total']);
$this->assertCount(0, array_filter(
$connection->getQueryLog(),
static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)')
&& str_contains($query['query'], 'js_jst_pdd_receiving_bill_rows'),
));
$connection->disableQueryLog();
}
private function markReady(): void
{
foreach ([StoreReceivingPinduoduoBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $dataset) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $dataset,
'initial_completed_at' => '2026-09-11 09:00:00',
'status' => 'succeeded',
'last_succeeded_at' => '2026-09-11 09:00:00',
]);
}
}
private function row(
int $id,
int $shopId,
string $channel,
string $time,
string $direction,
float $amount,
string $feeName = '技术服务费',
): array {
return [
'Id' => $id,
'shop_id' => $shopId,
'channel' => $channel,
'primary_key' => 'KEY-' . $id,
'fee_name' => $feeName,
'inout_type' => $direction,
'p_auto_id' => 'P-' . $id,
'shop_i_id' => 'ITEM-' . $id,
'shop_sku_id' => 'SHOP-SKU-' . $id,
'sku_id' => 'SKU-' . $id,
'so_id' => '260909-ORDER-' . $id,
'combine_type' => 'double',
'fee_time' => $time,
'createTime' => '2026-09-10 13:00:48',
'updateTime' => '2026-09-10 13:00:48',
'auto_id' => 380000000 + $id,
'amount' => $amount,
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Tests\Unit\View;
use PHPUnit\Framework\TestCase;
class AccountBillsTableTest extends TestCase
{
public function test_pinduoduo_uses_mirror_filter_options_and_existing_table_interactions(): void
{
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');
$this->assertStringContainsString("isDouyin(){return this.billType==='douyin';}", $source);
$this->assertStringContainsString("['douyin','pinduoduo'].indexOf(this.billType)!==-1", $source);
$this->assertStringContainsString('v-if="usesMirrorOptions && !filterOptionsError"', $source);
$this->assertStringContainsString('page-key="store-receiving-bills"', $source);
$this->assertStringContainsString(':tab-key="billType"', $source);
$this->assertStringContainsString("value=\"page-visible\"", $source);
$this->assertStringContainsString("value=\"filtered-visible\"", $source);
$this->assertStringContainsString("value=\"filtered-all\"", $source);
$this->assertStringContainsString('position:sticky', $source);
$this->assertStringContainsString('overflow:auto', $source);
}
}