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>
121 lines
5.9 KiB
JavaScript
121 lines
5.9 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 工单表结构契约:
|
||
* 1) 6 张 aksrt_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
|
||
* 2) SCHEMA_VERSION 已升到 3.0.0(dbDelta 只在版本变化时重跑);
|
||
* 3) aksrt_order_aftersale 已从 schema 与表名映射中彻底移除;
|
||
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
|
||
*/
|
||
|
||
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');
|
||
|
||
const TABLES = [
|
||
'aksrt_ticket',
|
||
'aksrt_ticket_reply',
|
||
'aksrt_ticket_status',
|
||
'aksrt_ticket_type',
|
||
'aksrt_ticket_prereply',
|
||
'aksrt_ticket_note',
|
||
];
|
||
|
||
test('schema version is bumped so dbDelta re-runs', () => {
|
||
const migrator = read('src/Core/DBMigrator.php');
|
||
assert.match(migrator, /const SCHEMA_VERSION = '3\.0\.0';/, 'SCHEMA_VERSION must be 3.0.0');
|
||
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
|
||
// 版本注释要说明 3.0.0 做了什么,否则后人看不懂这次升版的原因。
|
||
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);
|
||
});
|
||
|
||
test('order_aftersale is gone from schema and the table map', () => {
|
||
const part2 = read('src/Core/DBMigratorPart2.php');
|
||
const part3 = read('src/Core/DBMigratorPart3.php');
|
||
|
||
assert.ok(!part2.includes('aksrt_order_aftersale'), 'order_aftersale CREATE must be removed');
|
||
assert.ok(!part3.includes("'order_aftersale'"), 'order_aftersale table map entry must be removed');
|
||
assert.ok(!part3.includes('aksrt_order_aftersale'), 'order_aftersale table name must be removed');
|
||
});
|
||
|
||
test('six ticket tables are declared and the migrator is invoked', () => {
|
||
const part2 = read('src/Core/DBMigratorPart2.php');
|
||
// Part2 只负责调用;DDL 集中在 DBMigratorTicket,避免 run() 继续膨胀。
|
||
assert.ok(part2.includes('self::migrate_ticket_tables($prefix, $charset_collate);'),
|
||
'migrate_ticket_tables() must be called from run()');
|
||
assert.ok(!part2.includes('aksrt_order_aftersale'), 'Part2 must not carry the old table');
|
||
|
||
const ticket = read('src/Core/DBMigratorTicket.php');
|
||
for (const table of TABLES) {
|
||
assert.ok(ticket.includes(`CREATE TABLE {$prefix}${table} (`), `${table} DDL missing in trait`);
|
||
}
|
||
assert.ok(ticket.includes('self::seed_ticket_statuses($prefix);'), 'status seeds not invoked');
|
||
assert.ok(ticket.includes('self::seed_ticket_types($prefix);'), 'type seeds not invoked');
|
||
});
|
||
|
||
test('ticket table map exposes all six names', () => {
|
||
const part3 = read('src/Core/DBMigratorPart3.php');
|
||
for (const name of ['ticket', 'ticket_reply', 'ticket_status', 'ticket_type', 'ticket_prereply', 'ticket_note']) {
|
||
assert.ok(part3.includes(`'${name}'`), `table map missing ${name}`);
|
||
assert.ok(part3.includes(`'aksrt_${name}'`), `table map missing aksrt_${name}`);
|
||
}
|
||
});
|
||
|
||
test('ticket columns cover the state machine and optional order link', () => {
|
||
const ticket = read('src/Core/DBMigratorTicket.php');
|
||
|
||
// 工单号对外唯一;状态是可自定义的 key;订单关联可空(默认空串)。
|
||
assert.match(ticket, /UNIQUE KEY uniq_ticket_num \(ticket_num\)/);
|
||
assert.match(ticket, /status VARCHAR\(24\) NOT NULL DEFAULT 'pending'/);
|
||
assert.match(ticket, /order_number VARCHAR\(32\) NOT NULL DEFAULT ''/);
|
||
assert.match(ticket, /KEY idx_order \(order_number\)/);
|
||
|
||
// 沟通记录独立成行,支持引用回复。
|
||
assert.match(ticket, /author_type VARCHAR\(16\) NOT NULL DEFAULT 'user'/);
|
||
assert.match(ticket, /quote_reply_id BIGINT UNSIGNED NOT NULL DEFAULT 0/);
|
||
|
||
// 状态表带完结/默认两个开关,默认状态不可改删由服务层兜住。
|
||
assert.match(ticket, /status_key VARCHAR\(24\) NOT NULL/);
|
||
assert.match(ticket, /UNIQUE KEY uniq_status_key \(status_key\)/);
|
||
assert.match(ticket, /is_closed TINYINT\(1\) NOT NULL DEFAULT 0/);
|
||
assert.match(ticket, /is_default TINYINT\(1\) NOT NULL DEFAULT 0/);
|
||
});
|
||
|
||
test('status and department seeds are idempotent', () => {
|
||
const ticket = read('src/Core/DBMigratorTicket.php');
|
||
|
||
// 按 key / name 查重后跳过,重复执行不会写重复行。
|
||
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT status_key FROM \{\$table\}"\)/);
|
||
assert.match(ticket, /if \(in_array\(\$key, \$exists, true\)\) \{\s*\n\s*continue;/);
|
||
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT name FROM \{\$table\}"\)/);
|
||
assert.match(ticket, /if \(in_array\(\$label, \$exists, true\)\) \{\s*\n\s*continue;/);
|
||
|
||
// 五个内置状态与五个部门。
|
||
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
|
||
assert.ok(ticket.includes(`['${key}',`), `status seed ${key} missing`);
|
||
}
|
||
for (const label of ['技术支持', '账号问题', '财务问题', '产品建议', '其他']) {
|
||
assert.ok(ticket.includes(`'${label}'`), `department seed ${label} missing`);
|
||
}
|
||
// 默认状态是「待接单」,且是完结态的补集。
|
||
assert.match(ticket, /\['pending', '待接单', '#f59e0b', 0, 1\]/);
|
||
assert.match(ticket, /\['closed', '已关闭', '#6b7280', 1, 0\]/);
|
||
});
|
||
|
||
test('TicketStatusService seeds mirror the migrator and guard the default row', () => {
|
||
const service = read('src/Ticket/TicketStatusService.php');
|
||
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
|
||
assert.ok(service.includes(`KEY_${key.toUpperCase()}`), `KEY_${key.toUpperCase()} missing`);
|
||
}
|
||
// 状态机只挡确定非法的组合,其余交给「目标状态必须存在」。
|
||
assert.match(service, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
|
||
assert.match(service, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
|
||
// 颜色只接受 #rgb / #rrggbb。
|
||
assert.match(service, /'#6b7280'/);
|
||
assert.match(service, /preg_match\('\/\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$\/'/);
|
||
});
|