聚水潭小额打款工单页面 #102

Merged
yueyang merged 1 commits from feature/yueyang-jushuitan-small-payment-work-orders into main 2026-09-04 13:54:19 +08:00
10 changed files with 1348 additions and 2 deletions

View File

@ -36,6 +36,7 @@ use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
@ -68,6 +69,7 @@ class Pages extends Controller
private readonly InventoryRepository $inventory,
private readonly InstallationDetailMirrorRepository $installationDetails,
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders,
private readonly ProductCatalogMirrorRepository $productCatalog,
private readonly ProductPriceMirrorRepository $productPrices,
private readonly CourierInformationMirrorRepository $courierInformation,
@ -490,6 +492,7 @@ class Pages extends Controller
'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab),
'jingxi-refund-data' => $this->contentForJingxiRefundData(),
'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(),
'small-payment-work-orders' => $this->contentForSmallPaymentWorkOrders(),
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
'normal-products' => $this->contentForNormalProducts(),
'product-prices' => $this->contentForProductPrices(),
@ -790,6 +793,190 @@ class Pages extends Controller
]);
}
protected function contentForSmallPaymentWorkOrders(): array
{
$request = request();
$filters = $this->smallPaymentWorkOrderFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
$syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null];
$pagePaymentAmount = '0.00';
$error = null;
$pending = false;
$indexPending = false;
try {
if (! $this->smallPaymentWorkOrders->available()) {
$error = '共享业务镜像尚未建立,小额打款工单暂时无法读取。';
} else {
$states = $this->smallPaymentWorkOrders->states();
$syncStatus = $this->smallPaymentWorkOrders->syncStatus($states);
if (! $this->smallPaymentWorkOrders->ready()) {
$pending = true;
} elseif (! $this->smallPaymentWorkOrders->queryReady()) {
$indexPending = true;
} else {
$result = $this->smallPaymentWorkOrders->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
$pagePaymentAmount = (string) ($result['page_payment_amount'] ?? '0.00');
}
}
} catch (Throwable $exception) {
report($exception);
$error = '小额打款工单暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.refunds.small-payment-work-orders',
'filters' => $filters,
'rows' => $rows,
'smallPaymentSyncStatus' => $syncStatus,
'smallPaymentPageAmount' => $pagePaymentAmount,
'smallPaymentError' => $error,
'smallPaymentPending' => $pending,
'smallPaymentIndexPending' => $indexPending,
];
}
public function exportSmallPaymentWorkOrders(Request $request)
{
$filters = $this->smallPaymentWorkOrderFilters($request);
$redirectParameters = array_filter([
'section' => 'refunds',
'page' => 'small-payment-work-orders',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
try {
if (! $this->smallPaymentWorkOrders->available()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '共享业务镜像尚未建立,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->ready()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '业务镜像首次同步尚未完成,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->queryReady()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '页面查询索引尚未就绪,小额打款工单暂时不能导出。');
}
return $this->streamSmallPaymentWorkOrderCsv(
$this->smallPaymentWorkOrders->exportRows($filters),
$filters,
$redirectParameters,
);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '小额打款工单导出暂时无法完成,请稍后重试。');
}
}
/**
* @return array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string}
*/
private function smallPaymentWorkOrderFilters(Request $request): array
{
$defaultTo = now()->toDateString();
$defaultFrom = now()->subDays(29)->toDateString();
$from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom;
$to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo;
if ($from > $to) {
[$from, $to] = [$to, $from];
}
$text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length);
return [
'from' => $from,
'to' => $to,
'work_order_no' => $text($request->query('work_order_no', '')),
'shop_name' => $text($request->query('shop_name', ''), 191),
'transaction_no' => $text($request->query('transaction_no', '')),
'online_order_no' => $text($request->query('online_order_no', '')),
'status' => $text($request->query('status', ''), 60),
'payment_reason' => $text($request->query('payment_reason', ''), 191),
];
}
private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters)
{
$columns = [
['label' => '工单编号', 'field' => 'work_order_no'],
['label' => '支付宝批次号', 'field' => 'alipay_batch_no'],
['label' => '工单状态', 'field' => 'work_order_status'],
['label' => '打款状态', 'field' => 'payment_status'],
['label' => '工单标题', 'field' => 'work_order_title'],
['label' => '工单内容', 'field' => 'work_order_content'],
['label' => '工单类型', 'field' => 'work_order_type'],
['label' => '优先级', 'field' => 'priority'],
['label' => '打款金额', 'field' => 'payment_amount', 'number' => true],
['label' => '催处理次数', 'field' => 'urging_count', 'number' => true],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '线上订单号', 'field' => 'online_order_no'],
['label' => '买家账号', 'field' => 'buyer_account'],
['label' => '支付宝账号', 'field' => 'alipay_account'],
['label' => '支付宝姓名', 'field' => 'alipay_name'],
['label' => '商品编码', 'field' => 'product_code'],
['label' => 'SKU编码', 'field' => 'sku_code'],
['label' => '平台', 'field' => 'platform'],
['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true],
['label' => '支付时间', 'field' => 'payment_time'],
['label' => '打款原因', 'field' => 'payment_reason'],
['label' => '创建人', 'field' => 'creator'],
['label' => '创建时间', 'field' => 'work_order_created_at'],
['label' => '完成时间', 'field' => 'completed_at'],
];
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建小额打款工单导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($rows as $row) {
fputcsv($csv, array_map(
fn (array $column): mixed => ($column['number'] ?? false)
? data_get($row, $column['field'])
: $this->csvText(data_get($row, $column['field'])),
$columns,
));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '小额打款工单导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'small_payment_work_orders_' . $filters['from'] . '_' . $filters['to'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForMarketingAccountBalance(?string $tab): array
{
$content = [

View File

@ -402,6 +402,7 @@ return [
'pages' => [
['slug' => 'jingxi-refund-data', 'title' => '京喜退款数据'],
['slug' => 'non-jingxi-refund-data', 'title' => '售后(退货退款)'],
['slug' => 'small-payment-work-orders', 'title' => '小额打款'],
],
],
[

View File

@ -241,6 +241,46 @@ return [
],
],
// 聚水潭聚工单:协同工单 > 打款工单 > 小额打款。
// 页面只读共享镜像;businessFieldList 保留上游表单原文,由固定
// Mirror Repository 解析页面明确使用的字段,不在 HTTP 请求直连上游。
'jiansun.dbo.jst_workOrderInfo' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'jst_workOrderInfo',
'target' => 'js_jst_work_order_info',
'keys' => ['Id'],
'watermark' => 'updateTime',
'columns' => [
'Id', 'woNo', 'woTypeId', 'title', 'content', 'status', 'grade',
'urging', 'createby', 'executors', 'beginTime', 'endTime',
'completer', 'completeTime', 'updated', 'created',
'businessFieldList', 'createTime', 'updateTime',
],
'indexes' => [
['woTypeId', 'woNo', 'Id'],
['created', 'Id'],
],
],
'jiansun.dbo.jst_small_payment_order_map' => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'jst_small_payment_order_map',
'target' => 'js_jst_small_payment_order_map',
'keys' => ['woNo'],
'watermark' => 'updated_at',
'columns' => [
'woNo', 'woTypeId', 'transaction_no', 'shop_name',
'payment_reason', 'title', 'status', 'work_order_create_time',
'work_order_update_time', 'parse_status', 'parse_error',
'updated_at',
],
'indexes' => [
['woTypeId', 'work_order_create_time', 'woNo'],
['status', 'woNo'],
],
],
// JianSun 旧业务库:营销账单、供应商应付、板材、封边条和库存。
'jiansun.dbo.tmall_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'tmall_spread_bills', 'target' => 'js_tmall_spread_bills', 'keys' => ['id']],
'jiansun.dbo.pdd_spread_bills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'pdd_spread_bills', 'target' => 'js_pdd_spread_bills', 'keys' => ['id'], 'watermark' => 'createTime'],

View File

@ -16,6 +16,7 @@
| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 |
| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
| 本次接入 | 聚水潭聚工单协同工单登记 `work_order_main`、`work_order_sub`、`work_order_form_config`、`work_order_form_value` 四张表;工单 API 固定筛选类型“仓内未发”,首次同步完成前返回同步未完成状态。 |
| 待首次同步 | 聚水潭聚工单“小额打款”读取 `jiansun.dbo.jst_workOrderInfo` 与已解析映射 `dbo.jst_small_payment_order_map`;退款模块的小额打款子页面固定筛选工单类型 `82D94F5C54544EE3833CD8A3AB8B9F73`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 |
| 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 |
| 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 |
| 中央同步接入 | 店铺维度报表从 `jiansun.dbo.jst_originalBillInfo` 和 `dbo.fys_shopInfo_flow` 自动、可断点同步;不再依赖开发者电脑、SQLite 或人工 XLSX。 |
@ -56,6 +57,7 @@
| 库存与货款对账 | `dbo.pm_boardInventoryDetail_snapshot`、`dbo.fys_supplierGoods_sttlement_purchase`、`dbo.fys_supplierGoods_sttlement_sale`、`dbo.pm_purchaseIn` |
| 采购单价 | `dbo.jst_purchase`、`dbo.jst_purchaseItem`(受控字段联表镜像,不复制其他采购业务字段) |
| 快递资料 | `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`) |
其中 `fys_supplierGoods_sttlement_sale` 当前约 280 万行,是首次全量同步的最大对象。必须在低峰期小批量、可断点执行,禁止把全量同步塞进发布流程。
@ -94,3 +96,12 @@ php artisan business-mirror:sync-tables operations_replica.rpt.work_order_sub --
php artisan business-mirror:sync-tables operations_replica.rpt.work_order_form_config --full
php artisan business-mirror:sync-tables operations_replica.rpt.work_order_form_value --full
```
聚工单“小额打款”两个数据集同样必须在备份和负责人首次写入授权后分别执行全量同步;页面不会在 HTTP 请求中回退直连 `jiansun`:
```bash
php artisan business-mirror:sync-tables jiansun.dbo.jst_workOrderInfo --full
php artisan business-mirror:sync-tables jiansun.dbo.jst_small_payment_order_map --full
```
两张表完成后,页面按映射表的工单创建时间稳定分页,并以 `woNo` 关联最新的工单主记录。筛选支持日期、工单号、店铺、支付宝批次号、线上订单号、状态和打款原因;表单详情从镜像中的 `businessFieldList` 解析,导出与页面使用同一筛选口径。首次全量未完成、查询索引未就绪或刷新失败时均保留明确状态,不能把空结果解释为没有小额打款工单。

View File

@ -0,0 +1,452 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/** 只读聚水潭“小额打款”工单镜像;HTTP 请求不得回退直连上游。 */
class SmallPaymentWorkOrderMirrorRepository
{
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
public const PAYMENT_MAP_DATASET = 'jiansun.dbo.jst_small_payment_order_map';
public const SMALL_PAYMENT_WORK_TYPE_ID = '82D94F5C54544EE3833CD8A3AB8B9F73';
private const REQUIRED_QUERY_INDEXES = [
self::WORK_ORDER_DATASET => [['woTypeId', 'woNo', 'Id']],
self::PAYMENT_MAP_DATASET => [['woTypeId', 'work_order_create_time', 'woNo']],
];
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function available(): bool
{
$schema = Schema::connection($this->connection->getName());
try {
return $schema->hasTable('business_mirror_table_states')
&& $schema->hasTable($this->dataset(self::WORK_ORDER_DATASET)->targetTable())
&& $schema->hasTable($this->dataset(self::PAYMENT_MAP_DATASET)->targetTable());
} catch (Throwable) {
return false;
}
}
public function ready(): bool
{
return $this->mirror->ready($this->dataset(self::WORK_ORDER_DATASET))
&& $this->mirror->ready($this->dataset(self::PAYMENT_MAP_DATASET));
}
public function queryReady(): bool
{
$schema = Schema::connection($this->connection->getName());
try {
foreach (self::REQUIRED_QUERY_INDEXES as $datasetKey => $requiredIndexes) {
$table = $this->dataset($datasetKey)->targetTable();
if (! $schema->hasTable($table)) {
return false;
}
$indexes = collect($schema->getIndexes($table))
->map(static fn (array $index): array => array_map(
'strtolower',
(array) ($index['columns'] ?? []),
));
foreach ($requiredIndexes as $required) {
if (! $indexes->contains(array_map('strtolower', $required))) {
return false;
}
}
}
} catch (Throwable) {
return false;
}
return true;
}
/** @return array<string,array<string,mixed>|null> */
public function states(): array
{
return [
self::WORK_ORDER_DATASET => $this->mirror->state($this->dataset(self::WORK_ORDER_DATASET)),
self::PAYMENT_MAP_DATASET => $this->mirror->state($this->dataset(self::PAYMENT_MAP_DATASET)),
];
}
/**
* @param array<string,array<string,mixed>|null>|null $states
* @return array{code:string,last_succeeded_at:?string}
*/
public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array
{
$states ??= $this->states();
if (count(array_filter($states, 'is_array')) !== 2) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$initialized = collect($states)->every(
static fn (?array $state): bool => ($state['initial_completed_at'] ?? null) !== null
);
$statuses = collect($states)->pluck('status')->map('strval');
$lastSucceededAt = collect($states)
->pluck('last_succeeded_at')
->filter()
->sort()
->first();
if (! $initialized) {
$code = $statuses->contains('failed') ? 'initial_failed' : 'initializing';
} elseif ($statuses->contains('failed')) {
$code = 'failed';
} elseif ($statuses->contains('running')) {
$code = 'syncing';
} else {
$lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null;
$lagMinutes = $lastSucceeded === null
? null
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60);
$code = $lagMinutes === null || $lagMinutes > 30 ? 'stale' : 'healthy';
}
return ['code' => $code, 'last_succeeded_at' => $lastSucceededAt];
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return array{data:list<array<string,mixed>>,total:int,page_payment_amount:string}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$this->ensureReady();
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$total = (clone $query)->count();
$rows = $query
->select($this->selectColumns())
->orderByDesc('p.work_order_create_time')
->orderByDesc('p.woNo')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->decorate((array) $row))
->all();
return [
'data' => $rows,
'total' => $total,
'page_payment_amount' => $this->sumMoney(array_column($rows, 'payment_amount')),
];
}
/**
* @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters
* @return iterable<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $chunkSize = 100): iterable
{
$this->ensureReady();
$chunkSize = min(max($chunkSize, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$page = 1;
do {
$rows = (clone $query)
->select($this->selectColumns())
->orderByDesc('p.work_order_create_time')
->orderByDesc('p.woNo')
->forPage($page, $chunkSize)
->get();
foreach ($rows as $row) {
yield $this->decorate((array) $row);
}
$page++;
} while ($rows->count() === $chunkSize);
}
private function ensureReady(): void
{
if (! $this->ready() || ! $this->queryReady()) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
}
private function baseQuery(): Builder
{
$workOrders = $this->dataset(self::WORK_ORDER_DATASET)->targetTable();
$paymentMap = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable();
$latestWorkOrders = $this->connection->table($workOrders . ' AS work_order_versions')
->selectRaw('woNo, MAX(Id) AS Id')
->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->groupBy('woNo');
return $this->connection->table($paymentMap . ' AS p')
->joinSub($latestWorkOrders, 'work_order_versions', static function ($join): void {
$join->on('work_order_versions.woNo', '=', 'p.woNo');
})
->join($workOrders . ' AS w', 'w.Id', '=', 'work_order_versions.Id')
->where('p.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID);
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'w.Id AS source_id', 'p.woNo AS work_order_no',
'p.transaction_no AS alipay_batch_no', 'w.status AS work_order_status',
'p.status AS mapped_status', 'w.title AS work_order_title',
'p.title AS mapped_title', 'w.content AS work_order_content',
'w.grade AS priority', 'w.urging AS urging_count',
'w.createby AS creator', 'w.executors', 'w.completer',
'w.completeTime AS completed_at', 'w.created AS work_order_created_at',
'w.updated AS work_order_updated_at', 'p.shop_name',
'p.payment_reason', 'p.parse_status', 'p.parse_error',
'p.work_order_create_time', 'p.work_order_update_time',
'w.businessFieldList AS business_field_list',
];
}
/** @param array<string,string> $filters */
private function applyFilters(Builder $query, array $filters): void
{
$query->where('p.work_order_create_time', '>=', $filters['from'] . ' 00:00:00')
->where('p.work_order_create_time', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00'));
if ($filters['work_order_no'] !== '') {
$query->whereRaw("p.woNo LIKE ? ESCAPE '!'", [$this->contains($filters['work_order_no'])]);
}
if ($filters['shop_name'] !== '') {
$query->whereRaw("p.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
}
if ($filters['transaction_no'] !== '') {
$query->whereRaw("p.transaction_no LIKE ? ESCAPE '!'", [$this->contains($filters['transaction_no'])]);
}
if ($filters['online_order_no'] !== '') {
$query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['online_order_no'])]);
}
if ($filters['status'] !== '') {
$query->where(function (Builder $status) use ($filters): void {
$status->where('w.status', $filters['status'])
->orWhere('p.status', $filters['status'])
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['status'])]);
});
}
if ($filters['payment_reason'] !== '') {
$query->whereRaw("p.payment_reason LIKE ? ESCAPE '!'", [$this->contains($filters['payment_reason'])]);
}
}
/** @param array<string,mixed> $row @return array<string,mixed> */
private function decorate(array $row): array
{
$fields = $this->businessFields($row['business_field_list'] ?? null);
$row['work_order_status'] = $this->firstFilled($row['work_order_status'] ?? null, $row['mapped_status'] ?? null);
$row['work_order_title'] = $this->firstFilled($row['work_order_title'] ?? null, $row['mapped_title'] ?? null);
$row['work_order_type'] = $this->field($fields, ['工单类型']) ?: '小额打款';
$row['payment_status'] = $this->firstFilled(
$this->field($fields, ['打款状态', '支付打款状态', '付款状态']),
$row['mapped_status'] ?? null,
);
$row['payment_amount'] = $this->money($this->field($fields, [
'打款金额', '付款金额', '小额打款金额', 'amountField',
]));
$row['online_order_no'] = $this->field($fields, [
'线上订单号', '线上单号', '订单号', 'orderField',
]);
$row['buyer_account'] = $this->field($fields, [
'买家账号', '买家帐号', '买家', 'buyerIdField',
]);
$row['alipay_account'] = $this->field($fields, [
'支付宝账号', '支付宝帐号', 'alipayAccountField',
]);
$row['alipay_name'] = $this->field($fields, [
'支付宝姓名', '支付宝实名', '支付宝账户姓名', 'alipayNameField',
]);
$row['product_code'] = $this->field($fields, [
'商品编码', '商品ID', '商品编号', 'iid',
]);
$row['sku_code'] = $this->field($fields, [
'SKU编码', 'SKU', '规格编码', 'skuId',
]);
$row['platform'] = $this->field($fields, ['平台', '平台名称', 'platform']);
$row['actual_paid_amount'] = $this->money($this->field($fields, [
'实际已付金额', '实付金额', '买家实付金额', 'actualPaidAmountField',
]));
$row['payment_time'] = $this->field($fields, [
'支付时间', '打款时间', '付款时间', 'payDateField',
]);
unset($row['business_field_list'], $row['mapped_status'], $row['mapped_title']);
return $row;
}
/** @return array<string,string> */
private function businessFields(mixed $json): array
{
if (! is_string($json) || trim($json) === '') {
return [];
}
try {
$decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
$fields = [];
$this->flattenFields($decoded, $fields);
return $fields;
}
/** @param array<string,string> $fields */
private function flattenFields(mixed $node, array &$fields): void
{
if (! is_array($node)) {
return;
}
$label = $this->arrayValue($node, [
'fieldName', 'field_name', 'label', 'name', 'title', 'fieldId', 'field_id',
]);
$value = $this->arrayValue($node, [
'fieldValue', 'field_value', 'valueLabel', 'value_label', 'value', 'text',
]);
if ($label !== '' && $value !== '') {
$fields[$this->normalizeLabel($label)] = $value;
}
foreach ($node as $key => $child) {
if (is_string($key) && ! is_array($child) && ! is_object($child)) {
$text = trim((string) $child);
if ($text !== '') {
$fields[$this->normalizeLabel($key)] ??= $text;
}
}
if (is_array($child)) {
$this->flattenFields($child, $fields);
}
}
}
/** @param array<mixed> $row @param list<string> $keys */
private function arrayValue(array $row, array $keys): string
{
foreach ($keys as $key) {
if (array_key_exists($key, $row) && is_scalar($row[$key])) {
$value = trim((string) $row[$key]);
if ($value !== '') {
return $value;
}
}
}
return '';
}
/** @param array<string,string> $fields @param list<string> $labels */
private function field(array $fields, array $labels): string
{
foreach ($labels as $label) {
$value = $fields[$this->normalizeLabel($label)] ?? '';
if ($value !== '') {
return $value;
}
}
return '';
}
private function normalizeLabel(string $label): string
{
return mb_strtolower((string) preg_replace('/[\s::()()_\-]+/u', '', trim($label)));
}
private function money(string $value): ?string
{
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value));
if (preg_match('/^(-?)(\d+)(?:\.(\d{1,6}))?$/', $normalized, $matches) !== 1) {
return null;
}
$fraction = str_pad($matches[3] ?? '', 3, '0');
$cents = ((int) $matches[2] * 100) + (int) substr($fraction, 0, 2);
if ((int) $fraction[2] >= 5) {
$cents++;
}
$sign = $matches[1] === '-' && $cents !== 0 ? '-' : '';
return $sign . intdiv($cents, 100) . '.' . str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT);
}
/** @param list<mixed> $amounts */
private function sumMoney(array $amounts): string
{
$cents = 0;
foreach ($amounts as $amount) {
if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) {
continue;
}
$value = ((int) $matches[2] * 100) + (int) $matches[3];
$cents += $matches[1] === '-' ? -$value : $value;
}
$sign = $cents < 0 ? '-' : '';
$absolute = abs($cents);
return $sign . intdiv($absolute, 100) . '.' . str_pad((string) ($absolute % 100), 2, '0', STR_PAD_LEFT);
}
private function firstFilled(mixed ...$values): string
{
foreach ($values as $value) {
$text = trim((string) $value);
if ($text !== '') {
return $text;
}
}
return '';
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
private function dataset(string $key): BusinessMirrorDataset
{
return $this->datasets->get($key);
}
}

View File

@ -0,0 +1,245 @@
@php
$columns = [
['label' => '工单编号', 'field' => 'work_order_no'],
['label' => '支付宝批次号', 'field' => 'alipay_batch_no'],
['label' => '工单状态', 'field' => 'work_order_status'],
['label' => '打款状态', 'field' => 'payment_status'],
['label' => '工单标题', 'field' => 'work_order_title'],
['label' => '工单内容', 'field' => 'work_order_content'],
['label' => '工单类型', 'field' => 'work_order_type'],
['label' => '优先级', 'field' => 'priority'],
['label' => '打款金额', 'field' => 'payment_amount', 'number' => true],
['label' => '催处理次数', 'field' => 'urging_count', 'number' => true],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '线上订单号', 'field' => 'online_order_no'],
['label' => '买家账号', 'field' => 'buyer_account'],
['label' => '支付宝账号', 'field' => 'alipay_account'],
['label' => '支付宝姓名', 'field' => 'alipay_name'],
['label' => '商品编码', 'field' => 'product_code'],
['label' => 'SKU编码', 'field' => 'sku_code'],
['label' => '平台', 'field' => 'platform'],
['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true],
['label' => '支付时间', 'field' => 'payment_time'],
['label' => '打款原因', 'field' => 'payment_reason'],
['label' => '创建人', 'field' => 'creator'],
['label' => '创建时间', 'field' => 'work_order_created_at'],
['label' => '完成时间', 'field' => 'completed_at'],
];
$syncCode = $smallPaymentSyncStatus['code'] ?? 'unavailable';
$syncLabel = match ($syncCode) {
'healthy' => '业务镜像已同步',
'syncing' => '业务镜像同步中,当前显示最近成功数据',
'failed' => '本轮同步失败,当前显示最近成功数据',
'stale' => '业务镜像同步时间较早,当前显示最近成功数据',
'initializing' => '业务镜像正在执行首次同步',
'initial_failed' => '业务镜像首次同步失败',
default => '业务镜像尚未建立',
};
$lastSucceededAt = $smallPaymentSyncStatus['last_succeeded_at'] ?? null;
@endphp
<div class="upstream-data-page upstream-data-page--spaced" data-small-payment-work-orders="true">
@include('business.pages.partials.upstream-data-styles')
<style>
.small-payment-sync {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1.5rem 0 0;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.small-payment-sync .material-icons-outlined {
color: var(--vip-content-accent-dark, #856632);
font-size: 1.125rem;
}
.small-payment-summary {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0;
}
.small-payment-summary__item {
min-width: 12rem;
padding: 0.75rem 1rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
}
.small-payment-summary__label {
display: block;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.small-payment-summary__value {
display: block;
margin-top: 0.25rem;
color: var(--vip-content-ink, #171717);
font-size: 1rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.small-payment-table--wide { min-width: 190rem; }
.small-payment-table td.is-number,
.small-payment-table th.is-number {
text-align: right;
font-variant-numeric: tabular-nums;
}
.upstream-data-alert--warning {
border-color: rgba(147, 95, 7, 0.35);
background: rgba(147, 95, 7, 0.06);
color: var(--vip-warning, #935f07);
}
</style>
<div class="small-payment-sync" role="status" data-sync-state="{{ $syncCode }}">
<span class="material-icons-outlined" aria-hidden="true">sync</span>
<span>{{ $syncLabel }}</span>
@if ($lastSucceededAt)
<span>· 最近成功 {{ $lastSucceededAt }}</span>
@endif
</div>
@if ($smallPaymentError)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $smallPaymentError }}</span>
</div>
@elseif ($smallPaymentIndexPending)
<div class="upstream-data-alert upstream-data-alert--warning" role="status">
<span class="material-icons-outlined" aria-hidden="true">hourglass_top</span>
<span>页面查询索引正在准备,生产同步轮转完成后将自动显示数据。</span>
</div>
@endif
<form
class="upstream-data-toolbar"
method="GET"
action="{{ route('business.pages.show', ['section' => 'refunds', 'page' => 'small-payment-work-orders']) }}"
data-filter-contract="small-payment-work-orders.filters"
>
<div class="upstream-data-field">
<label for="small-payment-from">发起开始日期</label>
<input id="small-payment-from" type="date" name="from" value="{{ $filters['from'] }}">
</div>
<div class="upstream-data-field">
<label for="small-payment-to">发起结束日期</label>
<input id="small-payment-to" type="date" name="to" value="{{ $filters['to'] }}">
</div>
<div class="upstream-data-field">
<label for="small-payment-work-order-no">工单编号</label>
<input id="small-payment-work-order-no" type="search" name="work_order_no" value="{{ $filters['work_order_no'] }}" placeholder="例如 GD568…">
</div>
<div class="upstream-data-field">
<label for="small-payment-shop">店铺名称</label>
<input id="small-payment-shop" type="search" name="shop_name" value="{{ $filters['shop_name'] }}">
</div>
<div class="upstream-data-field">
<label for="small-payment-transaction">支付宝批次号</label>
<input id="small-payment-transaction" type="search" name="transaction_no" value="{{ $filters['transaction_no'] }}">
</div>
<div class="upstream-data-field">
<label for="small-payment-online-order">线上订单号</label>
<input id="small-payment-online-order" type="search" name="online_order_no" value="{{ $filters['online_order_no'] }}">
</div>
<div class="upstream-data-field">
<label for="small-payment-status">工单/打款状态</label>
<input id="small-payment-status" type="search" name="status" value="{{ $filters['status'] }}" placeholder="例如 已完成">
</div>
<div class="upstream-data-field">
<label for="small-payment-reason">打款原因</label>
<input id="small-payment-reason" type="search" name="payment_reason" value="{{ $filters['payment_reason'] }}">
</div>
<div class="upstream-data-actions">
<button class="upstream-data-button upstream-data-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a class="upstream-data-button" href="{{ route('business.pages.show', ['section' => 'refunds', 'page' => 'small-payment-work-orders']) }}">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<a
class="upstream-data-button {{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? 'is-disabled' : '' }}"
href="{{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? '#' : route('business.refunds.small-payment-work-orders.export', $filters) }}"
@if ($rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending) aria-disabled="true" @endif
>
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出筛选结果</span>
</a>
</div>
</form>
<div class="small-payment-summary" aria-label="小额打款汇总">
<div class="small-payment-summary__item">
<span class="small-payment-summary__label">筛选结果</span>
<span class="small-payment-summary__value">{{ number_format($rows->total()) }} 条</span>
</div>
<div class="small-payment-summary__item">
<span class="small-payment-summary__label">本页打款金额合计</span>
<span class="small-payment-summary__value">¥ {{ $smallPaymentPageAmount }}</span>
</div>
</div>
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table small-payment-table small-payment-table--wide" aria-label="聚水潭小额打款工单">
<thead>
<tr>
@foreach ($columns as $column)
<th scope="col" @class(['is-number' => $column['number'] ?? false])>{{ $column['label'] }}</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
@foreach ($columns as $column)
@php
$value = data_get($row, $column['field']);
$isNumber = (bool) ($column['number'] ?? false);
@endphp
<td @class(['is-number' => $isNumber])>
{{ $value === null || $value === '' ? '-' : $value }}
</td>
@endforeach
</tr>
@empty
<tr class="is-empty">
<td colspan="{{ count($columns) }}">
@if ($smallPaymentPending)
首次同步尚未完成,完成后将自动显示小额打款工单。
@elseif ($smallPaymentIndexPending)
页面查询索引尚未就绪,当前不会执行高压力查询。
@elseif ($smallPaymentError)
数据暂时无法读取。
@else
当前筛选范围没有小额打款工单,可调整日期或清空筛选后重试。
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>共 {{ number_format($rows->total()) }} 条记录,本页 {{ $rows->count() }} 条</span>
<div class="upstream-data-pagination__controls" aria-label="小额打款工单分页">
<a
@class(['upstream-data-button', 'is-disabled' => $rows->onFirstPage()])
href="{{ $rows->previousPageUrl() ?: '#' }}"
aria-label="上一页"
@if ($rows->onFirstPage()) aria-disabled="true" @endif
><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页</span>
<a
@class(['upstream-data-button', 'is-disabled' => ! $rows->hasMorePages()])
href="{{ $rows->nextPageUrl() ?: '#' }}"
aria-label="下一页"
@if (! $rows->hasMorePages()) aria-disabled="true" @endif
><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
</div>
</div>
</div>
</div>

View File

@ -115,6 +115,10 @@ Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@ex
->middleware('business.page:refunds,non-jingxi-refund-data')
->name('business.refunds.non-jingxi-refund-data.export');
Route::get('workspace/refunds/small-payment-work-orders/export', 'Business\Pages@exportSmallPaymentWorkOrders')
->middleware('business.page:refunds,small-payment-work-orders')
->name('business.refunds.small-payment-work-orders.export');
Route::get('workspace/courier-payables/courier-information/export', 'Business\Pages@exportCourierInformation')
->middleware('business.page:courier-payables,courier-information')
->name('business.courier-payables.courier-information.export');

View File

@ -0,0 +1,167 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Tests\Feature\FeatureTestCase;
class SmallPaymentWorkOrderPageTest extends FeatureTestCase
{
private function grantAdminPermission(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
public function test_page_renders_filtered_mirror_rows_summary_and_pagination(): void
{
$this->grantAdminPermission();
setting()->set('default.list_limit', 25);
$states = [
SmallPaymentWorkOrderMirrorRepository::WORK_ORDER_DATASET => [
'status' => 'running', 'initial_completed_at' => '2026-09-01 00:00:00',
],
SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET => [
'status' => 'running', 'initial_completed_at' => '2026-09-01 00:00:00',
],
];
$repository = \Mockery::mock(SmallPaymentWorkOrderMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('states')->once()->andReturn($states);
$repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([
'code' => 'syncing', 'last_succeeded_at' => '2026-09-03 12:00:00',
]);
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('queryReady')->once()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->withArgs(
static fn (array $filters, int $page, int $limit): bool => $filters === [
'from' => '2026-09-01', 'to' => '2026-09-03',
'work_order_no' => 'GD-001', 'shop_name' => '京东旗舰店',
'transaction_no' => 'BATCH-001', 'online_order_no' => 'ORDER-001',
'status' => '已完成', 'payment_reason' => '售后补偿',
] && $page === 2 && $limit === 25
)->andReturn([
'data' => [[
'work_order_no' => 'GD-001', 'alipay_batch_no' => 'BATCH-001',
'work_order_status' => '已完成', 'payment_status' => '支付宝已打款',
'work_order_title' => '小额打款', 'work_order_content' => '售后补偿',
'work_order_type' => '小额打款', 'priority' => '普通',
'payment_amount' => '50.00', 'urging_count' => 0,
'shop_name' => '京东旗舰店', 'online_order_no' => 'ORDER-001',
'buyer_account' => 'buyer-one', 'alipay_account' => 'account-one',
'alipay_name' => '测试买家', 'product_code' => 'ITEM-001',
'sku_code' => 'SKU-001', 'platform' => '京东',
'actual_paid_amount' => '199.90', 'payment_time' => '2026-09-02 10:30:00',
'payment_reason' => '售后补偿', 'creator' => '客服甲',
'work_order_created_at' => '2026-09-02 09:00:00',
'completed_at' => '2026-09-02 11:00:00',
]],
'total' => 26,
'page_payment_amount' => '50.00',
]);
$this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository);
$url = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'refunds', 'page' => 'small-payment-work-orders',
]) . '?' . http_build_query([
'from' => '2026-09-01', 'to' => '2026-09-03', 'work_order_no' => 'GD-001',
'shop_name' => '京东旗舰店', 'transaction_no' => 'BATCH-001',
'online_order_no' => 'ORDER-001', 'status' => '已完成',
'payment_reason' => '售后补偿', 'page' => 2,
]);
$this->loginAs()->get($url)
->assertOk()
->assertSee('data-small-payment-work-orders="true"', false)
->assertSee('data-filter-contract="small-payment-work-orders.filters"', false)
->assertSee('data-sync-state="syncing"', false)
->assertSeeText('业务镜像同步中,当前显示最近成功数据')
->assertSee('aria-label="聚水潭小额打款工单"', false)
->assertSeeText('GD-001')
->assertSeeText('支付宝已打款')
->assertSeeText('¥ 50.00')
->assertSeeText('共 26 条记录,本页 1 条')
->assertSeeText('第 2 / 2 页')
->assertSee('workspace/refunds/small-payment-work-orders/export', false)
->assertSee('value="ORDER-001"', false);
}
public function test_export_downloads_filtered_csv(): void
{
$this->grantAdminPermission();
$repository = \Mockery::mock(SmallPaymentWorkOrderMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('queryReady')->once()->andReturnTrue();
$repository->shouldReceive('exportRows')->once()->andReturn([[
'work_order_no' => 'GD-001', 'alipay_batch_no' => 'BATCH-001',
'work_order_status' => '已完成', 'payment_status' => '支付宝已打款',
'work_order_title' => '小额打款', 'work_order_content' => '售后补偿',
'work_order_type' => '小额打款', 'priority' => '普通',
'payment_amount' => '50.00', 'urging_count' => 0,
'shop_name' => '京东旗舰店', 'online_order_no' => 'ORDER-001',
'buyer_account' => 'buyer-one', 'alipay_account' => 'account-one',
'alipay_name' => '测试买家', 'product_code' => 'ITEM-001',
'sku_code' => 'SKU-001', 'platform' => '京东',
'actual_paid_amount' => '199.90', 'payment_time' => '2026-09-02 10:30:00',
'payment_reason' => '售后补偿', 'creator' => '客服甲',
'work_order_created_at' => '2026-09-02 09:00:00',
'completed_at' => '2026-09-02 11:00:00',
]]);
$this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.refunds.small-payment-work-orders.export', [
'from' => '2026-09-01', 'to' => '2026-09-03',
]));
$response->assertOk()->assertDownload('small_payment_work_orders_2026-09-01_2026-09-03.csv');
$content = $response->streamedContent();
$this->assertStringStartsWith("\xEF\xBB\xBF", $content);
$this->assertStringContainsString('工单编号', $content);
$this->assertStringContainsString('GD-001', $content);
$this->assertStringContainsString('50.00', $content);
}
public function test_page_does_not_report_empty_before_both_mirrors_finish(): void
{
$this->grantAdminPermission();
$states = [
SmallPaymentWorkOrderMirrorRepository::WORK_ORDER_DATASET => ['status' => 'running'],
SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET => ['status' => 'running'],
];
$repository = \Mockery::mock(SmallPaymentWorkOrderMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('states')->once()->andReturn($states);
$repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([
'code' => 'initializing', 'last_succeeded_at' => null,
]);
$repository->shouldReceive('ready')->once()->andReturnFalse();
$repository->shouldNotReceive('queryReady');
$repository->shouldNotReceive('paginate');
$this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'refunds', 'page' => 'small-payment-work-orders',
]))
->assertOk()
->assertSeeText('业务镜像正在执行首次同步')
->assertSeeText('首次同步尚未完成,完成后将自动显示小额打款工单。')
->assertDontSeeText('当前筛选范围没有小额打款工单');
}
public function test_page_rejects_users_without_sidebar_permission(): void
{
$subject = 'user_' . $this->user->id;
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [$subject => ['company-management/basic-information']],
], JSON_UNESCAPED_UNICODE));
$this->withExceptionHandling()->loginAs()->get(route('business.pages.show', [
'section' => 'refunds', 'page' => 'small-payment-work-orders',
]))->assertForbidden();
}
}

View File

@ -90,11 +90,11 @@ class NavigationTest extends FeatureTestCase
$this->assertSame('退款', $refund['title']);
$this->assertSame('assignment_return', $refund['icon']);
$this->assertSame(
['jingxi-refund-data', 'non-jingxi-refund-data'],
['jingxi-refund-data', 'non-jingxi-refund-data', 'small-payment-work-orders'],
array_column($refund['pages'], 'slug')
);
$this->assertSame(
['京喜退款数据', '售后(退货退款)'],
['京喜退款数据', '售后(退货退款)', '小额打款'],
array_column($refund['pages'], 'title')
);
}

View File

@ -0,0 +1,239 @@
<?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\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
{
private SmallPaymentWorkOrderMirrorRepository $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' => [
SmallPaymentWorkOrderMirrorRepository::WORK_ORDER_DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_workOrderInfo',
'target' => 'fixture_work_orders', 'keys' => ['Id'], 'watermark' => 'updateTime',
'indexes' => [['woTypeId', 'woNo', 'Id']],
],
SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_small_payment_order_map',
'target' => 'fixture_payment_map', 'keys' => ['woNo'], 'watermark' => 'updated_at',
'indexes' => [['woTypeId', 'work_order_create_time', 'woNo']],
],
],
]);
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->string('status')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
$schema->create('fixture_work_orders', function (Blueprint $table): void {
$table->integer('Id');
foreach (['woNo', 'woTypeId', 'title', 'content', 'status', 'grade', 'createby', 'executors', 'completer', 'completeTime', 'created', 'updated', 'updateTime', 'businessFieldList'] as $column) {
$table->text($column)->nullable();
}
$table->integer('urging')->nullable();
$table->index(['woTypeId', 'woNo', 'Id']);
});
$schema->create('fixture_payment_map', function (Blueprint $table): void {
foreach (['woNo', 'woTypeId', 'transaction_no', 'shop_name', 'payment_reason', 'title', 'status', 'work_order_create_time', 'work_order_update_time', 'parse_status', 'parse_error', 'updated_at'] as $column) {
$table->text($column)->nullable();
}
$table->index(['woTypeId', 'work_order_create_time', 'woNo']);
});
foreach (array_keys(config('upstream_mirrors.datasets')) as $key) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $key,
'status' => 'succeeded',
'initial_completed_at' => '2026-09-01 00:00:00',
'last_succeeded_at' => '2026-09-03 12:00:00',
]);
}
$type = SmallPaymentWorkOrderMirrorRepository::SMALL_PAYMENT_WORK_TYPE_ID;
$workOrderDefaults = array_fill_keys([
'Id', 'woNo', 'woTypeId', 'title', 'content', 'status', 'grade',
'urging', 'createby', 'executors', 'completer', 'completeTime',
'created', 'updated', 'updateTime', 'businessFieldList',
], null);
$workOrderRows = [
[
'Id' => 1, 'woNo' => 'GD-001', 'woTypeId' => $type,
'title' => '小额打款一', 'content' => '售后补偿', 'status' => '已完成',
'grade' => '普通', 'urging' => 1, 'createby' => '客服甲', 'executors' => '财务甲',
'completer' => '财务甲', 'completeTime' => '2026-09-02 11:00:00',
'created' => '2026-09-02 09:00:00', 'updated' => '2026-09-02 11:00:00',
'updateTime' => '2026-09-02 11:01:00',
'businessFieldList' => json_encode([
['fieldName' => '打款状态', 'fieldValue' => '支付宝已打款'],
['fieldName' => '打款金额', 'fieldValue' => '50.005'],
['fieldName' => '线上订单号', 'fieldValue' => 'ORDER-001'],
['fieldName' => '买家账号', 'fieldValue' => 'buyer-one'],
['fieldName' => '支付宝账号', 'fieldValue' => 'account-one'],
['fieldName' => '支付宝姓名', 'fieldValue' => '测试买家'],
['fieldName' => '商品编码', 'fieldValue' => 'ITEM-001'],
['fieldName' => 'SKU编码', 'fieldValue' => 'SKU-001'],
['fieldName' => '平台', 'fieldValue' => '京东'],
['fieldName' => '实际已付金额', 'fieldValue' => '199.90'],
['fieldName' => '支付时间', 'fieldValue' => '2026-09-02 10:30:00'],
], JSON_UNESCAPED_UNICODE),
],
[
'Id' => 2, 'woNo' => 'GD-002', 'woTypeId' => $type,
'title' => '小额打款二', 'content' => '少收多收', 'status' => '处理中',
'grade' => '紧急', 'urging' => 0, 'createby' => '客服乙',
'created' => '2026-09-03 09:00:00', 'updated' => '2026-09-03 09:30:00',
'updateTime' => '2026-09-03 09:31:00',
'businessFieldList' => json_encode([
['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '18526685', 'valueLabel' => '天猫-亿家达旗舰店'],
['fieldId' => 'alipayAccountField', 'type' => 'TEXT', 'value' => 'account-two'],
['fieldId' => 'alipayNameField', 'type' => 'TEXT', 'value' => '测试买家二'],
['fieldId' => 'buyerIdField', 'type' => 'TEXT', 'value' => 'buyer-two'],
['childFieldList' => [[
['fieldId' => 'iid', 'type' => 'TEXT', 'value' => 'ITEM-002'],
['fieldId' => 'skuId', 'type' => 'TEXT', 'value' => 'SKU-002'],
]], 'fieldId' => 'itemInfo', 'type' => 'FORM'],
['fieldId' => 'actualPaidAmountField', 'type' => 'MONEY', 'value' => '188.88'],
['fieldId' => 'platform', 'type' => 'SELECT', 'value' => '淘宝天猫'],
['fieldId' => 'payDateField', 'type' => 'DATE', 'value' => '2026-09-03 08:30:00'],
['fieldId' => 'amountField', 'type' => 'MONEY', 'value' => '20'],
['fieldId' => 'orderField', 'type' => 'TEXT', 'value' => 'ORDER-002'],
], JSON_UNESCAPED_UNICODE),
],
[
'Id' => 3, 'woNo' => 'OTHER-001', 'woTypeId' => 'OTHER',
'title' => '其他工单', 'status' => '已完成',
'created' => '2026-09-03 10:00:00', 'updated' => '2026-09-03 10:00:00',
'updateTime' => '2026-09-03 10:00:00', 'businessFieldList' => '{}',
],
];
DB::connection('business_mirror')->table('fixture_work_orders')->insert(array_map(
static fn (array $row): array => array_replace($workOrderDefaults, $row),
$workOrderRows,
));
DB::connection('business_mirror')->table('fixture_payment_map')->insert([
[
'woNo' => 'GD-001', 'woTypeId' => $type, 'transaction_no' => 'BATCH-001',
'shop_name' => '京东旗舰店', 'payment_reason' => '售后补偿', 'title' => '小额打款一',
'status' => '已完成', 'work_order_create_time' => '2026-09-02 09:00:00',
'work_order_update_time' => '2026-09-02 11:00:00', 'parse_status' => 'success',
'updated_at' => '2026-09-02 11:01:00',
],
[
'woNo' => 'GD-002', 'woTypeId' => $type, 'transaction_no' => 'BATCH-002',
'shop_name' => '天猫旗舰店', 'payment_reason' => '少收多收', 'title' => '小额打款二',
'status' => '处理中', 'work_order_create_time' => '2026-09-03 09:00:00',
'work_order_update_time' => '2026-09-03 09:30:00', 'parse_status' => 'success',
'updated_at' => '2026-09-03 09:31:00',
],
[
'woNo' => 'OTHER-001', 'woTypeId' => 'OTHER', 'transaction_no' => 'BATCH-003',
'shop_name' => '其他店铺', 'payment_reason' => '其他', 'title' => '其他工单',
'status' => '已完成', 'work_order_create_time' => '2026-09-03 10:00:00',
'work_order_update_time' => '2026-09-03 10:00:00', 'parse_status' => 'success',
'updated_at' => '2026-09-03 10:00:00',
],
]);
$this->repository = new SmallPaymentWorkOrderMirrorRepository(
new BusinessMirrorDatasetRegistry(),
new GenericBusinessMirrorRepository(app('db')),
);
}
public function test_filters_paginates_and_parses_small_payment_business_fields(): void
{
$result = $this->repository->paginate($this->filters([
'shop_name' => '京东',
'online_order_no' => 'ORDER-001',
'status' => '已完成',
]), 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('50.01', $result['page_payment_amount']);
$this->assertSame('GD-001', $result['data'][0]['work_order_no']);
$this->assertSame('小额打款', $result['data'][0]['work_order_type']);
$this->assertSame('支付宝已打款', $result['data'][0]['payment_status']);
$this->assertSame('ORDER-001', $result['data'][0]['online_order_no']);
$this->assertSame('SKU-001', $result['data'][0]['sku_code']);
$this->assertSame('199.90', $result['data'][0]['actual_paid_amount']);
$this->assertArrayNotHasKey('business_field_list', $result['data'][0]);
}
public function test_stable_order_second_page_and_exact_work_type_filter(): void
{
$result = $this->repository->paginate($this->filters(), 2, 1);
$this->assertSame(2, $result['total']);
$this->assertSame('GD-001', $result['data'][0]['work_order_no']);
$this->assertSame('50.01', $result['page_payment_amount']);
}
public function test_export_uses_same_filters_and_parsed_shape(): void
{
$rows = iterator_to_array($this->repository->exportRows($this->filters([
'transaction_no' => 'BATCH-002',
'payment_reason' => '少收',
]), 1));
$this->assertCount(1, $rows);
$this->assertSame('GD-002', $rows[0]['work_order_no']);
$this->assertSame('20.00', $rows[0]['payment_amount']);
$this->assertSame('ORDER-002', $rows[0]['online_order_no']);
$this->assertSame('buyer-two', $rows[0]['buyer_account']);
$this->assertSame('account-two', $rows[0]['alipay_account']);
$this->assertSame('测试买家二', $rows[0]['alipay_name']);
$this->assertSame('ITEM-002', $rows[0]['product_code']);
$this->assertSame('SKU-002', $rows[0]['sku_code']);
$this->assertSame('淘宝天猫', $rows[0]['platform']);
$this->assertSame('188.88', $rows[0]['actual_paid_amount']);
$this->assertSame('2026-09-03 08:30:00', $rows[0]['payment_time']);
$this->assertSame('处理中', $rows[0]['payment_status']);
}
public function test_unready_state_is_explicit(): void
{
DB::connection('business_mirror')->table('business_mirror_table_states')
->where('dataset_key', SmallPaymentWorkOrderMirrorRepository::PAYMENT_MAP_DATASET)
->update(['initial_completed_at' => null, 'status' => 'running']);
$states = $this->repository->states();
$status = $this->repository->syncStatus($states);
$this->assertFalse($this->repository->ready());
$this->assertSame('initializing', $status['code']);
$this->expectException(\RuntimeException::class);
$this->repository->paginate($this->filters(), 1, 25);
}
/** @param array<string,string> $overrides @return array<string,string> */
private function filters(array $overrides = []): array
{
return array_replace([
'from' => '2026-09-01', 'to' => '2026-09-30',
'work_order_no' => '', 'shop_name' => '', 'transaction_no' => '',
'online_order_no' => '', 'status' => '', 'payment_reason' => '',
], $overrides);
}
}