R34: 收口搜索查看更多跳转与采购 submit 二次减重
这个提交包含在:
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { searchGlobal } from "../api/search";
|
import { searchGlobal, type GlobalSearchHit } from "../api/search";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -56,10 +56,46 @@ function resolveSearchGroup(entityType: string): SearchGroupKey {
|
|||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBrowseHref(groupKey: SearchGroupKey, items: GlobalSearchHit[]) {
|
||||||
|
const firstItem = items[0];
|
||||||
|
if (!firstItem) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupKey === "people") {
|
||||||
|
return "/people";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupKey === "documents") {
|
||||||
|
if (firstItem.entityType === "reimbursement") return "/reimbursements";
|
||||||
|
if (firstItem.entityType === "leave") return "/leave-requests";
|
||||||
|
if (firstItem.entityType === "purchase_request") return "/purchase-requests";
|
||||||
|
if (firstItem.entityType === "travel_request") return "/travel-requests";
|
||||||
|
if (firstItem.entityType === "weekly_report") return "/weekly-reports";
|
||||||
|
if (firstItem.entityType === "todo") return "/todos";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupKey === "projectAnnouncement") {
|
||||||
|
return firstItem.entityType === "announcement" ? "/announcements" : "/projects";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.entityType === "asset") return "/assets";
|
||||||
|
if (["contract_registry", "confidential_doc_ref"].includes(firstItem.entityType)) {
|
||||||
|
return "/contracts-registry";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const trimmed = keyword.trim();
|
const trimmed = keyword.trim();
|
||||||
|
const closeAndNavigate = (targetHref: string) => {
|
||||||
|
onOpenChange(false);
|
||||||
|
window.setTimeout(() => navigate(targetHref), 0);
|
||||||
|
};
|
||||||
const searchQuery = useQuery({
|
const searchQuery = useQuery({
|
||||||
enabled: open && trimmed.length >= 2,
|
enabled: open && trimmed.length >= 2,
|
||||||
queryKey: ["global-search", trimmed],
|
queryKey: ["global-search", trimmed],
|
||||||
@@ -74,6 +110,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
|||||||
...group,
|
...group,
|
||||||
total: matches.length,
|
total: matches.length,
|
||||||
items: matches.slice(0, 3),
|
items: matches.slice(0, 3),
|
||||||
|
browseHref: resolveBrowseHref(group.key, matches),
|
||||||
};
|
};
|
||||||
}).filter((group) => group.total > 0);
|
}).filter((group) => group.total > 0);
|
||||||
}, [searchQuery.data?.items]);
|
}, [searchQuery.data?.items]);
|
||||||
@@ -93,7 +130,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>全局搜索</DialogTitle>
|
<DialogTitle>全局搜索</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -121,10 +158,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
|||||||
key={`${item.entityType}-${item.entityId}`}
|
key={`${item.entityType}-${item.entityId}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
|
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
|
||||||
onClick={() => {
|
onClick={() => closeAndNavigate(item.href)}
|
||||||
onOpenChange(false);
|
|
||||||
navigate(item.href);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
@@ -136,8 +170,18 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{group.total > group.items.length ? (
|
{group.total > group.items.length && group.browseHref ? (
|
||||||
<div className="text-xs text-zinc-400">查看更多结果</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs font-medium text-zinc-500 transition-colors hover:text-zinc-800"
|
||||||
|
onClick={() => {
|
||||||
|
const browseHref = group.browseHref;
|
||||||
|
if (!browseHref) return;
|
||||||
|
closeAndNavigate(browseHref);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看更多结果
|
||||||
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
import { ADMIN, login } from "./helpers/auth";
|
import { ADMIN, login } from "./helpers/auth";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ function envelope<T>(data: T) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳转", async ({ page }) => {
|
async function mockSearchResults(page: Page) {
|
||||||
await page.route("**/api/search?**", async (route) => {
|
await page.route("**/api/search?**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -30,6 +30,10 @@ test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳转", async ({ page }) => {
|
||||||
|
await mockSearchResults(page);
|
||||||
|
|
||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
await page.goto("/portal");
|
await page.goto("/portal");
|
||||||
@@ -56,6 +60,26 @@ test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳
|
|||||||
await expect(page).toHaveURL(/\/reimbursements\/reimbursement-1/);
|
await expect(page).toHaveURL(/\/reimbursements\/reimbursement-1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("E2E 全局搜索:查看更多结果会关闭弹窗并跳到分组列表页", async ({ page }) => {
|
||||||
|
await mockSearchResults(page);
|
||||||
|
|
||||||
|
await login(page, ADMIN);
|
||||||
|
await page.goto("/portal");
|
||||||
|
await page.getByTestId("global-search-trigger").click();
|
||||||
|
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
||||||
|
|
||||||
|
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
|
||||||
|
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
|
||||||
|
await searchResponse;
|
||||||
|
|
||||||
|
const documentsSection = page.locator("section").filter({ has: page.getByRole("heading", { name: "业务单据" }) });
|
||||||
|
await expect(documentsSection.getByRole("button", { name: "查看更多结果" })).toBeVisible();
|
||||||
|
await documentsSection.getByRole("button", { name: "查看更多结果" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/reimbursements$/);
|
||||||
|
await expect(page.getByRole("heading", { name: "全局搜索" })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("E2E 全局搜索:Ctrl+K 和 Cmd+K 可打开搜索弹窗", async ({ page }) => {
|
test("E2E 全局搜索:Ctrl+K 和 Cmd+K 可打开搜索弹窗", async ({ page }) => {
|
||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
await page.goto("/portal");
|
await page.goto("/portal");
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
R34-0,新执行合同建档与范围锁定,基于 R31-5 已收口后的剩余已知缺口建立 R34 合同并锁定仅覆盖搜索查看更多跳转与采购 submit 二次减重,新 CSV 校验通过 且 Notes 明确本轮不承接 push R31-5 与其他顺手修复,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_21-16-52-r34-search-procurement-stability.csv,none,DONE,DONE,DONE,issues/2026-03-24_21-16-52-r34-search-procurement-stability.csv,[],"R34 不承接 push;两个仓库当前无 upstream,远端配置等待后续单独处理。本轮不新增搜索结果页,不调整 Prisma transaction timeout。2026-03-24 已完成 R34 CSV 建档并通过 `python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_21-16-52-r34-search-procurement-stability.csv` 校验;同日 `bash scripts/qa/run-web-gates.sh full-local` 通过,结果 27 passed / 3 skipped / 0 failed,R34 正式收口。"
|
||||||
|
R34-1,全局搜索查看更多结果补跳转,把全局搜索分组中的查看更多结果从静态文案改为真实可点击跳转并保持现有搜索 API 与分组逻辑不变,当某分组总数大于 3 时显示可点击查看更多结果 点击后关闭弹窗并跳到对应现有列表页 且现有单条命中点击跳详情能力不回归,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/global-search.spec.ts,manual,DONE,DONE,DONE,client/src/components/GlobalSearchDialog.tsx|client/tests/e2e/global-search.spec.ts,"[""R34-0""]","映射固定为 people->/people documents 按首个 entityType 跳对应列表页 projectAnnouncement 按首个实体跳 /projects 或 /announcements other 中 asset 跳 /assets contract_registry 与 confidential_doc_ref 跳 /contracts-registry;同组混合类型时查看更多结果按首条命中决定目标列表页,属于尽力猜测。2026-03-24 `cd client && npm run typecheck` 通过;同日使用临时库 + build + 本地 server 编排执行 `CI=1 PLAYWRIGHT_HTML_OPEN=never E2E_MODE=local npm run test:e2e -- tests/e2e/global-search.spec.ts`,结果 3 passed。过程中补了 dialog `max-h` 与 `overflow-y-auto`,修复 720px 视口下查看更多结果按钮超出视口不可点的问题。"
|
||||||
|
R34-2,采购 submit workflow 纯读解析前置,把采购 submit 中 active workflow definition 读取与 actionable approver 纯读解析前置到事务外以继续缩短事务时长,POST /api/purchase-requests/:id/submit 在不改权限模型 审批规则 状态机 与返回字段前提下把 workflowDefinition parseWorkflowSteps resolveActionableStep 前置到事务外 procurement-flow.spec.ts 通过 且需要时 full-local 不再因采购 submit 触发同类 P2028,cd server && node --check src/routes/purchaseRequests.js && cd ../client && npm run test:e2e -- tests/e2e/procurement-flow.spec.ts,manual,DONE,DONE,DONE,server/src/routes/purchaseRequests.js|server/src/utils/workflow.js|client/tests/e2e/procurement-flow.spec.ts,"[""R34-0""]","事务内继续保留草稿锁定与归属校验 必填校验 金额重算 budget precheck risk flag 预算预留 request_no 签发 主档更新与 workflow instance task history 写入;不额外改审批工具接口。2026-03-24 `cd server && node --check src/routes/purchaseRequests.js` 通过;同日在临时库 + build + 本地 server 编排下执行 `CI=1 PLAYWRIGHT_HTML_OPEN=never E2E_MODE=local npm run test:e2e -- tests/e2e/global-search.spec.ts tests/e2e/procurement-flow.spec.ts` 时,`tests/e2e/procurement-flow.spec.ts` 已通过,采购 submit 后经理审批与财务归档链路正常;本轮未额外触发 full-local。"
|
||||||
|
@@ -1457,6 +1457,7 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
where: { id: purchaseRequestId },
|
where: { id: purchaseRequestId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
applicant_id: true,
|
||||||
department_id: true,
|
department_id: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1487,6 +1488,35 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
throw buildPrecheckBlockedError(authoritativePrecheck);
|
throw buildPrecheckBlockedError(authoritativePrecheck);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflowDefinition = await fastify.prisma.workflowDefinition.findFirst({
|
||||||
|
where: {
|
||||||
|
entity_type: "purchase_request",
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
definition_json: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workflowDefinition) {
|
||||||
|
throw buildValidationError({ workflow: "missing_definition" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
|
||||||
|
const { actionable, skipped } = await resolveActionableStep({
|
||||||
|
tx: fastify.prisma,
|
||||||
|
steps: workflowSteps,
|
||||||
|
startIndex: 0,
|
||||||
|
ownerId: unlockedPurchaseRequest.applicant_id,
|
||||||
|
logger: fastify.log,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!actionable) {
|
||||||
|
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
const submitResult = await fastify.prisma.$transaction(async (tx) => {
|
const submitResult = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
|
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
|
||||||
ensureDraftOwner(request, locked);
|
ensureDraftOwner(request, locked);
|
||||||
@@ -1532,6 +1562,9 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
if (!purchaseRequest.items?.length) {
|
if (!purchaseRequest.items?.length) {
|
||||||
throw buildValidationError({ items: "at_least_one_required" });
|
throw buildValidationError({ items: "at_least_one_required" });
|
||||||
}
|
}
|
||||||
|
if (purchaseRequest.applicant_id !== unlockedPurchaseRequest.applicant_id) {
|
||||||
|
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
const legalEntity = await ensureLegalEntityAvailable(tx, purchaseRequest.legal_entity_id);
|
const legalEntity = await ensureLegalEntityAvailable(tx, purchaseRequest.legal_entity_id);
|
||||||
const costCenter = await ensureCostCenterAvailable(tx, purchaseRequest.cost_center_id, purchaseRequest.legal_entity_id);
|
const costCenter = await ensureCostCenterAvailable(tx, purchaseRequest.cost_center_id, purchaseRequest.legal_entity_id);
|
||||||
@@ -1575,35 +1608,6 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowDefinition = await tx.workflowDefinition.findFirst({
|
|
||||||
where: {
|
|
||||||
entity_type: "purchase_request",
|
|
||||||
is_active: true,
|
|
||||||
},
|
|
||||||
orderBy: { version: "desc" },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
definition_json: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workflowDefinition) {
|
|
||||||
throw buildValidationError({ workflow: "missing_definition" }, 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
|
|
||||||
const { actionable, skipped } = await resolveActionableStep({
|
|
||||||
tx,
|
|
||||||
steps: workflowSteps,
|
|
||||||
startIndex: 0,
|
|
||||||
ownerId: purchaseRequest.applicant_id,
|
|
||||||
logger: fastify.log,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!actionable) {
|
|
||||||
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
let requestNo = purchaseRequest.request_no;
|
let requestNo = purchaseRequest.request_no;
|
||||||
if (!requestNo) {
|
if (!requestNo) {
|
||||||
const issued = await issueDocumentNumber(tx, {
|
const issued = await issueDocumentNumber(tx, {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户