Files
wordpress-theme-aksrt/tests/dev/render-after-sales-harness.php
T
kate522 5fd8c6fc31 feat(after-sale): 售后面板新增「标记订单已退款」勾选项
展开的操作面板在状态下拉与备注旁新增「标记订单已退款」勾选框:勾选后点击确认,会先把关联订单推到已退款,再更新售后工单状态。订单本已退款时勾选框呈现为不可改的已选状态,且不提交该标记。

顺序不能反:OrderRepository::update_status 的退款闸门要求售后工单仍为待审核/处理中,一旦 set_status 先执行就不再放行。因此退款失败时整次操作中止,避免出现「工单已关、订单未退」的半完成状态。订单号由服务端按 after_sale_id 反查,不经客户端传递。

AfterSaleService::get() 由 private 改为 public 以支撑该反查。dev harness 扩展为可驱动 handler 的六个场景,覆盖勾选/未勾选、重复退款、非已支付订单、已关闭工单与非法状态。
2026-10-08 21:03:23 +08:00

208 lines
11 KiB
PHP

<?php
// Dev harness: render the REAL PayOrders::render_after_sales_page() with WP stubs,
// 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 {
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 ----------
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_attr($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_url($s) { return (string) $s; }
function esc_url_raw($s) { return (string) $s; }
function esc_js($s) { return addslashes((string) $s); }
function esc_textarea($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_html__($s, $d = null) { return esc_html($s); }
function esc_attr__($s, $d = null) { return esc_attr($s); }
function esc_html_e($s, $d = null) { echo esc_html($s); }
function __($s, $d = null) { return $s; }
function wp_unslash($v) { return $v; }
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 selected($a, $b, $echo = true) { $r = ((string) $a === (string) $b) ? " selected='selected'" : ''; if ($echo) echo $r; return $r; }
function submit_button($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = []) { echo '<button type="submit" class="' . esc_attr($type) . '">' . esc_html($text ?: 'Submit') . '</button>'; }
function wp_nonce_field($a = -1, $n = '_wpnonce', $r = true, $e = true) { if ($e) echo '<input type="hidden" name="' . esc_attr($n) . '" value="stubnonce" />'; }
function wp_create_nonce($a = -1) { return 'stubnonce' . md5((string) $a); }
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 wp_json_encode($v, $f = 0) { return json_encode($v, $f); }
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 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; }
}
namespace AKSRT\Payment {
class AfterSaleService {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
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 get(int $id): ?array { return self::$rows[$id] ?? null; }
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
$row = self::$rows[$after_sale_id] ?? null;
$ok = is_array($row)
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
\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 {
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($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 {
private static $i;
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
public function decorate_order_view(array $o, bool $x): array { return ['product_name' => '测试商品']; }
}
}
namespace AKSRT\Core {
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 order_product_type_labels(): array { return ['reading' => '付费阅读']; }
}
}
namespace AKSRT\Settings {
class PaymentRuntime {
public static function get_after_sale_settings(): array {
return ['enabled' => true, 'time_limit' => 7, 'reasons' => [['name' => '原因A']], 'materials' => ['截图']];
}
}
}
namespace {
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'];
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
$ref = new ReflectionMethod($pay, 'render_after_sales_page');
ob_start();
$ref->invoke($pay);
$html = ob_get_clean();
file_put_contents(__DIR__ . '/after-sales-rendered.html', $html);
echo 'bytes=' . strlen($html) . "\n";
}