feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an order number, users could only file them from 「我的订单」against a paid order, and the admin action bottomed out at 「标记订单已退款」. It could not carry account questions or feature requests that have nothing to do with an order. Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket: title + department + description are required, linking an order is optional (user's own paid orders only). Add departments, custom statuses, preset replies and internal admin notes as four configuration surfaces. Conversation history moves off the ticket row's JSON log into dedicated ticket_reply rows, which unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end moves from 支付中心 → 售后服务 to users.php → 工单管理. Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new ones, delete the old code. No dual paths, no data migration. The upstream plugin's upstream/downstream delivery and internal-ticket conversion are deliberately left out — both are multi-provider platform concerns with no meaning on a single WordPress site. Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply, ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION 2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status 「待接单」is flagged as non-editable. Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse UserController::rest_upload_comment_image with a ticket-specific permission callback, mirroring the existing /report/upload-image route rather than growing a second upload path. Notifications go through AKSRT\Messages\Message::send() and deep-link to the right surface; email/SMS stay out of scope since the theme's mail and SMS stacks are separate implementations. Refund gate opens up: marking an order refunded no longer requires an in-flight ticket, only that the order is paid. Also generalise a hardcoded special case in tools/audit-admin-contracts.php — schema domain inference now reads the parent directory instead of matching "PostList" by name, so new leaf schema files need no per-name exception. Verification: php tools/audit-admin-contracts.php exit 0, errors=0 node --test (widget-system + admin) 664 pass / 0 fail php -l over all new/changed files clean node --check over all JS clean npm run build + build:public clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
761c84d70f
commit
dbb7de41e9
81 files changed
+5812
-4179
No files matched your search
@@ -1,331 +0,0 @@
|
||||
<?php
|
||||
// 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 -> 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__ . '/');
|
||||
|
||||
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\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';
|
||||
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 {
|
||||
$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 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)) {
|
||||
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;
|
||||
self::$rows[$id]['handling_log'][] = ['status' => $status, 'note' => $note, 'source' => 'admin', 'created_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 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 {
|
||||
\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;
|
||||
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 const ORDER_PRODUCT_RESOURCE = 'resource';
|
||||
public static function resolve_order_product_type(string $t, array $m): string { return $t; }
|
||||
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 => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 __DIR__ . '/../../src/Admin/Native/AdminListPagination.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',
|
||||
'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',
|
||||
'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'],
|
||||
'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 === '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 {
|
||||
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;
|
||||
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().
|
||||
}
|
||||
|
||||
$_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";
|
||||
}
|
||||
Reference in new issue
Block a user