R123/A1-A6 收敛移动发票反馈并补部署检查
这个提交包含在:
@@ -28,6 +28,7 @@ Read only what is relevant:
|
||||
7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes.
|
||||
8. Update Issue CSV statuses only after the stated `Test_Method` passes.
|
||||
9. Commit in Chinese batches with Issue IDs.
|
||||
10. 面向用户可测试的改动,最终必须明确“是否已部署”。只有部署与公网检查通过后,才能告知用户可以去企业微信或公网试运行环境测试。
|
||||
|
||||
## Default Verification Matrix
|
||||
|
||||
@@ -76,5 +77,6 @@ Final responses should include:
|
||||
|
||||
- What changed.
|
||||
- What was verified, with commands.
|
||||
- 面向用户可测改动的部署状态:commit hash、公网 URL、`/ready`、公网前端资源/文案检查,以及用户现在是否可以测试。
|
||||
- What remains blocked and why.
|
||||
- Commit hashes when commits were made.
|
||||
|
||||
@@ -332,7 +332,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
|
||||
{canManagePayments && order.actions.canBindInvoice ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-3">
|
||||
{availableInvoicesQuery.isError ? (
|
||||
<p className="text-xs text-zinc-500">可用发票列表暂不可用,可去发票池页面完成绑定。</p>
|
||||
<p className="text-xs text-zinc-500">可添加发票列表暂不可用,请稍后刷新或联系财务处理。</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import {
|
||||
archiveInvoice,
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoicePoolList,
|
||||
fetchInvoiceStatuses,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
type BindableInvoiceSummary,
|
||||
type InvoicePoolListItem,
|
||||
} from "../api/invoicePool";
|
||||
import {
|
||||
createReimbursementItem,
|
||||
@@ -58,6 +61,10 @@ type ItemFormState = {
|
||||
|
||||
type SelectedInvoiceMap = Record<string, string>;
|
||||
type InvoicePanel = "upload" | "library";
|
||||
type MobileInvoiceLibraryItem = BindableInvoiceSummary & {
|
||||
status: "available" | "ocr_failed";
|
||||
unavailableMessage?: string | null;
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<ReimbursementStatus, string> = {
|
||||
draft: "草稿",
|
||||
@@ -123,6 +130,24 @@ function formatInvoiceTitle(invoice: {
|
||||
return invoice.sellerName || invoice.originalName || "未识别票据";
|
||||
}
|
||||
|
||||
function matchInvoiceSearch(
|
||||
invoice: {
|
||||
invoiceCode?: string | null;
|
||||
invoiceNumber?: string | null;
|
||||
sellerName?: string | null;
|
||||
originalName?: string | null;
|
||||
},
|
||||
searchLower: string,
|
||||
) {
|
||||
if (!searchLower) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [invoice.invoiceCode, invoice.invoiceNumber, invoice.sellerName, invoice.originalName]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(searchLower));
|
||||
}
|
||||
|
||||
function isEditableStatus(status: ReimbursementStatus | undefined) {
|
||||
return status === "draft" || status === "rejected";
|
||||
}
|
||||
@@ -145,6 +170,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
|
||||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||||
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
|
||||
@@ -196,6 +222,18 @@ export function MobileReimbursementDetailPage() {
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const failedInvoicesQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog),
|
||||
queryKey: ["invoice-pool", "failed-own", reimbursementId],
|
||||
queryFn: () =>
|
||||
fetchInvoicePoolList({
|
||||
status: "ocr_failed",
|
||||
ownerScope: "own",
|
||||
pageSize: 20,
|
||||
}),
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const pendingInvoiceStatusesQuery = useQuery({
|
||||
enabled: pendingOcrInvoiceIds.length > 0,
|
||||
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
|
||||
@@ -203,6 +241,46 @@ export function MobileReimbursementDetailPage() {
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const invoiceStatusMessage = pendingOcrInvoiceIds.length
|
||||
? `${pendingOcrInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`
|
||||
: invoiceActionMessage;
|
||||
const invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success";
|
||||
const invoiceSearchLower = invoiceSearch.toLowerCase();
|
||||
const bindableLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
|
||||
() =>
|
||||
(bindableInvoicesQuery.data?.items ?? []).map((item) => ({
|
||||
...item,
|
||||
status: "available",
|
||||
})),
|
||||
[bindableInvoicesQuery.data?.items],
|
||||
);
|
||||
const failedLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
|
||||
() =>
|
||||
(failedInvoicesQuery.data?.items ?? [])
|
||||
.filter((item: InvoicePoolListItem) => !item.linkedReimbursementId && !item.linkedItemId && !item.linkedTravelOrderId)
|
||||
.filter((item: InvoicePoolListItem) => matchInvoiceSearch(item, invoiceSearchLower))
|
||||
.map((item: InvoicePoolListItem) => ({
|
||||
id: item.id,
|
||||
invoiceCode: item.invoiceCode,
|
||||
invoiceNumber: item.invoiceNumber,
|
||||
invoiceDate: item.invoiceDate,
|
||||
sellerName: null,
|
||||
totalAmount: item.totalAmount,
|
||||
originalName: item.originalName,
|
||||
createdAt: item.createdAt,
|
||||
status: "ocr_failed",
|
||||
unavailableMessage: "识别失败,可删除后重新上传。",
|
||||
})),
|
||||
[failedInvoicesQuery.data?.items, invoiceSearchLower],
|
||||
);
|
||||
const invoiceLibraryItems = useMemo(
|
||||
() => [...bindableLibraryInvoices, ...failedLibraryInvoices],
|
||||
[bindableLibraryInvoices, failedLibraryInvoices],
|
||||
);
|
||||
const invoiceLibraryLoading = bindableInvoicesQuery.isLoading || failedInvoicesQuery.isLoading;
|
||||
const invoiceLibraryFetching = bindableInvoicesQuery.isFetching || failedInvoicesQuery.isFetching;
|
||||
const invoiceLibraryError = bindableInvoicesQuery.error ?? failedInvoicesQuery.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
setInvoiceSearch(invoiceSearchInput.trim());
|
||||
@@ -211,11 +289,11 @@ export function MobileReimbursementDetailPage() {
|
||||
}, [invoiceSearchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id));
|
||||
const candidateIds = new Set(bindableLibraryInvoices.map((item) => item.id));
|
||||
setSelectedInvoices((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))),
|
||||
);
|
||||
}, [bindableInvoicesQuery.data?.items]);
|
||||
}, [bindableLibraryInvoices]);
|
||||
|
||||
const invalidateDetail = async () => {
|
||||
await Promise.all([
|
||||
@@ -382,6 +460,28 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAvailableInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
|
||||
onSuccess: async (_result, invoiceId) => {
|
||||
await invalidateDetail();
|
||||
await Promise.all([bindableInvoicesQuery.refetch(), failedInvoicesQuery.refetch()]);
|
||||
setSelectedInvoices((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[invoiceId];
|
||||
return next;
|
||||
});
|
||||
setConfirmDeleteInvoiceId("");
|
||||
setInvoiceActionError("");
|
||||
setInvoiceActionMessage("发票已删除");
|
||||
toast.success("发票已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
setInvoiceActionError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInvoiceMutation = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
if (!detail?.ownerId) {
|
||||
@@ -448,7 +548,7 @@ export function MobileReimbursementDetailPage() {
|
||||
messages.push(`${result.boundCount} 张已添加`);
|
||||
}
|
||||
if (result.pendingIds.length) {
|
||||
messages.push(`${result.pendingIds.length} 张识别中`);
|
||||
messages.push(`已上传 ${result.pendingIds.length} 张,正在识别`);
|
||||
}
|
||||
if (result.failures.length) {
|
||||
setInvoiceActionError(result.failures.map((item) => `${item.file.name}:${item.message}`).join(";"));
|
||||
@@ -461,6 +561,9 @@ export function MobileReimbursementDetailPage() {
|
||||
toast.error(`上传完成,${result.failures.length} 项需处理`);
|
||||
return;
|
||||
}
|
||||
if (result.pendingIds.length) {
|
||||
return;
|
||||
}
|
||||
toast.success(messages.length ? messages.join(",") : "上传完成");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -534,6 +637,7 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
if (failedMessages.length) {
|
||||
setInvoiceActionError(failedMessages.join(";"));
|
||||
setInvoiceActionMessage("");
|
||||
}
|
||||
|
||||
if (availableIds.length) {
|
||||
@@ -595,6 +699,7 @@ export function MobileReimbursementDetailPage() {
|
||||
setSelectedInvoiceFiles([]);
|
||||
setInvoiceActionError("");
|
||||
setInvoiceActionMessage("");
|
||||
setConfirmDeleteInvoiceId("");
|
||||
setPendingOcrInvoiceIds([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
@@ -1024,9 +1129,17 @@ export function MobileReimbursementDetailPage() {
|
||||
{invoiceActionError}
|
||||
</div>
|
||||
) : null}
|
||||
{invoiceActionMessage ? (
|
||||
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
{invoiceActionMessage}
|
||||
{invoiceStatusMessage ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||
invoiceStatusTone === "pending"
|
||||
? "border-amber-200 bg-amber-50 text-amber-700"
|
||||
: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
)}
|
||||
data-testid={invoiceStatusTone === "pending" ? TEST_IDS.reimbursement.invoicePendingHint : undefined}
|
||||
>
|
||||
{invoiceStatusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1064,12 +1177,6 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingOcrInvoiceIds.length ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后会自动刷新并添加。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||
@@ -1092,37 +1199,57 @@ export function MobileReimbursementDetailPage() {
|
||||
value={invoiceSearchInput}
|
||||
onChange={(event) => setInvoiceSearchInput(event.target.value)}
|
||||
/>
|
||||
<Button type="button" variant="secondary" disabled={bindableInvoicesQuery.isFetching} onClick={() => void bindableInvoicesQuery.refetch()}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={invoiceLibraryFetching}
|
||||
onClick={() => {
|
||||
void bindableInvoicesQuery.refetch();
|
||||
void failedInvoicesQuery.refetch();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[44dvh] space-y-2 overflow-y-auto pr-1">
|
||||
{bindableInvoicesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{bindableInvoicesQuery.isError ? (
|
||||
{invoiceLibraryLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{invoiceLibraryError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(bindableInvoicesQuery.error)}
|
||||
{resolveErrorMessage(invoiceLibraryError)}
|
||||
</div>
|
||||
) : null}
|
||||
{!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
|
||||
{!invoiceLibraryLoading && !invoiceLibraryError && !invoiceLibraryItems.length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||
暂无可添加发票
|
||||
</div>
|
||||
) : null}
|
||||
{(bindableInvoicesQuery.data?.items ?? []).map((invoice) => {
|
||||
{invoiceLibraryItems.map((invoice) => {
|
||||
const isAvailable = invoice.status === "available";
|
||||
const isFailed = invoice.status === "ocr_failed";
|
||||
const checked = Object.prototype.hasOwnProperty.call(selectedInvoices, invoice.id);
|
||||
return (
|
||||
<label
|
||||
<article
|
||||
key={invoice.id}
|
||||
className={cn("block rounded-lg border px-3 py-3", checked ? "border-primary-300 bg-primary-50" : "border-zinc-200 bg-white")}
|
||||
className={cn(
|
||||
"block rounded-lg border px-3 py-3",
|
||||
checked ? "border-primary-300 bg-primary-50" : "border-zinc-200 bg-white",
|
||||
isFailed ? "border-red-200 bg-red-50/40" : "",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => toggleInvoiceSelection(invoice, event.target.checked)}
|
||||
/>
|
||||
{isAvailable ? (
|
||||
<input
|
||||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => toggleInvoiceSelection(invoice, event.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<span className="mt-0.5 inline-flex shrink-0 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
|
||||
识别失败
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
@@ -1130,12 +1257,27 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{checked ? (
|
||||
{isFailed ? (
|
||||
<div className="mt-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{invoice.unavailableMessage ?? "识别失败,可删除后重新上传。"}
|
||||
</div>
|
||||
) : null}
|
||||
{isAvailable && checked ? (
|
||||
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
|
||||
添加后自动生成或匹配报销明细
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={deleteAvailableInvoiceMutation.isPending || bindInvoicesMutation.isPending}
|
||||
onClick={() => setConfirmDeleteInvoiceId(invoice.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1203,6 +1345,22 @@ export function MobileReimbursementDetailPage() {
|
||||
onConfirm={() => itemDeleteMutation.mutate(confirmDeleteItemId)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDeleteInvoiceId)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDeleteInvoiceId("");
|
||||
}}
|
||||
title="删除发票?"
|
||||
description="删除后将从可添加发票列表中移除。"
|
||||
variant="destructive"
|
||||
confirmLabel="删除"
|
||||
onConfirm={() => {
|
||||
if (confirmDeleteInvoiceId) {
|
||||
deleteAvailableInvoiceMutation.mutate(confirmDeleteInvoiceId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteReimbursement}
|
||||
onOpenChange={setConfirmDeleteReimbursement}
|
||||
|
||||
@@ -11,6 +11,8 @@ const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
||||
const ownerId = "employee-r111";
|
||||
const pendingInvoiceId = "22222222-2222-4222-8222-222222222222";
|
||||
const attachmentId = "33333333-3333-4333-8333-333333333333";
|
||||
const availableInvoiceId = "44444444-4444-4444-8444-444444444444";
|
||||
const failedInvoiceId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-r111-invoice-reimbursement", data };
|
||||
@@ -92,10 +94,54 @@ function buildAttachment() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAvailableInvoice() {
|
||||
return {
|
||||
id: availableInvoiceId,
|
||||
invoiceCode: "044002600111",
|
||||
invoiceNumber: "87654321",
|
||||
invoiceDate: "2026-05-08",
|
||||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||
totalAmount: "1683.00",
|
||||
originalName: "已有发票.pdf",
|
||||
createdAt: "2026-05-08T08:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailedInvoice() {
|
||||
return {
|
||||
id: failedInvoiceId,
|
||||
status: "ocr_failed",
|
||||
ocrType: "vat",
|
||||
ocrProvider: "tencent",
|
||||
ocrAttempts: 1,
|
||||
ocrLastError: "识别服务未返回完整发票信息",
|
||||
ocrCompletedAt: "2026-05-08T08:05:00.000Z",
|
||||
invoiceCode: null,
|
||||
invoiceNumber: null,
|
||||
invoiceDate: null,
|
||||
amount: null,
|
||||
taxAmount: null,
|
||||
totalAmount: null,
|
||||
linkedReimbursementId: null,
|
||||
linkedItemId: null,
|
||||
linkedTravelOrderId: null,
|
||||
originalName: "识别失败发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4096,
|
||||
verification: null,
|
||||
owner: { id: ownerId, employeeNo: "E111", name: "测试员工" },
|
||||
uploadedBy: { id: ownerId, employeeNo: "E111", name: "测试员工" },
|
||||
createdAt: "2026-05-08T08:01:00.000Z",
|
||||
updatedAt: "2026-05-08T08:05:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function buildReimbursementState() {
|
||||
return {
|
||||
invoices: [] as ReturnType<typeof buildInvoice>[],
|
||||
attachments: [] as ReturnType<typeof buildAttachment>[],
|
||||
availableInvoices: [buildAvailableInvoice()],
|
||||
failedInvoices: [buildFailedInvoice()],
|
||||
statusPollCount: 0,
|
||||
};
|
||||
}
|
||||
@@ -283,18 +329,18 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
|
||||
if (pathName === "/api/invoice-pool/bindable") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
invoiceCode: "044002600111",
|
||||
invoiceNumber: "87654321",
|
||||
invoiceDate: "2026-05-08",
|
||||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||
totalAmount: "1683.00",
|
||||
originalName: "已有发票.pdf",
|
||||
createdAt: "2026-05-08T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
items: state.availableInvoices,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/invoice-pool" && url.searchParams.get("status") === "ocr_failed") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: state.failedInvoices.length,
|
||||
ownerScope: "own",
|
||||
items: state.failedInvoices,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -314,26 +360,27 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
|
||||
if (pathName === "/api/invoice-pool/status") {
|
||||
state.statusPollCount += 1;
|
||||
const stillPending = state.statusPollCount === 1;
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: pendingInvoiceId,
|
||||
status: "available",
|
||||
reason: "available",
|
||||
message: "发票可添加",
|
||||
invoiceCode: "044002600111",
|
||||
invoiceNumber: "12345678",
|
||||
invoiceDate: "2026-05-08",
|
||||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||
totalAmount: "1683.00",
|
||||
status: stillPending ? "pending_ocr" : "available",
|
||||
reason: stillPending ? "pending_ocr" : "available",
|
||||
message: stillPending ? "发票正在识别" : "发票可添加",
|
||||
invoiceCode: stillPending ? null : "044002600111",
|
||||
invoiceNumber: stillPending ? null : "12345678",
|
||||
invoiceDate: stillPending ? null : "2026-05-08",
|
||||
sellerName: stillPending ? null : "北京悦海堂餐饮管理有限公司",
|
||||
totalAmount: stillPending ? null : "1683.00",
|
||||
originalName: "北京悦海堂发票.pdf",
|
||||
linkedReimbursementId: null,
|
||||
linkedItemId: null,
|
||||
linkedTravelOrderId: null,
|
||||
ocrAttempts: 1,
|
||||
ocrAttempts: stillPending ? 0 : 1,
|
||||
ocrLastError: null,
|
||||
ocrCompletedAt: "2026-05-08T08:05:00.000Z",
|
||||
updatedAt: "2026-05-08T08:05:00.000Z",
|
||||
ocrCompletedAt: stillPending ? null : "2026-05-08T08:05:00.000Z",
|
||||
updatedAt: stillPending ? "2026-05-08T08:04:00.000Z" : "2026-05-08T08:05:00.000Z",
|
||||
},
|
||||
],
|
||||
missingIds: [],
|
||||
@@ -367,6 +414,28 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/invoice-pool/${availableInvoiceId}/archive`) {
|
||||
state.availableInvoices = [];
|
||||
await fulfillJson(route, {
|
||||
id: availableInvoiceId,
|
||||
status: "archived",
|
||||
archivedReason: "manual",
|
||||
archivedAt: "2026-05-08T08:20:00.000Z",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/invoice-pool/${failedInvoiceId}/archive`) {
|
||||
state.failedInvoices = [];
|
||||
await fulfillJson(route, {
|
||||
id: failedInvoiceId,
|
||||
status: "archived",
|
||||
archivedReason: "manual",
|
||||
archivedAt: "2026-05-08T08:21:00.000Z",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
}
|
||||
@@ -494,15 +563,39 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
const dialogMetrics = await collectVisualMetrics(mobile.page);
|
||||
expect(dialogMetrics.activeElementTestId).not.toBe("mobile-reimbursement-invoice-search");
|
||||
|
||||
const invoiceDialog = mobile.page.getByTestId("reimbursement-invoice-dialog");
|
||||
await invoiceDialog.getByRole("button", { name: "已有发票" }).click();
|
||||
const availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600111-87654321" });
|
||||
const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" });
|
||||
await expect(availableInvoiceCard).toBeVisible();
|
||||
await expect(failedInvoiceCard.getByText("识别失败", { exact: true })).toBeVisible();
|
||||
await expect(failedInvoiceCard.getByText("识别失败,可删除后重新上传。")).toBeVisible();
|
||||
|
||||
await availableInvoiceCard.getByRole("button", { name: "删除" }).click();
|
||||
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
|
||||
await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click();
|
||||
await expect(availableInvoiceCard).toHaveCount(0);
|
||||
await expect(failedInvoiceCard).toBeVisible();
|
||||
|
||||
await failedInvoiceCard.getByRole("button", { name: "删除" }).click();
|
||||
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
|
||||
await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click();
|
||||
await expect(failedInvoiceCard).toHaveCount(0);
|
||||
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
|
||||
|
||||
await invoiceDialog.getByRole("button", { name: "上传" }).click();
|
||||
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||||
name: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF r111 mobile invoice"),
|
||||
});
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-pending-hint")).toHaveCount(1);
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).not.toContainText("识别完成后可在");
|
||||
await expect(
|
||||
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await mobile.page.keyboard.press("Escape");
|
||||
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
|
||||
|
||||
|
||||
@@ -187,9 +187,12 @@ else
|
||||
echo ">>> 5. 跳过部署后 live smoke(未提供完整 LIVE_SMOKE_* 凭据)"
|
||||
fi
|
||||
|
||||
echo ">>> 6. 部署完成"
|
||||
echo ">>> 6. 执行公网前端资源文案扫描"
|
||||
node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
|
||||
echo ">>> 7. 部署完成"
|
||||
echo " 远端目录: ${REMOTE_DIR}"
|
||||
echo " 环境文件: ${REMOTE_ENV_FILE}"
|
||||
echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke"
|
||||
echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描"
|
||||
echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke"
|
||||
echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 127.0.0.1:3000"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
A1,R123执行合同与部署差异基线,"固定本轮范围:移动发票反馈瘦身、已有发票删除、跨页面术语检查、交付部署收口;以当前生产旧 bundle 仍含绑定整单为基线。","1) 新 Issue CSV 校验通过;2) 范围不混入后端状态机或 Prisma 迁移;3) 明确部署后必须告知用户可测试。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-08_11-58-47-r123-mobile-invoice-feedback-and-deploy-closeout.csv;业务流程:企业微信用户反馈进入可执行合同。","python|manual",DONE,DONE,DONE,"issues/2026-05-08_11-58-47-r123-mobile-invoice-feedback-and-deploy-closeout.csv","[]","CSV 已创建并校验通过;生产旧 bundle 仅作为本轮验收前置证据。"
|
||||
A2,移动发票识别状态单一反馈,"重做移动报销详情添加发票弹窗的上传后反馈,避免 toast、面板消息、底部提示同时显示 1 张识别中。","1) 上传 pending 后弹窗内只保留一个识别状态区;2) 状态区显示正在自动刷新或等待秒数;3) OCR 完成后自动添加并清除 pending;4) 不出现“识别完成后可在已有发票中绑定”。","Playwright mobile/wecom:打开报销草稿->添加发票->上传 pending->检查仅一个识别提示->mock status available->自动添加;截图检查无重复提示。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/MobileReimbursementDetailPage.tsx|client/tests/e2e/r109-mobile-reimbursement-feedback.spec.ts|client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts","[""A1""]","已通过 client typecheck/build、R111 mock 双端发票回归、R109 本地移动企微门禁;仅前端体验收敛,不改 OCR provider。"
|
||||
A3,移动已有发票删除入口,"在移动添加发票的已有发票列表中为未绑定的本人发票提供删除入口,底层复用归档接口;已添加到报销单的发票继续使用移除。","1) 可添加/识别失败且未绑定发票显示删除并二次确认;2) 删除后从已有发票列表消失;3) 已绑定发票不显示删除,只能按报销单状态移除;4) 普通用户不看到手工归档等内部文案。","Playwright mobile/wecom:已有发票列表->删除未绑定发票->刷新后不可选;报销详情已添加发票->移除仍可用;业务流程:员工能清理误上传票据。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/MobileReimbursementDetailPage.tsx|client/src/api/invoicePool.ts|client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts","[""A2""]","已通过 R111 移动删除发票断言;复用现有 POST /api/invoice-pool/:id/archive,不做物理 DELETE。"
|
||||
A4,跨页面普通用户术语与发布扫描,"扩展普通用户文案检查并清理已发现差旅付款中的发票池表达,防止绑定整单、发票池、OCR 等内部术语进入用户主流程。","1) 移动报销、PC 报销、差旅付款普通页面不显示绑定整单/不绑定明细/发票池/OCR;2) 静态检查能报告上述术语;3) 生产 bundle 扫描可发现公网旧包。","命令:node scripts/qa/check-ui-design-rules.mjs --strict;生产扫描:对 https://oa.hyinda.com 递归 JS asset 检查禁用词;业务流程:普通员工页面不暴露内部术语。","node|typescript|curl",DONE,DONE,TODO,"scripts/qa/check-ui-design-rules.mjs|scripts/qa|client/src/components/travel/TravelPaymentTrackCard.tsx|client/tests/e2e","[""A1""]","源码静态检查已清零;公网旧 bundle 扫描当前失败属部署前预期,部署后由 A6 复核。"
|
||||
A5,交付部署可测试规则固化,"把“完成后必须明确是否已部署、是否可以去企业微信测试”写入项目交付规则,避免只完成本地代码未部署。","1) 交付规则要求最终回复包含部署状态、URL、commit、生产资源检查和是否可测试;2) 未部署时必须明确说明不能测试;3) 部署失败必须说明阻塞原因。","人工阅读 .agents/skills/hyb-oa-delivery/SKILL.md 或 docs/agent-development-practices.md;业务流程:每次用户需求完成后可判断是否该去企业微信验收。","manual",DONE,DONE,DONE,".agents/skills/hyb-oa-delivery/SKILL.md|docs/agent-development-practices.md","[""A1""]","Skill quick_validate 已通过;只改交付规则,不涉及业务运行时。"
|
||||
A6,本地回归与公网部署验收,"完成 A2-A5 后执行本地前端检查、移动企微 Playwright、构建、部署和公网资源核实,并告知用户可测试。","1) client typecheck/build 通过;2) R109/R111 相关 Playwright 通过;3) deploy/trialrun/deploy.sh 成功;4) https://oa.hyinda.com 新 bundle 不含绑定整单和旧识别提示;5) 最终回复明确可以去企业微信测试。","命令:cd client && npm run typecheck && npm run build && npx playwright test tests/e2e/r109-mobile-reimbursement-feedback.spec.ts tests/e2e/r111-invoice-reimbursement-guard.spec.ts --workers=1 --reporter=list;部署:bash deploy/trialrun/deploy.sh;公网:curl /ready + bundle 扫描;业务流程:企业微信上传发票->状态变化->删除误上传票据。","npm|playwright|bash|curl|manual",DOING,TODO,TODO,"client|scripts/qa|deploy/trialrun|issues/2026-05-08_11-58-47-r123-mobile-invoice-feedback-and-deploy-closeout.csv","[""A2"",""A3"",""A4"",""A5""]","client typecheck/build、R111、R109 已通过;待部署与公网资源扫描。若 LIVE_SMOKE_* 不完整,记录 live smoke 跳过并给人工真机步骤。"
|
||||
|
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
import process from "node:process";
|
||||
|
||||
const baseUrl = normalizeBaseUrl(process.argv[2] ?? process.env.PUBLIC_BASE_URL ?? process.env.LIVE_SMOKE_BASE_URL ?? "https://oa.hyinda.com");
|
||||
const focusedAssetPattern = /(MobileReimbursementDetailPage|ReimbursementDetailPage|page-mobile|page-assets|page-travel)/;
|
||||
const forbiddenTerms = ["绑定整单", "不绑定明细", "识别完成后可在", "发票池", "OCR"];
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function toAssetPath(value) {
|
||||
if (value.startsWith("/")) {
|
||||
return value;
|
||||
}
|
||||
return `/assets/${value.replace(/^\.\//, "")}`;
|
||||
}
|
||||
|
||||
async function fetchText(path) {
|
||||
const response = await fetch(`${baseUrl}${path}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET ${path} failed: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function collectAssets() {
|
||||
const seen = new Set();
|
||||
const queue = [];
|
||||
const html = await fetchText("/");
|
||||
|
||||
for (const match of html.matchAll(/(?:src|href)=["'](\/assets\/[^"']+\.js)["']/g)) {
|
||||
const assetPath = toAssetPath(match[1]);
|
||||
if (!seen.has(assetPath)) {
|
||||
queue.push(assetPath);
|
||||
seen.add(assetPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const assetPath = queue[index];
|
||||
const source = await fetchText(assetPath);
|
||||
for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) {
|
||||
const nextPath = toAssetPath(match[1]);
|
||||
if (!seen.has(nextPath)) {
|
||||
queue.push(nextPath);
|
||||
seen.add(nextPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(seen).sort();
|
||||
}
|
||||
|
||||
const assets = await collectAssets();
|
||||
const findings = [];
|
||||
|
||||
for (const assetPath of assets) {
|
||||
if (!focusedAssetPattern.test(assetPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = await fetchText(assetPath);
|
||||
for (const term of forbiddenTerms) {
|
||||
if (source.includes(term)) {
|
||||
findings.push({ assetPath, term });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} findings=${findings.length}`);
|
||||
for (const finding of findings) {
|
||||
console.log(`ERROR ${finding.assetPath} contains ${finding.term}`);
|
||||
}
|
||||
|
||||
if (findings.length) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -15,6 +15,12 @@ const scanRoots = [
|
||||
];
|
||||
const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]);
|
||||
const forbiddenCopy = ["先处理", "清爽"];
|
||||
const userFacingBusinessTermFiles = new Set([
|
||||
"client/src/pages/MobileReimbursementDetailPage.tsx",
|
||||
"client/src/pages/ReimbursementDetailPage.tsx",
|
||||
"client/src/components/travel/TravelPaymentTrackCard.tsx",
|
||||
]);
|
||||
const forbiddenUserFacingBusinessTerms = ["绑定整单", "不绑定明细", "发票池", "OCR"];
|
||||
const findings = [];
|
||||
|
||||
function toPosix(value) {
|
||||
@@ -104,7 +110,7 @@ function scanNativeFileInputs(file, source) {
|
||||
const fileInputPattern = /<input\b[^>]*\btype=["']file["'][^>]*>/g;
|
||||
let match = fileInputPattern.exec(source);
|
||||
while (match) {
|
||||
if (/\bhidden\b/.test(match[0])) {
|
||||
if (/\bhidden\b/.test(match[0]) || /\bsr-only\b/.test(match[0])) {
|
||||
match = fileInputPattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
@@ -121,6 +127,28 @@ function scanNativeFileInputs(file, source) {
|
||||
}
|
||||
}
|
||||
|
||||
function scanUserFacingBusinessTerms(file, source) {
|
||||
const normalized = toPosix(path.relative(rootDir, file));
|
||||
if (!userFacingBusinessTermFiles.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const term of forbiddenUserFacingBusinessTerms) {
|
||||
let start = source.indexOf(term);
|
||||
while (start !== -1) {
|
||||
addFinding({
|
||||
rule: "business.no_internal_invoice_terms",
|
||||
severity: "error",
|
||||
file,
|
||||
line: lineNumber(source, start),
|
||||
message: `普通用户主流程不应暴露内部发票术语:${term}`,
|
||||
evidence: term,
|
||||
});
|
||||
start = source.indexOf(term, start + term.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanRawBusinessIdentifiers(file, source) {
|
||||
const normalized = toPosix(file);
|
||||
if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) {
|
||||
@@ -195,6 +223,7 @@ function scanNavigationTitleCoverage(file, source) {
|
||||
function scanFile(file) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
scanForbiddenCopy(file, source);
|
||||
scanUserFacingBusinessTerms(file, source);
|
||||
scanMobileDarkControls(file, source);
|
||||
scanNativeFileInputs(file, source);
|
||||
scanRawBusinessIdentifiers(file, source);
|
||||
|
||||
在新工单中引用
屏蔽一个用户