diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 6e84c8e2..a1f35b66 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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); diff --git a/app/Http/Controllers/SystemSettings/Sidebar.php b/app/Http/Controllers/SystemSettings/Sidebar.php index 4563d8d1..c9bcf4d3 100644 --- a/app/Http/Controllers/SystemSettings/Sidebar.php +++ b/app/Http/Controllers/SystemSettings/Sidebar.php @@ -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) { diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 40c45c90..6c1e2c40 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -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)); + } } diff --git a/config/business_navigation.php b/config/business_navigation.php index 9f4d9c72..1855f019 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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' => '记账明细'], diff --git a/database/migrations/2026_09_11_110000_index_small_payment_completion_queries.php b/database/migrations/2026_09_11_110000_index_small_payment_completion_queries.php new file mode 100644 index 00000000..7dcc45ac --- /dev/null +++ b/database/migrations/2026_09_11_110000_index_small_payment_completion_queries.php @@ -0,0 +1,51 @@ + [ + '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)); + } + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index fa18a5e5..1a6fb885 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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` 解析,导出与页面使用同一筛选口径。首次全量未完成、查询索引未就绪或刷新失败时均保留明确状态,不能把空结果解释为没有小额打款工单。 diff --git a/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php b/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php index 27e307ed..74a6dd21 100644 --- a/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/SmallPaymentWorkOrderMirrorRepository.php @@ -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 */ @@ -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 $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 */ + 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) . '%'; diff --git a/resources/views/business/pages/refunds/small-payment-work-orders.blade.php b/resources/views/business/pages/refunds/small-payment-work-orders.blade.php index f7e6129f..f7f462b5 100644 --- a/resources/views/business/pages/refunds/small-payment-work-orders.blade.php +++ b/resources/views/business/pages/refunds/small-payment-work-orders.blade.php @@ -116,15 +116,15 @@
- +
- +
@@ -145,7 +145,7 @@
- +
@@ -156,14 +156,14 @@ 查询 - + 重置 total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending) aria-disabled="true" @endif > @@ -188,7 +188,7 @@ diff --git a/routes/admin.php b/routes/admin.php index 557b9342..5a9b34bc 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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') diff --git a/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php b/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php index 232a9040..d5b811b1 100644 --- a/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php +++ b/tests/Feature/Business/SmallPaymentWorkOrderPageTest.php @@ -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' => '测试店铺', + ])); + } } diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index a9839ad0..853558cc 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -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) + ); } } diff --git a/tests/Feature/SystemSettings/UserPermissionsTest.php b/tests/Feature/SystemSettings/UserPermissionsTest.php index 3fd2c92d..0873e4ab 100644 --- a/tests/Feature/SystemSettings/UserPermissionsTest.php +++ b/tests/Feature/SystemSettings/UserPermissionsTest.php @@ -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'); diff --git a/tests/Unit/EcommerceFinance/SmallPaymentCompletionIndexMigrationTest.php b/tests/Unit/EcommerceFinance/SmallPaymentCompletionIndexMigrationTest.php new file mode 100644 index 00000000..ec98b40d --- /dev/null +++ b/tests/Unit/EcommerceFinance/SmallPaymentCompletionIndexMigrationTest.php @@ -0,0 +1,52 @@ + [ + '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'); + } +} diff --git a/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php index 9068bd75..c1acea5c 100644 --- a/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/SmallPaymentWorkOrderMirrorRepositoryTest.php @@ -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