diff --git a/tests/test_douyu_collab_orders.py b/tests/test_douyu_collab_orders.py
index a470517..9d6b166 100644
--- a/tests/test_douyu_collab_orders.py
+++ b/tests/test_douyu_collab_orders.py
@@ -317,6 +317,122 @@ class TestDouyuCollabOrders:
assert result["order"].status == collab.STATUS_DONE
assert result["order"].done_at is not None
+ def test_admin_can_restore_voided_order_to_another_status(self):
+ order = collab.create_order(
+ self.session,
+ self.admin,
+ order_no="DY-RESTORE-1",
+ shop="店铺A",
+ account_username="douyu-account-a",
+ gift_name="礼包A",
+ required_points=100,
+ home_images=["collab/home-restore-1.png"],
+ client=FakeClient(),
+ )
+ collab.set_status(
+ self.session,
+ self.admin,
+ order,
+ status=collab.STATUS_VOID,
+ reason="误作废",
+ )
+
+ restored = collab.set_status(
+ self.session,
+ self.admin,
+ order,
+ status=collab.STATUS_SCRIPTED,
+ reason="管理员确认恢复",
+ )
+
+ assert restored.status == collab.STATUS_SCRIPTED
+ assert restored.void_reason == ""
+ assert restored.recharged_at is not None
+ assert restored.scripted_at is not None
+ payload = collab.serialize_one(self.session, restored)
+ serialized = DouyuCollabOrderOut.model_validate(payload).model_dump()
+ assert serialized["status"] == collab.STATUS_SCRIPTED
+ assert serialized["void_reason"] == ""
+
+ audit = (
+ self.session.query(AuditLog)
+ .filter(AuditLog.action == "douyu:collab:void_restore")
+ .order_by(AuditLog.id.desc())
+ .first()
+ )
+ assert audit is not None
+ assert audit.username == self.admin.username
+ assert '"from":"void"' in audit.detail
+ assert '"to":"scripted"' in audit.detail
+ assert '"previous_void_reason":"误作废"' in audit.detail
+
+ def test_non_admin_cannot_restore_voided_order(self):
+ operator = User(
+ username="douyu-collab-restore-operator",
+ password_hash="hash",
+ role="operation",
+ is_active=True,
+ )
+ self.session.add(operator)
+ self.session.commit()
+ order = collab.create_order(
+ self.session,
+ self.admin,
+ order_no="DY-RESTORE-2",
+ account_username="douyu-account-a",
+ draft=True,
+ )
+ collab.set_status(
+ self.session,
+ self.admin,
+ order,
+ status=collab.STATUS_VOID,
+ reason="测试权限",
+ )
+
+ with pytest.raises(collab.CollabError) as excinfo:
+ collab.set_status(
+ self.session,
+ operator,
+ order,
+ status=collab.STATUS_INCOMPLETE,
+ reason="越权恢复",
+ )
+
+ assert excinfo.value.status_code == 403
+ assert excinfo.value.code == "admin_required"
+ assert order.status == collab.STATUS_VOID
+ assert order.void_reason == "测试权限"
+
+ def test_void_restore_still_enforces_recharge_fields(self):
+ order = collab.create_order(
+ self.session,
+ self.admin,
+ order_no="DY-RESTORE-3",
+ account_username="douyu-account-a",
+ draft=True,
+ )
+ collab.set_status(
+ self.session,
+ self.admin,
+ order,
+ status=collab.STATUS_VOID,
+ reason="资料不全",
+ )
+
+ with pytest.raises(collab.CollabError) as excinfo:
+ collab.set_status(
+ self.session,
+ self.admin,
+ order,
+ status=collab.STATUS_RECHARGED,
+ reason="直接恢复",
+ )
+
+ assert excinfo.value.code == "recharge_precondition"
+ assert order.status == collab.STATUS_VOID
+ assert order.void_reason == "资料不全"
+
def test_points_shortage_enters_points_error(self):
order = collab.create_order(
self.session,
diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py
index 0fd282a..5346edc 100644
--- a/web/backend/services/douyu_collab_service.py
+++ b/web/backend/services/douyu_collab_service.py
@@ -908,6 +908,60 @@ def _assert_ready_for_recharge(order: DouyuExternalOrder) -> None:
)
+def _restore_void_status(
+ db: Session,
+ current: User,
+ order: DouyuExternalOrder,
+ *,
+ target: str,
+ reason: str,
+ commit: bool,
+) -> DouyuExternalOrder:
+ """超级管理员取消作废,并把订单转为指定状态。"""
+ if current.role != "super_admin":
+ raise CollabError(
+ "仅超级管理员可取消作废",
+ status_code=403,
+ code="admin_required",
+ )
+ if order.archived_at is not None:
+ raise CollabError("已归档的订单不能改状态", code="order_archived")
+
+ target_index = STAGE_INDEX.get(target)
+ if target_index is not None and target_index >= STAGE_INDEX[STATUS_TO_RECHARGE]:
+ _assert_ready_for_recharge(order)
+
+ old_void_reason = order.void_reason or ""
+ # 取消作废等于重新进入状态机:先清空旧流程时间,再按目标状态补当前时间。
+ for field in STATUS_TIMESTAMP_FIELDS.values():
+ setattr(order, field, None)
+ if target_index is not None:
+ for name, field in STATUS_TIMESTAMP_FIELDS.items():
+ if STAGE_INDEX[name] <= target_index:
+ setattr(order, field, _now())
+
+ order.status = target
+ order.void_reason = ""
+ order.updated_by = current.id
+ order.version = int(order.version or 0) + 1
+ record_audit(
+ db,
+ current,
+ action="douyu:collab:void_restore",
+ target=_status_audit_target(order),
+ detail={
+ "from": STATUS_VOID,
+ "to": target,
+ "previous_void_reason": old_void_reason,
+ "reason": _text(reason, max_length=256),
+ },
+ )
+ if commit:
+ db.commit()
+ db.refresh(order)
+ return order
+
+
def set_status(
db: Session,
current: User,
@@ -926,7 +980,16 @@ def set_status(
if order.archived_at is not None:
raise CollabError("已归档的订单不能改状态", code="order_archived")
if order.status == STATUS_VOID:
- raise CollabError("已作废的订单不能改状态", code="order_void")
+ if target == STATUS_VOID:
+ raise CollabError("已作废的订单不能改状态", code="order_void")
+ return _restore_void_status(
+ db,
+ current,
+ order,
+ target=target,
+ reason=reason,
+ commit=commit,
+ )
old = order.status
now = _now()
diff --git a/web/frontend/src/pages/DouyuCollabPage.tsx b/web/frontend/src/pages/DouyuCollabPage.tsx
index 0a81922..c29545b 100644
--- a/web/frontend/src/pages/DouyuCollabPage.tsx
+++ b/web/frontend/src/pages/DouyuCollabPage.tsx
@@ -748,6 +748,14 @@ export default function DouyuCollabPage() {
setBatchStatusRollback(false);
};
+ const openRestoreVoid = (order: DouyuCollabOrder) => {
+ setStatusTarget(order);
+ setBatchStatusOpen(false);
+ setStatusValue('incomplete');
+ setStatusReason('');
+ setBatchStatusRollback(false);
+ };
+
const openBatchStatus = () => {
if (selectedIds.length === 0) {
message.warning('请先勾选协作单');
@@ -800,13 +808,14 @@ export default function DouyuCollabPage() {
} else if (statusTarget) {
const currentIndex = FLOW_ORDER.indexOf(statusTarget.status);
const targetIndex = FLOW_ORDER.indexOf(statusValue);
+ const restoringVoid = statusTarget.status === 'void';
await douyuCollabApi.setStatus(statusTarget.id, {
status: statusValue,
reason: statusReason,
rollback: targetIndex >= 0 && currentIndex >= 0 && targetIndex < currentIndex,
expected_version: statusTarget.version,
});
- message.success('状态已更新');
+ message.success(restoringVoid ? '已取消作废并恢复状态' : '状态已更新');
setSelectedRowKeys((keys) => keys.filter((key) => Number(key) !== statusTarget.id));
setStatusTarget(null);
}
@@ -1350,6 +1359,17 @@ export default function DouyuCollabPage() {
>
)}
+ {canEdit && !row.is_archived && row.status === 'void' && isAdmin && (
+
+ }
+ onClick={() => openRestoreVoid(row)}
+ >
+ 取消作废
+
+
+ )}
{row.is_archived && canEdit && (
} onClick={() => void unarchiveOrders(row)}>
取消归档
@@ -1960,7 +1980,13 @@ export default function DouyuCollabPage() {
{
setBatchStatusOpen(false);
@@ -1968,7 +1994,13 @@ export default function DouyuCollabPage() {
}}
onOk={() => void submitStatus()}
confirmLoading={statusBusy}
- okText={batchStatusOpen ? `改成「${STATUS_META[statusValue].label}」` : '确定'}
+ okText={
+ batchStatusOpen
+ ? `改成「${STATUS_META[statusValue].label}」`
+ : statusTarget?.status === 'void'
+ ? `恢复为「${STATUS_META[statusValue].label}」`
+ : '确定'
+ }
okButtonProps={{
disabled: batchStatusOpen && batchRollbackRequired && !batchStatusRollback,
}}
@@ -1978,13 +2010,26 @@ export default function DouyuCollabPage() {
style={{ width: '100%' }}
value={statusValue}
onChange={(value: DouyuCollabStatus) => setStatusValue(value)}
- options={STATUS_ORDER.map((item) => ({
+ options={STATUS_ORDER.filter(
+ (item) => !(statusTarget?.status === 'void' && item === 'void'),
+ ).map((item) => ({
value: item,
label: STATUS_META[item].label,
}))}
/>
{statusValue === 'void' && (
-
+
+ )}
+ {statusTarget?.status === 'void' && (
+
)}
{batchStatusOpen && (
<>
@@ -2009,7 +2054,9 @@ export default function DouyuCollabPage() {
setStatusReason(event.target.value)}
/>