refactor(after-sale): replace review decisions with direct status panel

售后面板改为状态下拉(待审核/处理中/已完成/已拒绝/已取消)加确认/取消按钮:取消仅收起手风琴,确认为纯状态更新;删除已通过状态、review() 决策机制及其专用退款流水线,无双路无迁移;举证图片并入申请说明列与详情整行。
This commit is contained in:
2026-10-08 11:05:08 +08:00
parent 80373746f8
commit 88669f6989
6 changed files with 101 additions and 496 deletions
+52 -104
View File
@@ -30,8 +30,7 @@ class PayOrders {
add_action('admin_post_aksrt_pay_update_order_note', [$this, 'handle_update_order_note']);
add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']);
add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']);
add_action('admin_post_aksrt_after_sale_review', [$this, 'handle_after_sale_review']);
add_action('admin_post_aksrt_after_sale_bulk', [$this, 'handle_after_sale_bulk']);
add_action('admin_post_aksrt_after_sale_update', [$this, 'handle_after_sale_status']);
}
public function render_orders_page(): void {
@@ -359,7 +358,7 @@ class PayOrders {
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
if ($after_sales) {
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
$status_labels = ['pending' => '待审核', 'processing' => '处理中', 'approved' => '已通过', 'rejected' => '已拒绝', 'cancelled' => '已取消', 'completed' => '已完成'];
$status_labels = ['pending' => '待审核', 'processing' => '处理中', 'completed' => '已完成', 'rejected' => '已拒绝', 'cancelled' => '已取消'];
foreach ($after_sales as $application) {
echo '<p><strong>' . esc_html($status_labels[(string) ($application['status'] ?? '')] ?? (string) ($application['status'] ?? '')) . '</strong> · ' . esc_html((string) ($application['refund_amount'] ?? '')) . '<br>' . esc_html((string) ($application['reason'] ?? ''));
if (!empty($application['admin_note'])) echo '<br>' . esc_html__('处理备注:', 'aksrt') . esc_html((string) $application['admin_note']);
@@ -399,7 +398,7 @@ class PayOrders {
$items = $service->admin_list($filter_args + ['limit' => 100]);
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_AFTER_SALES_SLUG) . '" />';
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
foreach ([\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'] as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
foreach ([\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消'] as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
@@ -414,18 +413,12 @@ class PayOrders {
. '.aksrt-as-detail .aksrt-as-thumb{width:80px;height:80px;}'
. '.aksrt-as-review{margin-top:12px;padding-top:10px;border-top:1px solid #dcdcde;max-width:720px;}'
. '.aksrt-as-review textarea{width:100%;box-sizing:border-box;margin:4px 0 8px;}'
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;}'
. '.aksrt-qa-danger{color:#b32d2e;border-color:#b32d2e;}'
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;}'
. '.aksrt-as-field--wide{grid-column:1/-1;}'
. '</style>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" id="aksrt-after-sale-bulk-form">';
echo '<input type="hidden" name="action" value="aksrt_after_sale_bulk" />'; wp_nonce_field('aksrt_after_sale_bulk');
echo '<input type="hidden" name="bulk_note" id="aksrt-bulk-note" value="" />';
echo '<div class="tablenav top"><div class="alignleft actions bulkactions">';
echo '<select name="bulk_decision" id="aksrt-bulk-decision"><option value="">' . esc_html__('批量操作', 'aksrt') . '</option><option value="approve">' . esc_html__('批量通过', 'aksrt') . '</option><option value="reject">' . esc_html__('批量拒绝', 'aksrt') . '</option></select> ';
echo '<button type="submit" class="button action">' . esc_html__('应用', 'aksrt') . '</button></div><div class="tablenav-spacer" style="clear:both;"></div></div>';
echo '<table class="widefat striped"><thead><tr><th class="check-column"><input type="checkbox" class="aksrt-select-all" /></th><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>申请资料</th><th>申请说明</th><th>举证图片</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="11">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
$status_labels = [\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'];
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>申请资料</th><th>申请说明</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="9">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
$status_labels = [\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消'];
foreach ($items as $item) {
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
$user = $uid > 0 ? get_userdata($uid) : null;
@@ -446,19 +439,20 @@ class PayOrders {
}
$materials_cell = $material_lines ? implode('<br>', $material_lines) : '—';
$desc_text = trim((string) ($apply_form['description'] ?? ''));
$desc_cell = $desc_text !== '' ? nl2br(esc_html($desc_text)) : '—';
} else {
// 老数据:融合串原样展示在原因列
$reason_cell = nl2br(esc_html((string) $item['reason']));
$materials_cell = '—';
$desc_cell = '—';
$desc_text = '';
}
$image_urls = array_values(array_filter((array) ($item['images'] ?? []), 'is_string'));
$thumbs = [];
foreach ($image_urls as $url) {
$thumbs[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener"><img src="' . esc_url($url) . '" alt="" class="aksrt-as-thumb" loading="lazy" /></a>';
}
$images_cell = $thumbs ? implode('', $thumbs) : '—';
// 举证图片并入申请说明:缩略图展示在描述文字下方,不再单独成列。
$desc_cell = ($desc_text !== '' ? nl2br(esc_html($desc_text)) : '') . ($thumbs ? '<div class="aksrt-as-desc-thumbs" style="margin-top:6px;">' . implode('', $thumbs) . '</div>' : '');
if ($desc_cell === '') $desc_cell = '—';
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
$admin_note = (string) ($item['admin_note'] ?? '');
$gateway_text = '';
@@ -466,32 +460,19 @@ class PayOrders {
$result_data = json_decode((string) $item['gateway_result'], true);
$gateway_text = is_array($result_data) ? implode(' · ', array_filter([(string) ($result_data['status'] ?? ''), (string) ($result_data['message'] ?? ''), !empty($result_data['refund_id']) ? '退款单号 ' . (string) $result_data['refund_id'] : ''])) : (string) $item['gateway_result'];
}
// 按状态决定该记录可执行的审核动作(经 JS 以 POST 提交)
$quick_actions = [];
if ((string) $item['status'] === 'pending') {
$quick_actions = ['approve' => __('通过', 'aksrt'), 'reject' => __('拒绝', 'aksrt')];
} elseif ((string) $item['status'] === 'processing') {
$gateway_result = json_decode((string) ($item['gateway_result'] ?? ''), true);
$manual_refund = is_array($gateway_result) && sanitize_key((string) ($gateway_result['status'] ?? '')) === 'manual';
$quick_actions = $manual_refund
? ['complete' => __('确认线下退款完成', 'aksrt'), 'reject' => __('确认未退款并拒绝', 'aksrt')]
: ['approve' => __('同步退款状态', 'aksrt')];
}
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与审核表单
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与状态面板
echo '<tr class="aksrt-as-row">';
echo '<td class="check-column"><input type="checkbox" class="aksrt-row-check" name="after_sale_ids[]" value="' . esc_attr((string) $id) . '" /></td>';
echo '<td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td>';
echo '<td><a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a><br><span class="description">' . esc_html($type_label) . ($product_name !== '' ? ' · ' . esc_html($product_name) : '') . '</span></td>';
echo '<td>' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '</td>';
echo '<td>' . $reason_cell . '</td>';
echo '<td>' . $materials_cell . '</td>';
echo '<td>' . $desc_cell . '</td>';
echo '<td>' . $images_cell . '</td>';
echo '<td>' . esc_html($refund_amount) . '</td>';
echo '<td>' . esc_html($status_label) . '</td>';
echo '<td><a href="#" class="aksrt-toggle-row-actions" data-target="aksrt-as-detail-' . esc_attr((string) $id) . '" aria-expanded="false" aria-controls="aksrt-as-detail-' . esc_attr((string) $id) . '">' . esc_html__('操作', 'aksrt') . '</a></td>';
echo '</tr>';
echo '<tr class="aksrt-as-detail-row" id="aksrt-as-detail-' . esc_attr((string) $id) . '" hidden><td colspan="11">';
echo '<tr class="aksrt-as-detail-row" id="aksrt-as-detail-' . esc_attr((string) $id) . '" hidden><td colspan="9">';
$detail_fields = [
__('订单号', 'aksrt') => '<a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a>',
__('商品', 'aksrt') => esc_html($type_label . ($product_name !== '' ? ' · ' . $product_name : '')),
@@ -501,8 +482,6 @@ class PayOrders {
__('当前状态', 'aksrt') => esc_html($status_label),
__('售后原因', 'aksrt') => $reason_cell,
__('申请资料', 'aksrt') => $materials_cell,
__('申请说明', 'aksrt') => $desc_cell,
__('举证图片', 'aksrt') => $images_cell,
__('处理备注', 'aksrt') => $admin_note !== '' ? esc_html($admin_note) : '—',
__('渠道结果', 'aksrt') => $gateway_text !== '' ? '<code>' . esc_html($gateway_text) . '</code>' : '—',
__('审核时间', 'aksrt') => !empty($item['reviewed_at']) ? esc_html((string) $item['reviewed_at']) : '—',
@@ -511,110 +490,79 @@ class PayOrders {
foreach ($detail_fields as $field_label => $field_value) {
echo '<span class="aksrt-as-field"><span class="aksrt-as-label">' . esc_html($field_label) . '</span><span class="aksrt-as-value">' . $field_value . '</span></span>';
}
// 举证图片并入申请说明,单独占整行展示。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('申请说明', 'aksrt') . '</span><span class="aksrt-as-value">' . $desc_cell . '</span></span>';
echo '</div>';
if ($quick_actions) {
$status_options = '';
$current_status = (string) $item['status'];
foreach ($status_labels as $key => $label) {
$status_options .= '<option value="' . esc_attr($key) . '"' . selected($current_status, $key, false) . '>' . esc_html($label) . '</option>';
}
if (!isset($status_labels[$current_status])) {
// 历史状态(如已删除的 approved)不在可选项内:如实呈现且禁用,防止误提交。
$status_options .= '<option value="' . esc_attr($current_status) . '" selected="selected" disabled>' . esc_html($current_status) . '</option>';
}
echo '<div class="aksrt-as-review">';
echo '<p style="margin:0;"><strong>' . esc_html__('审核操作', 'aksrt') . '</strong> <span class="description">' . esc_html__('处理备注可留空,将记为管理员备注。', 'aksrt') . '</span></p>';
echo '<p style="margin:0;"><strong>' . esc_html__('售后状态', 'aksrt') . '</strong> <span class="description">' . esc_html__('选择目标状态后点击确认;取消仅收起面板。', 'aksrt') . '</span></p>';
echo '<p style="margin:4px 0;"><select class="aksrt-status-select">' . $status_options . '</select></p>';
echo '<textarea class="aksrt-review-note" rows="2" placeholder="' . esc_attr__('填写处理备注(可留空)', 'aksrt') . '"></textarea>';
echo '<div class="aksrt-as-review-actions">';
foreach ($quick_actions as $decision => $label) {
echo '<button type="button" class="button button-small aksrt-after-sale-quick-action' . ($decision === 'reject' ? ' aksrt-qa-danger' : ' button-primary') . '"'
echo '<button type="button" class="button aksrt-after-sale-cancel">' . esc_html__('取消', 'aksrt') . '</button>';
echo '<button type="button" class="button button-primary aksrt-after-sale-confirm"'
. ' data-id="' . esc_attr((string) $id) . '"'
. ' data-decision="' . esc_attr($decision) . '"'
. ' data-label="' . esc_attr($label) . '"'
. ' data-nonce="' . esc_attr(wp_create_nonce('aksrt_after_sale_review_' . $id)) . '">'
. esc_html($label) . '</button>';
}
. ' data-nonce="' . esc_attr(wp_create_nonce('aksrt_after_sale_status_' . $id)) . '">'
. esc_html__('确认', 'aksrt') . '</button>';
echo '</div></div>';
} else {
echo '<p class="description" style="margin:12px 0 0;">' . esc_html__('该售后申请已处理完毕,无需再审核。', 'aksrt') . '</p>';
}
echo '</div></td></tr>';
}
echo '</tbody></table>';
echo '</form>';
// 事件委托:点击「操作」切换手风琴详情;点击审核按钮经 JS 以 POST 提交(详情行在批量表单内,禁止嵌套 form)
// 事件委托:点击「操作」切换手风琴详情;「取消」仅收起面板;「确认」以 POST 提交选中的售后状态
echo '<script>(function(){'
. 'var postUrl=' . wp_json_encode(esc_url_raw(admin_url('admin-post.php'))) . ';'
. 'var tReject=' . wp_json_encode(__('请填写原因(将记为管理员备注,可留空):', 'aksrt')) . ';'
. 'var tConfirmA=' . wp_json_encode(__('确认执行', 'aksrt')) . ';'
. 'var tConfirmB=' . wp_json_encode(__('吗?', 'aksrt')) . ';'
. 'var tConfirmStatus=' . wp_json_encode(__('确认将售后状态设置为', 'aksrt')) . ';'
. 'function closeAllDetails(){'
. 'document.querySelectorAll(".aksrt-as-detail-row").forEach(function(x){x.setAttribute("hidden","");});'
. 'document.querySelectorAll(".aksrt-toggle-row-actions").forEach(function(t){t.setAttribute("aria-expanded","false");});'
. '}'
. 'document.addEventListener("click",function(e){'
. 'var el=e.target,t=el&&el.closest?el.closest(".aksrt-toggle-row-actions"):null;'
. 'var el=e.target;'
. 'var t=el&&el.closest?el.closest(".aksrt-toggle-row-actions"):null;'
. 'if(t){e.preventDefault();'
. 'var p=document.getElementById(t.getAttribute("data-target"));if(!p)return;'
. 'var willOpen=p.hasAttribute("hidden");closeAllDetails();'
. 'if(willOpen){p.removeAttribute("hidden");t.setAttribute("aria-expanded","true");'
. 'var n=p.querySelector(".aksrt-review-note");if(n)n.focus();}'
. 'var s=p.querySelector(".aksrt-status-select");if(s)s.focus();}'
. 'return;}'
. 'var a=el&&el.closest?el.closest(".aksrt-after-sale-quick-action"):null;'
. 'var c=el&&el.closest?el.closest(".aksrt-after-sale-cancel"):null;'
. 'if(c){e.preventDefault();closeAllDetails();return;}'
. 'var a=el&&el.closest?el.closest(".aksrt-after-sale-confirm"):null;'
. 'if(!a)return;e.preventDefault();'
. 'var panel=a.closest(".aksrt-as-detail-row");'
. 'var sel=panel?panel.querySelector(".aksrt-status-select"):null;'
. 'var noteEl=panel?panel.querySelector(".aksrt-review-note"):null;'
. 'var note=noteEl?noteEl.value:"";'
. 'if(!confirm(tConfirmA+"「"+a.getAttribute("data-label")+"」"+tConfirmB))return;'
. 'var opt=sel?sel.options[sel.selectedIndex]:null;'
. 'if(!sel||!opt||opt.disabled){alert(' . wp_json_encode(__('请选择售后状态。', 'aksrt')) . ');return;}'
. 'if(!confirm(tConfirmStatus+"「"+opt.text+"」?"))return;'
. 'var f=document.createElement("form");f.method="post";f.action=postUrl;'
. 'var fields={action:"aksrt_after_sale_review",after_sale_id:a.getAttribute("data-id"),decision:a.getAttribute("data-decision"),_wpnonce:a.getAttribute("data-nonce"),admin_note:note};'
. 'var fields={action:"aksrt_after_sale_update",after_sale_id:a.getAttribute("data-id"),after_sale_status:sel.value,_wpnonce:a.getAttribute("data-nonce"),admin_note:noteEl?noteEl.value:""};'
. 'Object.keys(fields).forEach(function(k){var i=document.createElement("input");i.type="hidden";i.name=k;i.value=fields[k];f.appendChild(i);});'
. 'document.body.appendChild(f);f.submit();'
. '});'
. 'var selAll=document.querySelector(".aksrt-select-all");'
. 'if(selAll){selAll.addEventListener("change",function(){'
. 'document.querySelectorAll(".aksrt-row-check").forEach(function(c){c.checked=selAll.checked;});});}'
. 'document.addEventListener("submit",function(e){'
. 'if(!e.target||e.target.id!=="aksrt-after-sale-bulk-form")return;'
. 'var checked=document.querySelectorAll(".aksrt-row-check:checked");'
. 'var decisionEl=document.getElementById("aksrt-bulk-decision");'
. 'var decision=decisionEl?decisionEl.value:"";'
. 'if(!checked.length){e.preventDefault();alert(' . wp_json_encode(__('请先勾选要操作的售后记录。', 'aksrt')) . ');return;}'
. 'if(!decision){e.preventDefault();alert(' . wp_json_encode(__('请选择批量操作。', 'aksrt')) . ');return;}'
. 'if(decision==="reject"){var note=prompt(tReject,"");if(note===null){e.preventDefault();return;}document.getElementById("aksrt-bulk-note").value=note;}'
. 'else if(!confirm(' . wp_json_encode(__('确认批量通过选中的记录吗?', 'aksrt')) . ')){e.preventDefault();return;}'
. '},true);'
. '})();</script>';
}
public function handle_after_sale_review(): void {
public function handle_after_sale_status(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
check_admin_referer('aksrt_after_sale_review_' . $id);
$decision = isset($_POST['decision']) ? sanitize_key((string) wp_unslash($_POST['decision'])) : '';
check_admin_referer('aksrt_after_sale_status_' . $id);
$status = isset($_POST['after_sale_status']) ? sanitize_key((string) wp_unslash($_POST['after_sale_status'])) : '';
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
$result = \AKSRT\Payment\AfterSaleService::get_instance()->review($id, $decision, $note);
$args = ['aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_review_failed' : 'after_sale_reviewed'];
$result = \AKSRT\Payment\AfterSaleService::get_instance()->set_status($id, $status, $note);
$args = ['aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_update_failed' : 'after_sale_updated'];
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, $args)); exit;
}
/**
* 批量审核售后申请(仅对待审核/处理中有效,其余由 review() 校验拒绝并计入失败)。
*/
public function handle_after_sale_bulk(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
check_admin_referer('aksrt_after_sale_bulk');
$decision = isset($_POST['bulk_decision']) ? sanitize_key((string) wp_unslash($_POST['bulk_decision'])) : '';
if (!in_array($decision, ['approve', 'reject'], true)) {
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, ['aksrt_pay_msg' => 'after_sale_review_failed'])); exit;
}
$note = isset($_POST['bulk_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['bulk_note'])) : '';
$ids = array_values(array_unique(array_map('absint', (array) ($_POST['after_sale_ids'] ?? []))));
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$done = 0; $failed = 0;
foreach ($ids as $id) {
if ($id <= 0) continue;
$result = $service->review($id, $decision, $note);
if (is_wp_error($result)) $failed++; else $done++;
}
$args = ['aksrt_pay_msg' => $failed > 0 ? 'after_sale_review_failed' : 'after_sale_reviewed'];
if ($done > 0) $args['aksrt_bulk_done'] = $done;
if ($failed > 0) $args['aksrt_bulk_failed'] = $failed;
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, $args)); exit;
}
public function handle_update_order_status(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
@@ -793,15 +741,15 @@ class PayOrders {
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
'after_sale_reviewed' => __('售后审核已处理。', 'aksrt'),
'after_sale_review_failed' => __('售后审核失败,请查看申请状态和错误原因。', 'aksrt'),
'after_sale_updated' => __('售后状态已更新。', 'aksrt'),
'after_sale_update_failed' => __('售后状态更新失败,请查看申请状态和错误原因。', 'aksrt'),
];
if (!isset($map[$code])) {
return;
}
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
if ($code === 'after_sale_review_failed' && isset($_GET['after_sale_error'])) {
if ($code === 'after_sale_update_failed' && isset($_GET['after_sale_error'])) {
echo '<div class="notice notice-error"><p>' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '</p></div>';
}
}
+16 -364
View File
@@ -1,20 +1,13 @@
<?php
/** User applications and administrator review for full order refunds. */
/** User applications and administrator status management for order after-sales. */
namespace AKSRT\Payment;
use AKSRT\Core\DomainModel;
use AKSRT\Ledger\AssetAmount;
use AKSRT\Ledger\AssetType;
use AKSRT\Ledger\LedgerCommand;
use AKSRT\Ledger\LedgerService;
defined('ABSPATH') || exit;
final class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
@@ -255,7 +248,7 @@ final class AfterSaleService {
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; }
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
@@ -291,7 +284,7 @@ final class AfterSaleService {
public function count_user_open(int $user_id): int {
if ($user_id <= 0) return 0;
global $wpdb;
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing','approved')", $user_id));
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')", $user_id));
}
/** A refund status transition is valid only for its own active application. */
@@ -300,371 +293,30 @@ final class AfterSaleService {
$row = $this->get($after_sale_id);
return is_array($row)
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED], true);
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
}
public function review(int $id, string $decision, string $note = '') {
/**
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
*/
public function set_status(int $id, string $status, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
$decision = sanitize_key($decision);
if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]);
global $wpdb;
$initial = $this->get($id);
if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
$order_number = (string) ($initial['order_number'] ?? '');
$lock = 'aksrt_after_sale_' . md5($order_number);
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
$status = sanitize_key($status);
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
$row = $this->get($id);
if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if ((string) $row['status'] === self::STATUS_PROCESSING) {
if ($decision === 'complete') {
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]);
}
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
if ($decision === 'reject') {
$gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true);
if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') {
return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]);
}
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]);
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
if (is_wp_error($revoke)) return $revoke;
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) return $compensated;
}
if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
}
return $this->get((int) $row['id']);
}
return $this->sync_gateway_refund($row, $note);
}
if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]);
if ($decision === 'reject') {
if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]);
}
$data = ['status' => $status];
if ($note !== '') $data['admin_note'] = $note;
if ($status !== self::STATUS_PENDING) $data['reviewed_at'] = current_time('mysql');
if (!$this->update($id, $data)) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
return $this->get($id);
}
return $this->approve($row, $note);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
private function approve(array $row, string $note) {
$order_number = (string) $row['order_number'];
$order = OrderRepository::get_instance()->get($order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]);
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]);
$revoke = $this->assets_to_revoke($order, $type, $meta);
if (is_wp_error($revoke)) return $revoke;
if ($method === 'card_pass') {
if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]);
return $this->commit_status_refund($row, $order, $note, $revoke);
}
if ($method === 'free') {
return $this->commit_status_refund($row, $order, $note, $revoke);
}
$refund_local = in_array($method, ['balance', 'points'], true);
if ($refund_local) {
$refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE;
$refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0'));
if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]);
$entries = $revoke;
// A points-purchase reversal already includes the original balance payment
// in assets_to_revoke(); do not return that payment twice.
if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) {
$entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)];
}
return $this->commit_local_refund($row, $order, $entries, $note);
}
$gateway = Gateway::get_instance()->get_gateway($method);
if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]);
$request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id'];
if (!$this->asset_entries_available($revoke)) {
return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]);
}
if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) {
return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]);
}
if ($revoke !== []) {
$held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note);
if (is_wp_error($held)) {
$this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]);
return $held;
}
}
try {
$result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id);
} catch (\Throwable $error) {
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
}
if (!is_array($result)) {
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
}
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]);
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if ($status === 'failed') {
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
return $this->get((int) $row['id']);
}
}
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]);
return $this->get((int) $row['id']);
}
// Missing, unknown, and pending provider outcomes are ambiguous. Keep the
// application processing and the asset clawback in place until query_refund
// explicitly confirms success or failure.
if (empty($result['success']) || $status !== 'succeeded') {
$this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]);
return $this->get((int) $row['id']);
}
$finished = $this->finish_refund($row, $order, $note);
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
private function commit_local_refund(array $row, array $order, array $entries, string $note) {
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
$guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission);
$mutation = function () use ($row, $order, $note, $commission) {
$commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
$finished = $this->finish_refund($row, $order, $note, false);
if ($finished === true) {
LedgerService::get_instance()->after_commit(static function (): void {
OrderRepository::get_instance()->dispatch_managed_status_events();
});
}
return $finished;
};
$result = LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries,
'order', $number, get_current_user_id(), ['order_number' => $number]
), $guard, $mutation);
if (is_wp_error($result)) return $result;
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) {
return LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries,
'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']]
));
}
private function commit_status_refund(array $row, array $order, string $note, array $entries = []) {
global $wpdb;
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
if ($entries !== []) {
$mutation = function () use ($row, $order, $note, $commission) {
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
$finished = $this->finish_refund($row, $order, $note, false);
if ($finished === true) {
LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); });
}
return $finished;
};
$result = LedgerService::get_instance()->post(new LedgerCommand(
\AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries,
'order', $number, get_current_user_id(), ['order_number' => $number]
), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation);
if (is_wp_error($result)) return $result;
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
$guard = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
$finished = $this->finish_order_and_application($row, $order, $note);
if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
OrderRepository::get_instance()->dispatch_managed_status_events();
CommissionLedger::get_instance()->clawback_after_refund($commission);
return $this->get((int) $row['id']);
}
private function sync_gateway_refund(array $row, string $note) {
$order = OrderRepository::get_instance()->get((string) $row['order_number']);
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
$gateway = Gateway::get_instance()->get_gateway($method);
$request_id = (string) ($row['gateway_request_id'] ?? '');
if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]);
try {
$result = $gateway->query_refund($request_id, (string) $row['order_number']);
} catch (\Throwable $error) {
$result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()];
}
if (!is_array($result)) {
$result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')];
}
$this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]);
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if ($status === 'succeeded' && !empty($result['success'])) {
$finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? ''));
return is_wp_error($finished) ? $finished : $this->get((int) $row['id']);
}
if ($status === 'failed' && !empty($result['success'])) {
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
$revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? []));
if (is_wp_error($revoke)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]);
return $this->get((int) $row['id']);
}
if ($revoke !== []) {
$compensated = $this->compensate_revoke($row, $order, $revoke, $note);
if (is_wp_error($compensated)) {
$this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]);
return $this->get((int) $row['id']);
}
}
$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]);
} elseif ($status !== 'succeeded') {
$this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]);
}
return $this->get((int) $row['id']);
}
private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error {
$compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries);
$result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note);
return is_wp_error($result) ? $result : true;
}
private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error {
$user_id = (int) ($order['user_id'] ?? 0);
if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) {
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); }
return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt'));
}
if ($type === DomainModel::ORDER_PRODUCT_POINTS) {
$card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : [];
$points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0));
if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]);
$entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt'));
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') {
try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); }
$entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')];
}
return $entries;
}
return [];
}
private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error {
if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]);
return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]];
}
private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) {
global $wpdb;
$number = (string) $order['order_number'];
$commission = CommissionLedger::get_instance()->prepare_full_refund($number);
if (is_wp_error($commission)) return $commission;
if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]);
$guarded = CommissionLedger::get_instance()->guard_full_refund($commission);
if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); }
$finished = $this->finish_order_and_application($row, $order, $note);
if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); }
$applied = CommissionLedger::get_instance()->apply_full_refund($commission);
if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); }
if ($manage_transaction) {
OrderRepository::get_instance()->dispatch_managed_status_events();
CommissionLedger::get_instance()->clawback_after_refund($commission);
}
return true;
}
private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error {
$number = (string) $order['order_number'];
if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]);
if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]);
return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]);
}
private function vip_reversal_is_safe(array $order, array $meta): bool {
$before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null;
$after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null;
if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false;
$current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']);
foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) {
if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false;
}
return true;
}
private function revoke_vip_if_needed(array $order): bool {
$type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? []));
if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true;
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
if (!$this->vip_reversal_is_safe($order, $meta)) return false;
$before = $meta['vip_before'];
$service = \AKSRT\Content\VipService::get_instance();
if ((int) ($before['level'] ?? 0) <= 0) {
$result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']);
} elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) {
$result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']);
} else {
return false;
}
return !is_wp_error($result);
}
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } }
/** @param array<int,array<string,mixed>> $entries */
private function asset_entries_available(array $entries): bool {
$required = [];
foreach ($entries as $entry) {
$delta = (int) ($entry['delta_minor'] ?? 0);
if ($delta < 0) {
$key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? '');
$required[$key] = ($required[$key] ?? 0) + abs($delta);
}
}
foreach ($required as $key => $amount) {
[$user_id, $asset] = explode(':', $key, 2);
$balance = (string) $asset === AssetType::POINTS->value
? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id)
: \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id);
if ($balance < (int) $amount) return false;
}
return true;
}
/** @param array<int,array<string,mixed>> $entries */
private function find_active(string $number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
+2 -2
View File
@@ -261,7 +261,7 @@ class UserOrders {
: [];
$settings = is_array($settings) ? $settings : [];
$open_statuses = ['pending', 'processing', 'approved'];
$open_statuses = ['pending', 'processing'];
$latest = null;
$open = false;
if ($service !== null) {
@@ -287,7 +287,7 @@ class UserOrders {
$has_application = $latest !== null;
// 可再次申请售后的状态判定(按工单状态推理,不再以「不能再申请」为常态):
// - pending / processing / approved:工单仍在流转(待审核 / 处理中 / 已通过待退款),不允许重复申请;
// - pending / processing:工单仍在流转(待审核 / 处理中),不允许重复申请;
// - rejected:审核未通过,允许补充资料后再次申请;
// - cancelled:用户自行取消,允许重新发起;
// - completed:退款已完成,工单终结,不允许再次申请。
+1 -2
View File
@@ -37,7 +37,6 @@ namespace AKSRT\Payment {
class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
@@ -47,7 +46,7 @@ namespace AKSRT\Payment {
return [
['id' => 5, 'order_number' => 'ORD100', 'user_id' => 3, 'reason' => 'legacy-reason',
'apply_form' => ['reason_label' => '商品与描述不符', 'materials' => [['label' => '联系方式', 'value' => 'wx-abc']], 'description' => '说明文字'],
'images' => [], 'refund_amount' => '12.50', 'status' => 'pending', 'admin_note' => '', 'gateway_result' => '', 'created_at' => '2026-10-01 10:00:00'],
'images' => ['https://example.test/img1.png', 'https://example.test/img2.png'], 'refund_amount' => '12.50', 'status' => 'pending', 'admin_note' => '', 'gateway_result' => '', 'created_at' => '2026-10-01 10:00:00'],
['id' => 6, 'order_number' => 'ORD101', 'user_id' => 4, 'reason' => 'r2',
'apply_form' => null, 'images' => [], 'refund_amount' => '8.00', 'status' => 'processing',
'admin_note' => '已有备注', 'gateway_result' => json_encode(['status' => 'manual', 'message' => '线下退款']), 'created_at' => '2026-10-02 11:00:00'],
@@ -143,20 +143,27 @@ test('order detail page drops tabs; after-sales becomes a standalone submenu', (
// 售后表格列对齐售后策略的申请表单配置项:原因 / 申请资料 / 申请说明
assert.ok(page.includes('<th>申请资料</th>'), 'materials column')
assert.ok(page.includes('<th>申请说明</th>'), 'description column')
assert.ok(page.includes('<th>举证图片</th>'), 'evidence images column')
assert.ok(page.includes('$images_cell'), 'evidence images cell rendered from item images')
// 操作列:点击「操作」以手风琴展开整条记录详情(含完整字段与内联审核表单),经 JS 以 POST 提交
assert.ok(!page.includes('<th>举证图片</th>'), 'evidence images must not be a standalone column')
assert.ok(page.includes('aksrt-as-desc-thumbs'), 'evidence thumbnails render inside the description cell')
// 操作列:点击「操作」以手风琴展开整条记录详情(含完整字段与状态面板),经 JS 以 POST 提交
assert.ok(page.includes('aksrt-toggle-row-actions'), 'toggle to expand record accordion')
assert.ok(page.includes('aksrt-as-detail-row'), 'accordion detail row per after-sale record')
assert.ok(page.includes('aksrt-as-detail-grid'), 'accordion panel renders full record fields')
assert.ok(page.includes('aksrt-review-note'), 'review note textarea inside expanded panel')
assert.ok(page.includes('aksrt-as-review-actions'), 'inline approve/reject buttons inside expanded panel')
assert.ok(page.includes('aksrt-as-review-actions'), 'review actions row inside expanded panel')
assert.ok(page.includes('aksrt-status-select'), 'status select inside expanded panel')
assert.ok(page.includes('aksrt-after-sale-confirm'), 'confirm button posts the selected status')
assert.ok(page.includes('aksrt-after-sale-cancel'), 'cancel button only collapses the panel')
assert.ok(page.includes('aksrt_after_sale_update'), 'status update handler registered')
assert.ok(page.includes('aksrt_after_sale_status_'), 'per-row nonce covers the status update')
assert.ok(page.includes('aksrt-as-field--wide'), 'description renders as a full-width row')
assert.ok(page.includes('justify-content:flex-end'), 'confirm/cancel buttons right-aligned')
assert.ok(page.includes('.aksrt-as-detail-row[hidden]'), 'hidden attribute guard keeps accordion closed')
assert.ok(page.includes('document.addEventListener("click"'), 'delegated click handler survives row re-render')
assert.ok(!page.includes('class="row-actions"'), 'no hover-only row-actions')
assert.ok(page.includes('aksrt_after_sale_bulk'), 'bulk review handler registered')
assert.ok(page.includes('批量操作'), 'bulk actions dropdown')
assert.ok(page.includes('after_sale_ids[]'), 'row checkboxes for bulk')
assert.ok(!page.includes('aksrt_after_sale_bulk'), 'bulk review handler removed')
assert.ok(!page.includes('批量操作'), 'bulk actions dropdown removed')
assert.ok(!page.includes('after_sale_ids[]'), 'row checkboxes removed')
assert.ok(page.includes('$materials_cell'), 'materials cell rendered from apply_form')
assert.ok(page.includes('$desc_cell'), 'description cell rendered from apply_form')
// 页头展示售后策略摘要(功能开关 / 时效 / 预置原因 / 申请材料)
@@ -165,7 +172,8 @@ test('order detail page drops tabs; after-sales becomes a standalone submenu', (
assert.ok(page.includes('申请所需材料'), 'policy summary shows materials')
const domain = read('src/Core/DomainModel.php')
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_APPROVED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED[\s\S]*?STATUS_COMPLETED/)
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_COMPLETED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED/)
assert.ok(!page.includes('STATUS_APPROVED'), 'approved status removed')
assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/)
})
@@ -49,7 +49,7 @@ test('free points keep a bounded daily summary alongside the recent points histo
assert.match(assets, /'points_record'/)
})
test('after-sale submissions are order-locked and only completed by their own refund application', () => {
test('after-sale submissions are order-locked and admins set status directly', () => {
const afterSale = read('src/Payment/AfterSaleService.php')
const schema = read('src/Core/DBMigratorPart2.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
@@ -57,14 +57,12 @@ test('after-sale submissions are order-locked and only completed by their own re
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?gateway_request_id[\s\S]*?gateway_result[\s\S]*?completed_at/)
assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?find_active\(\$order_number\)/)
assert.match(afterSale, /Order::STATUS_PAID/)
assert.match(afterSale, /\$gateway->refund\(\$order_number, \(string\) \$row\['refund_amount'\], \$request_id\)/)
assert.match(afterSale, /query_refund\(\$request_id, \(string\) \$row\['order_number'\]\)/)
assert.match(afterSale, /sanitize_key\(\(string\) \(\$gateway_result\['status'\] \?\? ''\)\) !== 'manual'/)
assert.match(afterSale, /\$decision === 'reject'[\s\S]*?compensate_revoke\(\$row, \$order, \$revoke, \$note\)/)
assert.match(afterSale, /Order::STATUS_REFUNDED/)
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.ok(!afterSale.includes('STATUS_APPROVED'), 'approved status removed')
assert.ok(!afterSale.includes('function review('), 'decision-based review removed')
assert.ok(!afterSale.includes('approve('), 'refund pipeline removed')
assert.match(repository, /can_finalize_order_refund\(\(int\) \(\$extra\['after_sale_id'\] \?\? 0\), \$order_number\)/)
assert.match(read('src/Payment/Gateways/PayPalGateway.php'), /'status' => 'manual'/)
assert.match(read('src/Payment/Gateways/EpayGateway.php'), /'status'\s*=>\s*'manual'/)
})
test('withdrawal holds and returns use the selected user-meta or commission pool', () => {