feat(douyu): 支持管理员取消作废协作单

This commit is contained in:
yml2213
2026-11-06 15:42:19 +08:00
parent 973042288f
commit afcbff2207
3 changed files with 233 additions and 7 deletions
+116
View File
@@ -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,
+64 -1
View File
@@ -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()
+53 -6
View File
@@ -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() {
</Dropdown>
</>
)}
{canEdit && !row.is_archived && row.status === 'void' && isAdmin && (
<Tooltip title="仅超级管理员可将已作废订单恢复为其它状态">
<Button
size="small"
icon={<UndoOutlined />}
onClick={() => openRestoreVoid(row)}
>
取消作废
</Button>
</Tooltip>
)}
{row.is_archived && canEdit && (
<Button size="small" icon={<UndoOutlined />} onClick={() => void unarchiveOrders(row)}>
取消归档
@@ -1960,7 +1980,13 @@ export default function DouyuCollabPage() {
</Modal>
<Modal
title={batchStatusOpen ? `批量改状态(${selectedCount} 单)` : `改状态:${statusTarget?.order_no || ''}`}
title={
batchStatusOpen
? `批量改状态(${selectedCount} 单)`
: statusTarget?.status === 'void'
? `取消作废:${statusTarget.order_no}`
: `改状态:${statusTarget?.order_no || ''}`
}
open={batchStatusOpen || Boolean(statusTarget)}
onCancel={() => {
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' && (
<Alert type="warning" showIcon message="作废后不可恢复;请在备注里写作废原因。" />
<Alert
type="warning"
showIcon
message="作废后普通用户不能恢复;如需恢复,请由管理员取消作废。"
/>
)}
{statusTarget?.status === 'void' && (
<Alert
type="warning"
showIcon
message="管理员操作:订单会恢复为所选状态,原作废原因会保留在审计记录中。"
/>
)}
{batchStatusOpen && (
<>
@@ -2009,7 +2054,9 @@ export default function DouyuCollabPage() {
<Input.TextArea
rows={2}
value={statusReason}
placeholder="原因 / 备注(可选)"
placeholder={
statusTarget?.status === 'void' ? '取消作废原因(可选)' : '原因 / 备注(可选)'
}
onChange={(event) => setStatusReason(event.target.value)}
/>
</Space>