diff --git a/src/Payment/AfterSaleService.php b/src/Payment/AfterSaleService.php index 54e11b3e..db3846ca 100644 --- a/src/Payment/AfterSaleService.php +++ b/src/Payment/AfterSaleService.php @@ -149,12 +149,23 @@ final class AfterSaleService { try { $order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]); - $active = $this->find_active($order_number); - if ($active) { - if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) { - return $active; + // 以「最新一条工单」为准,而不是只看进行中的:已完结的工单同样要挡住再次申请。 + // 订单有没有另标「已退款」不影响这条——工单 completed 即售后已终结。 + $latest = $this->latest_for_order($order_number); + if ($latest !== null) { + $latest_status = (string) ($latest['status'] ?? ''); + // 待审核 / 处理中:工单仍在流转,挡住重复申请;同人同因视为重复提交,幂等返回原工单。 + if (in_array($latest_status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) { + if ((int) ($latest['user_id'] ?? 0) === $user_id && hash_equals((string) ($latest['reason'] ?? ''), $reason)) { + return $latest; + } + return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]); } - return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]); + // 已完成:售后已终结,不再受理新的申请。 + if ($latest_status === self::STATUS_COMPLETED) { + return new \WP_Error('aksrt_after_sale_exists', __('该订单的售后申请已完结,不能再次申请', 'aksrt'), ['status' => 409]); + } + // rejected / cancelled:允许补充资料后再次发起。 } $amount = $this->refund_amount($order); if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]); diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php index 4ebe86c0..11611397 100644 --- a/src/Payment/UserOrders.php +++ b/src/Payment/UserOrders.php @@ -293,13 +293,16 @@ class UserOrders { // - completed:退款已完成,工单终结,不允许再次申请。 $reapplyable_statuses = [AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED]; $latest_status = (string) ($latest['status'] ?? ''); + // 有工单时只有 rejected / cancelled 才放行再次申请:completed 的工单已终结, + // 订单有没有另标「已退款」都不影响——AfterSaleService::create() 按同一口径拦截。 + $reapplyable = !$has_application || in_array($latest_status, $reapplyable_statuses, true); return [ 'enabled' => $enabled, 'expired' => (bool) $expired, 'has_application' => $has_application, 'open' => $open, - 'can_apply' => $enabled && !$expired && !$open, + 'can_apply' => $enabled && !$expired && !$open && $reapplyable, 'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true), 'latest' => $has_application ? [ 'status' => (string) ($latest['status'] ?? ''), diff --git a/tests/widget-system/after-sale-log-harness.php b/tests/widget-system/after-sale-log-harness.php index b4972e9d..13b29849 100644 --- a/tests/widget-system/after-sale-log-harness.php +++ b/tests/widget-system/after-sale-log-harness.php @@ -214,6 +214,40 @@ namespace { && $broken === [], 'detail' => ['normalized' => $normalized, 'broken_json' => $broken]]; + // 6) 再次申请的闸门看「最新一条工单」,不是只看进行中的:completed 一律挡住, + // rejected / cancelled 放行;订单有没有另标已退款不影响这条。 + $gate = static function (string $status) use ($service, $wpdb, $order) { + $wpdb->rows = []; + $wpdb->rows[950] = ['id' => 950, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => $status, + 'reason' => '旧申请', 'handling_log' => wp_json_encode([['status' => $status, 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:00:00']]), + 'created_at' => '2026-10-01 10:00:00']; + return $service->create(7, $order['order_number'], '新的申请原因'); + }; + $completed = $gate('completed'); + $rejected = $gate('rejected'); + $cancelled = $gate('cancelled'); + $results[] = ['name' => 'completed_ticket_blocks_reapply', 'ok' => $completed instanceof \WP_Error + && $completed->get_error_code() === 'aksrt_after_sale_exists' + && !$rejected instanceof \WP_Error + && !$cancelled instanceof \WP_Error, + 'detail' => [ + 'completed' => $completed instanceof \WP_Error ? $completed->get_error_code() : 'created', + 'rejected' => $rejected instanceof \WP_Error ? $rejected->get_error_code() : 'created', + 'cancelled' => $cancelled instanceof \WP_Error ? $cancelled->get_error_code() : 'created', + ]]; + + // 待审核:同人同因幂等返回原工单,换原因则挡住 + $wpdb->rows = []; + $wpdb->rows[951] = ['id' => 951, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending', + 'reason' => '同一个原因', 'handling_log' => '[]', 'created_at' => '2026-10-01 10:00:00']; + $same = $service->create(7, $order['order_number'], '同一个原因'); + $other = $service->create(7, $order['order_number'], '换了个原因'); + $results[] = ['name' => 'open_ticket_blocks_duplicate_apply', 'ok' => is_array($same) + && (int) $same['id'] === 951 + && $other instanceof \WP_Error + && $other->get_error_code() === 'aksrt_after_sale_exists', + 'detail' => ['same' => is_array($same) ? (int) $same['id'] : 'error', 'other' => $other instanceof \WP_Error ? $other->get_error_code() : 'created']]; + return $results; } diff --git a/tests/widget-system/after-sale-policy-contract.test.cjs b/tests/widget-system/after-sale-policy-contract.test.cjs index 05d292fd..07478626 100644 --- a/tests/widget-system/after-sale-policy-contract.test.cjs +++ b/tests/widget-system/after-sale-policy-contract.test.cjs @@ -233,7 +233,9 @@ test('order view model exposes the after-sale button matrix', () => { assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/) assert.match(userOrders, /'has_application' => \$has_application,/) assert.match(userOrders, /'open'\s*=> \$open,/) - assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/) + // completed 的工单已终结,订单没另标「已退款」也不能再申请 + assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open && \$reapplyable,/) + assert.match(userOrders, /\$reapplyable = !\$has_application \|\| in_array\(\$latest_status, \$reapplyable_statuses, true\);/) // 可再次申请售后按工单状态推理:rejected(被拒绝)/ cancelled(已取消)允许再次申请; // pending / processing / approved(流转中)与 completed(已完结)不允许 assert.match(userOrders, /\$reapplyable_statuses = \[AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED\];/) @@ -503,6 +505,8 @@ test('handling-log behaviour is covered against the real service', () => { 'cancel_appends_user_entry', 'log_is_capped_at_50', 'normalize_drops_malformed_entries', + 'completed_ticket_blocks_reapply', + 'open_ticket_blocks_duplicate_apply', ]) { assert.ok(names.includes(name), `missing handling-log scenario: ${name}`) } diff --git a/tests/widget-system/order-after-sale-assets-contract.test.cjs b/tests/widget-system/order-after-sale-assets-contract.test.cjs index 3a736c4d..4dae153d 100644 --- a/tests/widget-system/order-after-sale-assets-contract.test.cjs +++ b/tests/widget-system/order-after-sale-assets-contract.test.cjs @@ -62,7 +62,16 @@ test('after-sale submissions are order-locked and admins set status directly', ( const column = block.slice(0, block.indexOf('ENGINE=InnoDB')) assert.ok(!column.includes(dead), `${dead} column must be removed from aksrt_order_aftersale`) } - assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?find_active\(\$order_number\)/) + // create() 的重复申请闸门看「最新一条工单」而不是只看进行中的:已完结同样要挡 + assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?latest_for_order\(\$order_number\)/) + assert.ok(afterSale.includes("return new \\WP_Error('aksrt_after_sale_exists', __('该订单的售后申请已完结,不能再次申请', 'aksrt'), ['status' => 409]);"), + 'completed ticket must block a new application') + // 待审核 / 处理中仍走同人同因幂等,rejected / cancelled 放行再次发起 + assert.match(afterSale, /in_array\(\$latest_status, \[self::STATUS_PENDING, self::STATUS_PROCESSING\], true\)/) + assert.match(afterSale, /hash_equals\(\(string\) \(\$latest\['reason'\] \?\? ''\), \$reason\)/) + // 只看 create() 方法体,避免跨方法匹配到 cancel_for_user() 里的 find_active + const createBody = afterSale.slice(afterSale.indexOf('public function create('), afterSale.indexOf('private function sanitize_images(')) + assert.ok(!createBody.includes('find_active'), 'create must not gate on find_active alone') assert.match(afterSale, /Order::STATUS_PAID/) assert.match(afterSale, /public function set_status\(int \$id, string \$status, string \$note = ''\)/) assert.match(afterSale, /in_array\(\$status, \[self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED\], true\)/)