Files
wordpress-theme-aksrt/tests/widget-system/ticket-lifecycle-contract.test.cjs
T
kate522andClaude Opus 5 dbb7de41e9 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>
2026-10-25 23:29:51 +08:00

124 lines
6.8 KiB
JavaScript

'use strict';
/**
* 工单状态机契约:
* 1) 迁移规则集中在 TicketStatusService::can_transition(),上层不各自判合法性;
* 2) 回复会把工单推到「已回复 / 待回复」,管理员「接收」只能从待接单出发;
* 3) 已完结状态不可再流转,也不可从未接单一步跳到关闭;
* 4) 催单冷却用 transient,关闭走通知;
* 5) 默认状态在后台被拒绝改删,非默认状态带工单时也拒删。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('transition rules live in one place', () => {
const service = read('src/Ticket/TicketStatusService.php');
const body = service.slice(service.indexOf('public static function can_transition'));
// 目标必须存在;同状态幂等;已完结不可流转;待接单不可直接关闭。
assert.match(body, /if \(\$to === '' \|\| !self::exists\(\$to\)\) \{\s*\n\s*return false;/);
assert.match(body, /if \(\$from === \$to\) \{\s*\n\s*return true;/);
assert.match(body, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
assert.match(body, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
// 上层只调用 can_transition,不复制合法性判断。
for (const file of ['src/Ticket/TicketRepository-traits/TicketStatus.php', 'src/Ticket/TicketRestAdmin.php', 'src/Admin/Native/UsersMenusTicketsCenter.php']) {
const src = read(file);
assert.ok(src.includes('can_transition'), `${file} must delegate to can_transition`);
}
});
test('reply and receive drive the documented state changes', () => {
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
// 管理员回复 → 已回复;用户回复 → 待回复。
assert.match(status, /\$target = \$author_type === 'admin'\s*\n\s*\? TicketStatusService::KEY_ADMIN_REPLIED\s*\n\s*: TicketStatusService::KEY_USER_REPLIED;/);
// 同状态不重复迁移;已完结工单上的回复不改变状态。
assert.match(status, /if \(\$from === \$target\) \{\s*\n\s*return true;/);
assert.match(status, /if \(TicketStatusService::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
// 「接收」只允许从未接单转处理中。
assert.match(status, /if \(\(string\) \(\$ticket\['status'\] \?\? ''\) !== TicketStatusService::KEY_PENDING\) \{\s*\n\s*return false;/);
assert.match(status, /return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_HANDLING, 'admin', get_current_user_id\(\)\);/);
// 「已解决」落到已关闭。
assert.match(status, /private function resolve_ticket\(int \$ticket_id, array \$ticket\): bool \{\s*\n\s*return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_CLOSED, 'admin', get_current_user_id\(\)\);/);
});
test('closing notifies the counterparty exactly once', () => {
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
assert.match(status, /if \(\$to === TicketStatusService::KEY_CLOSED\) \{\s*\n\s*TicketNotifier::closed\(\$ticket, \$actor\);/);
const notifier = read('src/Ticket/TicketNotifier.php');
// 关单双分支:管理员关 → 通知用户;用户关 → 通知跟进人。
assert.match(notifier, /if \(\$closer === 'admin'\) \{/);
assert.match(notifier, /self::send\(\$user_id, 'ticket_closed'/);
assert.match(notifier, /self::send\(\$admin_id, 'ticket_closed'/);
});
test('default status cannot be edited or deleted in the admin surface', () => {
// REST 侧的状态 CRUD 在 TicketConfigRest trait 里,由 TicketRestAdmin 组合。
const config = read('src/Ticket/TicketConfigRest.php');
assert.equal((config.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'both update and delete must honour is_default');
assert.match(config, /'aksrt_ticket_status_default'/);
// 原生后台页同样要挡。
const native = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.equal((native.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
'native admin must honour is_default in both paths');
// 删除前还要确认没有工单挂在该状态/部门下(守卫在 TicketConfigRest trait 中)。
assert.match(config, /count_by_status\(\(string\) \$row\['status_key'\]\) > 0/);
assert.match(config, /count_by_type\(\$id\) > 0/);
assert.match(config, /'aksrt_ticket_status_in_use'/);
assert.match(config, /'aksrt_ticket_type_in_use'/);
const repo = read('src/Ticket/TicketRepository.php');
assert.match(repo, /public function count_by_status\(string \$status_key\): int/);
assert.match(repo, /public function count_by_type\(int \$type_id\): int/);
});
test('prereply reorder requires the full id set', () => {
const service = read('src/Ticket/TicketPrereplyService.php');
const body = service.slice(service.indexOf('public static function reorder'));
// 数量不符或含未知 ID 一律拒绝,避免漏排导致顺序错乱。
assert.match(body, /count\(\$ids\) !== count\(\$existing\) \|\| array_diff\(\$ids, \$existing\) !== \[\]/);
assert.match(body, /return false;/);
// 新建时追加到末尾。
assert.match(service, /return \(int\) \$max \+ 1;/);
});
test('ticket numbers are unique and settings-driven', () => {
const number = read('src/Ticket/TicketNumber.php');
assert.match(number, /TicketSettings::number_prefix\(\)/);
assert.match(number, /current_time\('Ymd'\)/);
assert.match(number, /wp_rand\(100000, 999999\)/);
assert.match(number, /if \(!self::exists\(\$candidate\)\) \{\s*\n\s*return \$candidate;/);
// 冲突重试有上限,极端情况追加随机后缀兜底。
assert.match(number, /private const MAX_ATTEMPTS = 12;/);
const settings = read('src/Ticket/TicketSettings.php');
assert.match(settings, /strtoupper\(\$raw\)/);
assert.match(settings, /sanitize_key/);
});
test('notifications go through the message inbox with deep links', () => {
const notifier = read('src/Ticket/TicketNotifier.php');
// 所有通知都走 Message::send(),持久化到 aksrt_notification。
assert.match(notifier, /\\AKSRT\\Messages\\Message::get_instance\(\)->send\(/);
assert.match(notifier, /class_exists\('\\AKSRT\\Messages\\Message'\)/);
// 新建工单通知全体管理员,催单只通知跟进人。
assert.match(notifier, /get_users\(\['role' => 'administrator', 'fields' => 'ID', 'number' => 20\]\)/);
assert.match(notifier, /\$admin_id = \(int\) \(\$ticket\['admin_id'\] \?\? 0\);/);
// 深链分别指向后台工单页与用户中心工单位。
assert.match(notifier, /admin_url\('users\.php\?page=aksrt_tickets'\)/);
assert.match(notifier, /aksrt_get_user_center_url\('tickets'\)/);
});