对齐聚水潭小额打款完成时间和状态口径 #157

Merged
yueyang merged 1 commits from feature/yueyang-move-small-payment-menu into main 2026-09-11 11:23:21 +08:00
14 changed files with 431 additions and 85 deletions

View File

@ -107,6 +107,8 @@ class Pages extends Controller
protected const ACCOUNT_BALANCE_SECTION = 'account-balance-data';
protected const SMALL_PAYMENT_SECTION = 'financial-operations';
protected const ACCOUNT_BALANCE_PAGES = [
'bank-balance',
'store-receiving-balance',
@ -138,6 +140,16 @@ class Pages extends Controller
public function show(string $section, ?string $page = null, ?string $tab = null)
{
if ($section === 'refunds' && $page === 'small-payment-work-orders') {
$url = route('business.pages.show', [
'section' => self::SMALL_PAYMENT_SECTION,
'page' => $page,
]);
$query = request()->getQueryString();
return redirect()->to($url . ($query === null ? '' : '?' . $query));
}
if ($section === 'billing-transactions' && in_array($page, self::ACCOUNT_BALANCE_PAGES, true)) {
$url = route('business.pages.show', array_filter([
'section' => self::ACCOUNT_BALANCE_SECTION,
@ -1159,7 +1171,7 @@ class Pages extends Controller
{
$filters = $this->smallPaymentWorkOrderFilters($request);
$redirectParameters = array_filter([
'section' => 'refunds',
'section' => self::SMALL_PAYMENT_SECTION,
'page' => 'small-payment-work-orders',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);

View File

@ -36,12 +36,14 @@ class Sidebar extends Controller
$settings = SidebarPermissions::settings();
$configured = in_array($subject, $settings['configured'] ?? [], true);
$selected = $configured ? (array) ($settings['subjects'][$subject] ?? []) : SidebarPermissions::allKeys();
$selected = $configured
? SidebarPermissions::normalizeAllowedKeys((array) ($settings['subjects'][$subject] ?? []))
: SidebarPermissions::allKeys();
$source = $configured ? '当前使用单独配置。' : '未单独配置,使用系统默认完整页面。';
$canInherit = str_starts_with($subject, 'user_');
if (str_starts_with($subject, 'group_')) {
$group = UserAccessGroup::forCompany()->findOrFail(substr($subject, 6));
$selected = $group->page_permissions ?? [];
$selected = SidebarPermissions::normalizeAllowedKeys($group->page_permissions ?? []);
$configured = true;
$source = '分组权限与用户的其他分组权限合并;用户例外配置优先。新分组默认没有页面权限。';
} elseif ($canInherit && ! $configured) {

View File

@ -7,6 +7,10 @@ use App\Models\Auth\UserAccessGroup;
class SidebarPermissions
{
private const LEGACY_PAGE_KEYS = [
'refunds/small-payment-work-orders' => 'financial-operations/small-payment-work-orders',
];
public static function settings(): array
{
$value = setting('system.sidebar_permissions', []);
@ -39,12 +43,14 @@ class SidebarPermissions
$settings = self::settings();
$userSubject = 'user_' . $user->id;
if (in_array($userSubject, $settings['configured'] ?? [], true)) {
return array_values(array_unique((array) ($settings['subjects'][$userSubject] ?? [])));
return self::normalizeAllowedKeys((array) ($settings['subjects'][$userSubject] ?? []));
}
$groups = UserAccessGroup::forUser($user);
if ($groups->isNotEmpty()) {
return $groups->flatMap(fn ($group) => $group->page_permissions ?? [])->unique()->values()->all();
return self::normalizeAllowedKeys(
$groups->flatMap(fn ($group) => $group->page_permissions ?? [])->all()
);
}
$subject = 'role_' . SystemAccess::roleName($user);
@ -53,7 +59,7 @@ class SidebarPermissions
return null;
}
return array_values(array_unique((array) ($settings['subjects'][$subject] ?? [])));
return self::normalizeAllowedKeys((array) ($settings['subjects'][$subject] ?? []));
}
public static function navigationFor(User $user): array
@ -127,4 +133,20 @@ class SidebarPermissions
return $keys;
}
public static function normalizeAllowedKeys(array $keys): array
{
$normalized = [];
foreach ($keys as $key) {
$key = (string) $key;
$normalized[] = self::LEGACY_PAGE_KEYS[$key] ?? $key;
if ($key === 'refunds') {
$normalized[] = self::LEGACY_PAGE_KEYS['refunds/small-payment-work-orders'];
}
}
return array_values(array_unique($normalized));
}
}

View File

@ -430,7 +430,6 @@ $navigation = [
'pages' => [
['slug' => 'jingxi-refund-data', 'title' => '京喜退款数据'],
['slug' => 'non-jingxi-refund-data', 'title' => '售后(退货退款)'],
['slug' => 'small-payment-work-orders', 'title' => '小额打款'],
],
],
[
@ -563,6 +562,7 @@ $navigation = [
['slug' => 'advertising-expenses', 'title' => '广告费用'],
['slug' => 'marketing-expenses', 'title' => '营销费用'],
['slug' => 'after-sales-expenses', 'title' => '售后费用'],
['slug' => 'small-payment-work-orders', 'title' => '小额打款'],
['slug' => 'management-expenses', 'title' => '管理费用'],
['slug' => 'labor-expenses', 'title' => '人工费用'],
['slug' => 'bookkeeping-details', 'title' => '记账明细'],

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const INDEXES = [
'js_jst_work_order_info' => [
'idx_small_payment_completion',
['woTypeId', 'completeTime', 'woNo', 'Id'],
],
'js_jst_small_payment_order_map' => [
'idx_small_payment_order_map',
['woTypeId', 'woNo'],
],
];
public function up(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
foreach (self::INDEXES as $table => [$name, $columns]) {
if (! $schema->hasTable($table) || $schema->hasIndex($table, $name)) {
continue;
}
$schema->table($table, static fn (Blueprint $blueprint) => $blueprint->index($columns, $name));
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
foreach (self::INDEXES as $table => [$name]) {
if (! $schema->hasTable($table) || ! $schema->hasIndex($table, $name)) {
continue;
}
$schema->table($table, static fn (Blueprint $blueprint) => $blueprint->dropIndex($name));
}
}
};

View File

@ -16,7 +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`,页面只读共享镜像,首次同步完成前明确显示待同步状态。 |
| 待首次同步 | 聚水潭聚工单“小额打款”读取 `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。 |
@ -118,4 +118,4 @@ 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` 解析,导出与页面使用同一筛选口径。首次全量未完成、查询索引未就绪或刷新失败时均保留明确状态,不能把空结果解释为没有小额打款工单。
两张表完成后,页面按工单主记录的完成时间筛选并稳定分页,以 `woNo` 关联最新的工单主记录;查询依赖工单表 `(woTypeId, completeTime, woNo, Id)` 和映射表 `(woTypeId, woNo)` 两个索引。映射表的 `transaction_no` 是线上订单号后备来源,不得展示为支付宝批次号;支付宝批次号只读取表单中的明确字段。工单状态按聚水潭状态码翻译,已完成工单再结合打款方式显示打款状态。筛选支持完成日期、工单号、店铺、支付宝批次号、线上订单号、状态和打款原因;表单详情从镜像中的 `businessFieldList` 解析,导出与页面使用同一筛选口径。首次全量未完成、查询索引未就绪或刷新失败时均保留明确状态,不能把空结果解释为没有小额打款工单。

View File

@ -22,10 +22,24 @@ class SmallPaymentWorkOrderMirrorRepository
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']],
self::WORK_ORDER_DATASET => [
['woTypeId', 'woNo', 'Id'],
['woTypeId', 'completeTime', 'woNo', 'Id'],
],
self::PAYMENT_MAP_DATASET => [['woTypeId', 'woNo']],
];
private const WORK_ORDER_STATUS_LABELS = [
'WAIT_AUDIT' => '待审核',
'REJECT_AUDIT' => '审核驳回',
'WAIT_CONFIRM' => '待确认',
'CONFIRMING' => '确认中',
'CONFIRMED' => '已完成',
'DISABLED' => '已作废',
];
private const COMPLETED_WORK_ORDER_STATUSES = ['CONFIRMED', '已完成', '已确认'];
private readonly ConnectionInterface $connection;
public function __construct(
@ -147,7 +161,7 @@ class SmallPaymentWorkOrderMirrorRepository
$total = (clone $query)->count();
$rows = $query
->select($this->selectColumns())
->orderByDesc('p.work_order_create_time')
->orderByDesc('w.completeTime')
->orderByDesc('p.woNo')
->forPage($page, $perPage)
->get()
@ -177,7 +191,7 @@ class SmallPaymentWorkOrderMirrorRepository
do {
$rows = (clone $query)
->select($this->selectColumns())
->orderByDesc('p.work_order_create_time')
->orderByDesc('w.completeTime')
->orderByDesc('p.woNo')
->forPage($page, $chunkSize)
->get();
@ -201,21 +215,18 @@ class SmallPaymentWorkOrderMirrorRepository
{
$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');
return $this->connection->table($workOrders . ' AS w')
->join($paymentMap . ' AS p', static function ($join): void {
$join->on('p.woTypeId', '=', 'w.woTypeId')
->on('p.woNo', '=', 'w.woNo');
})
->join($workOrders . ' AS w', 'w.Id', '=', 'work_order_versions.Id')
// Match the leading columns of (woTypeId, woNo, Id), rather than
// rescanning all orders of this type for every payment-map row.
->whereColumn('w.woNo', 'p.woNo')
->where('p.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID);
->leftJoin($workOrders . ' AS newer_work_order', static function ($join): void {
$join->on('newer_work_order.woTypeId', '=', 'w.woTypeId')
->on('newer_work_order.woNo', '=', 'w.woNo')
->on('newer_work_order.Id', '>', 'w.Id');
})
->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID)
->whereNull('newer_work_order.Id');
}
/** @return list<string> */
@ -223,7 +234,7 @@ class SmallPaymentWorkOrderMirrorRepository
{
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.transaction_no AS mapped_online_order_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',
@ -239,8 +250,8 @@ class SmallPaymentWorkOrderMirrorRepository
/** @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'));
$query->where('w.completeTime', '>=', $filters['from'] . ' 00:00:00')
->where('w.completeTime', '<', 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'])]);
@ -249,16 +260,31 @@ class SmallPaymentWorkOrderMirrorRepository
$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'])]);
$query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['transaction_no'])]);
}
if ($filters['online_order_no'] !== '') {
$query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['online_order_no'])]);
$query->where(function (Builder $onlineOrder) use ($filters): void {
$needle = $this->contains($filters['online_order_no']);
$onlineOrder->whereRaw("p.transaction_no LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$needle]);
});
}
if ($filters['status'] !== '') {
$query->where(function (Builder $status) use ($filters): void {
$status->where('w.status', $filters['status'])
->orWhere('p.status', $filters['status'])
$statuses = $this->workOrderStatusesForFilter($filters['status']);
$status->whereIn('w.status', $statuses)
->orWhereIn('p.status', $statuses)
->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['status'])]);
$paymentMethod = $this->paymentMethodForStatus($filters['status']);
if ($paymentMethod !== null || trim($filters['status']) === '已打款') {
$status->orWhere(function (Builder $payment) use ($paymentMethod): void {
$payment->whereIn('w.status', self::COMPLETED_WORK_ORDER_STATUSES);
if ($paymentMethod !== null) {
$this->applyPaymentMethodFilter($payment, $paymentMethod);
}
});
}
});
}
if ($filters['payment_reason'] !== '') {
@ -270,20 +296,32 @@ class SmallPaymentWorkOrderMirrorRepository
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, ['打款状态', '支付打款状态', '付款状态']),
$sourceWorkOrderStatus = $this->firstFilled(
$row['work_order_status'] ?? null,
$row['mapped_status'] ?? null,
);
$row['work_order_status'] = $this->workOrderStatusLabel($sourceWorkOrderStatus);
$row['work_order_title'] = $this->firstFilled($row['work_order_title'] ?? null, $row['mapped_title'] ?? null);
$row['work_order_type'] = $this->field($fields, ['工单类型']) ?: '小额打款';
$paymentMethod = $this->field($fields, [
'打款方式', '收款方式', '支付方式', 'SELECTField1413856620250724113227',
]);
$row['payment_status'] = $this->firstFilled(
$this->field($fields, ['打款状态', '支付打款状态', '付款状态']),
$this->completedPaymentStatus($sourceWorkOrderStatus, $paymentMethod),
'未提供',
);
$row['alipay_batch_no'] = $this->field($fields, [
'支付宝批次号', '支付宝打款批次号', '打款批次号', '批次号', 'alipayBatchNo', 'batchNo',
]);
$row['payment_amount'] = $this->money($this->field($fields, [
'打款金额', '付款金额', '小额打款金额', 'amountField',
]));
$row['online_order_no'] = $this->field($fields, [
'线上订单号', '线上单号', '订单号', 'orderField',
]);
$row['online_order_no'] = $this->firstFilled(
$this->field($fields, ['线上订单号', '线上单号', '订单号', 'orderField']),
$row['mapped_online_order_no'] ?? null,
);
$row['buyer_account'] = $this->field($fields, [
'买家账号', '买家帐号', '买家', 'buyerIdField',
]);
@ -306,7 +344,12 @@ class SmallPaymentWorkOrderMirrorRepository
$row['payment_time'] = $this->field($fields, [
'支付时间', '打款时间', '付款时间', 'payDateField',
]);
unset($row['business_field_list'], $row['mapped_status'], $row['mapped_title']);
unset(
$row['business_field_list'],
$row['mapped_online_order_no'],
$row['mapped_status'],
$row['mapped_title'],
);
return $row;
}
@ -443,6 +486,63 @@ class SmallPaymentWorkOrderMirrorRepository
return '';
}
private function workOrderStatusLabel(string $status): string
{
$code = strtoupper(trim($status));
return self::WORK_ORDER_STATUS_LABELS[$code] ?? $status;
}
private function completedPaymentStatus(string $workOrderStatus, string $paymentMethod): string
{
$normalizedStatus = strtoupper(trim($workOrderStatus));
if (! in_array($normalizedStatus, self::COMPLETED_WORK_ORDER_STATUSES, true)) {
return '';
}
if (str_contains($paymentMethod, '支付宝')) {
return '支付宝已打款';
}
if (str_contains($paymentMethod, '银行卡')) {
return '银行卡已打款';
}
return '已打款';
}
private function paymentMethodForStatus(string $status): ?string
{
return match (trim($status)) {
'支付宝已打款' => '支付宝账号',
'银行卡已打款' => '银行卡',
default => null,
};
}
private function applyPaymentMethodFilter(Builder $query, string $paymentMethod): void
{
$query->where(function (Builder $fields) use ($paymentMethod): void {
foreach (['value', 'fieldValue', 'valueLabel'] as $key) {
$fields->orWhereRaw(
"w.businessFieldList LIKE ? ESCAPE '!'",
[$this->contains(sprintf('"%s":"%s"', $key, $paymentMethod))],
);
}
});
}
/** @return list<string> */
private function workOrderStatusesForFilter(string $status): array
{
$status = trim($status);
$code = array_search($status, self::WORK_ORDER_STATUS_LABELS, true);
return array_values(array_unique(array_filter([
$status,
is_string($code) ? $code : null,
], static fn (?string $value): bool => $value !== null && $value !== '')));
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';

View File

@ -116,15 +116,15 @@
<form
class="upstream-data-toolbar"
method="GET"
action="{{ route('business.pages.show', ['section' => 'refunds', 'page' => 'small-payment-work-orders']) }}"
action="{{ route('business.pages.show', ['section' => 'financial-operations', 'page' => 'small-payment-work-orders']) }}"
data-filter-contract="small-payment-work-orders.filters"
>
<div class="upstream-data-field">
<label for="small-payment-from">发起开始日期</label>
<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>
<label for="small-payment-to">完成结束日期</label>
<input id="small-payment-to" type="date" name="to" value="{{ $filters['to'] }}">
</div>
<div class="upstream-data-field">
@ -145,7 +145,7 @@
</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="例如 已完成">
<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>
@ -156,14 +156,14 @@
<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']) }}">
<a class="upstream-data-button" href="{{ route('business.pages.show', ['section' => 'financial-operations', 'page' => 'small-payment-work-orders']) }}">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<a
class="upstream-data-button finance-table-fallback-export {{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? 'is-disabled' : '' }}"
data-finance-fallback-export
href="{{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? '#' : route('business.refunds.small-payment-work-orders.export', $filters) }}"
href="{{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? '#' : route('business.financial-operations.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>
@ -188,7 +188,7 @@
<table
class="upstream-data-table small-payment-table small-payment-table--wide"
aria-label="聚水潭小额打款工单"
data-finance-export-base="{{ route('business.refunds.small-payment-work-orders.export') }}"
data-finance-export-base="{{ route('business.financial-operations.small-payment-work-orders.export') }}"
data-finance-export-query="{{ e(json_encode($filters, JSON_UNESCAPED_UNICODE)) }}"
data-finance-export-disabled="{{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? 'true' : 'false' }}"
>

View File

@ -130,8 +130,12 @@ 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/financial-operations/small-payment-work-orders/export', 'Business\Pages@exportSmallPaymentWorkOrders')
->middleware('business.page:financial-operations,small-payment-work-orders')
->name('business.financial-operations.small-payment-work-orders.export');
Route::get('workspace/refunds/small-payment-work-orders/export', 'Business\Pages@exportSmallPaymentWorkOrders')
->middleware('business.page:refunds,small-payment-work-orders')
->middleware('business.page:financial-operations,small-payment-work-orders')
->name('business.refunds.small-payment-work-orders.export');
Route::get('workspace/courier-payables/courier-information/export', 'Business\Pages@exportCourierInformation')

View File

@ -65,7 +65,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
$url = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'refunds', 'page' => 'small-payment-work-orders',
'section' => 'financial-operations', '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',
@ -79,13 +79,15 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
->assertSee('data-filter-contract="small-payment-work-orders.filters"', false)
->assertSee('data-sync-state="syncing"', false)
->assertSeeText('业务镜像同步中,当前显示最近成功数据')
->assertSeeText('完成开始日期')
->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('workspace/financial-operations/small-payment-work-orders/export', false)
->assertSee('data-finance-export-base=', false)
->assertSee('data-export-field="work_order_no"', false)
->assertSee('value="ORDER-001"', false);
@ -115,7 +117,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
]]);
$this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.refunds.small-payment-work-orders.export', [
$response = $this->loginAs()->get(route('business.financial-operations.small-payment-work-orders.export', [
'from' => '2026-09-01', 'to' => '2026-09-03',
]));
@ -146,7 +148,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
$this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'refunds', 'page' => 'small-payment-work-orders',
'section' => 'financial-operations', 'page' => 'small-payment-work-orders',
]))
->assertOk()
->assertSeeText('业务镜像正在执行首次同步')
@ -168,7 +170,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
]]);
$this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.refunds.small-payment-work-orders.export', [
$response = $this->loginAs()->get(route('business.financial-operations.small-payment-work-orders.export', [
'columns' => 'shop_name,work_order_no,not_a_real_field,shop_name',
]));
@ -190,7 +192,24 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
], JSON_UNESCAPED_UNICODE));
$this->withExceptionHandling()->loginAs()->get(route('business.pages.show', [
'section' => 'refunds', 'page' => 'small-payment-work-orders',
'section' => 'financial-operations', 'page' => 'small-payment-work-orders',
]))->assertForbidden();
}
public function test_legacy_refund_url_redirects_to_financial_operations_and_preserves_filters(): void
{
$this->grantAdminPermission();
$this->loginAs()->get(route('business.pages.show', [
'section' => 'refunds',
'page' => 'small-payment-work-orders',
'from' => '2026-09-01',
'shop_name' => '测试店铺',
]))->assertRedirect(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'small-payment-work-orders',
'from' => '2026-09-01',
'shop_name' => '测试店铺',
]));
}
}

View File

@ -57,6 +57,7 @@ class NavigationTest extends FeatureTestCase
$receivablesIndex = array_search('store-receivables', $slugs, true);
$orderData = $navigation[$orderIndex] ?? null;
$refund = $navigation[$refundIndex] ?? null;
$financialOperations = collect($navigation)->firstWhere('slug', 'financial-operations');
$this->assertNotFalse($invoiceIndex);
$this->assertNotFalse($fundIndex);
@ -125,13 +126,20 @@ class NavigationTest extends FeatureTestCase
$this->assertSame('退款数据', $refund['title']);
$this->assertSame('assignment_return', $refund['icon']);
$this->assertSame(
['jingxi-refund-data', 'non-jingxi-refund-data', 'small-payment-work-orders'],
['jingxi-refund-data', 'non-jingxi-refund-data'],
array_column($refund['pages'], 'slug')
);
$this->assertSame(
['京喜退款数据', '售后(退货退款)', '小额打款'],
['京喜退款数据', '售后(退货退款)'],
array_column($refund['pages'], 'title')
);
$this->assertNotNull($financialOperations);
$smallPayment = collect($financialOperations['pages'])->firstWhere('slug', 'small-payment-work-orders');
$this->assertSame('小额打款', $smallPayment['title'] ?? null);
$this->assertGreaterThan(
array_search('after-sales-expenses', array_column($financialOperations['pages'], 'slug'), true),
array_search('small-payment-work-orders', array_column($financialOperations['pages'], 'slug'), true)
);
}
}

View File

@ -128,6 +128,53 @@ class UserPermissionsTest extends FeatureTestCase
$this->assertSame(['subject-trademark-rules'], $pages->pluck('slug')->all());
}
public function test_legacy_small_payment_permission_follows_the_page_to_financial_operations(): void
{
$regular = $this->account('user');
$subject = 'user_'.$regular->id;
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [$subject => ['refunds/small-payment-work-orders']],
]);
$this->assertSame(
['financial-operations/small-payment-work-orders'],
SidebarPermissions::allowedKeys($regular)
);
$navigation = SidebarPermissions::navigationFor($regular);
$this->assertSame(['financial-operations'], array_column($navigation, 'slug'));
$this->assertSame(
['small-payment-work-orders'],
array_column($navigation[0]['pages'], 'slug')
);
}
public function test_legacy_refunds_section_permission_keeps_small_payment_access(): void
{
$regular = $this->account('user');
$subject = 'user_'.$regular->id;
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [$subject => ['refunds']],
]);
$this->assertSame(
['refunds', 'financial-operations/small-payment-work-orders'],
SidebarPermissions::allowedKeys($regular)
);
$navigation = SidebarPermissions::navigationFor($regular);
$this->assertSame(
['refunds', 'financial-operations'],
array_column($navigation, 'slug')
);
$this->assertSame(
['small-payment-work-orders'],
array_column($navigation[1]['pages'], 'slug')
);
}
public function test_dingtalk_bound_user_cannot_be_deleted_into_an_orphaned_identity(): void
{
$target = $this->account('user');

View File

@ -0,0 +1,52 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SmallPaymentCompletionIndexMigrationTest extends TestCase
{
public function test_indexes_are_idempotent_and_reversible_without_changing_rows(): void
{
config([
'database.connections.small_payment_index_fixture' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
],
'upstream_mirrors.connection' => 'small_payment_index_fixture',
'system_database.mode' => 'shared',
]);
$schema = Schema::connection('small_payment_index_fixture');
$db = DB::connection('small_payment_index_fixture');
$schema->create('js_jst_work_order_info', static function (Blueprint $table): void {
$table->integer('Id');
$table->string('woTypeId');
$table->string('woNo');
$table->dateTime('completeTime')->nullable();
});
$schema->create('js_jst_small_payment_order_map', static function (Blueprint $table): void {
$table->string('woTypeId');
$table->string('woNo');
});
$db->table('js_jst_work_order_info')->insert([
'Id' => 1, 'woTypeId' => 'TYPE', 'woNo' => 'GD-001', 'completeTime' => '2026-09-11 10:00:00',
]);
$db->table('js_jst_small_payment_order_map')->insert(['woTypeId' => 'TYPE', 'woNo' => 'GD-001']);
$migration = require base_path('database/migrations/2026_09_11_110000_index_small_payment_completion_queries.php');
$migration->up();
$migration->up();
$this->assertTrue($schema->hasIndex('js_jst_work_order_info', 'idx_small_payment_completion'));
$this->assertTrue($schema->hasIndex('js_jst_small_payment_order_map', 'idx_small_payment_order_map'));
$this->assertSame(1, $db->table('js_jst_work_order_info')->count());
$this->assertSame(1, $db->table('js_jst_small_payment_order_map')->count());
$migration->down();
$this->assertFalse($schema->hasIndex('js_jst_work_order_info', 'idx_small_payment_completion'));
$this->assertFalse($schema->hasIndex('js_jst_small_payment_order_map', 'idx_small_payment_order_map'));
DB::purge('small_payment_index_fixture');
}
}

View File

@ -29,12 +29,15 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
SmallPaymentWorkOrderMirrorRepository::WORK_ORDER_DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_workOrderInfo',
'target' => 'fixture_work_orders', 'keys' => ['Id'], 'watermark' => 'updateTime',
'indexes' => [['woTypeId', 'woNo', 'Id']],
'indexes' => [
['woTypeId', 'woNo', 'Id'],
['woTypeId', 'completeTime', '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']],
'indexes' => [['woTypeId', 'woNo']],
],
],
]);
@ -54,12 +57,13 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
}
$table->integer('urging')->nullable();
$table->index(['woTypeId', 'woNo', 'Id']);
$table->index(['woTypeId', 'completeTime', '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']);
$table->index(['woTypeId', 'woNo']);
});
foreach (array_keys(config('upstream_mirrors.datasets')) as $key) {
@ -80,15 +84,15 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
$workOrderRows = [
[
'Id' => 1, 'woNo' => 'GD-001', 'woTypeId' => $type,
'title' => '小额打款一', 'content' => '售后补偿', 'status' => '已完成',
'title' => '小额打款一', 'content' => '售后补偿', 'status' => 'CONFIRMED',
'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' => '支付宝已打款'],
['fieldId' => 'SELECTField1413856620250724113227', 'value' => '支付宝账号'],
['fieldName' => '支付宝批次号', 'fieldValue' => 'BATCH-001'],
['fieldName' => '打款金额', 'fieldValue' => '50.005'],
['fieldName' => '线上订单号', 'fieldValue' => 'ORDER-001'],
['fieldName' => '买家账号', 'fieldValue' => 'buyer-one'],
['fieldName' => '支付宝账号', 'fieldValue' => 'account-one'],
['fieldName' => '支付宝姓名', 'fieldValue' => '测试买家'],
@ -101,11 +105,13 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
],
[
'Id' => 2, 'woNo' => 'GD-002', 'woTypeId' => $type,
'title' => '小额打款二', 'content' => '少收多收', 'status' => '处理中',
'title' => '小额打款二', 'content' => '少收多收', 'status' => 'CONFIRMED',
'grade' => '紧急', 'urging' => 0, 'createby' => '客服乙',
'created' => '2026-09-03 09:00:00', 'updated' => '2026-09-03 09:30:00',
'completer' => '财务乙', 'completeTime' => '2026-09-03 10:00:00',
'created' => '2026-08-31 09:00:00', 'updated' => '2026-09-03 10:00:00',
'updateTime' => '2026-09-03 09:31:00',
'businessFieldList' => json_encode([
['fieldId' => 'SELECTField1413856620250724113227', 'value' => '银行卡'],
['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '18526685', 'valueLabel' => '天猫-亿家达旗舰店'],
['fieldId' => 'alipayAccountField', 'type' => 'TEXT', 'value' => 'account-two'],
['fieldId' => 'alipayNameField', 'type' => 'TEXT', 'value' => '测试买家二'],
@ -134,16 +140,16 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
));
DB::connection('business_mirror')->table('fixture_payment_map')->insert([
[
'woNo' => 'GD-001', 'woTypeId' => $type, 'transaction_no' => 'BATCH-001',
'woNo' => 'GD-001', 'woTypeId' => $type, 'transaction_no' => 'ORDER-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',
'woNo' => 'GD-002', 'woTypeId' => $type, 'transaction_no' => 'ORDER-002',
'shop_name' => '天猫旗舰店', 'payment_reason' => '少收多收', 'title' => '小额打款二',
'status' => '处理中', 'work_order_create_time' => '2026-09-03 09:00:00',
'status' => 'CONFIRMED', 'work_order_create_time' => '2026-08-31 09:00:00',
'work_order_update_time' => '2026-09-03 09:30:00', 'parse_status' => 'success',
'updated_at' => '2026-09-03 09:31:00',
],
@ -173,6 +179,8 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
$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('BATCH-001', $result['data'][0]['alipay_batch_no']);
$this->assertSame('已完成', $result['data'][0]['work_order_status']);
$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']);
@ -193,23 +201,44 @@ class SmallPaymentWorkOrderMirrorRepositoryTest extends TestCase
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' => '少收',
'transaction_no' => 'BATCH-001',
'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']);
$this->assertSame('GD-001', $rows[0]['work_order_no']);
$this->assertSame('BATCH-001', $rows[0]['alipay_batch_no']);
$this->assertSame('ORDER-001', $rows[0]['online_order_no']);
$this->assertSame('已完成', $rows[0]['work_order_status']);
$this->assertSame('支付宝已打款', $rows[0]['payment_status']);
}
public function test_completion_date_and_chinese_status_follow_upstream_semantics(): void
{
$result = $this->repository->paginate($this->filters([
'from' => '2026-09-03',
'to' => '2026-09-03',
'status' => '已完成',
]), 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('GD-002', $result['data'][0]['work_order_no']);
$this->assertSame('2026-08-31 09:00:00', $result['data'][0]['work_order_created_at']);
$this->assertSame('2026-09-03 10:00:00', $result['data'][0]['completed_at']);
$this->assertSame('已完成', $result['data'][0]['work_order_status']);
$this->assertSame('银行卡已打款', $result['data'][0]['payment_status']);
$this->assertSame('', $result['data'][0]['alipay_batch_no']);
}
public function test_derived_payment_status_filter_matches_payment_method_field(): void
{
$result = $this->repository->paginate($this->filters([
'status' => '支付宝已打款',
]), 1, 25);
$this->assertSame(1, $result['total']);
$this->assertSame('GD-001', $result['data'][0]['work_order_no']);
$this->assertSame('支付宝已打款', $result['data'][0]['payment_status']);
}
public function test_unready_state_is_explicit(): void