feat(client): 完成 F34 票据中心增强\n\n- 补齐人工修正/手工归档/解绑/重试 OCR/判重例外等入口\n- 补充 data-testid 并与后端错误码映射保持一致\n- 更新 P5 蓝图 v3 中 F34 状态为 DONE
这个提交包含在:
@@ -46,21 +46,35 @@ export type InvoicePoolListResponse = {
|
||||
export type InvoicePoolDetail = {
|
||||
id: string;
|
||||
status: InvoicePoolStatus;
|
||||
archivedReason: string | null;
|
||||
archivedAt: string | null;
|
||||
ownerEmployeeId: string;
|
||||
uploadedByEmployeeId: string;
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
fileSha256: string | null;
|
||||
remarks: string | null;
|
||||
ocrType: string;
|
||||
ocrProvider: string;
|
||||
ocrAttempts: number;
|
||||
ocrLastRequestedAt: string | null;
|
||||
ocrLastError: string | null;
|
||||
ocrCompletedAt: string | null;
|
||||
invoiceCode: string | null;
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
sellerName: string | null;
|
||||
buyerName: string | null;
|
||||
amount: string | null;
|
||||
taxAmount: string | null;
|
||||
totalAmount: string | null;
|
||||
linkedReimbursementId: string | null;
|
||||
linkedItemId: string | null;
|
||||
isDuplicateOverride: boolean;
|
||||
overrideRefFormNumber: string | null;
|
||||
overrideReason: string | null;
|
||||
overriddenBy: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
@@ -131,3 +145,51 @@ export async function fetchInvoiceSignedUrl(invoiceId: string) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type InvoicePatchPayload = Partial<{
|
||||
status: "available";
|
||||
remarks: string | null;
|
||||
invoiceCode: string | null;
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
sellerName: string | null;
|
||||
buyerName: string | null;
|
||||
amount: string | null;
|
||||
taxAmount: string | null;
|
||||
totalAmount: string | null;
|
||||
}>;
|
||||
|
||||
export async function patchInvoice(invoiceId: string, payload: InvoicePatchPayload) {
|
||||
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveInvoice(invoiceId: string) {
|
||||
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unbindInvoice(invoiceId: string) {
|
||||
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}/unbind`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function retryInvoiceOcr(invoiceId: string) {
|
||||
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}/retry-ocr`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function overrideInvoiceDuplicate(invoiceId: string, payload: { refFormNumber: string; reason: string }) {
|
||||
return apiRequestData<InvoicePoolDetail>(`/api/invoice-pool/${invoiceId}/override-duplicate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchMyDelegations } from "../api/delegations";
|
||||
import {
|
||||
archiveInvoice,
|
||||
bindInvoice,
|
||||
fetchInvoicePoolDetail,
|
||||
fetchInvoicePoolList,
|
||||
fetchInvoiceSignedUrl,
|
||||
overrideInvoiceDuplicate,
|
||||
patchInvoice,
|
||||
retryInvoiceOcr,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
type InvoicePoolStatus,
|
||||
} from "../api/invoicePool";
|
||||
@@ -25,6 +30,8 @@ export function InvoicePoolPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions, me } = useAuth();
|
||||
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
||||
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
|
||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||
@@ -36,6 +43,13 @@ export function InvoicePoolPage() {
|
||||
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
|
||||
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
|
||||
const [detailInvoiceId, setDetailInvoiceId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editSetAvailable, setEditSetAvailable] = useState(false);
|
||||
const [editRemarks, setEditRemarks] = useState("");
|
||||
const [editInvoiceCode, setEditInvoiceCode] = useState("");
|
||||
const [editInvoiceNumber, setEditInvoiceNumber] = useState("");
|
||||
const [editInvoiceDate, setEditInvoiceDate] = useState("");
|
||||
const [editTotalAmount, setEditTotalAmount] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const listQuery = useQuery({
|
||||
@@ -73,6 +87,19 @@ export function InvoicePoolPage() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailQuery.data || editOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditSetAvailable(false);
|
||||
setEditRemarks(detailQuery.data.remarks ?? "");
|
||||
setEditInvoiceCode(detailQuery.data.invoiceCode ?? "");
|
||||
setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? "");
|
||||
setEditInvoiceDate(detailQuery.data.invoiceDate ?? "");
|
||||
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
|
||||
}, [detailQuery.data, editOpen]);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedFile) {
|
||||
@@ -145,7 +172,109 @@ export function InvoicePoolPage() {
|
||||
}
|
||||
}, [detailInvoiceId, listQuery.data?.items]);
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!detailInvoiceId) {
|
||||
throw new Error("请选择一条票据");
|
||||
}
|
||||
|
||||
return patchInvoice(detailInvoiceId, {
|
||||
...(editSetAvailable ? { status: "available" } : {}),
|
||||
remarks: editRemarks.trim() ? editRemarks.trim() : null,
|
||||
invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null,
|
||||
invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null,
|
||||
invoiceDate: editInvoiceDate ? editInvoiceDate : null,
|
||||
totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
setEditOpen(false);
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("保存失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => archiveInvoice(invoiceId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
} else {
|
||||
setError("归档失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => unbindInvoice(invoiceId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
} else {
|
||||
setError("解绑失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const retryOcrMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => retryInvoiceOcr(invoiceId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
} else {
|
||||
setError("OCR 重试失败,请稍后再试");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const overrideDuplicateMutation = useMutation({
|
||||
mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) =>
|
||||
overrideInvoiceDuplicate(params.invoiceId, {
|
||||
refFormNumber: params.refFormNumber,
|
||||
reason: params.reason,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
} else {
|
||||
setError("判重例外失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
const detail = detailQuery.data;
|
||||
const canEditDetail = Boolean(
|
||||
detail &&
|
||||
(canManageInvoice ||
|
||||
detail.ownerEmployeeId === me?.id ||
|
||||
detail.uploadedByEmployeeId === me?.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -367,14 +496,214 @@ export function InvoicePoolPage() {
|
||||
|
||||
<Card className="space-y-2 p-6 text-sm" data-testid="invoice-detail-panel">
|
||||
<h3 className="text-base font-semibold text-slate-900">详情轮询</h3>
|
||||
{detailQuery.data ? (
|
||||
{detail ? (
|
||||
<>
|
||||
<div>票据ID:{detailQuery.data.id}</div>
|
||||
<div>状态:{detailQuery.data.status}</div>
|
||||
<div>OCR错误:{detailQuery.data.ocrLastError || "-"}</div>
|
||||
<div>票据ID:{detail.id}</div>
|
||||
<div>状态:{detail.status}</div>
|
||||
<div>OCR错误:{detail.ocrLastError || "-"}</div>
|
||||
<div>
|
||||
发票号:{detailQuery.data.invoiceCode || "-"}-{detailQuery.data.invoiceNumber || "-"}
|
||||
发票号:{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
|
||||
</div>
|
||||
<div>开票日期:{detail.invoiceDate || "-"}</div>
|
||||
<div>总金额:{detail.totalAmount || "-"}</div>
|
||||
<div>备注:{detail.remarks || "-"}</div>
|
||||
<div>关联报销:{detail.linkedReimbursementId || "-"}</div>
|
||||
<div>
|
||||
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
|
||||
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{canEditDetail ? (
|
||||
<Button
|
||||
data-testid="invoice-edit-open"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={patchMutation.isPending}
|
||||
onClick={() => setEditOpen((prev) => !prev)}
|
||||
>
|
||||
{editOpen ? "关闭编辑" : "人工修正"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail && detail.status === "ocr_failed" ? (
|
||||
<Button
|
||||
data-testid="invoice-retry-ocr"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={retryOcrMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm("确认重试 OCR?将重新进入 pending_ocr 并消耗一次尝试次数。")) return;
|
||||
retryOcrMutation.mutate(detail.id);
|
||||
}}
|
||||
>
|
||||
重试 OCR
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail && canUploadInvoice && detail.status === "bound" ? (
|
||||
<Button
|
||||
data-testid="invoice-unbind"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={unbindMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm("确认解绑该票据?仅草稿/驳回报销允许解绑。")) return;
|
||||
unbindMutation.mutate(detail.id);
|
||||
}}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? (
|
||||
<Button
|
||||
data-testid="invoice-archive"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={archiveMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm("确认归档该票据?归档后将不再用于绑定与判重。")) return;
|
||||
archiveMutation.mutate(detail.id);
|
||||
}}
|
||||
>
|
||||
手工归档
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canOverrideDuplicate &&
|
||||
!detail.isDuplicateOverride &&
|
||||
["available", "ocr_failed"].includes(detail.status) ? (
|
||||
<Button
|
||||
data-testid="invoice-override-duplicate"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={overrideDuplicateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm("确认执行判重例外?该操作将写入审计并影响判重口径。")) return;
|
||||
const refFormNumber = window.prompt("请输入参考单号(必填)");
|
||||
if (!refFormNumber?.trim()) return;
|
||||
const reason = window.prompt("请输入例外原因(必填)");
|
||||
if (!reason?.trim()) return;
|
||||
overrideDuplicateMutation.mutate({
|
||||
invoiceId: detail.id,
|
||||
refFormNumber: refFormNumber.trim(),
|
||||
reason: reason.trim(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
判重例外
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editOpen ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
|
||||
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
|
||||
|
||||
{detail.status === "ocr_failed" ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
data-testid="invoice-edit-set-available"
|
||||
checked={editSetAvailable}
|
||||
onChange={(event) => setEditSetAvailable(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>将状态标记为 available(人工确认可用)</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-600" htmlFor="invoiceCode">
|
||||
发票代码
|
||||
</label>
|
||||
<input
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="invoice-edit-code"
|
||||
id="invoiceCode"
|
||||
onChange={(event) => setEditInvoiceCode(event.target.value)}
|
||||
value={editInvoiceCode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-600" htmlFor="invoiceNumber">
|
||||
发票号码
|
||||
</label>
|
||||
<input
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="invoice-edit-number"
|
||||
id="invoiceNumber"
|
||||
onChange={(event) => setEditInvoiceNumber(event.target.value)}
|
||||
value={editInvoiceNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-600" htmlFor="invoiceDate">
|
||||
开票日期
|
||||
</label>
|
||||
<input
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="invoice-edit-date"
|
||||
id="invoiceDate"
|
||||
onChange={(event) => setEditInvoiceDate(event.target.value)}
|
||||
type="date"
|
||||
value={editInvoiceDate}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-600" htmlFor="invoiceTotalAmount">
|
||||
总金额
|
||||
</label>
|
||||
<input
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="invoice-edit-total-amount"
|
||||
id="invoiceTotalAmount"
|
||||
onChange={(event) => setEditTotalAmount(event.target.value)}
|
||||
value={editTotalAmount}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<label className="text-xs text-slate-600" htmlFor="invoiceRemarks">
|
||||
备注
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="invoice-edit-remarks"
|
||||
id="invoiceRemarks"
|
||||
onChange={(event) => setEditRemarks(event.target.value)}
|
||||
rows={2}
|
||||
value={editRemarks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="invoice-edit-save"
|
||||
disabled={patchMutation.isPending}
|
||||
onClick={() => patchMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{patchMutation.isPending ? "保存中..." : "保存修正"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="invoice-edit-cancel"
|
||||
variant="secondary"
|
||||
disabled={patchMutation.isPending}
|
||||
onClick={() => setEditOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-slate-500">请选择一条票据查看详情</p>
|
||||
|
||||
@@ -35,7 +35,7 @@ F30,通知触发集成(workflow/pay/auth),"把关键事件写入 notificatio
|
||||
F31,Delegation Grants API对齐(新增别名/增强响应),"新增 /api/delegation-grants 别名路由并增强现有 delegation API:响应内嵌 principal/delegate 员工摘要;system.users 可代维护 principal;delegate 资格校验:scope=reimbursement_submit 需 expense.proxy_submit(administration);scope=all 需 expense.approve;支持 includeInactive","/api/delegation-grants 与 /api/delegations 兼容;返回包含员工摘要;无权限阻断;代维护审计完整","manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/delegation-grants' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/delegation-grants' -d '{delegateEmployeeId:... scope:... validTo:...}';验证 /api/delegations/mine 与 /api/delegation-grants 返回一致且包含 principal/delegate 摘要",manual,DONE,DONE,DONE,"server/src/routes/delegations.js | server/src/app.js | server/README.md","[""F27""]","不破坏既有 /api/delegations 客户端;新增别名优先"
|
||||
F32,前端组织与员工最小运营,"补齐前端最小组织能力:部门列表/创建/归档;在用户管理页支持部门选择与直属上级选择(管理员视角)","admin 可完成部门增改归档(含约束提示);可创建员工并选择部门与直属上级;typecheck/build 通过","cd client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,"client/src/pages/DepartmentsPage.tsx | client/src/pages/UserManagementPage.tsx | client/src/api/departments.ts | client/src/api/employees.ts | client/src/router/index.tsx","[""F26"",""F27""]","单机试运行优先;部门树可简化为层级列表"
|
||||
F33,前端行政代提交/代上传入口,"为 administration 增加 owner 选择入口:报销创建可选 owner;票据上传可选 owner;并基于 delegation-grants 限定可选范围","有授权时可为指定员工创建/提交/上传;无授权时提示并阻断;typecheck/build/e2e 通过","cd client && npm run typecheck && npm run build && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/pages/ReimbursementsPage.tsx | client/src/pages/InvoicePoolPage.tsx | client/src/api/reimbursements.ts | client/src/api/invoicePool.ts | client/src/api/delegations.ts | client/src/api/employees.ts","[""F7"",""F8"",""F31"",""F27""]","为 E2E 增加 data-testid(owner selector / submit/upload)"
|
||||
F34,前端票据中心增强(编辑/归档/解绑/重试/例外),"补齐票据中心 UI:人工修正/手工归档/解绑重绑/重试 OCR;finance_mgr 判重例外入口;并补 data-testid","上述动作按角色/权限可见;状态与错误码展示清晰;typecheck/build/e2e 通过","cd client && npm run typecheck && npm run build && npm run test:e2e",manual,TODO,TODO,TODO,"client/src/pages/InvoicePoolPage.tsx | client/src/api/invoicePool.ts | client/src/api/errorMessages.ts","[""F28"",""F9"",""F10"",""F11""]","动作按钮必须二次确认并给出不可逆提示(如归档)"
|
||||
F34,前端票据中心增强(编辑/归档/解绑/重试/例外),"补齐票据中心 UI:人工修正/手工归档/解绑重绑/重试 OCR;finance_mgr 判重例外入口;并补 data-testid","上述动作按角色/权限可见;状态与错误码展示清晰;typecheck/build/e2e 通过","cd client && npm run typecheck && npm run build && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/pages/InvoicePoolPage.tsx | client/src/api/invoicePool.ts | client/src/api/errorMessages.ts","[""F28"",""F9"",""F10"",""F11""]","动作按钮必须二次确认并给出不可逆提示(如归档)"
|
||||
F35,前端错误码文案补齐(VALID/AUTH/PERM),"补齐新增错误码文案与 details 映射:VALID_004/006/007 等;保证 SYS_001 提示可读且可操作","相关错误在页面/Toast 可读;不暴露敏感字段;typecheck/build 通过","cd client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,"client/src/api/errorMessages.ts | client/src/api/http.ts | client/src/api/ApiError.ts","[""F34""]","错误文案遵循:可行动/可定位(建议带 requestId)"
|
||||
F36,首次登录强制改密(后端),"落实规格书:首次登录必须修改初始密码;新增 must_change_password 标记与改密接口(校验旧密码+密码策略+tokenVersion 强制下线);管理员重置密码后仍要求首次改密","首次登录返回 mustChangePassword=true;改密成功后 mustChangePassword=false 且旧会话失效;审计包含改密动作","manual: 创建员工使用初始密码登录->提示改密->改密后可进入系统;管理员重置密码后再次触发改密",manual,DONE,DONE,DONE,"server/src/routes/auth.js | server/src/routes/users.js | server/src/utils/passwordPolicy.js | server/prisma/schema.prisma | server/prisma/migrations/* | server/README.md","[""F1""]","默认策略:允许登录但强制前端在改密前禁止访问业务页(路由守卫)"
|
||||
F37,登录失败锁定与解锁(AUTH_002),"落实规格书:15分钟内失败5次锁定30分钟;返回 AUTH_002(423);system.users 可解锁;所有动作写审计","锁定窗口/次数/时长按配置生效;锁定期间无法登录;解锁后可登录;审计可检索","manual: 连续输错触发锁定->验证423与AUTH_002;管理员解锁->恢复登录",manual,DONE,DONE,DONE,"server/src/routes/auth.js | server/src/routes/users.js | server/src/config.js | server/prisma/schema.prisma | server/prisma/migrations/* | server/README.md","[""F1""]","优先用 DB 字段记录失败窗口与锁定截止时间;避免引入外部缓存"
|
||||
|
||||
|
在新工单中引用
屏蔽一个用户