取消报销资产事前批准强制(08-30-reimbursement-assets-prior-approval-correction)
这个提交包含在:
@@ -89,7 +89,6 @@ type ItemFormState = {
|
||||
expenseDate: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
assetPriorApprovalBasis: string;
|
||||
};
|
||||
|
||||
type SelectedInvoiceMap = Record<string, string>;
|
||||
@@ -331,7 +330,6 @@ export function MobileReimbursementDetailPage() {
|
||||
expenseDate: detail?.reimbursementDate ?? todayDate(),
|
||||
amount: "",
|
||||
description: "",
|
||||
assetPriorApprovalBasis: "",
|
||||
});
|
||||
setItemError("");
|
||||
};
|
||||
@@ -344,7 +342,6 @@ export function MobileReimbursementDetailPage() {
|
||||
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
||||
amount: item.amount,
|
||||
description: item.description ?? "",
|
||||
assetPriorApprovalBasis: item.assetPriorApprovalBasis ?? "",
|
||||
});
|
||||
setItemError("");
|
||||
};
|
||||
@@ -360,7 +357,6 @@ export function MobileReimbursementDetailPage() {
|
||||
expenseDate: form.expenseDate,
|
||||
amount: form.amount,
|
||||
description: form.description.trim() || undefined,
|
||||
assetPriorApprovalBasis: form.assetPriorApprovalBasis.trim() || null,
|
||||
};
|
||||
|
||||
if (form.mode === "edit" && form.itemId) {
|
||||
@@ -1093,29 +1089,17 @@ export function MobileReimbursementDetailPage() {
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
|
||||
/>
|
||||
</label>
|
||||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" ? (
|
||||
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
|
||||
<span>事前批准依据</span>
|
||||
<Textarea
|
||||
disabled={itemSaveMutation.isPending}
|
||||
placeholder="填写批准人、批准时间和简短依据"
|
||||
rows={2}
|
||||
value={itemForm.assetPriorApprovalBasis}
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, assetPriorApprovalBasis: event.target.value } : current)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{shouldShowOfficeElectronicsHint(
|
||||
expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.code,
|
||||
itemForm.amount,
|
||||
) ? (
|
||||
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
||||
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-two-action-grid">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || (expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" && !itemForm.assetPriorApprovalBasis.trim()) || itemSaveMutation.isPending}
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
|
||||
onClick={() => itemSaveMutation.mutate(itemForm)}
|
||||
>
|
||||
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
||||
@@ -1140,10 +1124,7 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
||||
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
||||
) : null}
|
||||
{item.assetPriorApprovalBasis ? (
|
||||
<p className="mt-2 text-xs text-zinc-500">事前批准依据:{item.assetPriorApprovalBasis}</p>
|
||||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
|
||||
@@ -136,7 +136,6 @@ export function ReimbursementDetailPage() {
|
||||
expenseDate: "",
|
||||
amount: "",
|
||||
description: "",
|
||||
assetPriorApprovalBasis: "",
|
||||
});
|
||||
const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
|
||||
const navigationState = location.state as PcDetailNavigationState | null;
|
||||
@@ -291,7 +290,6 @@ export function ReimbursementDetailPage() {
|
||||
expenseDate: data.expenseDate,
|
||||
amount: data.amount,
|
||||
description: data.description.trim() || undefined,
|
||||
assetPriorApprovalBasis: data.assetPriorApprovalBasis.trim() || null,
|
||||
};
|
||||
|
||||
if (itemEditor.mode === "create") {
|
||||
@@ -814,7 +812,6 @@ export function ReimbursementDetailPage() {
|
||||
expenseTypeId: defaultExpenseTypeId,
|
||||
amount: item?.amount ?? "",
|
||||
description: item?.description ?? "",
|
||||
assetPriorApprovalBasis: item?.assetPriorApprovalBasis ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1279,12 +1276,7 @@ export function ReimbursementDetailPage() {
|
||||
<div>{item.description ?? "-"}</div>
|
||||
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||
<div className="mt-1 text-xs text-amber-600">
|
||||
该金额通常应选择办公电子设备并填写事前批准依据。
|
||||
</div>
|
||||
) : null}
|
||||
{item.assetPriorApprovalBasis ? (
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
事前批准依据:{item.assetPriorApprovalBasis}
|
||||
该金额通常应选择办公电子设备。
|
||||
</div>
|
||||
) : null}
|
||||
{precheckStandardChecks
|
||||
|
||||
@@ -22,7 +22,6 @@ export type ItemEditorInitialValues = {
|
||||
expenseDate: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
assetPriorApprovalBasis: string;
|
||||
};
|
||||
|
||||
export type ItemEditorSaveData = {
|
||||
@@ -30,7 +29,6 @@ export type ItemEditorSaveData = {
|
||||
expenseDate: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
assetPriorApprovalBasis: string;
|
||||
};
|
||||
|
||||
export interface ReimbursementItemEditorProps {
|
||||
@@ -60,7 +58,6 @@ export function ReimbursementItemEditor({
|
||||
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
|
||||
const [amount, setAmount] = useState(initialValues.amount);
|
||||
const [description, setDescription] = useState(initialValues.description);
|
||||
const [assetPriorApprovalBasis, setAssetPriorApprovalBasis] = useState(initialValues.assetPriorApprovalBasis);
|
||||
|
||||
// Reset internal state when initialValues change (e.g. switching between create/edit)
|
||||
useEffect(() => {
|
||||
@@ -68,11 +65,9 @@ export function ReimbursementItemEditor({
|
||||
setExpenseDate(initialValues.expenseDate);
|
||||
setAmount(initialValues.amount);
|
||||
setDescription(initialValues.description);
|
||||
setAssetPriorApprovalBasis(initialValues.assetPriorApprovalBasis);
|
||||
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description, initialValues.assetPriorApprovalBasis]);
|
||||
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
|
||||
|
||||
const selectedExpenseType = expenseTypes.find((item) => item.id === expenseTypeId) ?? null;
|
||||
const isAssetExpense = selectedExpenseType?.category === "assets";
|
||||
const numericAmount = Number(amount || 0);
|
||||
const shouldWarnOfficeEquipment =
|
||||
shouldShowOfficeElectronicsHint(selectedExpenseType?.code, numericAmount);
|
||||
@@ -102,7 +97,7 @@ export function ReimbursementItemEditor({
|
||||
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ expenseTypeId, expenseDate, amount, description, assetPriorApprovalBasis });
|
||||
onSave({ expenseTypeId, expenseDate, amount, description });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -185,28 +180,11 @@ export function ReimbursementItemEditor({
|
||||
) : null}
|
||||
{shouldWarnOfficeEquipment ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
该金额通常应选择办公电子设备并填写事前批准依据。
|
||||
该金额通常应选择办公电子设备。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isAssetExpense ? (
|
||||
<div className="space-y-1 md:col-span-4">
|
||||
<label className="text-xs text-zinc-600" htmlFor="assetPriorApprovalBasis">
|
||||
事前批准依据
|
||||
</label>
|
||||
<Textarea
|
||||
data-testid="reimbursement-item-asset-approval-basis"
|
||||
disabled={isPending}
|
||||
id="assetPriorApprovalBasis"
|
||||
onChange={(event) => setAssetPriorApprovalBasis(event.target.value)}
|
||||
placeholder="填写批准人、批准时间和简短依据"
|
||||
rows={2}
|
||||
value={assetPriorApprovalBasis}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1 md:col-span-4">
|
||||
<label className="text-xs text-zinc-600" htmlFor="itemDescription">
|
||||
说明
|
||||
@@ -230,8 +208,7 @@ export function ReimbursementItemEditor({
|
||||
!expenseTypeId ||
|
||||
!expenseDate ||
|
||||
!amount.trim() ||
|
||||
!hasPositiveAmount ||
|
||||
(isAssetExpense && !assetPriorApprovalBasis.trim())
|
||||
!hasPositiveAmount
|
||||
}
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
|
||||
@@ -83,7 +83,7 @@ function buildExpenseTypes() {
|
||||
code: "assets_office_electronics",
|
||||
name: "办公电子设备",
|
||||
category: "assets",
|
||||
description: "2,000 元及以上通常选本类型,不等同于会计固定资产标准。",
|
||||
description: "单条明细金额达到 2,000 元的办公电子设备通常选择本类型;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。该门槛不等同于会计固定资产确认标准。",
|
||||
requiresInvoice: true,
|
||||
enabled: true,
|
||||
},
|
||||
@@ -186,6 +186,7 @@ function buildReimbursementState() {
|
||||
failedInvoices: [buildFailedInvoice()],
|
||||
statusPollCount: 0,
|
||||
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
|
||||
itemSaveRequests: [] as Array<Record<string, unknown>>,
|
||||
submitRequestCount: 0,
|
||||
invoiceGuardEnabled: false,
|
||||
detailItems: null as null | Array<Record<string, unknown>>,
|
||||
@@ -384,6 +385,26 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(pathName === `/api/reimbursements/${reimbursementId}/items` && request.method() === "POST")
|
||||
|| (pathName.startsWith(`/api/reimbursements/${reimbursementId}/items/`) && request.method() === "PATCH")
|
||||
) {
|
||||
const payload = request.postDataJSON() as Record<string, unknown>;
|
||||
state.itemSaveRequests.push(payload);
|
||||
await fulfillJson(route, {
|
||||
item: {
|
||||
id: "asset-item-r111",
|
||||
expenseTypeId: payload.expenseTypeId ?? "expense-type-assets-r111",
|
||||
expenseDate: payload.expenseDate ?? "2026-05-08",
|
||||
amount: payload.amount ?? "2000.00",
|
||||
description: payload.description ?? null,
|
||||
assetPriorApprovalBasis: null,
|
||||
},
|
||||
totalAmount: "2000.00",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
|
||||
await fulfillJson(route, {
|
||||
decision: "pass",
|
||||
@@ -735,7 +756,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("B1/B2 在 PC 和移动端显示设备提示并要求明确关联明细", async ({ page }) => {
|
||||
test("B1/B2 在 PC 和移动端取消批准依据并保持设备提示与明确关联", async ({ page }) => {
|
||||
const state = buildReimbursementState();
|
||||
state.invoiceGuardEnabled = true;
|
||||
state.detailItems = [{
|
||||
@@ -758,11 +779,18 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await login(page);
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
|
||||
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
|
||||
await page.getByTestId("reimbursement-item-add").click();
|
||||
await page.locator("#expenseTypeId").selectOption("expense-type-office-supplies-r111");
|
||||
await page.getByTestId("reimbursement-item-amount").fill("2000.00");
|
||||
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
|
||||
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
|
||||
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toBeVisible();
|
||||
await expect(page.getByText("2,000 元及以上通常选本类型,不等同于会计固定资产标准。")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toHaveCount(0);
|
||||
await expect(page.getByText(/请在明细说明中写明物品名称、实际使用人或责任人、存放位置/)).toBeVisible();
|
||||
await page.getByTestId("reimbursement-item-description").fill("合成设备,测试员工使用,测试位置");
|
||||
await page.getByTestId("reimbursement-item-save").click();
|
||||
await expect.poll(() => state.itemSaveRequests.length).toBe(1);
|
||||
expect(state.itemSaveRequests[0]).not.toHaveProperty("assetPriorApprovalBasis");
|
||||
|
||||
await page.getByTestId("reimbursement-invoice-add").click();
|
||||
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
|
||||
@@ -788,10 +816,17 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
|
||||
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
|
||||
await page.getByRole("button", { name: "新增", exact: true }).click();
|
||||
await page.getByLabel("费用类型").selectOption("expense-type-office-supplies-r111");
|
||||
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
|
||||
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
|
||||
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
|
||||
await expect(page.getByText("事前批准依据", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
|
||||
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
|
||||
await page.getByRole("button", { name: "保存", exact: true }).click();
|
||||
await expect.poll(() => state.itemSaveRequests.length).toBe(2);
|
||||
expect(state.itemSaveRequests[1]).not.toHaveProperty("assetPriorApprovalBasis");
|
||||
|
||||
state.invoiceGuardEnabled = false;
|
||||
state.invoices = [];
|
||||
|
||||
在新工单中引用
屏蔽一个用户