feat(P16C-4,P16C-6): 完成供应商主数据前端与矩阵收口

这个提交包含在:
2026-03-07 17:44:55 +08:00
父节点 aafa911ee7
当前提交 09b6f4fbb0
共修改 6 个文件,包含 465 行新增和 8 行删除
+79
查看文件
@@ -94,6 +94,27 @@ export type HolidayCalendarDayItem = {
updatedAt: string | null; updatedAt: string | null;
}; };
export type SupplierItem = {
id: string;
code: string;
name: string;
shortName: string | null;
taxNo: string | null;
contactName: string | null;
contactPhone: string | null;
paymentTerms: string | null;
legalEntityId: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
legalEntity: {
id: string;
code: string;
name: string;
} | null;
};
export type MasterDataListResponse<T> = { export type MasterDataListResponse<T> = {
includeArchived: boolean; includeArchived: boolean;
items: T[]; items: T[];
@@ -345,3 +366,61 @@ export async function replaceHolidayCalendarDays(
}, },
); );
} }
export async function fetchSuppliers(params: {
includeArchived?: boolean;
enabled?: boolean;
legalEntityId?: string;
q?: string;
} = {}) {
return apiRequestData<MasterDataListResponse<SupplierItem>>(
`/api/master-data/suppliers${toQueryString(params as Record<string, string | boolean | undefined>)}`,
);
}
export async function createSupplier(payload: {
code: string;
name: string;
shortName?: string | null;
taxNo?: string | null;
contactName?: string | null;
contactPhone?: string | null;
paymentTerms?: string | null;
legalEntityId?: string | null;
enabled?: boolean;
}) {
return apiRequestData<{ supplier: SupplierItem }>("/api/master-data/suppliers", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchSupplier(
supplierId: string,
payload: Partial<{
code: string;
name: string;
shortName: string | null;
taxNo: string | null;
contactName: string | null;
contactPhone: string | null;
paymentTerms: string | null;
legalEntityId: string | null;
enabled: boolean;
}>,
) {
return apiRequestData<{ supplier: SupplierItem }>(`/api/master-data/suppliers/${supplierId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function archiveSupplier(supplierId: string) {
return apiRequestData<{ supplier: SupplierItem; outcome: string }>(
`/api/master-data/suppliers/${supplierId}/archive`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}
@@ -0,0 +1,360 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../../api/errorMessages";
import {
archiveSupplier,
createSupplier,
fetchSuppliers,
type LegalEntityItem,
type SupplierItem,
} from "../../api/masterData";
import { isApiError } from "../../api/types";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { StatusBadge } from "../common/StatusBadge";
import { TableSkeleton } from "../common/TableSkeleton";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
import { Textarea } from "../ui/textarea";
type SupplierTabProps = {
canManage: boolean;
includeArchived: boolean;
legalEntities: LegalEntityItem[];
};
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "操作失败,请稍后重试。";
}
function toEnabledFilter(value: "" | "true" | "false") {
if (value === "") {
return undefined;
}
return value === "true";
}
function renderEnabledBadge(item: { archivedAt: string | null; enabled: boolean }) {
if (item.archivedAt) {
return <StatusBadge status="archived" />;
}
if (item.enabled) {
return <StatusBadge status="active" />;
}
return <StatusBadge status="archived" label="已停用" className="badge-neutral" />;
}
function renderLegalEntityLabel(item: SupplierItem) {
if (!item.legalEntity) {
return <span className="text-zinc-400">全局</span>;
}
return `${item.legalEntity.code} / ${item.legalEntity.name}`;
}
export function SupplierTab({ canManage, includeArchived, legalEntities }: SupplierTabProps) {
const queryClient = useQueryClient();
const [error, setError] = useState("");
const [enabledFilter, setEnabledFilter] = useState<"" | "true" | "false">("");
const [legalEntityFilter, setLegalEntityFilter] = useState("");
const [keyword, setKeyword] = useState("");
const [supplierCode, setSupplierCode] = useState("");
const [supplierName, setSupplierName] = useState("");
const [supplierShortName, setSupplierShortName] = useState("");
const [supplierTaxNo, setSupplierTaxNo] = useState("");
const [supplierContactName, setSupplierContactName] = useState("");
const [supplierContactPhone, setSupplierContactPhone] = useState("");
const [supplierPaymentTerms, setSupplierPaymentTerms] = useState("");
const [supplierLegalEntityId, setSupplierLegalEntityId] = useState("");
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null);
const suppliersQuery = useQuery({
queryKey: ["master-data", "suppliers", includeArchived, enabledFilter, legalEntityFilter, keyword],
queryFn: () =>
fetchSuppliers({
includeArchived,
enabled: toEnabledFilter(enabledFilter),
legalEntityId: legalEntityFilter || undefined,
q: keyword.trim() || undefined,
}),
});
const suppliers = suppliersQuery.data?.items ?? [];
const activeLegalEntities = useMemo(
() => legalEntities.filter((item) => !item.archivedAt),
[legalEntities],
);
const invalidateSuppliers = async () => {
await queryClient.invalidateQueries({ queryKey: ["master-data", "suppliers"] });
};
const createMutation = useMutation({
mutationFn: async () =>
createSupplier({
code: supplierCode,
name: supplierName,
shortName: supplierShortName || null,
taxNo: supplierTaxNo || null,
contactName: supplierContactName || null,
contactPhone: supplierContactPhone || null,
paymentTerms: supplierPaymentTerms || null,
legalEntityId: supplierLegalEntityId || null,
}),
onSuccess: async () => {
await invalidateSuppliers();
setSupplierCode("");
setSupplierName("");
setSupplierShortName("");
setSupplierTaxNo("");
setSupplierContactName("");
setSupplierContactPhone("");
setSupplierPaymentTerms("");
setSupplierLegalEntityId("");
setError("");
toast.success("供应商已创建");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
setError(message);
toast.error(message);
},
});
const archiveMutation = useMutation({
mutationFn: async (supplierId: string) => archiveSupplier(supplierId),
onSuccess: async () => {
await invalidateSuppliers();
setConfirmArchiveId(null);
setError("");
toast.success("供应商已归档");
},
onError: (mutationError) => {
const message = resolveError(mutationError);
setConfirmArchiveId(null);
setError(message);
toast.error(message);
},
});
return (
<div className="space-y-6">
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<div className="card-std p-6 space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<select
className="select-std"
value={enabledFilter}
onChange={(event) => setEnabledFilter(event.target.value as "" | "true" | "false")}
>
<option value="">全部状态</option>
<option value="true">仅启用</option>
<option value="false">仅停用</option>
</select>
<select
className="select-std"
value={legalEntityFilter}
onChange={(event) => setLegalEntityFilter(event.target.value)}
>
<option value="">全部法体</option>
{activeLegalEntities.map((item) => (
<option key={item.id} value={item.id}>
{item.code} / {item.name}
</option>
))}
</select>
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索编码 / 名称 / 税号 / 联系人"
/>
</div>
</div>
{canManage ? (
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">新建供应商</h3>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="supplierCode">编码</Label>
<Input
id="supplierCode"
value={supplierCode}
onChange={(event) => setSupplierCode(event.target.value)}
placeholder="VENDOR_001"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="supplierName">名称</Label>
<Input
id="supplierName"
value={supplierName}
onChange={(event) => setSupplierName(event.target.value)}
placeholder="海音达演示供应商"
/>
</div>
<div className="space-y-1">
<Label htmlFor="supplierShortName">简称</Label>
<Input
id="supplierShortName"
value={supplierShortName}
onChange={(event) => setSupplierShortName(event.target.value)}
placeholder="演示供应商"
/>
</div>
<div className="space-y-1">
<Label htmlFor="supplierTaxNo">税号</Label>
<Input
id="supplierTaxNo"
value={supplierTaxNo}
onChange={(event) => setSupplierTaxNo(event.target.value)}
placeholder="9144..."
/>
</div>
<div className="space-y-1">
<Label htmlFor="supplierContactName">联系人</Label>
<Input
id="supplierContactName"
value={supplierContactName}
onChange={(event) => setSupplierContactName(event.target.value)}
placeholder="张采购"
/>
</div>
<div className="space-y-1">
<Label htmlFor="supplierContactPhone">联系电话</Label>
<Input
id="supplierContactPhone"
value={supplierContactPhone}
onChange={(event) => setSupplierContactPhone(event.target.value)}
placeholder="0755-12345678"
/>
</div>
<div className="space-y-1 md:col-span-4">
<Label htmlFor="supplierPaymentTerms">账期说明</Label>
<Textarea
id="supplierPaymentTerms"
value={supplierPaymentTerms}
onChange={(event) => setSupplierPaymentTerms(event.target.value)}
placeholder="例如:月结30天 / 预付50% + 到货结50%"
rows={2}
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="supplierLegalEntity">所属法体(可选)</Label>
<select
id="supplierLegalEntity"
className="select-std"
value={supplierLegalEntityId}
onChange={(event) => setSupplierLegalEntityId(event.target.value)}
>
<option value="">全局供应商</option>
{activeLegalEntities.map((item) => (
<option key={item.id} value={item.id}>
{item.code} / {item.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={() => createMutation.mutate()}
disabled={
createMutation.isPending || !supplierCode.trim() || !supplierName.trim()
}
>
{createMutation.isPending ? "创建中..." : "创建供应商"}
</Button>
</div>
</div>
) : null}
<div className="card-std p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">供应商列表</h3>
<span className="text-sm text-zinc-500">共 {suppliers.length} 条</span>
</div>
{suppliersQuery.isLoading ? (
<TableSkeleton rows={6} columns={6} />
) : suppliers.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>编码 / 名称</TableHead>
<TableHead>法体</TableHead>
<TableHead>联系人</TableHead>
<TableHead>税号 / 账期</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suppliers.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.code}</div>
<div className="text-sm text-zinc-500">{item.name}</div>
{item.shortName ? <div className="text-xs text-zinc-400">简称:{item.shortName}</div> : null}
</TableCell>
<TableCell className="text-sm text-zinc-600">{renderLegalEntityLabel(item)}</TableCell>
<TableCell className="text-sm text-zinc-600">
<div>{item.contactName || "-"}</div>
<div className="text-xs text-zinc-400">{item.contactPhone || "-"}</div>
</TableCell>
<TableCell className="text-sm text-zinc-600">
<div>{item.taxNo || "-"}</div>
<div className="text-xs text-zinc-400 line-clamp-2">{item.paymentTerms || "-"}</div>
</TableCell>
<TableCell>{renderEnabledBadge(item)}</TableCell>
<TableCell className="text-right">
{canManage && !item.archivedAt ? (
<Button type="button" variant="outline" onClick={() => setConfirmArchiveId(item.id)}>
归档
</Button>
) : (
<span className="text-xs text-zinc-400">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-center text-sm text-zinc-500">
当前筛选条件下暂无供应商。
</div>
)}
</div>
<ConfirmDialog
open={Boolean(confirmArchiveId)}
title="归档供应商"
description="归档后该供应商将不能被后续业务单据选择。是否继续?"
confirmLabel={archiveMutation.isPending ? "归档中..." : "确认归档"}
onConfirm={() => {
if (confirmArchiveId) {
archiveMutation.mutate(confirmArchiveId);
}
}}
onOpenChange={(open) => {
if (!open && !archiveMutation.isPending) {
setConfirmArchiveId(null);
}
}}
/>
</div>
);
}
+11 -2
查看文件
@@ -21,6 +21,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions"; import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab"; import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab";
import { SupplierTab } from "../components/master-data/SupplierTab";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -78,7 +79,7 @@ export function MasterDataPage() {
const canView = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]); const canView = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]);
const canManage = hasPermission(permissions, "masterdata.manage"); const canManage = hasPermission(permissions, "masterdata.manage");
const [tab, setTab] = useState<"legal-entities" | "cost-centers" | "document-categories" | "holiday-calendars">( const [tab, setTab] = useState<"legal-entities" | "cost-centers" | "document-categories" | "holiday-calendars" | "suppliers">(
"legal-entities", "legal-entities",
); );
const [includeArchived, setIncludeArchived] = useState(false); const [includeArchived, setIncludeArchived] = useState(false);
@@ -302,7 +303,7 @@ export function MasterDataPage() {
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="主数据" title="主数据"
subtitle="集中维护法体、成本中心、单据分类与日历,为预算、采购、请假与资产扩展提供统一骨架。" subtitle="集中维护法体、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一骨架。"
actions={ actions={
<Button variant="secondary" type="button" onClick={() => setIncludeArchived((value) => !value)}> <Button variant="secondary" type="button" onClick={() => setIncludeArchived((value) => !value)}>
{includeArchived ? "隐藏归档" : "显示归档"} {includeArchived ? "隐藏归档" : "显示归档"}
@@ -320,6 +321,7 @@ export function MasterDataPage() {
<TabsTrigger value="cost-centers">成本中心</TabsTrigger> <TabsTrigger value="cost-centers">成本中心</TabsTrigger>
<TabsTrigger value="document-categories">单据分类</TabsTrigger> <TabsTrigger value="document-categories">单据分类</TabsTrigger>
<TabsTrigger value="holiday-calendars">日历</TabsTrigger> <TabsTrigger value="holiday-calendars">日历</TabsTrigger>
<TabsTrigger value="suppliers">供应商</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="legal-entities" className="space-y-6"> <TabsContent value="legal-entities" className="space-y-6">
@@ -762,6 +764,13 @@ export function MasterDataPage() {
legalEntities={legalEntities} legalEntities={legalEntities}
/> />
</TabsContent> </TabsContent>
<TabsContent value="suppliers" className="space-y-6">
<SupplierTab
canManage={canManage}
includeArchived={includeArchived}
legalEntities={legalEntities}
/>
</TabsContent>
</Tabs> </Tabs>
<ConfirmDialog <ConfirmDialog
+11 -3
查看文件
@@ -1,8 +1,8 @@
# hyb-oa 实现状态矩阵(P5-P16B) # hyb-oa 实现状态矩阵(P5-P16C)
**快照日期**:2026-03-07 **快照日期**:2026-03-07
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6`,共 138 项。 **范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6`,共 145 项。
**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv`(执行合同) **事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv`(执行合同)
**说明**: **说明**:
- 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。 - 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。
@@ -159,3 +159,11 @@
| P16B-4 | 前端主数据日历Tab | §18(日历主数据治理) | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07 | DONE | client:typecheck/build | `MasterDataPage` 新增日历 Tab 与功能开关展示文案 | | P16B-4 | 前端主数据日历Tab | §18(日历主数据治理) | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07 | DONE | client:typecheck/build | `MasterDataPage` 新增日历 Tab 与功能开关展示文案 |
| P16B-5 | 请假日历增强计算器 | §18(日历主数据治理) | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07 | DONE | server:test + manual leave flow | 关闭开关保持自然日 开启后按法体默认日历计算 requestedDays | | P16B-5 | 请假日历增强计算器 | §18(日历主数据治理) | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07 | DONE | server:test + manual leave flow | 关闭开关保持自然日 开启后按法体默认日历计算 requestedDays |
| P16B-6 | P16B回归与矩阵收口 | §18(日历主数据治理) | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07 | DONE | regression-all + manual flag toggle | finance 只读 finance_mgr 可维护 默认日历禁止归档 且请假 6 天→4 天验证通过 | | P16B-6 | P16B回归与矩阵收口 | §18(日历主数据治理) | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv | 2026-03-07 | DONE | regression-all + manual flag toggle | finance 只读 finance_mgr 可维护 默认日历禁止归档 且请假 6 天→4 天验证通过 |
| P16C-0 | P16C执行合同建档 | §18(供应商主数据治理) | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07 | DONE | CSV 校验 | P16C仅补供应商主数据与主数据页入口 |
| P16C-1 | Prisma供应商模型与迁移 | §18(供应商主数据治理) | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07 | DONE | prisma:validate + prisma:migrate + prisma:generate | Supplier 模型、法体关联与迁移落地 |
| P16C-2 | 种子与权限边界补齐 | §18(供应商主数据治理) | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07 | DONE | server:test + seed:config + seed:dev-users | 沿用 masterdata.view/manage 并补开发演示供应商 |
| P16C-3 | 后端供应商CRUD API | §18(供应商主数据治理) | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07 | DONE | server:test + manual supplier flow | `/api/master-data/suppliers*` 列表/创建/更新/归档可用 |
| P16C-4 | 前端供应商Tab与API封装 | §18(供应商主数据治理) | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07 | DONE | client:typecheck/build | `MasterDataPage` 新增供应商 Tab 与 API 封装完成 |
| P16C-5 | 矩阵与蓝图事实源同步 | §18(供应商主数据治理) | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07 | DONE | validate-feature-matrix | P16C 条目、范围与事实源同步完成 |
| P16C-6 | P16C回归与安全收口 | §18(供应商主数据治理) | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07_17-14-39-p16c-l5-suppliers.csv | 2026-03-07 | DONE | regression-all + manual supplier flow | finance 只读 finance_mgr 可维护 employee 无查看权限 且重复/归档幂等验证通过 |
@@ -3,6 +3,6 @@ P16C-0,P16C执行合同建档,创建并校验P16C供应商主数据执行合同C
P16C-1,Prisma供应商模型与迁移,新增Supplier模型及唯一性归档索引并生成迁移,Prisma schema与迁移可通过校验并成功生成客户端,cd server && npm run prisma:validate && npx prisma migrate dev --name p16c_suppliers && npm run prisma:generate,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/prisma/migrations/20260307093703_p16c_suppliers,"[""P16C-0""]",legal_entity_id首版可空且不拆联系人子表 P16C-1,Prisma供应商模型与迁移,新增Supplier模型及唯一性归档索引并生成迁移,Prisma schema与迁移可通过校验并成功生成客户端,cd server && npm run prisma:validate && npx prisma migrate dev --name p16c_suppliers && npm run prisma:generate,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/prisma/migrations/20260307093703_p16c_suppliers,"[""P16C-0""]",legal_entity_id首版可空且不拆联系人子表
P16C-2,种子与权限边界补齐,补齐开发演示供应商种子并固定继续复用masterdata.view/manage权限边界,seed后可看到演示供应商且无新增supplier权限字,cd server && npm run test && npm run seed:config && npm run seed:dev-users,manual,DONE,DONE,DONE,server/src/scripts/seed-dev.js,"[""P16C-1""]",seed-config继续保持不预置真实供应商且无新增supplier权限字 P16C-2,种子与权限边界补齐,补齐开发演示供应商种子并固定继续复用masterdata.view/manage权限边界,seed后可看到演示供应商且无新增supplier权限字,cd server && npm run test && npm run seed:config && npm run seed:dev-users,manual,DONE,DONE,DONE,server/src/scripts/seed-dev.js,"[""P16C-1""]",seed-config继续保持不预置真实供应商且无新增supplier权限字
P16C-3,后端供应商CRUD API,在master-data命名空间实现供应商列表 新建 更新 归档接口并补审计与唯一性校验,供应商API支持权限校验 编码唯一 归档限制和按法体筛选,cd server && npm run test,manual,DONE,DONE,DONE,server/src/routes/masterDataSuppliers.js | server/src/app.js,"[""P16C-2""]",接口范围固定为GET POST PATCH POST archive并支持includeArchived enabled legalEntityId q P16C-3,后端供应商CRUD API,在master-data命名空间实现供应商列表 新建 更新 归档接口并补审计与唯一性校验,供应商API支持权限校验 编码唯一 归档限制和按法体筛选,cd server && npm run test,manual,DONE,DONE,DONE,server/src/routes/masterDataSuppliers.js | server/src/app.js,"[""P16C-2""]",接口范围固定为GET POST PATCH POST archive并支持includeArchived enabled legalEntityId q
P16C-4,前端供应商Tab与API封装,在MasterDataPage加入供应商Tab并补masterData API封装与基础筛选表单,具备masterdata.view/manage的用户可查看 创建 归档供应商,cd client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,client/src/api/masterData.ts | client/src/pages/MasterDataPage.tsx,"[""P16C-3""]",继续复用/system/master-data单页多Tab不新增独立菜单 P16C-4,前端供应商Tab与API封装,在MasterDataPage加入供应商Tab并补masterData API封装与基础筛选表单,具备masterdata.view/manage的用户可查看 创建 归档供应商,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/api/masterData.ts | client/src/pages/MasterDataPage.tsx | client/src/components/master-data/SupplierTab.tsx,"[""P16C-3""]",继续复用/system/master-data单页多Tab不新增独立菜单
P16C-5,矩阵与蓝图事实源同步,在供应商主数据完成后同步feature-matrix事实源并保持与P16-P18蓝图一致,feature-matrix校验通过且P16C条目全部可追溯,python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv,"[""P16C-4""]",P16C仍归属共享主数据阶段并保持P17依赖关系不变 P16C-5,矩阵与蓝图事实源同步,在供应商主数据完成后同步feature-matrix事实源并保持与P16-P18蓝图一致,feature-matrix校验通过且P16C条目全部可追溯,python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv,"[""P16C-4""]",P16C仍归属共享主数据阶段并保持P17依赖关系不变
P16C-6,P16C回归与安全收口,完成供应商主数据最小手工流转验证并执行后端前端回归及越权负测,CRUD 权限 归档限制与现有主流程均通过验证,cd server && npm run test && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,server/tests/unit | client/src/pages/MasterDataPage.tsx | server/src/routes/masterData.js,"[""P16C-5""]",需补充供应商编码重复 归档后不可选 越权写入等负测 P16C-6,P16C回归与安全收口,完成供应商主数据最小手工流转验证并执行后端前端回归及越权负测,CRUD 权限 归档限制与现有主流程均通过验证,cd server && npm run test && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv | client/src/components/master-data/SupplierTab.tsx | server/src/routes/masterDataSuppliers.js,"[""P16C-5""]",已完成finance只读 finance_mgr可维护 employee无查看权限 编码重复与归档幂等验证
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
3 P16C-1 Prisma供应商模型与迁移 新增Supplier模型及唯一性归档索引并生成迁移 Prisma schema与迁移可通过校验并成功生成客户端 cd server && npm run prisma:validate && npx prisma migrate dev --name p16c_suppliers && npm run prisma:generate manual DONE DONE DONE server/prisma/schema.prisma | server/prisma/migrations/20260307093703_p16c_suppliers ["P16C-0"] legal_entity_id首版可空且不拆联系人子表
4 P16C-2 种子与权限边界补齐 补齐开发演示供应商种子并固定继续复用masterdata.view/manage权限边界 seed后可看到演示供应商且无新增supplier权限字 cd server && npm run test && npm run seed:config && npm run seed:dev-users manual DONE DONE DONE server/src/scripts/seed-dev.js ["P16C-1"] seed-config继续保持不预置真实供应商且无新增supplier权限字
5 P16C-3 后端供应商CRUD API 在master-data命名空间实现供应商列表 新建 更新 归档接口并补审计与唯一性校验 供应商API支持权限校验 编码唯一 归档限制和按法体筛选 cd server && npm run test manual DONE DONE DONE server/src/routes/masterDataSuppliers.js | server/src/app.js ["P16C-2"] 接口范围固定为GET POST PATCH POST archive并支持includeArchived enabled legalEntityId q
6 P16C-4 前端供应商Tab与API封装 在MasterDataPage加入供应商Tab并补masterData API封装与基础筛选表单 具备masterdata.view/manage的用户可查看 创建 归档供应商 cd client && npm run typecheck && npm run build manual TODO DONE TODO DONE TODO DONE client/src/api/masterData.ts | client/src/pages/MasterDataPage.tsx client/src/api/masterData.ts | client/src/pages/MasterDataPage.tsx | client/src/components/master-data/SupplierTab.tsx ["P16C-3"] 继续复用/system/master-data单页多Tab不新增独立菜单
7 P16C-5 矩阵与蓝图事实源同步 在供应商主数据完成后同步feature-matrix事实源并保持与P16-P18蓝图一致 feature-matrix校验通过且P16C条目全部可追溯 python3 scripts/validate-feature-matrix.py manual TODO DONE TODO DONE TODO DONE docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv ["P16C-4"] P16C仍归属共享主数据阶段并保持P17依赖关系不变
8 P16C-6 P16C回归与安全收口 完成供应商主数据最小手工流转验证并执行后端前端回归及越权负测 CRUD 权限 归档限制与现有主流程均通过验证 cd server && npm run test && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build manual TODO DONE TODO DONE TODO DONE server/tests/unit | client/src/pages/MasterDataPage.tsx | server/src/routes/masterData.js issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv | client/src/components/master-data/SupplierTab.tsx | server/src/routes/masterDataSuppliers.js ["P16C-5"] 需补充供应商编码重复 归档后不可选 越权写入等负测 已完成finance只读 finance_mgr可维护 employee无查看权限 编码重复与归档幂等验证
+1
查看文件
@@ -30,6 +30,7 @@ CSV_FACT_SOURCES = [
ROOT / "issues" / "2026-03-06_14-30-00-p15b-l5-leave-v1.csv", ROOT / "issues" / "2026-03-06_14-30-00-p15b-l5-leave-v1.csv",
ROOT / "issues" / "2026-03-07_13-47-05-p16a-l5-master-data-core.csv", ROOT / "issues" / "2026-03-07_13-47-05-p16a-l5-master-data-core.csv",
ROOT / "issues" / "2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv", ROOT / "issues" / "2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv",
ROOT / "issues" / "2026-03-07_17-14-39-p16c-l5-suppliers.csv",
] ]
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$") ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$")