feat(after-sale): 售后处理记录改为每次提交留痕并新增时间线
工单行内新增 handling_log 列,用户申请、用户撤销与后台每次改状态都落一条
「状态 + 备注 + 来源 + 时间」,按时间正序存放、上限 50 条(对齐
MetaAssetService::MAX_RECORDS)。set_status 因此变成读-改-写,补上
GET_LOCK/RELEASE_LOCK 包裹,并在锁内重读工单快照——锁外取快照仍会被并发
追加覆盖。
三处界面由「只显示最后一条」改为回放整条处理链路:
- 后台订单明细「售后记录」按工单渲染头部(状态/金额/申请时间)+ 原因 +
完整处理记录
- 售后面板手风琴「处理备注」单独占整行并置于「申请说明」上方,删掉已无
意义的「审核时间」
- 前台售后详细弹窗与订单详情弹窗的售后记录改为时间线:左列绳结状态条
(节点 + 状态 + 时间),右列处理备注;样式只在 modal-runtime.css 定义
一份,两个弹窗共用
清理随之失效的死代码:工单的 admin_note / reviewed_at / completed_at,以及
全仓库无写入方的 gateway_request_id / gateway_refund_id / gateway_result;
连带删掉 gatewayResultText()、uninstall 里从未存在过的 aksrt_after_sale
表项,并把两处后台页面重复的状态标签数组收成 after_sale_status_labels()、
重复的 .aksrt-as-log 样式收成 after_sale_log_css()。
顺带修复 88669f69 重构退款流水线时误删 refund_amount()、却仍在 create()
里调用的问题——此前任何用户提交售后都会直接 fatal。
验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;新增
after-sale-log-harness.php 驱动真实 AfterSaleService,覆盖首条日志、空备注
也留痕、撤销记录、50 条上限与畸形行规范化;两个后台页面经 dev harness
渲染校验「处理备注」宽行位于「申请说明」之前;未做浏览器视觉验收。
This commit is contained in:
@@ -6693,6 +6693,29 @@ body.aksrt-modal-runtime-open {
|
||||
.aksrt-after-sale-status.is-rejected,
|
||||
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
|
||||
|
||||
/* 售后处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为 aside 的
|
||||
::before,末项不收口,避免出现悬空的线段。 */
|
||||
.aksrt-after-sale-log { display: grid; gap: 6px; }
|
||||
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
|
||||
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 10px; }
|
||||
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
|
||||
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
|
||||
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
|
||||
.aksrt-after-sale-timeline__item.is-rejected,
|
||||
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
|
||||
.aksrt-after-sale-timeline__aside { position: relative; display: grid; gap: 2px; justify-items: start; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__aside::before { content: ""; position: absolute; top: 12px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside::before { display: none; }
|
||||
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
|
||||
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.4; }
|
||||
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.4; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-after-sale-timeline__main { min-width: 0; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__main { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
|
||||
|
||||
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
|
||||
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -816,6 +816,29 @@ body.aksrt-modal-runtime-open {
|
||||
.aksrt-after-sale-status.is-rejected,
|
||||
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
|
||||
|
||||
/* 售后处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为 aside 的
|
||||
::before,末项不收口,避免出现悬空的线段。 */
|
||||
.aksrt-after-sale-log { display: grid; gap: 6px; }
|
||||
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
|
||||
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 10px; }
|
||||
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
|
||||
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
|
||||
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
|
||||
.aksrt-after-sale-timeline__item.is-rejected,
|
||||
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
|
||||
.aksrt-after-sale-timeline__aside { position: relative; display: grid; gap: 2px; justify-items: start; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__aside::before { content: ""; position: absolute; top: 12px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside::before { display: none; }
|
||||
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
|
||||
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.4; }
|
||||
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.4; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-after-sale-timeline__main { min-width: 0; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__main { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
|
||||
|
||||
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
|
||||
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
|
||||
|
||||
@@ -169,6 +169,43 @@
|
||||
'<span class="aksrt-after-sale-detail__value">' + escapeHtml(value) + '</span></div>';
|
||||
}
|
||||
|
||||
// 处理记录节点标题:用户侧提交/撤脱离不开工单状态,单独措辞;
|
||||
// 后台提交直接用目标状态,与后台列表口径一致。
|
||||
function handlingEntryTitle(entry) {
|
||||
var status = String((entry && entry.status) || '');
|
||||
if (String((entry && entry.source) || '') === 'user') {
|
||||
return status === 'cancelled' ? '用户撤销售后' : '用户提交申请';
|
||||
}
|
||||
return STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
// 处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
// 数据源为 latest.handling_log(工单行内 JSON,按时间正序)。
|
||||
function handlingLogHtml(log) {
|
||||
var entries = Array.isArray(log) ? log : [];
|
||||
if (!entries.length) {
|
||||
return '<div class="aksrt-after-sale-log"><div class="aksrt-after-sale-log__empty">暂无处理记录</div></div>';
|
||||
}
|
||||
var html = '<div class="aksrt-after-sale-log"><div class="muted-2-color em09 mb6">处理记录</div>';
|
||||
html += '<ol class="aksrt-after-sale-timeline">';
|
||||
entries.forEach(function (entry) {
|
||||
var status = String((entry && entry.status) || '');
|
||||
var note = String((entry && entry.note) || '');
|
||||
html += '<li class="aksrt-after-sale-timeline__item is-' + escapeHtml(status) + '">';
|
||||
html += '<div class="aksrt-after-sale-timeline__aside">' +
|
||||
'<span class="aksrt-after-sale-timeline__knot"></span>' +
|
||||
'<span class="aksrt-after-sale-timeline__status">' + escapeHtml(handlingEntryTitle(entry)) + '</span>' +
|
||||
'<time class="aksrt-after-sale-timeline__time">' + escapeHtml(String((entry && entry.created_at) || '')) + '</time>' +
|
||||
'</div>';
|
||||
html += '<div class="aksrt-after-sale-timeline__main">' +
|
||||
(note !== '' ? '<p class="aksrt-after-sale-timeline__note">' + escapeHtml(note).replace(/\r\n|\r|\n/g, '<br>') + '</p>' : '') +
|
||||
'</div>';
|
||||
html += '</li>';
|
||||
});
|
||||
html += '</ol></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildDetailHtml(order) {
|
||||
var afterSale = (order && order.after_sale) || {};
|
||||
var latest = afterSale.latest || {};
|
||||
@@ -192,9 +229,9 @@
|
||||
html += '<div class="aksrt-after-sale-detail__rows">';
|
||||
html += detailRow('申请时间', String(latest.created_at || ''));
|
||||
html += detailRow('退款金额', aftersaleAmountText(latest.refund_amount, order));
|
||||
html += detailRow('处理备注', String(latest.admin_note || ''));
|
||||
html += detailRow('完成时间', String(latest.completed_at || ''));
|
||||
html += '</div>';
|
||||
// 处理记录:每次后台提交一条,替代原先单行的「处理备注 / 完成时间」。
|
||||
html += handlingLogHtml(latest.handling_log);
|
||||
|
||||
// 申请表单分行展示:预置原因 / 申请资料 / 申请说明各自独立成行,
|
||||
// 与上方「申请时间」等明细行同格式;老数据无 apply_form 时兜底展示融合串。
|
||||
|
||||
+33
-19
@@ -106,26 +106,41 @@
|
||||
return "<div><dt>" + escapeHtml(label) + "</dt><dd>" + cell + "</dd></div>";
|
||||
}
|
||||
|
||||
function gatewayResultText(value) {
|
||||
if (!value) return "";
|
||||
var result = value;
|
||||
if (typeof value === "string") {
|
||||
try { result = JSON.parse(value); } catch (_) { return String(value); }
|
||||
}
|
||||
if (!result || typeof result !== "object") return String(result);
|
||||
var labels = { succeeded: "退款成功", failed: "渠道明确拒绝", pending: "渠道处理中", unknown: "结果待确认" };
|
||||
var parts = [];
|
||||
if (result.status) parts.push(labels[String(result.status)] || String(result.status));
|
||||
if (result.message) parts.push(String(result.message));
|
||||
if (result.refund_id) parts.push("退款单号 " + String(result.refund_id));
|
||||
return parts.join(" · ") || "渠道暂未提供可读结果";
|
||||
}
|
||||
|
||||
function afterSaleAmount(value, order) {
|
||||
var amount = String(value == null ? "" : value);
|
||||
return order && order.is_points ? amount + " " + String(order.currency_symbol || "积分") : "¥" + amount;
|
||||
}
|
||||
|
||||
// 处理记录时间线:与售后详细弹窗(order-after-sale.js)同款标记与样式,
|
||||
// 由 modal-runtime.css 的 .aksrt-after-sale-timeline 规则统一呈现。
|
||||
var AFTER_SALE_STATUS_LABELS = { pending: "待审核", processing: "处理中", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" };
|
||||
function handlingEntryTitle(entry) {
|
||||
var status = String((entry && entry.status) || "");
|
||||
if (String((entry && entry.source) || "") === "user") {
|
||||
return status === "cancelled" ? "用户撤销售后" : "用户提交申请";
|
||||
}
|
||||
return AFTER_SALE_STATUS_LABELS[status] || status;
|
||||
}
|
||||
function orderDetailHandlingLogHtml(log) {
|
||||
var entries = Array.isArray(log) ? log : [];
|
||||
if (!entries.length) return "";
|
||||
var html = '<div class="aksrt-after-sale-log"><ol class="aksrt-after-sale-timeline">';
|
||||
entries.forEach(function (entry) {
|
||||
var status = String((entry && entry.status) || "");
|
||||
var note = String((entry && entry.note) || "");
|
||||
html += '<li class="aksrt-after-sale-timeline__item is-' + escapeHtml(status) + '">';
|
||||
html += '<div class="aksrt-after-sale-timeline__aside">' +
|
||||
'<span class="aksrt-after-sale-timeline__knot"></span>' +
|
||||
'<span class="aksrt-after-sale-timeline__status">' + escapeHtml(handlingEntryTitle(entry)) + '</span>' +
|
||||
'<time class="aksrt-after-sale-timeline__time">' + escapeHtml(String((entry && entry.created_at) || "")) + '</time>' +
|
||||
'</div>';
|
||||
html += '<div class="aksrt-after-sale-timeline__main">' +
|
||||
(note !== "" ? '<p class="aksrt-after-sale-timeline__note">' + escapeHtml(note).replace(/\r\n|\r|\n/g, "<br>") + '</p>' : "") +
|
||||
'</div></li>';
|
||||
});
|
||||
return html + '</ol></div>';
|
||||
}
|
||||
|
||||
function colorfulHeader(title, icon) {
|
||||
return '<div class="modal-colorful-header colorful-bg jb-blue">' +
|
||||
'<button type="button" class="close" data-aksrt-modal-close aria-label="关闭"><svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>' +
|
||||
@@ -207,9 +222,9 @@
|
||||
}
|
||||
html += '<dl class="aksrt-order-detail__after-sale-meta">';
|
||||
html += "<div><dt>退款金额</dt><dd>" + escapeHtml(afterSaleAmount(item.refund_amount, order)) + "</dd></div>";
|
||||
if (item.admin_note) html += "<div><dt>处理备注</dt><dd>" + escapeHtml(item.admin_note) + "</dd></div>";
|
||||
if (item.gateway_result) html += "<div><dt>渠道结果</dt><dd>" + escapeHtml(gatewayResultText(item.gateway_result)) + "</dd></div>";
|
||||
html += "</dl>";
|
||||
// 处理记录时间线:左列绳结状态条与时间,右列处理备注;与售后详细弹窗同款结构。
|
||||
html += orderDetailHandlingLogHtml(item.handling_log);
|
||||
// 查看详细:逐记录构造售后详细弹窗载荷(after_sale.latest 指向本条记录);
|
||||
// 再次申请仅对最新一条且订单级可再申请时开放(after_sales 按 id 倒序,首条即最新)。
|
||||
var recordPayload = escapeHtml(JSON.stringify({
|
||||
@@ -230,8 +245,7 @@
|
||||
images: Array.isArray(item.images) ? item.images : [],
|
||||
refund_amount: String(item.refund_amount || "0"),
|
||||
created_at: String(item.created_at || ""),
|
||||
completed_at: String(item.completed_at || ""),
|
||||
admin_note: String(item.admin_note || ""),
|
||||
handling_log: Array.isArray(item.handling_log) ? item.handling_log : [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -351,6 +351,13 @@ class PayOrders {
|
||||
|
||||
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">← ' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
|
||||
|
||||
// 售后记录区块:工单卡片 + 处理记录列表(与售后面板共用 .aksrt-as-log 样式)。
|
||||
echo '<style>'
|
||||
. '.aksrt-as-ticket{padding:10px 0;border-bottom:1px solid #f0f0f1;}'
|
||||
. '.aksrt-as-ticket:last-child{padding-bottom:0;border-bottom:0;}'
|
||||
. '.aksrt-as-ticket__head{margin:0 0 4px;}'
|
||||
. $this->after_sale_log_css()
|
||||
. '</style>';
|
||||
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
|
||||
$rows = [
|
||||
__('订单号', 'aksrt') => $order_number,
|
||||
@@ -371,12 +378,17 @@ 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' => '处理中', 'completed' => '已完成', 'rejected' => '已拒绝', 'cancelled' => '已取消'];
|
||||
$status_labels = $this->after_sale_status_labels();
|
||||
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']);
|
||||
if (!empty($application['gateway_result'])) echo '<br><code>' . esc_html((string) $application['gateway_result']) . '</code>';
|
||||
echo '</p>';
|
||||
$as_status = (string) ($application['status'] ?? '');
|
||||
echo '<div class="aksrt-as-ticket is-' . esc_attr($as_status) . '">';
|
||||
echo '<p class="aksrt-as-ticket__head"><strong>' . esc_html($status_labels[$as_status] ?? $as_status) . '</strong>'
|
||||
. ' <span class="description">' . esc_html((string) ($application['refund_amount'] ?? '')) . '</span>'
|
||||
. ' <span class="description">' . esc_html((string) ($application['created_at'] ?? '')) . '</span></p>';
|
||||
echo '<p>' . nl2br(esc_html((string) ($application['reason'] ?? ''))) . '</p>';
|
||||
// 处理记录:后台每一次提交都留一条(状态 + 备注 + 时间),与售后面板同款标记。
|
||||
echo $this->render_after_sale_log((array) ($application['handling_log'] ?? []), $status_labels); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '</div>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
@@ -429,7 +441,7 @@ class PayOrders {
|
||||
|
||||
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_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>';
|
||||
foreach ($this->after_sale_status_labels() 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>';
|
||||
@@ -452,12 +464,13 @@ class PayOrders {
|
||||
. '.aksrt-as-review textarea{flex:1 1 240px;min-width:0;box-sizing:border-box;margin:0;}'
|
||||
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}'
|
||||
. '.aksrt-as-field--wide{grid-column:1/-1;}'
|
||||
. $this->after_sale_log_css()
|
||||
. '@media (min-width:1500px){.aksrt-as-detail-grid{grid-template-columns:repeat(5,minmax(0,1fr));}}'
|
||||
. '@media (max-width:600px){.aksrt-as-detail-grid{grid-template-columns:minmax(0,1fr);}}'
|
||||
. '</style>';
|
||||
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 => '已取消'];
|
||||
$status_labels = $this->after_sale_status_labels();
|
||||
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;
|
||||
@@ -496,7 +509,6 @@ class PayOrders {
|
||||
$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'] ?? '');
|
||||
$order_refunded = is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
|
||||
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与状态面板
|
||||
echo '<tr class="aksrt-as-row">';
|
||||
@@ -520,13 +532,15 @@ class PayOrders {
|
||||
__('当前状态', 'aksrt') => esc_html($status_label),
|
||||
__('售后原因', 'aksrt') => $reason_cell,
|
||||
__('申请资料', 'aksrt') => $materials_cell,
|
||||
__('处理备注', 'aksrt') => $admin_note !== '' ? esc_html($admin_note) : '—',
|
||||
__('审核时间', 'aksrt') => !empty($item['reviewed_at']) ? esc_html((string) $item['reviewed_at']) : '—',
|
||||
];
|
||||
echo '<div class="aksrt-as-detail"><div class="aksrt-as-detail-grid">';
|
||||
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">'
|
||||
. $this->render_after_sale_log((array) ($item['handling_log'] ?? []), $status_labels) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
. '</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>';
|
||||
@@ -619,6 +633,58 @@ class PayOrders {
|
||||
];
|
||||
}
|
||||
|
||||
/** 售后状态中文标签:订单明细页与售后面板(含筛选下拉)共用,取代两处内联数组。 */
|
||||
/** 处理记录列表样式:两个后台页面各自输出同一套规则,不依赖对方加载。 */
|
||||
private function after_sale_log_css(): string {
|
||||
return '.aksrt-as-log{display:grid;gap:5px;margin:6px 0 0;padding:0;list-style:none;}'
|
||||
. '.aksrt-as-log__item{display:flex;flex-wrap:wrap;gap:3px 10px;align-items:baseline;font-size:12px;line-height:1.7;}'
|
||||
. '.aksrt-as-log__time{color:#646970;font-variant-numeric:tabular-nums;}'
|
||||
. '.aksrt-as-log__status{font-weight:600;}'
|
||||
. '.aksrt-as-log__item.is-pending .aksrt-as-log__status{color:#b26200;}'
|
||||
. '.aksrt-as-log__item.is-processing .aksrt-as-log__status{color:#2271b1;}'
|
||||
. '.aksrt-as-log__item.is-completed .aksrt-as-log__status{color:#007017;}'
|
||||
. '.aksrt-as-log__item.is-rejected .aksrt-as-log__status,.aksrt-as-log__item.is-cancelled .aksrt-as-log__status{color:#b32d2e;}'
|
||||
. '.aksrt-as-log__note{flex:1 1 100%;color:#3c434a;}';
|
||||
}
|
||||
|
||||
private function after_sale_status_labels(): array {
|
||||
return [
|
||||
\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 => '已取消',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理记录列表:每一次状态提交一条「时间 + 状态 + 备注」,备注为空也留痕。
|
||||
* 后台订单明细页与售后面板共用同一套标记与样式。
|
||||
*
|
||||
* @param list<array<string,mixed>> $log
|
||||
* @param array<string,string> $status_labels
|
||||
*/
|
||||
private function render_after_sale_log(array $log, array $status_labels): string {
|
||||
if (!$log) {
|
||||
return '<p class="description">' . esc_html__('暂无处理记录', 'aksrt') . '</p>';
|
||||
}
|
||||
$service = \AKSRT\Payment\AfterSaleService::class;
|
||||
$html = '<ul class="aksrt-as-log">';
|
||||
foreach ($log as $entry) {
|
||||
$status = (string) ($entry['status'] ?? '');
|
||||
$label = (string) ($entry['source'] ?? '') === $service::SOURCE_USER
|
||||
? ($status === $service::STATUS_CANCELLED ? esc_html__('用户撤销售后', 'aksrt') : esc_html__('用户提交申请', 'aksrt'))
|
||||
: ($status_labels[$status] ?? $status);
|
||||
$note = (string) ($entry['note'] ?? '');
|
||||
$html .= '<li class="aksrt-as-log__item is-' . esc_attr($status) . '">'
|
||||
. '<span class="aksrt-as-log__time">' . esc_html((string) ($entry['created_at'] ?? '')) . '</span>'
|
||||
. '<span class="aksrt-as-log__status">' . esc_html($label) . '</span>'
|
||||
. '<span class="aksrt-as-log__note">' . ($note !== '' ? nl2br(esc_html($note)) : '—') . '</span>'
|
||||
. '</li>';
|
||||
}
|
||||
return $html . '</ul>';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -17,7 +17,10 @@ class DBMigrator {
|
||||
// 2.5.1: order_aftersale 新增 images 列(售后举证图片)。dbDelta 仅在版本变化时
|
||||
// 重跑,新增/调整列必须同步升版本,否则老安装不会执行 ALTER TABLE。
|
||||
// 2.5.2: order_aftersale 新增 apply_form 列(结构化申请表单:预置原因 / 申请资料 / 申请说明)。
|
||||
const SCHEMA_VERSION = '2.5.2';
|
||||
// 2.6.0: order_aftersale 新增 handling_log 列(处理记录:每次状态提交一条
|
||||
// 「状态 + 备注 + 来源 + 时间」),取代单一的 admin_note / reviewed_at / completed_at,
|
||||
// 并移除从未被写入的 gateway_request_id / gateway_refund_id / gateway_result。
|
||||
const SCHEMA_VERSION = '2.6.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -213,15 +213,10 @@ trait DBMigratorPart2 {
|
||||
reason TEXT NOT NULL,
|
||||
apply_form LONGTEXT DEFAULT NULL,
|
||||
images LONGTEXT DEFAULT NULL,
|
||||
handling_log LONGTEXT DEFAULT NULL,
|
||||
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
admin_note TEXT DEFAULT NULL,
|
||||
gateway_request_id VARCHAR(128) DEFAULT NULL,
|
||||
gateway_refund_id VARCHAR(128) DEFAULT NULL,
|
||||
gateway_result LONGTEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
reviewed_at DATETIME DEFAULT NULL,
|
||||
completed_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order_status (order_number, status),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
|
||||
@@ -11,6 +11,11 @@ final class AfterSaleService {
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
/** 处理记录来源:后台提交 / 用户提交(申请与撤销)。 */
|
||||
public const SOURCE_ADMIN = 'admin';
|
||||
public const SOURCE_USER = 'user';
|
||||
/** 处理记录上限:与 MetaAssetService::MAX_RECORDS 同口径,超出后只保留最近的一段。 */
|
||||
private const MAX_LOG_ENTRIES = 50;
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function get_instance(): self { return self::$instance ??= new self(); }
|
||||
@@ -153,13 +158,15 @@ final class AfterSaleService {
|
||||
}
|
||||
$amount = $this->refund_amount($order);
|
||||
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
|
||||
$now = current_time('mysql');
|
||||
$inserted = $wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
|
||||
'reason' => $reason,
|
||||
'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'images' => wp_json_encode($images), 'refund_amount' => $amount,
|
||||
'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
|
||||
'status' => self::STATUS_PENDING, 'created_at' => $now,
|
||||
'handling_log' => $this->encode_log([$this->log_entry(self::STATUS_PENDING, '', self::SOURCE_USER, $now)]),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
|
||||
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
@@ -212,6 +219,15 @@ final class AfterSaleService {
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款金额:积分单取整,现金单经 AssetAmount 归一为两位小数;
|
||||
* 金额无法解析时返回 null,由 create() 拒掉这次申请。
|
||||
*/
|
||||
private function refund_amount(array $order): ?string {
|
||||
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
|
||||
try { return \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
|
||||
}
|
||||
|
||||
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
|
||||
public function latest_for_order(string $order_number): ?array {
|
||||
global $wpdb;
|
||||
@@ -229,6 +245,7 @@ final class AfterSaleService {
|
||||
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
|
||||
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
|
||||
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
|
||||
$this->append_log((int) $row['id'], $row, self::STATUS_CANCELLED, '', self::SOURCE_USER);
|
||||
return $this->get((int) $row['id']);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
@@ -298,6 +315,10 @@ final class AfterSaleService {
|
||||
|
||||
/**
|
||||
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
|
||||
*
|
||||
* 每次提交都往 handling_log 落一条「状态 + 备注 + 来源 + 时间」,备注为空也留痕,
|
||||
* 后台与前台才能回放整条处理链路。读取-追加-写回需要与 create()/cancel_for_user()
|
||||
* 同一把订单锁,否则两条并发提交会互相覆盖日志。
|
||||
*/
|
||||
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]);
|
||||
@@ -305,12 +326,48 @@ final class AfterSaleService {
|
||||
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) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
|
||||
$note = sanitize_textarea_field($note);
|
||||
$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);
|
||||
global $wpdb;
|
||||
$lock = 'aksrt_after_sale_' . md5((string) ($row['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 {
|
||||
// 锁内重读:追加必须基于最新快照,否则两条并发提交仍会互相覆盖日志。
|
||||
$row = $this->get($id);
|
||||
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
|
||||
$note = sanitize_textarea_field($note);
|
||||
if (!$this->update($id, ['status' => $status])) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
|
||||
$this->append_log($id, $row, $status, $note, self::SOURCE_ADMIN);
|
||||
return $this->get($id);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条处理记录。日志按时间正序存放,超出 MAX_LOG_ENTRIES 条时只保留最近的一段。
|
||||
*
|
||||
* @param array<string,mixed> $current 追加前的工单行(含已规范化的 handling_log)。
|
||||
*/
|
||||
private function append_log(int $id, array $current, string $status, string $note, string $source): void {
|
||||
$log = is_array($current['handling_log'] ?? null) ? $current['handling_log'] : [];
|
||||
$log[] = $this->log_entry($status, $note, $source, current_time('mysql'));
|
||||
if (count($log) > self::MAX_LOG_ENTRIES) {
|
||||
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
|
||||
}
|
||||
$this->update($id, ['handling_log' => $this->encode_log($log)]);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function log_entry(string $status, string $note, string $source, string $created_at): array {
|
||||
return [
|
||||
'status' => $status,
|
||||
'note' => $note,
|
||||
'source' => $source,
|
||||
'created_at' => $created_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function encode_log(array $log): string {
|
||||
return wp_json_encode($log, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
|
||||
@@ -345,6 +402,30 @@ final class AfterSaleService {
|
||||
: [];
|
||||
$apply_form = json_decode((string) ($row['apply_form'] ?? ''), true);
|
||||
$row['apply_form'] = is_array($apply_form) ? $apply_form : null;
|
||||
$row['handling_log'] = $this->normalize_log(json_decode((string) ($row['handling_log'] ?? ''), true));
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理记录规范化:只保留 status 非空的条目,source 只认 user,其余归 admin;
|
||||
* 非数组(含损坏 JSON)整体视为空,界面对老工单按「暂无处理记录」处理。
|
||||
*
|
||||
* @return list<array<string,string>>
|
||||
*/
|
||||
private function normalize_log($log): array {
|
||||
if (!is_array($log)) return [];
|
||||
$clean = [];
|
||||
foreach ($log as $entry) {
|
||||
if (!is_array($entry)) continue;
|
||||
$status = sanitize_key((string) ($entry['status'] ?? ''));
|
||||
if ($status === '') continue;
|
||||
$clean[] = [
|
||||
'status' => $status,
|
||||
'note' => (string) ($entry['note'] ?? ''),
|
||||
'source' => (string) ($entry['source'] ?? '') === self::SOURCE_USER ? self::SOURCE_USER : self::SOURCE_ADMIN,
|
||||
'created_at' => (string) ($entry['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,9 +308,8 @@ class UserOrders {
|
||||
'images' => array_values((array) ($latest['images'] ?? [])),
|
||||
'refund_amount' => (string) ($latest['refund_amount'] ?? '0'),
|
||||
'created_at' => (string) ($latest['created_at'] ?? ''),
|
||||
'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''),
|
||||
'completed_at' => (string) ($latest['completed_at'] ?? ''),
|
||||
'admin_note' => (string) ($latest['admin_note'] ?? ''),
|
||||
// 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。
|
||||
'handling_log' => array_values((array) ($latest['handling_log'] ?? [])),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -77,11 +77,16 @@ namespace AKSRT\Payment {
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const SOURCE_ADMIN = 'admin';
|
||||
public const SOURCE_USER = 'user';
|
||||
private static $i;
|
||||
/** @var array<int,array<string,mixed>> keyed by after-sale id */
|
||||
public static array $rows = [];
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function admin_list(array $args = []): array { return array_values(self::$rows); }
|
||||
public function list_for_order(string $order_number): array {
|
||||
return array_values(array_filter(self::$rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $order_number));
|
||||
}
|
||||
public function count(array $args = []): int { return count(self::$rows); }
|
||||
public function get(int $id): ?array { return self::$rows[$id] ?? null; }
|
||||
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
|
||||
@@ -109,8 +114,7 @@ namespace AKSRT\Payment {
|
||||
}
|
||||
if (!isset(self::$rows[$id])) return new \WP_Error('aksrt_after_sale_state', '售后申请不存在', ['status' => 409]);
|
||||
self::$rows[$id]['status'] = $status;
|
||||
if ($note !== '') self::$rows[$id]['admin_note'] = $note;
|
||||
if ($status !== self::STATUS_PENDING) self::$rows[$id]['reviewed_at'] = current_time('mysql');
|
||||
self::$rows[$id]['handling_log'][] = ['status' => $status, 'note' => $note, 'source' => 'admin', 'created_at' => current_time('mysql')];
|
||||
return self::$rows[$id];
|
||||
}
|
||||
}
|
||||
@@ -121,6 +125,7 @@ namespace AKSRT\Payment {
|
||||
public static array $orders = [];
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function get($n): ?array { return self::$orders[(string) $n] ?? null; }
|
||||
public function get_admin_order_context(string $order_number): array { return ['customer_note' => '买家备注示例']; }
|
||||
public function get_orders_admin(array $args = [], string $search = ''): array { return array_values(self::$orders); }
|
||||
public function get_orders_count(array $args = [], string $search = ''): int { return count(self::$orders); }
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
@@ -207,11 +212,19 @@ require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php'
|
||||
5 => ['id' => 5, 'order_number' => 'ORD100', 'user_id' => 3, 'reason' => 'legacy-reason',
|
||||
'apply_form' => ['reason_label' => '商品与描述不符', 'materials' => [['label' => '联系方式', 'value' => 'wx-abc']], 'description' => '说明文字'],
|
||||
'images' => ['https://example.test/img1.png'], 'refund_amount' => '12.50', 'status' => 'pending',
|
||||
'admin_note' => '', 'gateway_result' => '', 'created_at' => '2026-10-01 10:00:00', 'reviewed_at' => ''],
|
||||
'handling_log' => [
|
||||
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
|
||||
['status' => 'processing', 'note' => '已联系仓库核实库存', 'source' => 'admin', 'created_at' => '2026-10-01 14:20:00'],
|
||||
],
|
||||
'created_at' => '2026-10-01 10:00:00'],
|
||||
6 => ['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', 'reviewed_at' => '2026-10-03 09:00:00'],
|
||||
'handling_log' => [
|
||||
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-02 11:00:00'],
|
||||
['status' => 'processing', 'note' => '资料已补齐,进入处理', 'source' => 'admin', 'created_at' => '2026-10-02 15:40:00'],
|
||||
['status' => 'rejected', 'note' => '超出七天无理由范围,已驳回', 'source' => 'admin', 'created_at' => '2026-10-03 09:00:00'],
|
||||
],
|
||||
'created_at' => '2026-10-02 11:00:00'],
|
||||
];
|
||||
\AKSRT\Payment\OrderRepository::$orders = [
|
||||
'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => ['product_title' => '测试商品'], 'currency' => 'CNY', 'amount' => '12.50', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-01 09:00:00', 'paid_at' => '2026-10-01 09:05:00'],
|
||||
@@ -230,6 +243,16 @@ require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php'
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// 订单明细页:售后记录单元格按工单渲染完整处理记录。
|
||||
if ($scenario === 'order-detail') {
|
||||
$_GET = ['page' => 'aksrt_orders', 'order' => 'ORD100'];
|
||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||
ob_start();
|
||||
$pay->render_orders_page();
|
||||
file_put_contents(__DIR__ . '/order-detail-rendered.html', ob_get_clean());
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($scenario !== '') {
|
||||
// The handler ends in exit(), so the call log is flushed on shutdown.
|
||||
register_shutdown_function(static function (): void {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* After-sale handling-log harness.
|
||||
*
|
||||
* Drives the REAL AfterSaleService against an in-memory $wpdb stand-in so the
|
||||
* handling-log contract is covered end to end: every state submission appends one
|
||||
* 「status + note + source + created_at」 entry, the log stays chronological, it is
|
||||
* capped, and malformed rows normalize instead of fataling.
|
||||
*
|
||||
* Usage: php after-sale-log-harness.php -> prints a JSON result object.
|
||||
*/
|
||||
|
||||
namespace {
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
|
||||
final class WP_Error {
|
||||
public function __construct(private string $code, private string $message, private array $data = []) {}
|
||||
public function get_error_code(): string { return $this->code; }
|
||||
public function get_error_message(): string { return $this->message; }
|
||||
public function get_error_data(): array { return $this->data; }
|
||||
}
|
||||
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
// ---------- WP function stubs ----------
|
||||
function __($s, $d = null) { return (string) $s; }
|
||||
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
||||
function esc_url_raw($s) { return (string) $s; }
|
||||
function sanitize_key($s) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $s)) ?? ''; }
|
||||
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
|
||||
function sanitize_textarea_field($s) { return trim(strip_tags((string) $s)); }
|
||||
function wp_json_encode($v, $f = 0) { return (string) json_encode($v, $f); }
|
||||
function current_user_can($c) { return true; }
|
||||
|
||||
// Monotonic clock: every append must land on a strictly later timestamp so the
|
||||
// chronological ordering assertion is meaningful.
|
||||
$GLOBALS['aksrt_log_now'] = strtotime('2026-10-01 10:00:00');
|
||||
function current_time($type = 'mysql', $gmt = false) {
|
||||
$GLOBALS['aksrt_log_now'] += 1;
|
||||
return gmdate('Y-m-d H:i:s', $GLOBALS['aksrt_log_now']);
|
||||
}
|
||||
|
||||
// ---------- In-memory $wpdb ----------
|
||||
final class FakeWpdb {
|
||||
public string $prefix = 'wp_';
|
||||
/** @var array<int,array<string,mixed>> keyed by after-sale id */
|
||||
public array $rows = [];
|
||||
public int $insert_id = 0;
|
||||
private int $next_id = 100;
|
||||
|
||||
public function prepare(string $sql, ...$args) {
|
||||
if (count($args) === 1 && is_array($args[0])) $args = $args[0];
|
||||
$i = 0;
|
||||
return preg_replace_callback('/%[sd]/', static function ($m) use (&$i, $args): string {
|
||||
if ($i >= count($args)) return $m[0];
|
||||
$value = $args[$i++];
|
||||
return $m[0][1] === 'd' ? (string) (int) $value : "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}, $sql) ?? $sql;
|
||||
}
|
||||
|
||||
public function get_var(string $sql) {
|
||||
if (preg_match("/GET_LOCK\('/", $sql)) return 1;
|
||||
if (preg_match("/RELEASE_LOCK\('/", $sql)) return 1;
|
||||
if (str_starts_with($sql, 'SELECT COUNT(*)')) return count($this->rows);
|
||||
throw new RuntimeException('unsupported get_var: ' . $sql);
|
||||
}
|
||||
|
||||
public function get_row(string $sql, $output = null) {
|
||||
if (preg_match("/WHERE id = (\d+)$/", $sql, $m)) return $this->rows[(int) $m[1]] ?? null;
|
||||
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
|
||||
$statuses = preg_match("/status IN \(([^)]*)\)/", $sql, $s) ? explode(',', str_replace("'", '', $s[1])) : null;
|
||||
$found = null;
|
||||
foreach ($this->rows as $row) {
|
||||
if ((string) ($row['order_number'] ?? '') !== $m[1]) continue;
|
||||
if ($statuses !== null && !in_array((string) ($row['status'] ?? ''), array_map('trim', $statuses), true)) continue;
|
||||
if ($found === null || (int) $row['id'] > (int) $found['id']) $found = $row;
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
throw new RuntimeException('unsupported get_row: ' . $sql);
|
||||
}
|
||||
|
||||
public function get_results(string $sql, $output = null) {
|
||||
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
|
||||
$rows = array_values(array_filter($this->rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $m[1]));
|
||||
usort($rows, static fn($a, $b): int => (int) $b['id'] <=> (int) $a['id']);
|
||||
return $rows;
|
||||
}
|
||||
throw new RuntimeException('unsupported get_results: ' . $sql);
|
||||
}
|
||||
|
||||
public function insert(string $table, array $data, array $formats = []) {
|
||||
$id = $this->next_id++;
|
||||
$this->rows[$id] = $data + ['id' => $id];
|
||||
$this->insert_id = $id;
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function update(string $table, array $data, array $where, array $formats = [], array $where_formats = []) {
|
||||
$id = (int) ($where['id'] ?? 0);
|
||||
if (!isset($this->rows[$id])) return false;
|
||||
$this->rows[$id] = array_merge($this->rows[$id], $data);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Domain stubs the service collaborates with ----------
|
||||
require __DIR__ . '/../../src/Ledger/AssetType.php';
|
||||
require __DIR__ . '/../../src/Ledger/AssetAmount.php';
|
||||
}
|
||||
|
||||
namespace AKSRT\Core {
|
||||
final class DBMigrator {
|
||||
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Payment {
|
||||
final class Order {
|
||||
public const STATUS_PAID = 'paid';
|
||||
}
|
||||
|
||||
final class OrderRepository {
|
||||
private static $i;
|
||||
/** @var array<string,array<string,mixed>> keyed by order number */
|
||||
public static array $orders = [];
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function get_user_order(int $user_id, string $order_number): ?array {
|
||||
return self::$orders[$order_number] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../../src/Payment/AfterSaleService.php';
|
||||
}
|
||||
|
||||
namespace {
|
||||
$GLOBALS['aksrt_wpdb'] = new FakeWpdb();
|
||||
// The service reaches for the real global $wpdb, so the stand-in has to live there.
|
||||
$GLOBALS['wpdb'] = $GLOBALS['aksrt_wpdb'];
|
||||
|
||||
/** @return list<string> */
|
||||
function run(): array {
|
||||
$wpdb = $GLOBALS['aksrt_wpdb'];
|
||||
$service = \AKSRT\Payment\AfterSaleService::get_instance();
|
||||
$order = ['order_number' => '20261001100000000001', 'user_id' => 7, 'status' => 'paid', 'currency' => 'CNY', 'amount' => '12.50'];
|
||||
\AKSRT\Payment\OrderRepository::$orders = [$order['order_number'] => $order];
|
||||
|
||||
// 1) create() seeds the log with the user's own submission.
|
||||
$wpdb->rows = [];
|
||||
$created = $service->create(7, $order['order_number'], '商品与描述不符');
|
||||
if ($created instanceof \WP_Error) return ['error' => 'create failed: ' . $created->get_error_message()];
|
||||
$log = $created['handling_log'];
|
||||
$results = [];
|
||||
$results[] = ['name' => 'create_seeds_single_user_entry', 'ok' => count($log) === 1
|
||||
&& $log[0]['status'] === 'pending'
|
||||
&& $log[0]['source'] === 'user'
|
||||
&& $log[0]['note'] === ''
|
||||
&& $log[0]['created_at'] === $created['created_at'],
|
||||
'detail' => $log];
|
||||
|
||||
// 2) Every admin submission appends, even with an empty note; order is chronological.
|
||||
$service->set_status((int) $created['id'], 'processing', '已联系仓库核实');
|
||||
$service->set_status((int) $created['id'], 'completed', '已原路退款');
|
||||
$service->set_status((int) $created['id'], 'rejected', '');
|
||||
$log = $service->get((int) $created['id'])['handling_log'];
|
||||
$results[] = ['name' => 'status_changes_append_even_without_note', 'ok' => count($log) === 4
|
||||
&& $log[1] === ['status' => 'processing', 'note' => '已联系仓库核实', 'source' => 'admin', 'created_at' => $log[1]['created_at']]
|
||||
&& $log[2]['status'] === 'completed' && $log[2]['note'] === '已原路退款'
|
||||
&& $log[3]['status'] === 'rejected' && $log[3]['note'] === ''
|
||||
&& $log[0]['created_at'] < $log[1]['created_at']
|
||||
&& $log[1]['created_at'] < $log[2]['created_at']
|
||||
&& $log[2]['created_at'] < $log[3]['created_at'],
|
||||
'detail' => $log];
|
||||
|
||||
// 3) A user-side cancel is logged as a user entry.
|
||||
$wpdb->rows = [];
|
||||
$second = $service->create(7, $order['order_number'], '拍错了');
|
||||
$service->cancel_for_user(7, $order['order_number']);
|
||||
$log = $service->get((int) $second['id'])['handling_log'];
|
||||
$last = end($log);
|
||||
$results[] = ['name' => 'cancel_appends_user_entry', 'ok' => count($log) === 2
|
||||
&& $last['status'] === 'cancelled' && $last['source'] === 'user' && $last['note'] === '',
|
||||
'detail' => $log];
|
||||
|
||||
// 4) The log is capped, keeping the most recent entries.
|
||||
$wpdb->rows = [];
|
||||
$seeded = [];
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
$seeded[] = ['status' => 'processing', 'note' => 'seed-' . $i, 'source' => 'admin', 'created_at' => sprintf('2026-10-01 10:%02d:00', $i)];
|
||||
}
|
||||
$wpdb->rows[900] = ['id' => 900, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing', 'handling_log' => wp_json_encode($seeded), 'created_at' => '2026-10-01 10:01:00'];
|
||||
$service->set_status(900, 'completed', '第五十条');
|
||||
$log = $service->get(900)['handling_log'];
|
||||
$results[] = ['name' => 'log_is_capped_at_50', 'ok' => count($log) === 50
|
||||
&& $log[0]['note'] === 'seed-2'
|
||||
&& end($log)['note'] === '第五十条',
|
||||
'detail' => ['count' => count($log), 'first' => $log[0]['note'], 'last' => end($log)['note']]];
|
||||
|
||||
// 5) Malformed rows normalize instead of fataling.
|
||||
$wpdb->rows[901] = ['id' => 901, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing',
|
||||
'handling_log' => wp_json_encode([
|
||||
['status' => 'pending', 'note' => 'ok', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
|
||||
['status' => '', 'note' => 'no status', 'source' => 'admin', 'created_at' => '2026-10-01 10:05:00'],
|
||||
['note' => 'not an array value', 'source' => 'admin', 'created_at' => '2026-10-01 10:06:00'],
|
||||
'garbage',
|
||||
['status' => 'completed', 'source' => 'mystery', 'created_at' => '2026-10-01 10:07:00'],
|
||||
])];
|
||||
$wpdb->rows[902] = ['id' => 902, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending', 'handling_log' => 'not-json'];
|
||||
$normalized = $service->get(901)['handling_log'];
|
||||
$broken = $service->get(902)['handling_log'];
|
||||
$results[] = ['name' => 'normalize_drops_malformed_entries', 'ok' => count($normalized) === 2
|
||||
&& $normalized[0]['status'] === 'pending'
|
||||
&& $normalized[1] === ['status' => 'completed', 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:07:00']
|
||||
&& $broken === [],
|
||||
'detail' => ['normalized' => $normalized, 'broken_json' => $broken]];
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
$results = run();
|
||||
$failed = array_values(array_filter($results, static fn($r): bool => empty($r['ok'])));
|
||||
echo json_encode(['passed' => count($results) - count($failed), 'failed' => count($failed), 'results' => $results], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
exit($failed ? 1 : 0);
|
||||
}
|
||||
@@ -240,6 +240,8 @@ test('order view model exposes the after-sale button matrix', () => {
|
||||
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, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/)
|
||||
// 处理记录取代 reviewed_at / completed_at / admin_note 三个单点字段
|
||||
assert.match(userOrders, /'handling_log'\s*=> array_values\(\(array\) \(\$latest\['handling_log'\] \?\? \[\]\)\),/)
|
||||
|
||||
// 仅已支付订单开放;开关关闭整体不处理售后
|
||||
assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/)
|
||||
@@ -452,3 +454,94 @@ test('after-sale assets, template buttons and runtime data are wired', () => {
|
||||
assert.match(field, /handle="\.aksrt-field__repeater-handle"/)
|
||||
assert.match(field, /function repeaterDragKey\(item\)/)
|
||||
})
|
||||
|
||||
test('every after-sale submission is logged with a status, note, source and time', () => {
|
||||
const service = read('src/Payment/AfterSaleService.php')
|
||||
|
||||
// 记录落在工单行内,按时间正序追加,超过上限只保留最近的一段
|
||||
assert.match(service, /private const MAX_LOG_ENTRIES = 50;/)
|
||||
assert.match(service, /private function append_log\(int \$id, array \$current, string \$status, string \$note, string \$source\): void \{/)
|
||||
assert.match(service, /array_slice\(\$log, -self::MAX_LOG_ENTRIES\)/)
|
||||
assert.match(service, /JSON_UNESCAPED_UNICODE \| JSON_UNESCAPED_SLASHES/)
|
||||
assert.match(service, /'handling_log' => \$this->encode_log\(\[\$this->log_entry\(self::STATUS_PENDING, '', self::SOURCE_USER, \$now\)\]\),/)
|
||||
assert.match(service, /public const SOURCE_ADMIN = 'admin';/)
|
||||
assert.match(service, /public const SOURCE_USER = 'user';/)
|
||||
|
||||
// 三条写入路径都必须留痕:用户申请 / 用户撤销 / 后台改状态
|
||||
assert.match(service, /create\(int \$user_id[\s\S]*?'handling_log'/)
|
||||
assert.match(service, /cancel_for_user\([\s\S]*?append_log\(\(int\) \$row\['id'\], \$row, self::STATUS_CANCELLED, '', self::SOURCE_USER\)/)
|
||||
assert.match(service, /set_status\([\s\S]*?append_log\(\$id, \$row, \$status, \$note, self::SOURCE_ADMIN\)/)
|
||||
|
||||
// set_status 现在是「读-改-写」JSON,必须与 create/cancel 共用订单锁
|
||||
assert.match(service, /set_status\(int \$id, string \$status, string \$note = ''\)[\s\S]*?GET_LOCK\(%s, 5\)[\s\S]*?RELEASE_LOCK\(%s\)/)
|
||||
|
||||
// 单点备注 / 单点时间戳 / 从未写入的渠道字段全部移除
|
||||
for (const dead of ['admin_note', 'reviewed_at', 'completed_at', 'gateway_result', 'gateway_request_id', 'gateway_refund_id']) {
|
||||
assert.ok(!service.includes(dead), `${dead} must be removed from AfterSaleService`)
|
||||
}
|
||||
|
||||
// 规范化:畸形条目丢弃,未知 source 归 admin,非数组整体视为空
|
||||
assert.match(service, /private function normalize_log\(\$log\): array \{/)
|
||||
assert.match(service, /\$row\['handling_log'\] = \$this->normalize_log\(json_decode\(\(string\) \(\$row\['handling_log'\] \?\? ''\), true\)\);/)
|
||||
|
||||
// 退款金额工具在 create() 里被调用,不能再次缺失(88669f69 曾误删导致 fatal)
|
||||
assert.match(service, /private function refund_amount\(array \$order\): \?string \{/)
|
||||
})
|
||||
|
||||
test('handling-log behaviour is covered against the real service', () => {
|
||||
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-log-harness.php')], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout)
|
||||
const payload = JSON.parse(result.stdout)
|
||||
assert.equal(payload.failed, 0, JSON.stringify(payload.results))
|
||||
const names = payload.results.map((entry) => entry.name)
|
||||
for (const name of [
|
||||
'create_seeds_single_user_entry',
|
||||
'status_changes_append_even_without_note',
|
||||
'cancel_appends_user_entry',
|
||||
'log_is_capped_at_50',
|
||||
'normalize_drops_malformed_entries',
|
||||
]) {
|
||||
assert.ok(names.includes(name), `missing handling-log scenario: ${name}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('frontend renders the handling log as a rope-knot timeline', () => {
|
||||
const js = read('assets/js/order-after-sale.js')
|
||||
const center = read('assets/js/order-center.js')
|
||||
|
||||
// 售后详细弹窗:处理记录时间线取代单行的「处理备注 / 完成时间」
|
||||
assert.ok(!js.includes("detailRow('处理备注'"), 'single admin-note row must be removed')
|
||||
assert.ok(!js.includes("detailRow('完成时间'"), 'single completed-at row must be removed')
|
||||
assert.match(js, /function handlingEntryTitle\(entry\)/)
|
||||
assert.match(js, /function handlingLogHtml\(log\)/)
|
||||
assert.match(js, /handlingLogHtml\(latest\.handling_log\)/)
|
||||
assert.match(js, /status === 'cancelled' \? '用户撤销售后' : '用户提交申请'/)
|
||||
assert.match(js, /'<ol class="aksrt-after-sale-timeline">'/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__knot/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__status/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__time/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__note/)
|
||||
|
||||
// 订单详情弹窗:每条工单同样渲染时间线,载荷带上 handling_log
|
||||
assert.ok(!center.includes('gatewayResultText'), 'dead gateway-result renderer must be removed')
|
||||
assert.ok(!center.includes('<dt>渠道结果</dt>'), 'dead 渠道结果 row must be removed')
|
||||
assert.ok(!center.includes('<dt>处理备注</dt>'), 'single 处理备注 row must be removed')
|
||||
assert.match(center, /function orderDetailHandlingLogHtml\(log\)/)
|
||||
assert.match(center, /orderDetailHandlingLogHtml\(item\.handling_log\)/)
|
||||
assert.match(center, /handling_log: Array\.isArray\(item\.handling_log\) \? item\.handling_log : \[\],/)
|
||||
|
||||
// 时间线样式:竖线穿节点、末项不收口,左列固定宽度、右列自适应
|
||||
const css = read('assets/css/frontend/modal-runtime.css')
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__item \{ --as-accent: var\(--modal-muted\); display: grid; grid-template-columns: 88px minmax\(0, 1fr\)/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__aside::before \{ content: ""; position: absolute; top: 12px; bottom: -2px/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__item:last-child \.aksrt-after-sale-timeline__aside::before \{ display: none; \}/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__knot/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__note/)
|
||||
|
||||
// 打包产物同步:public.css / public.min.css 都含时间线规则
|
||||
assert.ok(read('assets/css/build/public.css').includes('.aksrt-after-sale-timeline__knot'))
|
||||
assert.ok(read('assets/css/build/public.min.css').includes('.aksrt-after-sale-timeline__knot'))
|
||||
})
|
||||
|
||||
@@ -54,7 +54,14 @@ test('after-sale submissions are order-locked and admins set status directly', (
|
||||
const schema = read('src/Core/DBMigratorPart2.php')
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?gateway_request_id[\s\S]*?gateway_result[\s\S]*?completed_at/)
|
||||
// 处理记录落在工单行内:每次状态提交一条「状态 + 备注 + 来源 + 时间」。
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?handling_log LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
|
||||
for (const dead of ['admin_note', 'gateway_request_id', 'gateway_refund_id', 'gateway_result', 'reviewed_at', 'completed_at']) {
|
||||
// 只截到本表 CREATE 语句结束,避免误判其它表里同名的 completed_at
|
||||
const block = schema.slice(schema.indexOf('CREATE TABLE {$prefix}aksrt_order_aftersale'))
|
||||
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\)/)
|
||||
assert.match(afterSale, /Order::STATUS_PAID/)
|
||||
assert.match(afterSale, /public function set_status\(int \$id, string \$status, string \$note = ''\)/)
|
||||
|
||||
@@ -36,7 +36,6 @@ $tables = [
|
||||
'aksrt_follow',
|
||||
'aksrt_user_restriction',
|
||||
'aksrt_product_variant',
|
||||
'aksrt_after_sale',
|
||||
'aksrt_order_shipping',
|
||||
'aksrt_card_key',
|
||||
'aksrt_revenue',
|
||||
|
||||
Reference in New Issue
Block a user