feat(admin): 订单明细页已支付订单新增「退款」按钮
操作栏「详情」右侧为已支付订单渲染「退款」按钮:点击经 aksrt_pay_refund_order 把订单推到已退款。 退款沿既有的受闸路径执行:OrderRepository::update_status 要求 allow_refund,且 can_finalize_order_refund 要求该售后工单仍为待审核/处理中。因此工单号由服务端按订单号反查 find_active 得到,客户端只提交订单号;没有进行中的工单时整次操作中止并给出可操作提示,不会出现订单已退款但无工单依据的状态。 按钮仅在已支付订单上渲染——已退款是已支付的唯一后继状态,其他状态点击也只会在服务端被拒。附带清理 AfterSaleService::find_active 上方一处早已错配的 @param 文档注释(描述的是 \,与该函数签名无关)。
This commit is contained in:
@@ -30,6 +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_pay_refund_order', [$this, 'handle_refund_order']);
|
||||
add_action('admin_post_aksrt_after_sale_update', [$this, 'handle_after_sale_status']);
|
||||
}
|
||||
|
||||
@@ -254,6 +255,15 @@ class PayOrders {
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '">' . esc_html__('详情', 'aksrt') . '</a>';
|
||||
// 已支付是已退款的唯一前置状态;退款仍需落到一条进行中的售后工单上。
|
||||
if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;" onsubmit="return confirm(\'' . esc_js(__('确认将该订单标记为已退款?不可恢复。', 'aksrt')) . '\');">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_refund_order" />';
|
||||
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
|
||||
wp_nonce_field('aksrt_pay_refund_order_' . $order_number);
|
||||
submit_button(__('退款', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
$this->render_order_quick_status_buttons($order_number, $status_key);
|
||||
if ($status_key === 'closed') {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;" onsubmit="return confirm(\'' . esc_js(__('确认永久删除此订单?不可恢复。', 'aksrt')) . '\');">';
|
||||
@@ -631,6 +641,47 @@ class PayOrders {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单列表「退款」:把已支付订单推到已退款。退款必须落到一条进行中的售后工单上,
|
||||
* 工单号由服务端按订单号反查,不经客户端传递。
|
||||
*/
|
||||
public function handle_refund_order(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
|
||||
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
|
||||
check_admin_referer('aksrt_pay_refund_order_' . $order_number);
|
||||
|
||||
$code = 'order_refund_failed';
|
||||
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->get($order_number);
|
||||
if (!is_array($order)) {
|
||||
$code = 'order_refund_missing';
|
||||
} elseif ((string) ($order['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
|
||||
$code = 'order_refund_not_paid';
|
||||
} else {
|
||||
$ticket = class_exists('\AKSRT\Payment\AfterSaleService')
|
||||
? \AKSRT\Payment\AfterSaleService::get_instance()->find_active($order_number)
|
||||
: null;
|
||||
if (!is_array($ticket)) {
|
||||
$code = 'order_refund_no_ticket';
|
||||
} else {
|
||||
$ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
|
||||
'allow_refund' => true,
|
||||
'after_sale_id' => (int) ($ticket['id'] ?? 0),
|
||||
'note' => __('管理员在订单列表中标记退款', 'aksrt'),
|
||||
]);
|
||||
$code = $ok ? 'order_refunded' : 'order_refund_failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wp_safe_redirect($this->page_url('aksrt_orders', ['aksrt_pay_msg' => $code]));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handle_update_order_note(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
@@ -769,6 +820,11 @@ class PayOrders {
|
||||
$map = [
|
||||
'order_updated' => __('订单状态已更新。', 'aksrt'),
|
||||
'order_failed' => __('订单状态更新失败。', 'aksrt'),
|
||||
'order_refunded' => __('订单已标记为已退款。', 'aksrt'),
|
||||
'order_refund_failed' => __('订单标记退款失败。', 'aksrt'),
|
||||
'order_refund_missing' => __('订单不存在。', 'aksrt'),
|
||||
'order_refund_not_paid' => __('仅已支付订单可以标记退款。', 'aksrt'),
|
||||
'order_refund_no_ticket' => __('该订单没有进行中的售后工单,无法标记退款;请先在「售后服务」中处理。', 'aksrt'),
|
||||
'order_note_updated' => __('订单备注已保存。', 'aksrt'),
|
||||
'order_note_failed' => __('订单备注保存失败。', 'aksrt'),
|
||||
'orders_cleared' => sprintf(
|
||||
|
||||
@@ -313,8 +313,8 @@ final class AfterSaleService {
|
||||
return $this->get($id);
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $entries */
|
||||
private function find_active(string $number): ?array {
|
||||
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
|
||||
public 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') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
// Dev harness: render the REAL PayOrders::render_after_sales_page() with WP stubs,
|
||||
// and drive PayOrders::handle_after_sale_status() through the 「标记订单已退款」 flow.
|
||||
// Dev harness: render the REAL PayOrders pages with WP stubs, and drive the refund
|
||||
// handlers through their scenarios.
|
||||
// Usage:
|
||||
// php render-after-sales-harness.php -> render page, write after-sales-rendered.html
|
||||
// php render-after-sales-harness.php -> after-sales page -> after-sales-rendered.html
|
||||
// php render-after-sales-harness.php orders -> orders list page -> orders-rendered.html
|
||||
// php render-after-sales-harness.php <scenario> -> run one handler scenario, print JSON log
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
@@ -54,9 +55,23 @@ 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_the_title($id) { return 'Product' . $id; }
|
||||
function is_admin() { return true; }
|
||||
function paginate_links($args = []) { return ''; }
|
||||
}
|
||||
|
||||
namespace AKSRT\Ledger {
|
||||
final class AssetAmount {
|
||||
public static function balance($value): self { return new self(); }
|
||||
public function decimal(): string { return '0.00'; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Payment {
|
||||
class Gateway {
|
||||
private static $i;
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function get_all(): array { return ['balance' => (object) [], 'points' => (object) []]; }
|
||||
}
|
||||
|
||||
class AfterSaleService {
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
@@ -77,6 +92,16 @@ namespace AKSRT\Payment {
|
||||
\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 find_active(string $order_number): ?array {
|
||||
$found = null;
|
||||
foreach (self::$rows as $row) {
|
||||
if ((string) ($row['order_number'] ?? '') !== $order_number) continue;
|
||||
if (!in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) continue;
|
||||
if ($found === null || (int) ($row['id'] ?? 0) > (int) ($found['id'] ?? 0)) $found = $row;
|
||||
}
|
||||
\HarnessLog::add('find_active', ['order' => $order_number, 'ticket' => $found === null ? null : (int) ($found['id'] ?? 0)]);
|
||||
return $found;
|
||||
}
|
||||
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)) {
|
||||
@@ -96,10 +121,13 @@ 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_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 {
|
||||
\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 ($new_status !== 'refunded') return true;
|
||||
if (empty($extra['allow_refund'])) return false;
|
||||
$order = self::$orders[$order_number] ?? null;
|
||||
if (!is_array($order) || (string) ($order['status'] ?? '') !== 'paid') return false;
|
||||
@@ -121,8 +149,42 @@ namespace AKSRT\Core {
|
||||
public const ORDER_STATUS_PAID = 'paid';
|
||||
public const ORDER_STATUS_REFUNDED = 'refunded';
|
||||
public const ORDER_STATUS_CLOSED = 'closed';
|
||||
public const ORDER_PRODUCT_RESOURCE = 'resource';
|
||||
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 const ORDER_PRODUCT_READING = 'reading';
|
||||
public const ORDER_PRODUCT_DOWNLOAD = 'download';
|
||||
public const ORDER_PRODUCT_IMAGE = 'image';
|
||||
public const ORDER_PRODUCT_VIDEO = 'video';
|
||||
public const ORDER_PRODUCT_VIP = 'vip';
|
||||
public const ORDER_PRODUCT_RECHARGE = 'recharge';
|
||||
public const ORDER_PRODUCT_POINTS = 'points';
|
||||
public static function order_product_type_labels(): array {
|
||||
return [
|
||||
self::ORDER_PRODUCT_READING => '付费阅读',
|
||||
self::ORDER_PRODUCT_DOWNLOAD => '付费下载',
|
||||
self::ORDER_PRODUCT_IMAGE => '付费图片',
|
||||
self::ORDER_PRODUCT_VIDEO => '付费视频',
|
||||
self::ORDER_PRODUCT_VIP => '购买会员',
|
||||
self::ORDER_PRODUCT_RECHARGE => '余额充值',
|
||||
self::ORDER_PRODUCT_POINTS => '购买积分',
|
||||
];
|
||||
}
|
||||
public static function order_status_labels(): array {
|
||||
return [
|
||||
self::ORDER_STATUS_PENDING => '待支付',
|
||||
self::ORDER_STATUS_PAID => '已支付',
|
||||
self::ORDER_STATUS_REFUNDED => '已退款',
|
||||
self::ORDER_STATUS_CLOSED => '已关闭',
|
||||
];
|
||||
}
|
||||
public static function order_transitions(): array {
|
||||
return [
|
||||
self::ORDER_STATUS_PENDING => [self::ORDER_STATUS_PAID, self::ORDER_STATUS_CLOSED],
|
||||
self::ORDER_STATUS_PAID => [self::ORDER_STATUS_REFUNDED],
|
||||
self::ORDER_STATUS_REFUNDED => [],
|
||||
self::ORDER_STATUS_CLOSED => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,10 +213,22 @@ namespace {
|
||||
'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'],
|
||||
'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'],
|
||||
'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '8.00', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-02 09:00:00', 'paid_at' => '2026-10-02 09:05:00'],
|
||||
'ORD102' => ['order_number' => 'ORD102', 'user_id' => 5, 'product_id' => 11, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '20.00', 'payment_method' => 'balance', 'status' => 'refunded', 'created_at' => '2026-10-03 09:00:00', 'paid_at' => '2026-10-03 09:05:00'],
|
||||
'ORD103' => ['order_number' => 'ORD103', 'user_id' => 6, 'product_id' => 12, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '5.00', 'payment_method' => 'balance', 'status' => 'pending', 'created_at' => '2026-10-04 09:00:00', 'paid_at' => ''],
|
||||
'ORD104' => ['order_number' => 'ORD104', 'user_id' => 7, 'product_id' => 13, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '7.00', 'payment_method' => 'balance', 'status' => 'closed', 'created_at' => '2026-10-05 09:00:00', 'paid_at' => '2026-10-05 09:05:00'],
|
||||
];
|
||||
|
||||
if ($scenario === 'orders') {
|
||||
$_GET = ['page' => 'aksrt_orders'];
|
||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||
ob_start();
|
||||
$pay->render_orders_page();
|
||||
file_put_contents(__DIR__ . '/orders-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 {
|
||||
@@ -185,13 +259,39 @@ namespace {
|
||||
case 'bad_status':
|
||||
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'bogus', 'admin_note' => ''];
|
||||
break;
|
||||
case 'refund_paid':
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_no_ticket':
|
||||
unset(\AKSRT\Payment\AfterSaleService::$rows[5]);
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_inactive_ticket':
|
||||
\AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed';
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_not_paid':
|
||||
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending';
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_already_refunded':
|
||||
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded';
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_missing':
|
||||
$_POST = ['order_number' => 'ORDNOPE'];
|
||||
break;
|
||||
default:
|
||||
fwrite(STDERR, "unknown scenario: {$scenario}\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||
if (str_starts_with($scenario, 'refund')) {
|
||||
$pay->handle_refund_order();
|
||||
} else {
|
||||
$pay->handle_after_sale_status();
|
||||
}
|
||||
// Unreachable: the handler ends in exit().
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,29 @@ test('order detail page drops tabs; after-sales becomes a standalone submenu', (
|
||||
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]*?购买积分/)
|
||||
})
|
||||
|
||||
test('paid orders expose a refund button next to the detail link', () => {
|
||||
const page = read('src/Admin/Native/PayOrders.php')
|
||||
const service = read('src/Payment/AfterSaleService.php')
|
||||
// 「退款」紧跟在「详情」右侧,仅已支付订单可见——已退款是已支付的唯一后继状态
|
||||
assert.match(page, /详情', 'aksrt'\) \. '<\/a>';\s*\n\s*\/\/ 已支付是已退款的唯一前置状态[\s\S]*?aksrt_pay_refund_order/)
|
||||
assert.ok(page.includes("if (\$status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
|
||||
'refund button only renders for paid orders')
|
||||
assert.ok(page.includes('aksrt_pay_refund_order_'), 'per-order nonce covers the refund')
|
||||
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [\$this, 'handle_refund_order'])"),
|
||||
'refund action registered')
|
||||
assert.ok(page.includes('handle_refund_order'), 'refund handler exists')
|
||||
// 工单号由服务端按订单号反查,客户端只能传订单号
|
||||
assert.ok(page.includes("AfterSaleService::get_instance()->find_active(\$order_number)"),
|
||||
'active after-sale application is resolved server-side')
|
||||
assert.ok(!page.includes('after_sale_id\' => absint'), 'after-sale id is never taken from the request')
|
||||
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
|
||||
// 各种失败都要给出可操作的提示,而不是静默失败
|
||||
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid', 'order_refund_no_ticket']) {
|
||||
assert.ok(page.includes(`'${code}'`), `flash code ${code} is mapped`)
|
||||
}
|
||||
assert.match(service, /public function find_active\(string \$number\)/, 'active application lookup is exposed')
|
||||
})
|
||||
|
||||
test('order creation retries only on order-number unique key collisions', () => {
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
assert.match(repository, /private const ORDER_NUMBER_MAX_ATTEMPTS = \d+;/)
|
||||
|
||||
Reference in New Issue
Block a user