feat(after-sale): 售后面板新增「标记订单已退款」勾选项
展开的操作面板在状态下拉与备注旁新增「标记订单已退款」勾选框:勾选后点击确认,会先把关联订单推到已退款,再更新售后工单状态。订单本已退款时勾选框呈现为不可改的已选状态,且不提交该标记。 顺序不能反:OrderRepository::update_status 的退款闸门要求售后工单仍为待审核/处理中,一旦 set_status 先执行就不再放行。因此退款失败时整次操作中止,避免出现「工单已关、订单未退」的半完成状态。订单号由服务端按 after_sale_id 反查,不经客户端传递。 AfterSaleService::get() 由 private 改为 public 以支撑该反查。dev harness 扩展为可驱动 handler 的六个场景,覆盖勾选/未勾选、重复退款、非已支付订单、已关闭工单与非法状态。
This commit is contained in:
@@ -417,6 +417,7 @@ class PayOrders {
|
|||||||
. '.aksrt-as-review-main{flex:1 1 460px;min-width:0;display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 12px;}'
|
. '.aksrt-as-review-main{flex:1 1 460px;min-width:0;display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 12px;}'
|
||||||
. '.aksrt-as-review-main p{margin:0;}'
|
. '.aksrt-as-review-main p{margin:0;}'
|
||||||
. '.aksrt-status-select{min-width:150px;}'
|
. '.aksrt-status-select{min-width:150px;}'
|
||||||
|
. '.aksrt-as-refund-flag{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin:0;}'
|
||||||
. '.aksrt-as-review textarea{flex:1 1 240px;min-width:0;box-sizing:border-box;margin:0;}'
|
. '.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-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}'
|
||||||
. '.aksrt-as-field--wide{grid-column:1/-1;}'
|
. '.aksrt-as-field--wide{grid-column:1/-1;}'
|
||||||
@@ -462,6 +463,7 @@ class PayOrders {
|
|||||||
if ($desc_cell === '') $desc_cell = '—';
|
if ($desc_cell === '') $desc_cell = '—';
|
||||||
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
|
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
|
||||||
$admin_note = (string) ($item['admin_note'] ?? '');
|
$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">';
|
echo '<tr class="aksrt-as-row">';
|
||||||
echo '<td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td>';
|
echo '<td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td>';
|
||||||
@@ -509,6 +511,10 @@ class PayOrders {
|
|||||||
echo '<p><select class="aksrt-status-select">' . $status_options . '</select></p>';
|
echo '<p><select class="aksrt-status-select">' . $status_options . '</select></p>';
|
||||||
echo '<textarea class="aksrt-review-note" rows="2" placeholder="' . esc_attr__('填写处理备注(可留空)', 'aksrt') . '"></textarea>';
|
echo '<textarea class="aksrt-review-note" rows="2" placeholder="' . esc_attr__('填写处理备注(可留空)', 'aksrt') . '"></textarea>';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
|
// 勾选后在确认时把关联订单推到已退款;订单本已退款时呈现为不可改的已选状态
|
||||||
|
echo '<label class="aksrt-as-refund-flag"><input type="checkbox" class="aksrt-mark-order-refunded" value="1"'
|
||||||
|
. ($order_refunded ? ' checked="checked" disabled' : '')
|
||||||
|
. ' /> ' . esc_html__('标记订单已退款', 'aksrt') . '</label>';
|
||||||
echo '<div class="aksrt-as-review-actions">';
|
echo '<div class="aksrt-as-review-actions">';
|
||||||
echo '<button type="button" class="button aksrt-after-sale-cancel">' . esc_html__('取消', 'aksrt') . '</button>';
|
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"'
|
echo '<button type="button" class="button button-primary aksrt-after-sale-confirm"'
|
||||||
@@ -543,11 +549,15 @@ class PayOrders {
|
|||||||
. 'var panel=a.closest(".aksrt-as-detail-row");'
|
. 'var panel=a.closest(".aksrt-as-detail-row");'
|
||||||
. 'var sel=panel?panel.querySelector(".aksrt-status-select"):null;'
|
. 'var sel=panel?panel.querySelector(".aksrt-status-select"):null;'
|
||||||
. 'var noteEl=panel?panel.querySelector(".aksrt-review-note"):null;'
|
. 'var noteEl=panel?panel.querySelector(".aksrt-review-note"):null;'
|
||||||
|
. 'var refundEl=panel?panel.querySelector(".aksrt-mark-order-refunded"):null;'
|
||||||
|
. 'var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'
|
||||||
. 'var opt=sel?sel.options[sel.selectedIndex]:null;'
|
. 'var opt=sel?sel.options[sel.selectedIndex]:null;'
|
||||||
. 'if(!sel||!opt||opt.disabled){alert(' . wp_json_encode(__('请选择售后状态。', 'aksrt')) . ');return;}'
|
. 'if(!sel||!opt||opt.disabled){alert(' . wp_json_encode(__('请选择售后状态。', 'aksrt')) . ');return;}'
|
||||||
. 'if(!confirm(tConfirmStatus+"「"+opt.text+"」?"))return;'
|
. 'var confirmText=tConfirmStatus+"「"+opt.text+"」?";'
|
||||||
|
. 'if(markRefund)confirmText+=' . wp_json_encode(__('同时会将关联订单标记为已退款。', 'aksrt')) . ';'
|
||||||
|
. 'if(!confirm(confirmText))return;'
|
||||||
. 'var f=document.createElement("form");f.method="post";f.action=postUrl;'
|
. 'var f=document.createElement("form");f.method="post";f.action=postUrl;'
|
||||||
. '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:""};'
|
. 'var fields={action:"aksrt_after_sale_update",after_sale_id:a.getAttribute("data-id"),after_sale_status:sel.value,mark_order_refunded:markRefund?"1":"",_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);});'
|
. '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();'
|
. 'document.body.appendChild(f);f.submit();'
|
||||||
. '});'
|
. '});'
|
||||||
@@ -560,12 +570,43 @@ class PayOrders {
|
|||||||
check_admin_referer('aksrt_after_sale_status_' . $id);
|
check_admin_referer('aksrt_after_sale_status_' . $id);
|
||||||
$status = isset($_POST['after_sale_status']) ? sanitize_key((string) wp_unslash($_POST['after_sale_status'])) : '';
|
$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'])) : '';
|
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
|
||||||
|
$mark_refund = isset($_POST['mark_order_refunded']) && (string) wp_unslash($_POST['mark_order_refunded']) === '1';
|
||||||
|
|
||||||
|
// 勾选「标记订单已退款」时先落订单状态:售后工单一旦离开 pending/processing,
|
||||||
|
// OrderRepository 的退款闸门就不再放行,所以顺序不能反。
|
||||||
|
if ($mark_refund && $this->mark_related_order_refunded($id) === 'failed') {
|
||||||
|
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, ['aksrt_pay_msg' => 'after_sale_order_refund_failed']));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$result = \AKSRT\Payment\AfterSaleService::get_instance()->set_status($id, $status, $note);
|
$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'];
|
$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);
|
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
|
||||||
|
elseif ($mark_refund) $args['aksrt_pay_msg'] = 'after_sale_updated_order_refunded';
|
||||||
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, $args)); exit;
|
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, $args)); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把售后申请关联的订单推到已退款。仅在该工单仍为待审核/处理中时放行,
|
||||||
|
* 避免对已关闭的工单或非已支付订单误标退款。
|
||||||
|
*
|
||||||
|
* @return string 'done' | 'failed'
|
||||||
|
*/
|
||||||
|
private function mark_related_order_refunded(int $after_sale_id): string {
|
||||||
|
if (!class_exists('\AKSRT\Payment\AfterSaleService') || !class_exists('\AKSRT\Payment\OrderRepository')) return 'failed';
|
||||||
|
$service = \AKSRT\Payment\AfterSaleService::get_instance();
|
||||||
|
$row = $service->get($after_sale_id);
|
||||||
|
$order_number = is_array($row) ? (string) ($row['order_number'] ?? '') : '';
|
||||||
|
if ($order_number === '' || !$service->can_finalize_order_refund($after_sale_id, $order_number)) return 'failed';
|
||||||
|
|
||||||
|
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||||
|
$order = $repo->get($order_number);
|
||||||
|
if (is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) return 'done';
|
||||||
|
return $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
|
||||||
|
'allow_refund' => true,
|
||||||
|
'after_sale_id' => $after_sale_id,
|
||||||
|
]) ? 'done' : 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
public function handle_update_order_status(): void {
|
public function handle_update_order_status(): void {
|
||||||
if (!current_user_can(self::CAPABILITY)) {
|
if (!current_user_can(self::CAPABILITY)) {
|
||||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||||
@@ -745,6 +786,8 @@ class PayOrders {
|
|||||||
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
|
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
|
||||||
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
|
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
|
||||||
'after_sale_updated' => __('售后状态已更新。', 'aksrt'),
|
'after_sale_updated' => __('售后状态已更新。', 'aksrt'),
|
||||||
|
'after_sale_updated_order_refunded' => __('售后状态已更新,关联订单已标记为已退款。', 'aksrt'),
|
||||||
|
'after_sale_order_refund_failed' => __('关联订单标记已退款失败:需售后工单仍为待审核/处理中,且订单为已支付。', 'aksrt'),
|
||||||
'after_sale_update_failed' => __('售后状态更新失败,请查看申请状态和错误原因。', 'aksrt'),
|
'after_sale_update_failed' => __('售后状态更新失败,请查看申请状态和错误原因。', 'aksrt'),
|
||||||
];
|
];
|
||||||
if (!isset($map[$code])) {
|
if (!isset($map[$code])) {
|
||||||
|
|||||||
@@ -320,7 +320,8 @@ final class AfterSaleService {
|
|||||||
return is_array($row) ? $this->normalize($row) : null;
|
return is_array($row) ? $this->normalize($row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function get(int $id): ?array {
|
/** 按 ID 读取售后申请(含关联订单号),供后台操作面板解析关联订单。 */
|
||||||
|
public function get(int $id): ?array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
|
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
|
||||||
return is_array($row) ? $this->normalize($row) : null;
|
return is_array($row) ? $this->normalize($row) : null;
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
<?php
|
<?php
|
||||||
// Dev harness: render the REAL PayOrders::render_after_sales_page() with WP stubs,
|
// Dev harness: render the REAL PayOrders::render_after_sales_page() with WP stubs,
|
||||||
// capturing the generated HTML so the inline JS and markup can be verified offline.
|
// and drive PayOrders::handle_after_sale_status() through the 「标记订单已退款」 flow.
|
||||||
|
// Usage:
|
||||||
|
// php render-after-sales-harness.php -> render page, write after-sales-rendered.html
|
||||||
|
// php render-after-sales-harness.php <scenario> -> run one handler scenario, print JSON log
|
||||||
namespace {
|
namespace {
|
||||||
define('ABSPATH', __DIR__ . '/');
|
define('ABSPATH', __DIR__ . '/');
|
||||||
|
|
||||||
|
final class HarnessLog {
|
||||||
|
/** @var list<array<string,mixed>> */
|
||||||
|
public static array $events = [];
|
||||||
|
public static function add(string $what, array $data = []): void {
|
||||||
|
self::$events[] = ['what' => $what] + $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- WP function stubs ----------
|
// ---------- WP function stubs ----------
|
||||||
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
||||||
function esc_attr($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
function esc_attr($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
||||||
@@ -27,6 +45,11 @@ function admin_url($p = '') { return 'https://example.test/wp-admin/' . $p; }
|
|||||||
function add_query_arg($k, $v = null, $u = null) { if (is_array($k)) { $u = $v ?: ''; $q = http_build_query($k); return $u . (str_contains($u, '?') ? '&' : '?') . $q; } return ($u ?: '') . (str_contains(($u ?: ''), '?') ? '&' : '?') . $k . '=' . $v; }
|
function add_query_arg($k, $v = null, $u = null) { if (is_array($k)) { $u = $v ?: ''; $q = http_build_query($k); return $u . (str_contains($u, '?') ? '&' : '?') . $q; } return ($u ?: '') . (str_contains(($u ?: ''), '?') ? '&' : '?') . $k . '=' . $v; }
|
||||||
function wp_json_encode($v, $f = 0) { return json_encode($v, $f); }
|
function wp_json_encode($v, $f = 0) { return json_encode($v, $f); }
|
||||||
function current_user_can($c) { return true; }
|
function current_user_can($c) { return true; }
|
||||||
|
function current_time($type = 'mysql') { return '2026-10-08 12:00:00'; }
|
||||||
|
function is_wp_error($t) { return $t instanceof WP_Error; }
|
||||||
|
function absint($v) { return abs((int) $v); }
|
||||||
|
function check_admin_referer($a = -1, $n = '_wpnonce') { \HarnessLog::add('check_admin_referer', ['action' => (string) $a]); }
|
||||||
|
function wp_safe_redirect($url) { \HarnessLog::add('wp_safe_redirect', ['url' => (string) $url]); }
|
||||||
function wp_die($m) { throw new Exception('wp_die: ' . $m); }
|
function wp_die($m) { throw new Exception('wp_die: ' . $m); }
|
||||||
function get_userdata($id) { return $id > 0 ? (object) ['display_name' => 'User' . $id] : null; }
|
function get_userdata($id) { return $id > 0 ? (object) ['display_name' => 'User' . $id] : null; }
|
||||||
function get_the_title($id) { return 'Product' . $id; }
|
function get_the_title($id) { return 'Product' . $id; }
|
||||||
@@ -41,23 +64,50 @@ namespace AKSRT\Payment {
|
|||||||
public const STATUS_CANCELLED = 'cancelled';
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
public const STATUS_COMPLETED = 'completed';
|
public const STATUS_COMPLETED = 'completed';
|
||||||
private static $i;
|
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 static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||||
public function admin_list(array $args = []): array {
|
public function admin_list(array $args = []): array { return array_values(self::$rows); }
|
||||||
return [
|
public function get(int $id): ?array { return self::$rows[$id] ?? null; }
|
||||||
['id' => 5, 'order_number' => 'ORD100', 'user_id' => 3, 'reason' => 'legacy-reason',
|
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
|
||||||
'apply_form' => ['reason_label' => '商品与描述不符', 'materials' => [['label' => '联系方式', 'value' => 'wx-abc']], 'description' => '说明文字'],
|
$row = self::$rows[$after_sale_id] ?? null;
|
||||||
'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'],
|
$ok = is_array($row)
|
||||||
['id' => 6, 'order_number' => 'ORD101', 'user_id' => 4, 'reason' => 'r2',
|
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
|
||||||
'apply_form' => null, 'images' => [], 'refund_amount' => '8.00', 'status' => 'processing',
|
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
|
||||||
'admin_note' => '已有备注', 'gateway_result' => json_encode(['status' => 'manual', 'message' => '线下退款']), 'created_at' => '2026-10-02 11:00:00'],
|
\HarnessLog::add('can_finalize_order_refund', ['id' => $after_sale_id, 'order' => $order_number, 'ticket_status' => is_array($row) ? (string) ($row['status'] ?? '') : null, 'ok' => $ok]);
|
||||||
];
|
return $ok;
|
||||||
|
}
|
||||||
|
public function set_status(int $id, string $status, string $note = '') {
|
||||||
|
\HarnessLog::add('set_status', ['id' => $id, 'status' => $status, 'note' => $note]);
|
||||||
|
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', '售后状态无效', ['status' => 400]);
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
return self::$rows[$id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class OrderRepository {
|
class OrderRepository {
|
||||||
private static $i;
|
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 static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||||
public function get($n): array { return ['order_number' => $n, 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'status' => 'paid']; }
|
public function get($n): ?array { return self::$orders[(string) $n] ?? null; }
|
||||||
|
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||||
|
\HarnessLog::add('update_status', ['order' => $order_number, 'status' => $new_status, 'extra' => $extra]);
|
||||||
|
// Mirrors OrderRepository::update_status(): refund needs allow_refund, an active
|
||||||
|
// after-sale id, and a valid paid -> refunded transition.
|
||||||
|
if (empty($extra['allow_refund'])) return false;
|
||||||
|
$order = self::$orders[$order_number] ?? null;
|
||||||
|
if (!is_array($order) || (string) ($order['status'] ?? '') !== 'paid') return false;
|
||||||
|
self::$orders[$order_number]['status'] = $new_status;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UserOrders {
|
class UserOrders {
|
||||||
private static $i;
|
private static $i;
|
||||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||||
@@ -67,6 +117,10 @@ namespace AKSRT\Payment {
|
|||||||
|
|
||||||
namespace AKSRT\Core {
|
namespace AKSRT\Core {
|
||||||
class DomainModel {
|
class DomainModel {
|
||||||
|
public const ORDER_STATUS_PENDING = 'pending';
|
||||||
|
public const ORDER_STATUS_PAID = 'paid';
|
||||||
|
public const ORDER_STATUS_REFUNDED = 'refunded';
|
||||||
|
public const ORDER_STATUS_CLOSED = 'closed';
|
||||||
public static function resolve_order_product_type(string $t, array $m): string { return $t; }
|
public static function resolve_order_product_type(string $t, array $m): string { return $t; }
|
||||||
public static function order_product_type_labels(): array { return ['reading' => '付费阅读']; }
|
public static function order_product_type_labels(): array { return ['reading' => '付费阅读']; }
|
||||||
}
|
}
|
||||||
@@ -83,11 +137,68 @@ namespace AKSRT\Settings {
|
|||||||
namespace {
|
namespace {
|
||||||
require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php';
|
require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php';
|
||||||
|
|
||||||
|
$scenario = $argv[1] ?? '';
|
||||||
|
|
||||||
|
// Seed data shared by the render pass and every handler scenario.
|
||||||
|
\AKSRT\Payment\AfterSaleService::$rows = [
|
||||||
|
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' => ''],
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
\AKSRT\Payment\OrderRepository::$orders = [
|
||||||
|
'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'status' => 'paid'],
|
||||||
|
'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'status' => 'paid'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($scenario !== '') {
|
||||||
|
// The handler ends in exit(), so the call log is flushed on shutdown.
|
||||||
|
register_shutdown_function(static function (): void {
|
||||||
|
echo json_encode(\HarnessLog::$events, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||||
|
echo 'rows=' . json_encode(array_map(static fn($r): string => (string) ($r['status'] ?? ''), \AKSRT\Payment\AfterSaleService::$rows), JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo 'orders=' . json_encode(array_map(static fn($o): string => (string) ($o['status'] ?? ''), \AKSRT\Payment\OrderRepository::$orders), JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
});
|
||||||
|
|
||||||
|
switch ($scenario) {
|
||||||
|
case 'unchecked':
|
||||||
|
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'admin_note' => '已退款'];
|
||||||
|
break;
|
||||||
|
case 'checked_paid':
|
||||||
|
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||||
|
break;
|
||||||
|
case 'checked_already_refunded':
|
||||||
|
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded';
|
||||||
|
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||||
|
break;
|
||||||
|
case 'checked_unpaid_order':
|
||||||
|
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending';
|
||||||
|
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||||
|
break;
|
||||||
|
case 'checked_closed_ticket':
|
||||||
|
\AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed';
|
||||||
|
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'cancelled', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||||
|
break;
|
||||||
|
case 'bad_status':
|
||||||
|
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'bogus', 'admin_note' => ''];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fwrite(STDERR, "unknown scenario: {$scenario}\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||||
|
$pay->handle_after_sale_status();
|
||||||
|
// Unreachable: the handler ends in exit().
|
||||||
|
}
|
||||||
|
|
||||||
$_GET = ['page' => 'aksrt_after_sales'];
|
$_GET = ['page' => 'aksrt_after_sales'];
|
||||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||||
|
|
||||||
$ref = new ReflectionMethod($pay, 'render_after_sales_page');
|
$ref = new ReflectionMethod($pay, 'render_after_sales_page');
|
||||||
$ref->setAccessible(true);
|
|
||||||
ob_start();
|
ob_start();
|
||||||
$ref->invoke($pay);
|
$ref->invoke($pay);
|
||||||
$html = ob_get_clean();
|
$html = ob_get_clean();
|
||||||
|
|||||||
@@ -158,6 +158,23 @@ test('order detail page drops tabs; after-sales becomes a standalone submenu', (
|
|||||||
assert.ok(page.includes('aksrt_after_sale_status_'), 'per-row nonce covers the status update')
|
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('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('justify-content:flex-end'), 'confirm/cancel buttons right-aligned')
|
||||||
|
// 「标记订单已退款」:勾选后确认时把关联订单推到已退款
|
||||||
|
assert.ok(page.includes('aksrt-mark-order-refunded'), 'refund checkbox inside expanded panel')
|
||||||
|
assert.ok(page.includes('标记订单已退款'), 'refund checkbox label')
|
||||||
|
assert.ok(page.includes('mark_order_refunded:markRefund?"1":""'), 'refund flag posted only when checked')
|
||||||
|
assert.ok(page.includes('var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'),
|
||||||
|
'a disabled (already refunded) checkbox submits nothing')
|
||||||
|
assert.ok(page.includes('aksrt-as-refund-flag'), 'refund checkbox keeps its own row in the panel')
|
||||||
|
// 订单退款必须早于售后状态更新:工单一离开 pending/processing,OrderRepository 的退款闸门就不再放行
|
||||||
|
assert.ok(page.indexOf('mark_related_order_refunded($id)') < page.indexOf('set_status($id'),
|
||||||
|
'order refund runs before the after-sale status update')
|
||||||
|
assert.ok(page.includes('DomainModel::ORDER_STATUS_REFUNDED'), 'refund marking targets the refunded order status')
|
||||||
|
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
|
||||||
|
assert.ok(page.includes("'after_sale_id' => $after_sale_id"), 'refund is bound to this after-sale application')
|
||||||
|
assert.ok(page.includes('after_sale_order_refund_failed'), 'refund failure is reported to the admin')
|
||||||
|
assert.ok(page.includes('after_sale_updated_order_refunded'), 'refund success is reported to the admin')
|
||||||
|
const service = read('src/Payment/AfterSaleService.php')
|
||||||
|
assert.match(service, /public function get\(int \$id\)/, 'after-sale lookup is exposed for the admin handler')
|
||||||
assert.ok(page.includes('.aksrt-as-detail-row[hidden]'), 'hidden attribute guard keeps accordion closed')
|
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('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('class="row-actions"'), 'no hover-only row-actions')
|
||||||
|
|||||||
Reference in New Issue
Block a user