Files
wordpress-theme-aksrt/src/Ticket/TicketStatusService.php
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

159 lines
5.3 KiB
PHP

<?php
/**
* 工单状态(自定义):CRUD + seeds 兜底 + 状态机迁移规则。
*
* 状态是数据驱动的:管理员可增删改,因此状态机不写死合法目标集合,
* 只校验「目标状态必须存在」以及几条业务不变量(已完结不可流转、
* 「已解决」不允许从未接单直接跳、默认状态不可改删)。
*
* @package AKSRT
*/
namespace AKSRT\Ticket;
defined('ABSPATH') || exit;
final class TicketStatusService {
/** 内置状态 key:seeds 写入后不可改删的默认态。 */
public const KEY_PENDING = 'pending';
public const KEY_HANDLING = 'handling';
public const KEY_USER_REPLIED = 'user_replied';
public const KEY_ADMIN_REPLIED = 'admin_replied';
public const KEY_CLOSED = 'closed';
/** @var array<string,array<string,mixed>>|null status_key => row */
private static ?array $cache = null;
public static function flush(): void {
self::$cache = null;
}
/**
* 全部状态,按 sort_order / id 正序。
*
* @return list<array<string,mixed>>
*/
public static function all(): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return [];
}
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
if (!is_array($rows)) {
return [];
}
self::$cache = [];
$out = [];
foreach ($rows as $row) {
$normalized = self::normalize($row);
self::$cache[(string) $normalized['status_key']] = $normalized;
$out[] = $normalized;
}
return $out;
}
/** @return array<string,array<string,mixed>> status_key => row */
public static function map(): array {
if (self::$cache === null) {
self::all();
}
return self::$cache ?? [];
}
/** @return array<string,mixed>|null */
public static function find(string $status_key): ?array {
$map = self::map();
return $map[$status_key] ?? null;
}
public static function exists(string $status_key): bool {
return self::find($status_key) !== null;
}
public static function is_closed(string $status_key): bool {
$row = self::find($status_key);
return $row !== null && !empty($row['is_closed']);
}
/** 状态展示名;未知 key 回落 key 本身(工单可能引用已被删的状态)。 */
public static function label(string $status_key): string {
$row = self::find($status_key);
return $row !== null ? (string) $row['name'] : $status_key;
}
public static function color(string $status_key): string {
$row = self::find($status_key);
return $row !== null ? (string) $row['color'] : '#6b7280';
}
/**
* 状态机:从 $from 迁移到 $to 是否合法。
*
* 只挡确定非法的组合,其余交给「目标状态必须存在」:
* - 已完结状态(is_closed)不可再流转;
* - 「已解决/关闭」类终态不允许从未接单一步跳过去(必须先接收或回复),
* 避免管理员误把刚提交的工单直接关掉;
* - 同状态原地提交视为合法(幂等,便于后台重复点击)。
*/
public static function can_transition(string $from, string $to): bool {
if ($to === '' || !self::exists($to)) {
return false;
}
if ($from === $to) {
return true;
}
if (self::is_closed($from)) {
return false;
}
if ($from === self::KEY_PENDING && $to === self::KEY_CLOSED) {
return false;
}
return true;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private static function normalize(array $row): array {
return [
'id' => (int) ($row['id'] ?? 0),
'status_key' => sanitize_key((string) ($row['status_key'] ?? '')),
'name' => sanitize_text_field((string) ($row['name'] ?? '')),
'color' => self::sanitize_color((string) ($row['color'] ?? '')),
'is_closed' => !empty($row['is_closed']),
'is_default' => !empty($row['is_default']),
'sort_order' => (int) ($row['sort_order'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
/** 颜色只接受 #rgb / #rrggbb,其余回落默认灰。 */
public static function sanitize_color(string $color): string {
$color = strtolower(trim($color));
if (preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/', $color) !== 1) {
return '#6b7280';
}
return $color;
}
/** 新建状态时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
public static function make_key(string $name): string {
$key = sanitize_key($name);
if ($key === '') {
$key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8);
}
$base = $key;
$suffix = 1;
while (self::exists($key)) {
$key = $base . $suffix;
$suffix++;
}
return $key;
}
}