fix(after-sale): 售后工单已完结仍可再次申请,改由订单是否已退款决定

上一个提交把「工单 completed」当成了不能再申请售后的判据,方向搞反了:
能否再售后应该看订单有没有被标「已退款」,而不是看工单状态。订单已退款时
status_group 变成 refunded、enabled 直接为 false,那道闸门本来就够用;用工单
状态再判一次,反而把「工单已完结但订单没退款」的用户堵死,售后详细弹窗底部
也只剩下一个「我知道了」死胡同。

- reapplyable_statuses 补上 STATUS_COMPLETED:rejected / cancelled / completed
  一律放行再次申请,售后详细弹窗底部按钮对已完成订单渲染「再次申请售后」
- can_apply 还原为只看有没有进行中的工单(!open),不再二次校验工单状态
- create() 的重复申请闸门还原为 find_active():只挡仍在流转的工单,
  completed 不挡;订单已退款由上面的 STATUS_PAID 校验先挡掉
- 点「再次申请售后」后清 has_application 落回申请表单,提交走 create()
  新建工单,链路本来就通,无需改动

harness 场景名改为 settled_ticket_still_allows_reapply,断言 completed /
rejected / cancelled 三种已终结状态都能新建工单;待审核工单同因幂等、换因
挡住的场景保留。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;after-sale-log-harness
7/7。未做浏览器视觉验收。
This commit is contained in:
2026-10-09 18:38:18 +08:00
parent c955fd35f2
commit 1875470ac7
5 changed files with 33 additions and 42 deletions
+7 -16
View File
@@ -149,23 +149,14 @@ final class AfterSaleService {
try { try {
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); $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]); if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
// 以「最新一条工单」为准,而不是只看进行中的:已完结的工单同样要挡住再次申请。 // 只挡「仍在流转」的工单。已完结(completed)的工单不挡——能否再售后由
// 订单有没有另标「已退款」不影响这条——工单 completed 即售后已终结。 // 「订单是否已退款」决定,而订单已退款时上面的 STATUS_PAID 校验已先挡掉。
$latest = $this->latest_for_order($order_number); $active = $this->find_active($order_number);
if ($latest !== null) { if ($active) {
$latest_status = (string) ($latest['status'] ?? ''); if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
// 待审核 / 处理中:工单仍在流转,挡住重复申请;同人同因视为重复提交,幂等返回原工单。 return $active;
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); $amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]); if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
+9 -6
View File
@@ -290,19 +290,22 @@ class UserOrders {
// - pending / processing:工单仍在流转(待审核 / 处理中),不允许重复申请; // - pending / processing:工单仍在流转(待审核 / 处理中),不允许重复申请;
// - rejected:审核未通过,允许补充资料后再次申请; // - rejected:审核未通过,允许补充资料后再次申请;
// - cancelled:用户自行取消,允许重新发起; // - cancelled:用户自行取消,允许重新发起;
// - completed:退款已完成,工单终结,不允许再次申请。 // - completed:工单已完结,但只要订单没被另标「已退款」(enabled 仍为 true),
$reapplyable_statuses = [AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED]; // 依旧允许再次申请。能否再售后由「订单是否已退款」决定,不由工单状态决定:
// 订单已退款时 $group 变成 refunded、enabled 直接为 false,上面那条自然挡死。
$latest_status = (string) ($latest['status'] ?? ''); $latest_status = (string) ($latest['status'] ?? '');
// 有工单时只有 rejected / cancelled 才放行再次申请:completed 的工单已终结, $reapplyable_statuses = [
// 订单有没有另标「已退款」都不影响——AfterSaleService::create() 按同一口径拦截。 AfterSaleService::STATUS_REJECTED,
$reapplyable = !$has_application || in_array($latest_status, $reapplyable_statuses, true); AfterSaleService::STATUS_CANCELLED,
AfterSaleService::STATUS_COMPLETED,
];
return [ return [
'enabled' => $enabled, 'enabled' => $enabled,
'expired' => (bool) $expired, 'expired' => (bool) $expired,
'has_application' => $has_application, 'has_application' => $has_application,
'open' => $open, 'open' => $open,
'can_apply' => $enabled && !$expired && !$open && $reapplyable, 'can_apply' => $enabled && !$expired && !$open,
'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true), 'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true),
'latest' => $has_application ? [ 'latest' => $has_application ? [
'status' => (string) ($latest['status'] ?? ''), 'status' => (string) ($latest['status'] ?? ''),
@@ -214,8 +214,8 @@ namespace {
&& $broken === [], && $broken === [],
'detail' => ['normalized' => $normalized, 'broken_json' => $broken]]; 'detail' => ['normalized' => $normalized, 'broken_json' => $broken]];
// 6) 再次申请的闸门看「最新一条工单」,不是只看进行中的:completed 一律挡住, // 6) 再次申请的闸门只看「进行中」的工单:completed / rejected / cancelled 都放行,
// rejected / cancelled 放行;订单有没有另标已退款不影响这条。 // 订单有没有另标已退款不影响这条(订单已退款时 create() 的 STATUS_PAID 校验先挡)。
$gate = static function (string $status) use ($service, $wpdb, $order) { $gate = static function (string $status) use ($service, $wpdb, $order) {
$wpdb->rows = []; $wpdb->rows = [];
$wpdb->rows[950] = ['id' => 950, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => $status, $wpdb->rows[950] = ['id' => 950, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => $status,
@@ -226,8 +226,7 @@ namespace {
$completed = $gate('completed'); $completed = $gate('completed');
$rejected = $gate('rejected'); $rejected = $gate('rejected');
$cancelled = $gate('cancelled'); $cancelled = $gate('cancelled');
$results[] = ['name' => 'completed_ticket_blocks_reapply', 'ok' => $completed instanceof \WP_Error $results[] = ['name' => 'settled_ticket_still_allows_reapply', 'ok' => !$completed instanceof \WP_Error
&& $completed->get_error_code() === 'aksrt_after_sale_exists'
&& !$rejected instanceof \WP_Error && !$rejected instanceof \WP_Error
&& !$cancelled instanceof \WP_Error, && !$cancelled instanceof \WP_Error,
'detail' => [ 'detail' => [
@@ -233,12 +233,13 @@ test('order view model exposes the after-sale button matrix', () => {
assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/) assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/)
assert.match(userOrders, /'has_application' => \$has_application,/) assert.match(userOrders, /'has_application' => \$has_application,/)
assert.match(userOrders, /'open'\s*=> \$open,/) assert.match(userOrders, /'open'\s*=> \$open,/)
// completed 的工单已终结,订单没另标「已退款」也不能再申请 // can_apply 只问「有没有进行中的工单」:completed 不挡,能否再售后由订单是否已退款决定
assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open && \$reapplyable,/) assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/)
assert.match(userOrders, /\$reapplyable = !\$has_application \|\| in_array\(\$latest_status, \$reapplyable_statuses, true\);/) // 可再次申请售后按工单状态推理:rejected / cancelled / completed 都允许再次申请;
// 可再次申请售后按工单状态推理:rejected(被拒绝)/ cancelled(已取消)允许再次申请; // pending / processing / approved(流转中)不允许。订单已退款时 enabled 直接为 false,
// pending / processing / approved(流转中)与 completed(已完结)不允许 // 上面那条自然挡死,不需要工单状态再判一次。
assert.match(userOrders, /\$reapplyable_statuses = \[AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED\];/) assert.match(userOrders, /\$reapplyable_statuses = \[\s*AfterSaleService::STATUS_REJECTED,\s*AfterSaleService::STATUS_CANCELLED,\s*AfterSaleService::STATUS_COMPLETED,\s*\];/)
assert.ok(!userOrders.includes('$reapplyable ='), 'can_apply must not re-check the ticket status')
assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && \$has_application && in_array\(\$latest_status, \$reapplyable_statuses, true\),/) assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && \$has_application && in_array\(\$latest_status, \$reapplyable_statuses, true\),/)
assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/) assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/)
assert.match(userOrders, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/) assert.match(userOrders, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/)
@@ -505,7 +506,7 @@ test('handling-log behaviour is covered against the real service', () => {
'cancel_appends_user_entry', 'cancel_appends_user_entry',
'log_is_capped_at_50', 'log_is_capped_at_50',
'normalize_drops_malformed_entries', 'normalize_drops_malformed_entries',
'completed_ticket_blocks_reapply', 'settled_ticket_still_allows_reapply',
'open_ticket_blocks_duplicate_apply', 'open_ticket_blocks_duplicate_apply',
]) { ]) {
assert.ok(names.includes(name), `missing handling-log scenario: ${name}`) assert.ok(names.includes(name), `missing handling-log scenario: ${name}`)
@@ -63,15 +63,12 @@ test('after-sale submissions are order-locked and admins set status directly', (
assert.ok(!column.includes(dead), `${dead} column must be removed from aksrt_order_aftersale`) assert.ok(!column.includes(dead), `${dead} column must be removed from aksrt_order_aftersale`)
} }
// create() 的重复申请闸门看「最新一条工单」而不是只看进行中的:已完结同样要挡 // create() 的重复申请闸门看「最新一条工单」而不是只看进行中的:已完结同样要挡
assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?latest_for_order\(\$order_number\)/) // 只挡仍在流转的工单:已完结(completed)不挡,能否再售后由订单是否已退款决定
assert.ok(afterSale.includes("return new \\WP_Error('aksrt_after_sale_exists', __('该订单的售后申请已完结,不能再次申请', 'aksrt'), ['status' => 409]);"), assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?find_active\(\$order_number\)/)
'completed ticket must block a new application') // 待审核 / 处理中:同人同因幂等返回原工单
// 待审核 / 处理中仍走同人同因幂等,rejected / cancelled 放行再次发起 assert.match(afterSale, /hash_equals\(\(string\) \(\$active\['reason'\] \?\? ''\), \$reason\)/)
assert.match(afterSale, /in_array\(\$latest_status, \[self::STATUS_PENDING, self::STATUS_PROCESSING\], true\)/) // 订单已退款走 STATUS_PAID 校验挡掉,工单 completed 不参与这道闸门
assert.match(afterSale, /hash_equals\(\(string\) \(\$latest\['reason'\] \?\? ''\), \$reason\)/) assert.ok(!afterSale.includes('该订单的售后申请已完结,不能再次申请'), 'completed-block copy must be removed')
// 只看 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, /Order::STATUS_PAID/)
assert.match(afterSale, /public function set_status\(int \$id, string \$status, string \$note = ''\)/) 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\)/) assert.match(afterSale, /in_array\(\$status, \[self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED\], true\)/)