接入拼多多店铺收款货款账单 #164
@ -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' => '拼多多推广账单'],
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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` 副本。
|
||||
|
||||
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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 . ' < ?';
|
||||
|
||||
@ -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;}},
|
||||
|
||||
142
tests/Feature/Business/PinduoduoReceivingBillsTest.php
Normal file
142
tests/Feature/Business/PinduoduoReceivingBillsTest.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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> */
|
||||
|
||||
@ -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 []; }
|
||||
};
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
tests/Unit/View/AccountBillsTableTest.php
Normal file
24
tests/Unit/View/AccountBillsTableTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user