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:
kate522andClaude Opus 5 committed 2026-10-25 23:29:51 +08:00
1 parent 761c84d70f
commit dbb7de41e9
81 files changed
+5812 -4179

No files matched your search

@@ -124,99 +124,58 @@ test('flow retention and archive were deliberately removed', () => {
assert.doesNotMatch(read('functions.php'), /FinancialFlowRetention/)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
})
test('order detail page drops tabs; after-sales becomes a standalone submenu', () => {
test('order detail page drops tabs; ticket center replaces the after-sale submenu', () => {
const page = read('src/Admin/Native/PayOrders.php')
const menus = read('src/Admin/Native/PayMenus.php')
const repository = read('src/Payment/OrderRepositoryCore.php')
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除,售后 Tab 移出
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除
assert.ok(!page.includes('render_workbench_tabs'), 'workbench tabs must be removed')
assert.ok(!page.includes("$_GET['tab']"), 'tab query param must not be read')
assert.ok(!page.includes('render_statistics_page'), 'statistics page must be removed')
assert.ok(!page.includes('stat_card'), 'stat cards must be removed')
assert.ok(!repository.includes('get_admin_order_stats'), 'dead stats query must be removed')
// 售后独立为子菜单「售后服务」(aksrt_after_sales),无兼容跳转
assert.ok(menus.includes("'slug' => 'aksrt_after_sales'"), 'after-sales submenu slug')
assert.ok(page.includes('render_after_sales_admin_page'), 'after-sales admin page entry')
assert.ok(page.includes("PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales'"), 'after-sales slug constant')
assert.ok(!page.includes('after-sales'), 'old tab slug must not remain')
// 售后表格列对齐售后策略的申请表单配置项:原因 / 申请资料 / 申请说明
assert.ok(page.includes('<th>申请资料</th>'), 'materials column')
assert.ok(page.includes('<th>申请说明</th>'), 'description column')
assert.ok(!page.includes('<th>举证图片</th>'), 'evidence images must not be a standalone column')
assert.ok(page.includes('aksrt-as-desc-thumbs'), 'evidence thumbnails render inside the description cell')
// 操作列:点击「操作」以手风琴展开整条记录详情(含完整字段与状态面板),经 JS 以 POST 提交
assert.ok(page.includes('aksrt-toggle-row-actions'), 'toggle to expand record accordion')
assert.ok(page.includes('aksrt-as-detail-row'), 'accordion detail row per after-sale record')
assert.ok(page.includes('aksrt-as-detail-grid'), 'accordion panel renders full record fields')
assert.ok(page.includes('aksrt-review-note'), 'review note textarea inside expanded panel')
assert.ok(page.includes('aksrt-as-review-actions'), 'review actions row inside expanded panel')
assert.ok(page.includes('aksrt-status-select'), 'status select inside expanded panel')
assert.ok(page.includes('aksrt-after-sale-confirm'), 'confirm button posts the selected status')
assert.ok(page.includes('aksrt-after-sale-cancel'), 'cancel button only collapses the panel')
assert.ok(page.includes('aksrt_after_sale_update'), 'status update handler registered')
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('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('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('aksrt_after_sale_bulk'), 'bulk review handler removed')
assert.ok(!page.includes('批量操作'), 'bulk actions dropdown removed')
assert.ok(!page.includes('after_sale_ids[]'), 'row checkboxes removed')
assert.ok(page.includes('$materials_cell'), 'materials cell rendered from apply_form')
assert.ok(page.includes('$desc_cell'), 'description cell rendered from apply_form')
// 页头展示售后策略摘要(功能开关 / 时效 / 预置原因 / 申请材料)
assert.ok(page.includes('get_after_sale_settings()'), 'policy summary reads settings')
assert.ok(page.includes('预置原因'), 'policy summary shows reasons')
assert.ok(page.includes('申请所需材料'), 'policy summary shows materials')
// 售后整体移除:工单改走 users.php 的「工单管理」子菜单,支付中心不再承载售后
assert.ok(!menus.includes('aksrt_after_sales'), 'after-sales submenu slug must be gone')
assert.ok(!page.includes('render_after_sales_admin_page'), 'after-sales admin page entry must be gone')
assert.ok(!page.includes('PAGE_AFTER_SALES_SLUG'), 'after-sales slug constant must be gone')
assert.ok(!page.includes('after_sale'), 'no after-sale handler may remain on the orders page')
const usersMenus = read('src/Admin/Native/UsersMenus.php')
assert.ok(usersMenus.includes("'aksrt_tickets'"), 'ticket submenu slug registered under users.php')
assert.ok(usersMenus.includes('render_tickets_page'), 'ticket admin page entry registered')
const ticketTrait = read('src/Admin/Native/UsersMenusTicketsCenter.php')
assert.ok(ticketTrait.includes('use UsersMenusTicketsList;'), 'ticket list renderer trait composed')
assert.ok(ticketTrait.includes('use UsersMenusTicketsConfig;'), 'ticket config renderer trait composed')
const domain = read('src/Core/DomainModel.php')
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_COMPLETED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED/)
assert.ok(!page.includes('STATUS_APPROVED'), 'approved status removed')
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) {"),
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'])"),
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')
// 退款闸门只剩「订单为已支付」:工单系统与订单退款已解耦,不再要求先有进行中的工单
assert.ok(!page.includes('AfterSaleService'), 'no after-sale lookup in the refund path')
assert.ok(!page.includes('after_sale_id'), 'refund is no longer bound to an after-sale application')
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.ok(!repository.includes('can_finalize_order_refund'),
'OrderRepository no longer gates refunds on an after-sale application')
// 各种失败都要给出可操作的提示,而不是静默失败
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid', 'order_refund_no_ticket']) {
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid']) {
assert.ok(page.includes(`'${code}'`), `flash code ${code} is mapped`)
}
assert.match(service, /public function find_active\(string \$number\)/, 'active application lookup is exposed')
assert.ok(!page.includes('order_refund_no_ticket'), 'the no-ticket refund failure is gone')
})
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+;/)