feat(G1,P16A,P16B): 收口主数据核心与节假日日历增强
这个提交包含在:
@@ -119,6 +119,58 @@ export function mapApiErrorMessage(
|
||||
if (details?.verification === "disabled") {
|
||||
return "当前环境未开启发票验真。";
|
||||
}
|
||||
if (details?.code === "duplicate") {
|
||||
return "编码已存在,请更换后重试。";
|
||||
}
|
||||
|
||||
if (details?.legalEntityId === "not_found") {
|
||||
return "所选法体不存在。";
|
||||
}
|
||||
|
||||
if (details?.legalEntityId === "archived") {
|
||||
return "所选法体已归档,无法继续使用。";
|
||||
}
|
||||
|
||||
if (details?.ownerEmployeeId === "not_found") {
|
||||
return "负责人不存在或已停用。";
|
||||
}
|
||||
|
||||
if (details?.parentId === "not_found") {
|
||||
return "上级成本中心不存在。";
|
||||
}
|
||||
|
||||
if (details?.parentId === "archived") {
|
||||
return "上级成本中心已归档,无法继续使用。";
|
||||
}
|
||||
|
||||
if (details?.parentId === "self_not_allowed") {
|
||||
return "上级成本中心不能选择自身。";
|
||||
}
|
||||
|
||||
if (details?.parentId === "cycle_not_allowed") {
|
||||
return "上级成本中心不能形成循环层级。";
|
||||
}
|
||||
|
||||
if (details?.parentId === "cross_legal_entity_not_allowed") {
|
||||
return "上级成本中心必须与当前法体一致。";
|
||||
}
|
||||
|
||||
if (details?.legalEntity === "has_cost_centers") {
|
||||
return "当前法体下仍存在未归档的成本中心,无法归档。";
|
||||
}
|
||||
|
||||
if (details?.costCenter === "has_children") {
|
||||
return "当前成本中心存在下级节点,无法归档。";
|
||||
}
|
||||
|
||||
if (details?.holidayCalendar === "bound_as_default") {
|
||||
return `当前日历仍被法体 ${details?.legalEntityCode ?? ""} 作为默认日历引用,无法归档。`;
|
||||
}
|
||||
|
||||
if (details?.date === "duplicate") {
|
||||
return "同一日历内不允许存在重复日期。";
|
||||
}
|
||||
|
||||
|
||||
if (details?.verification === "already_pending") {
|
||||
return "已有验真任务正在处理中,请稍后查看结果。";
|
||||
@@ -148,6 +200,10 @@ export function mapApiErrorMessage(
|
||||
return "请假开始/结束日期与半天时段不合法。";
|
||||
}
|
||||
|
||||
if (details?.leave === "non_working_range") {
|
||||
return "所选时间段均为非工作日,无需请假。";
|
||||
}
|
||||
|
||||
if (details?.leave === "missing_reserve_ledger") {
|
||||
return "请假冻结账本状态异常,请联系管理员处理。";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type FeatureFlagKey = "erp_sync" | "invoice_verification";
|
||||
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar";
|
||||
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
|
||||
export type FeatureFlagRolloutType = "global" | "department_allowlist";
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type DocumentCategoryEntityType =
|
||||
| "reimbursement"
|
||||
| "leave"
|
||||
| "purchase_request"
|
||||
| "asset_card"
|
||||
| "general";
|
||||
|
||||
export type DocumentResetPeriod = "none" | "daily" | "monthly" | "yearly";
|
||||
export type HolidayCalendarDayType = "holiday" | "workday" | "adjusted_workday";
|
||||
|
||||
export type LegalEntityItem = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
shortName: string | null;
|
||||
currencyCode: string;
|
||||
taxNo: string | null;
|
||||
enabled: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type CostCenterItem = {
|
||||
id: string;
|
||||
legalEntityId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
ownerEmployeeId: string | null;
|
||||
enabled: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
legalEntity: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
parent: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
owner: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type DocumentCategoryItem = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: DocumentCategoryEntityType;
|
||||
sequencePrefix: string;
|
||||
numberLength: number;
|
||||
resetPeriod: DocumentResetPeriod;
|
||||
defaultWorkflowKey: string | null;
|
||||
enabled: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type HolidayCalendarItem = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
timezone: string;
|
||||
legalEntityId: string | null;
|
||||
enabled: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
daysCount: number;
|
||||
isDefaultForLegalEntity: boolean;
|
||||
legalEntity: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type HolidayCalendarDayItem = {
|
||||
id: string;
|
||||
date: string;
|
||||
dayType: HolidayCalendarDayType;
|
||||
name: string;
|
||||
note: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type MasterDataListResponse<T> = {
|
||||
includeArchived: boolean;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | boolean | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value == null || value === "") {
|
||||
continue;
|
||||
}
|
||||
params.set(key, typeof value === "boolean" ? String(value) : value);
|
||||
}
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export async function fetchLegalEntities(params: { includeArchived?: boolean; enabled?: boolean } = {}) {
|
||||
return apiRequestData<MasterDataListResponse<LegalEntityItem>>(
|
||||
`/api/master-data/legal-entities${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLegalEntity(payload: {
|
||||
code: string;
|
||||
name: string;
|
||||
shortName?: string | null;
|
||||
currencyCode: string;
|
||||
taxNo?: string | null;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ legalEntity: LegalEntityItem }>("/api/master-data/legal-entities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchLegalEntity(
|
||||
legalEntityId: string,
|
||||
payload: Partial<{
|
||||
code: string;
|
||||
name: string;
|
||||
shortName: string | null;
|
||||
currencyCode: string;
|
||||
taxNo: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ legalEntity: LegalEntityItem }>(`/api/master-data/legal-entities/${legalEntityId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveLegalEntity(legalEntityId: string) {
|
||||
return apiRequestData<{ legalEntity: LegalEntityItem; outcome: string }>(
|
||||
`/api/master-data/legal-entities/${legalEntityId}/archive`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCostCenters(params: {
|
||||
includeArchived?: boolean;
|
||||
enabled?: boolean;
|
||||
legalEntityId?: string;
|
||||
} = {}) {
|
||||
return apiRequestData<MasterDataListResponse<CostCenterItem>>(
|
||||
`/api/master-data/cost-centers${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCostCenter(payload: {
|
||||
legalEntityId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
ownerEmployeeId?: string | null;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ costCenter: CostCenterItem }>("/api/master-data/cost-centers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchCostCenter(
|
||||
costCenterId: string,
|
||||
payload: Partial<{
|
||||
legalEntityId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
ownerEmployeeId: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ costCenter: CostCenterItem }>(`/api/master-data/cost-centers/${costCenterId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCostCenter(costCenterId: string) {
|
||||
return apiRequestData<{ costCenter: CostCenterItem; outcome: string }>(
|
||||
`/api/master-data/cost-centers/${costCenterId}/archive`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchDocumentCategories(params: {
|
||||
includeArchived?: boolean;
|
||||
enabled?: boolean;
|
||||
entityType?: DocumentCategoryEntityType;
|
||||
} = {}) {
|
||||
return apiRequestData<MasterDataListResponse<DocumentCategoryItem>>(
|
||||
`/api/master-data/document-categories${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createDocumentCategory(payload: {
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: DocumentCategoryEntityType;
|
||||
sequencePrefix: string;
|
||||
numberLength?: number;
|
||||
resetPeriod: DocumentResetPeriod;
|
||||
defaultWorkflowKey?: string | null;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ documentCategory: DocumentCategoryItem }>(
|
||||
"/api/master-data/document-categories",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function patchDocumentCategory(
|
||||
documentCategoryId: string,
|
||||
payload: Partial<{
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: DocumentCategoryEntityType;
|
||||
sequencePrefix: string;
|
||||
numberLength: number;
|
||||
resetPeriod: DocumentResetPeriod;
|
||||
defaultWorkflowKey: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ documentCategory: DocumentCategoryItem }>(
|
||||
`/api/master-data/document-categories/${documentCategoryId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function archiveDocumentCategory(documentCategoryId: string) {
|
||||
return apiRequestData<{ documentCategory: DocumentCategoryItem; outcome: string }>(
|
||||
`/api/master-data/document-categories/${documentCategoryId}/archive`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchHolidayCalendars(params: {
|
||||
includeArchived?: boolean;
|
||||
enabled?: boolean;
|
||||
legalEntityId?: string;
|
||||
} = {}) {
|
||||
return apiRequestData<MasterDataListResponse<HolidayCalendarItem>>(
|
||||
`/api/master-data/holiday-calendars${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createHolidayCalendar(payload: {
|
||||
code: string;
|
||||
name: string;
|
||||
timezone?: string;
|
||||
legalEntityId?: string | null;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ holidayCalendar: HolidayCalendarItem }>("/api/master-data/holiday-calendars", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchHolidayCalendar(
|
||||
holidayCalendarId: string,
|
||||
payload: Partial<{
|
||||
code: string;
|
||||
name: string;
|
||||
timezone: string;
|
||||
legalEntityId: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ holidayCalendar: HolidayCalendarItem }>(
|
||||
`/api/master-data/holiday-calendars/${holidayCalendarId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function archiveHolidayCalendar(holidayCalendarId: string) {
|
||||
return apiRequestData<{ holidayCalendar: HolidayCalendarItem; outcome: string }>(
|
||||
`/api/master-data/holiday-calendars/${holidayCalendarId}/archive`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchHolidayCalendarDays(holidayCalendarId: string) {
|
||||
return apiRequestData<{ items: HolidayCalendarDayItem[] }>(
|
||||
`/api/master-data/holiday-calendars/${holidayCalendarId}/days`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function replaceHolidayCalendarDays(
|
||||
holidayCalendarId: string,
|
||||
items: Array<{
|
||||
date: string;
|
||||
dayType: HolidayCalendarDayType;
|
||||
name: string;
|
||||
note?: string | null;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ holidayCalendarId: string; replacedCount: number }>(
|
||||
`/api/master-data/holiday-calendars/${holidayCalendarId}/days`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ items }),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ const LEAVE_SELF_SERVICE_PERMISSIONS = [
|
||||
"leave.submit",
|
||||
] as const;
|
||||
|
||||
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] as const;
|
||||
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const;
|
||||
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
@@ -35,6 +38,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
],
|
||||
ceo: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_VIEW_PERMISSIONS,
|
||||
"expense.approve",
|
||||
"expense.transfer",
|
||||
"expense.view_all",
|
||||
@@ -45,6 +49,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
],
|
||||
finance: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_VIEW_PERMISSIONS,
|
||||
"invoice.manage",
|
||||
"invoice.verify",
|
||||
"report.dashboard.view",
|
||||
@@ -55,6 +60,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
],
|
||||
finance_mgr: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_MANAGE_PERMISSIONS,
|
||||
"invoice.manage",
|
||||
"invoice.verify",
|
||||
"report.dashboard.view",
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../../api/errorMessages";
|
||||
import {
|
||||
archiveHolidayCalendar,
|
||||
createHolidayCalendar,
|
||||
fetchHolidayCalendarDays,
|
||||
fetchHolidayCalendars,
|
||||
replaceHolidayCalendarDays,
|
||||
type HolidayCalendarDayItem,
|
||||
type HolidayCalendarDayType,
|
||||
type LegalEntityItem,
|
||||
} 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";
|
||||
|
||||
const DAY_TYPE_OPTIONS: Array<{ value: HolidayCalendarDayType; label: string }> = [
|
||||
{ value: "holiday", label: "假日" },
|
||||
{ value: "workday", label: "工作日" },
|
||||
{ value: "adjusted_workday", label: "调休补班" },
|
||||
];
|
||||
|
||||
type HolidayCalendarTabProps = {
|
||||
canManage: boolean;
|
||||
includeArchived: boolean;
|
||||
legalEntities: LegalEntityItem[];
|
||||
};
|
||||
|
||||
type DraftDay = {
|
||||
date: string;
|
||||
dayType: HolidayCalendarDayType;
|
||||
name: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
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 renderDayTypeLabel(value: HolidayCalendarDayType) {
|
||||
return DAY_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function sortDraftDays(items: DraftDay[]) {
|
||||
return [...items].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
export function HolidayCalendarTab({ canManage, includeArchived, legalEntities }: HolidayCalendarTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState("");
|
||||
const [enabledFilter, setEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||
const [calendarCode, setCalendarCode] = useState("");
|
||||
const [calendarName, setCalendarName] = useState("");
|
||||
const [calendarTimezone, setCalendarTimezone] = useState("Asia/Shanghai");
|
||||
const [calendarLegalEntityId, setCalendarLegalEntityId] = useState("");
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState("");
|
||||
const [dayDate, setDayDate] = useState("");
|
||||
const [dayType, setDayType] = useState<HolidayCalendarDayType>("holiday");
|
||||
const [dayName, setDayName] = useState("");
|
||||
const [dayNote, setDayNote] = useState("");
|
||||
const [draftDays, setDraftDays] = useState<DraftDay[]>([]);
|
||||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null);
|
||||
|
||||
const calendarsQuery = useQuery({
|
||||
queryKey: ["master-data", "holiday-calendars", includeArchived, enabledFilter, legalEntityFilter],
|
||||
queryFn: () =>
|
||||
fetchHolidayCalendars({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(enabledFilter),
|
||||
legalEntityId: legalEntityFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const holidayCalendars = calendarsQuery.data?.items ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!holidayCalendars.length) {
|
||||
setSelectedCalendarId("");
|
||||
return;
|
||||
}
|
||||
if (!selectedCalendarId || !holidayCalendars.some((item) => item.id === selectedCalendarId)) {
|
||||
setSelectedCalendarId(holidayCalendars[0].id);
|
||||
}
|
||||
}, [holidayCalendars, selectedCalendarId]);
|
||||
|
||||
const selectedCalendar = useMemo(
|
||||
() => holidayCalendars.find((item) => item.id === selectedCalendarId) ?? null,
|
||||
[holidayCalendars, selectedCalendarId],
|
||||
);
|
||||
|
||||
const daysQuery = useQuery({
|
||||
queryKey: ["master-data", "holiday-calendar-days", selectedCalendarId],
|
||||
enabled: Boolean(selectedCalendarId),
|
||||
queryFn: () => fetchHolidayCalendarDays(selectedCalendarId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCalendarId) {
|
||||
setDraftDays([]);
|
||||
return;
|
||||
}
|
||||
if (daysQuery.data?.items) {
|
||||
setDraftDays(
|
||||
sortDraftDays(
|
||||
daysQuery.data.items.map((item: HolidayCalendarDayItem) => ({
|
||||
date: item.date,
|
||||
dayType: item.dayType,
|
||||
name: item.name,
|
||||
note: item.note ?? "",
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [daysQuery.data?.items, selectedCalendarId]);
|
||||
|
||||
const invalidateHolidayData = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "holiday-calendars"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "holiday-calendar-days"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "legal-entities"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
createHolidayCalendar({
|
||||
code: calendarCode,
|
||||
name: calendarName,
|
||||
timezone: calendarTimezone,
|
||||
legalEntityId: calendarLegalEntityId || null,
|
||||
}),
|
||||
onSuccess: async (response) => {
|
||||
await invalidateHolidayData();
|
||||
setCalendarCode("");
|
||||
setCalendarName("");
|
||||
setCalendarTimezone("Asia/Shanghai");
|
||||
setCalendarLegalEntityId("");
|
||||
setSelectedCalendarId(response.holidayCalendar.id);
|
||||
setError("");
|
||||
toast.success("日历已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const replaceDaysMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedCalendarId) {
|
||||
throw new Error("请先选择日历");
|
||||
}
|
||||
return replaceHolidayCalendarDays(
|
||||
selectedCalendarId,
|
||||
draftDays.map((item) => ({
|
||||
date: item.date,
|
||||
dayType: item.dayType,
|
||||
name: item.name,
|
||||
note: item.note || null,
|
||||
})),
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateHolidayData();
|
||||
setError("");
|
||||
toast.success("日期明细已覆盖保存");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (holidayCalendarId: string) => archiveHolidayCalendar(holidayCalendarId),
|
||||
onSuccess: async () => {
|
||||
await invalidateHolidayData();
|
||||
setConfirmArchiveId(null);
|
||||
setError("");
|
||||
toast.success("日历已归档");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
setConfirmArchiveId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const addOrReplaceDraftDay = () => {
|
||||
if (!dayDate || !dayName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftDays((prev) => {
|
||||
const next = prev.filter((item) => item.date !== dayDate);
|
||||
next.push({
|
||||
date: dayDate,
|
||||
dayType,
|
||||
name: dayName.trim(),
|
||||
note: dayNote.trim(),
|
||||
});
|
||||
return sortDraftDays(next);
|
||||
});
|
||||
setDayDate("");
|
||||
setDayType("holiday");
|
||||
setDayName("");
|
||||
setDayNote("");
|
||||
};
|
||||
|
||||
const removeDraftDay = (date: string) => {
|
||||
setDraftDays((prev) => prev.filter((item) => item.date !== date));
|
||||
};
|
||||
|
||||
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>
|
||||
{legalEntities
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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="holidayCalendarCode">编码</Label>
|
||||
<Input
|
||||
id="holidayCalendarCode"
|
||||
value={calendarCode}
|
||||
onChange={(event) => setCalendarCode(event.target.value)}
|
||||
placeholder="HYB_CN_STD"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="holidayCalendarName">名称</Label>
|
||||
<Input
|
||||
id="holidayCalendarName"
|
||||
value={calendarName}
|
||||
onChange={(event) => setCalendarName(event.target.value)}
|
||||
placeholder="海音达默认日历"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="holidayCalendarTimezone">时区</Label>
|
||||
<Input
|
||||
id="holidayCalendarTimezone"
|
||||
value={calendarTimezone}
|
||||
onChange={(event) => setCalendarTimezone(event.target.value)}
|
||||
placeholder="Asia/Shanghai"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<Label htmlFor="holidayCalendarLegalEntity">所属法体(可选)</Label>
|
||||
<select
|
||||
id="holidayCalendarLegalEntity"
|
||||
className="select-std"
|
||||
value={calendarLegalEntityId}
|
||||
onChange={(event) => setCalendarLegalEntityId(event.target.value)}
|
||||
>
|
||||
<option value="">全局日历</option>
|
||||
{legalEntities
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!calendarCode.trim() ||
|
||||
!calendarName.trim() ||
|
||||
!calendarTimezone.trim()
|
||||
}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
创建日历
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
首个绑定到法体的日历会自动成为该法体默认日历;后续切换默认日历将放在后续阶段补齐。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr,1fr]">
|
||||
<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">共 {holidayCalendars.length} 项</span>
|
||||
</div>
|
||||
|
||||
{calendarsQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
|
||||
|
||||
{!calendarsQuery.isLoading ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>法体</TableHead>
|
||||
<TableHead>日期数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{holidayCalendars.length ? (
|
||||
holidayCalendars.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={selectedCalendarId === item.id ? "bg-zinc-50" : undefined}
|
||||
onClick={() => setSelectedCalendarId(item.id)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div>{item.code}</div>
|
||||
<div className="text-xs text-zinc-500">{item.timezone}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.name}</div>
|
||||
{item.isDefaultForLegalEntity ? (
|
||||
<div className="text-xs text-emerald-600">默认法体日历</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{item.legalEntity ? `${item.legalEntity.code} / ${item.legalEntity.name}` : "全局"}</TableCell>
|
||||
<TableCell>{item.daysCount}</TableCell>
|
||||
<TableCell>{item.archivedAt ? <StatusBadge status="archived" /> : renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setSelectedCalendarId(item.id)}>
|
||||
维护日期
|
||||
</Button>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setConfirmArchiveId(item.id)}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-zinc-500">
|
||||
暂无日历数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">日期明细维护</h3>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{selectedCalendar
|
||||
? `当前:${selectedCalendar.code} / ${selectedCalendar.name}`
|
||||
: "请选择左侧日历后维护日期明细"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedCalendarId && daysQuery.isLoading ? <TableSkeleton columns={4} rows={4} /> : null}
|
||||
|
||||
{selectedCalendar ? (
|
||||
<>
|
||||
{canManage ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="holidayDayDate">日期</Label>
|
||||
<Input
|
||||
id="holidayDayDate"
|
||||
type="date"
|
||||
value={dayDate}
|
||||
onChange={(event) => setDayDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="holidayDayType">类型</Label>
|
||||
<select
|
||||
id="holidayDayType"
|
||||
className="select-std"
|
||||
value={dayType}
|
||||
onChange={(event) => setDayType(event.target.value as HolidayCalendarDayType)}
|
||||
>
|
||||
{DAY_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="holidayDayName">名称</Label>
|
||||
<Input
|
||||
id="holidayDayName"
|
||||
value={dayName}
|
||||
onChange={(event) => setDayName(event.target.value)}
|
||||
placeholder="如:元旦 / 调休补班"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="holidayDayNote">备注(可选)</Label>
|
||||
<Textarea
|
||||
id="holidayDayNote"
|
||||
value={dayNote}
|
||||
onChange={(event) => setDayNote(event.target.value)}
|
||||
placeholder="补充说明"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 md:col-span-2">
|
||||
<Button type="button" onClick={addOrReplaceDraftDay} disabled={!dayDate || !dayName.trim()}>
|
||||
添加 / 覆盖日期
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setDraftDays(
|
||||
sortDraftDays(
|
||||
(daysQuery.data?.items ?? []).map((item: HolidayCalendarDayItem) => ({
|
||||
date: item.date,
|
||||
dayType: item.dayType,
|
||||
name: item.name,
|
||||
note: item.note ?? "",
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
重置草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={replaceDaysMutation.isPending}
|
||||
onClick={() => replaceDaysMutation.mutate()}
|
||||
>
|
||||
保存当前草稿
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{draftDays.length ? (
|
||||
draftDays.map((item) => (
|
||||
<TableRow key={item.date}>
|
||||
<TableCell className="font-medium">{item.date}</TableCell>
|
||||
<TableCell>{renderDayTypeLabel(item.dayType)}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.note || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<Button type="button" variant="secondary" onClick={() => removeDraftDay(item.date)}>
|
||||
删除
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-sm text-zinc-500">
|
||||
当前没有日期明细
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-sm text-zinc-500">
|
||||
左侧选择一个日历后可查看与维护日期明细。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmArchiveId)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmArchiveId(null);
|
||||
}
|
||||
}}
|
||||
title="确认归档日历"
|
||||
description={
|
||||
confirmArchiveId
|
||||
? `确认归档 ${holidayCalendars.find((item) => item.id === confirmArchiveId)?.code ?? "当前日历"} 吗?`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="确认归档"
|
||||
cancelLabel="取消"
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (!confirmArchiveId) {
|
||||
return;
|
||||
}
|
||||
await archiveMutation.mutateAsync(confirmArchiveId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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" />;
|
||||
}
|
||||
@@ -78,6 +78,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
icon: "account_tree",
|
||||
anyOf: ["system.users"],
|
||||
},
|
||||
{
|
||||
to: "/system/master-data",
|
||||
label: "主数据",
|
||||
icon: "dataset",
|
||||
anyOf: ["masterdata.view", "masterdata.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/approval-rules",
|
||||
label: "审批规则",
|
||||
|
||||
@@ -31,6 +31,7 @@ const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string
|
||||
function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "erp_sync") return "ERP 同步";
|
||||
if (flagKey === "invoice_verification") return "发票验真";
|
||||
if (flagKey === "leave_holiday_calendar") return "请假日历增强";
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ export function FeatureFlagsPage() {
|
||||
throw new Error("未找到待提交草稿");
|
||||
}
|
||||
|
||||
return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification", {
|
||||
return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification" | "leave_holiday_calendar", {
|
||||
overrideMode: draft.overrideMode,
|
||||
rolloutType: draft.rolloutType,
|
||||
departmentIds:
|
||||
@@ -131,7 +132,7 @@ export function FeatureFlagsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="功能开关"
|
||||
subtitle="统一治理 ERP 与验真灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
subtitle="统一治理 ERP、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
archiveCostCenter,
|
||||
archiveDocumentCategory,
|
||||
archiveLegalEntity,
|
||||
createCostCenter,
|
||||
createDocumentCategory,
|
||||
createLegalEntity,
|
||||
fetchCostCenters,
|
||||
fetchDocumentCategories,
|
||||
fetchLegalEntities,
|
||||
type CostCenterItem,
|
||||
type DocumentCategoryEntityType,
|
||||
type DocumentResetPeriod,
|
||||
} from "../api/masterData";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const DOCUMENT_ENTITY_TYPE_OPTIONS: Array<{ value: DocumentCategoryEntityType; label: string }> = [
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
{ value: "leave", label: "请假" },
|
||||
{ value: "purchase_request", label: "采购申请" },
|
||||
{ value: "asset_card", label: "资产卡片" },
|
||||
{ value: "general", label: "通用" },
|
||||
];
|
||||
|
||||
const RESET_PERIOD_OPTIONS: Array<{ value: DocumentResetPeriod; label: string }> = [
|
||||
{ value: "none", label: "不重置" },
|
||||
{ value: "daily", label: "按日重置" },
|
||||
{ value: "monthly", label: "按月重置" },
|
||||
{ value: "yearly", label: "按年重置" },
|
||||
];
|
||||
|
||||
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" />;
|
||||
}
|
||||
|
||||
export function MasterDataPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canView = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]);
|
||||
const canManage = hasPermission(permissions, "masterdata.manage");
|
||||
|
||||
const [tab, setTab] = useState<"legal-entities" | "cost-centers" | "document-categories" | "holiday-calendars">(
|
||||
"legal-entities",
|
||||
);
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmArchive, setConfirmArchive] = useState<
|
||||
| null
|
||||
| {
|
||||
kind: "legal-entity" | "cost-center" | "document-category";
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
>(null);
|
||||
|
||||
const [legalEntityEnabledFilter, setLegalEntityEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [costCenterEnabledFilter, setCostCenterEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [costCenterLegalEntityFilter, setCostCenterLegalEntityFilter] = useState("");
|
||||
const [documentCategoryEnabledFilter, setDocumentCategoryEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [documentCategoryEntityTypeFilter, setDocumentCategoryEntityTypeFilter] = useState<
|
||||
"" | DocumentCategoryEntityType
|
||||
>("");
|
||||
|
||||
const [legalEntityCode, setLegalEntityCode] = useState("");
|
||||
const [legalEntityName, setLegalEntityName] = useState("");
|
||||
const [legalEntityShortName, setLegalEntityShortName] = useState("");
|
||||
const [legalEntityCurrency, setLegalEntityCurrency] = useState("CNY");
|
||||
const [legalEntityTaxNo, setLegalEntityTaxNo] = useState("");
|
||||
|
||||
const [costCenterLegalEntityId, setCostCenterLegalEntityId] = useState("");
|
||||
const [costCenterCode, setCostCenterCode] = useState("");
|
||||
const [costCenterName, setCostCenterName] = useState("");
|
||||
const [costCenterParentId, setCostCenterParentId] = useState("");
|
||||
|
||||
const [documentCategoryCode, setDocumentCategoryCode] = useState("");
|
||||
const [documentCategoryName, setDocumentCategoryName] = useState("");
|
||||
const [documentCategoryEntityType, setDocumentCategoryEntityType] =
|
||||
useState<DocumentCategoryEntityType>("reimbursement");
|
||||
const [documentCategorySequencePrefix, setDocumentCategorySequencePrefix] = useState("BX");
|
||||
const [documentCategoryNumberLength, setDocumentCategoryNumberLength] = useState("4");
|
||||
const [documentCategoryResetPeriod, setDocumentCategoryResetPeriod] =
|
||||
useState<DocumentResetPeriod>("yearly");
|
||||
const [documentCategoryWorkflowKey, setDocumentCategoryWorkflowKey] = useState("");
|
||||
|
||||
const legalEntitiesQuery = useQuery({
|
||||
queryKey: ["master-data", "legal-entities", includeArchived, legalEntityEnabledFilter],
|
||||
enabled: canView,
|
||||
queryFn: () =>
|
||||
fetchLegalEntities({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(legalEntityEnabledFilter),
|
||||
}),
|
||||
});
|
||||
|
||||
const costCentersQuery = useQuery({
|
||||
queryKey: [
|
||||
"master-data",
|
||||
"cost-centers",
|
||||
includeArchived,
|
||||
costCenterEnabledFilter,
|
||||
costCenterLegalEntityFilter,
|
||||
],
|
||||
enabled: canView,
|
||||
queryFn: () =>
|
||||
fetchCostCenters({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(costCenterEnabledFilter),
|
||||
legalEntityId: costCenterLegalEntityFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const documentCategoriesQuery = useQuery({
|
||||
queryKey: [
|
||||
"master-data",
|
||||
"document-categories",
|
||||
includeArchived,
|
||||
documentCategoryEnabledFilter,
|
||||
documentCategoryEntityTypeFilter,
|
||||
],
|
||||
enabled: canView,
|
||||
queryFn: () =>
|
||||
fetchDocumentCategories({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(documentCategoryEnabledFilter),
|
||||
entityType: documentCategoryEntityTypeFilter || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const legalEntities = legalEntitiesQuery.data?.items ?? [];
|
||||
const costCenters = costCentersQuery.data?.items ?? [];
|
||||
const documentCategories = documentCategoriesQuery.data?.items ?? [];
|
||||
|
||||
const availableParentCostCenters = useMemo(() => {
|
||||
return costCenters.filter(
|
||||
(item) => !item.archivedAt && item.legalEntityId === costCenterLegalEntityId,
|
||||
);
|
||||
}, [costCenterLegalEntityId, costCenters]);
|
||||
|
||||
const invalidateMasterData = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "legal-entities"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "cost-centers"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["master-data", "document-categories"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const legalEntityCreateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return createLegalEntity({
|
||||
code: legalEntityCode,
|
||||
name: legalEntityName,
|
||||
shortName: legalEntityShortName || null,
|
||||
currencyCode: legalEntityCurrency,
|
||||
taxNo: legalEntityTaxNo || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setLegalEntityCode("");
|
||||
setLegalEntityName("");
|
||||
setLegalEntityShortName("");
|
||||
setLegalEntityCurrency("CNY");
|
||||
setLegalEntityTaxNo("");
|
||||
setError("");
|
||||
toast.success("法体已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const costCenterCreateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return createCostCenter({
|
||||
legalEntityId: costCenterLegalEntityId,
|
||||
code: costCenterCode,
|
||||
name: costCenterName,
|
||||
parentId: costCenterParentId || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setCostCenterLegalEntityId("");
|
||||
setCostCenterCode("");
|
||||
setCostCenterName("");
|
||||
setCostCenterParentId("");
|
||||
setError("");
|
||||
toast.success("成本中心已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const documentCategoryCreateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return createDocumentCategory({
|
||||
code: documentCategoryCode,
|
||||
name: documentCategoryName,
|
||||
entityType: documentCategoryEntityType,
|
||||
sequencePrefix: documentCategorySequencePrefix,
|
||||
numberLength: Number(documentCategoryNumberLength || 4),
|
||||
resetPeriod: documentCategoryResetPeriod,
|
||||
defaultWorkflowKey: documentCategoryWorkflowKey || null,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setDocumentCategoryCode("");
|
||||
setDocumentCategoryName("");
|
||||
setDocumentCategoryEntityType("reimbursement");
|
||||
setDocumentCategorySequencePrefix("BX");
|
||||
setDocumentCategoryNumberLength("4");
|
||||
setDocumentCategoryResetPeriod("yearly");
|
||||
setDocumentCategoryWorkflowKey("");
|
||||
setError("");
|
||||
toast.success("单据分类已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (target: NonNullable<typeof confirmArchive>) => {
|
||||
if (target.kind === "legal-entity") {
|
||||
return archiveLegalEntity(target.id);
|
||||
}
|
||||
if (target.kind === "cost-center") {
|
||||
return archiveCostCenter(target.id);
|
||||
}
|
||||
return archiveDocumentCategory(target.id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateMasterData();
|
||||
setError("");
|
||||
toast.success("已归档");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
setConfirmArchive(null);
|
||||
},
|
||||
});
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="主数据" subtitle="你当前没有主数据权限,请联系管理员开通。" />
|
||||
<div className="card-std p-6 text-sm text-zinc-600">所需权限:masterdata.view 或 masterdata.manage</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="主数据"
|
||||
subtitle="集中维护法体、成本中心、单据分类与日历,为预算、采购、请假与资产扩展提供统一骨架。"
|
||||
actions={
|
||||
<Button variant="secondary" type="button" onClick={() => setIncludeArchived((value) => !value)}>
|
||||
{includeArchived ? "隐藏归档" : "显示归档"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="legal-entities">法体</TabsTrigger>
|
||||
<TabsTrigger value="cost-centers">成本中心</TabsTrigger>
|
||||
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
|
||||
<TabsTrigger value="holiday-calendars">日历</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="legal-entities" className="space-y-6">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
|
||||
<select
|
||||
className="select-std max-w-xs"
|
||||
value={legalEntityEnabledFilter}
|
||||
onChange={(event) => setLegalEntityEnabledFilter(event.target.value as "" | "true" | "false")}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">仅启用</option>
|
||||
<option value="false">仅停用</option>
|
||||
</select>
|
||||
</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-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityCode">编码</Label>
|
||||
<Input id="legalEntityCode" value={legalEntityCode} onChange={(event) => setLegalEntityCode(event.target.value)} placeholder="HYB_CN" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityName">名称</Label>
|
||||
<Input id="legalEntityName" value={legalEntityName} onChange={(event) => setLegalEntityName(event.target.value)} placeholder="海音达科技有限公司" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityShortName">简称</Label>
|
||||
<Input id="legalEntityShortName" value={legalEntityShortName} onChange={(event) => setLegalEntityShortName(event.target.value)} placeholder="海音达" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityCurrency">币种</Label>
|
||||
<Input id="legalEntityCurrency" value={legalEntityCurrency} onChange={(event) => setLegalEntityCurrency(event.target.value)} placeholder="CNY" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="legalEntityTaxNo">税号</Label>
|
||||
<Input id="legalEntityTaxNo" value={legalEntityTaxNo} onChange={(event) => setLegalEntityTaxNo(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={legalEntityCreateMutation.isPending || !legalEntityCode.trim() || !legalEntityName.trim() || !legalEntityCurrency.trim()}
|
||||
onClick={() => legalEntityCreateMutation.mutate()}
|
||||
>
|
||||
{legalEntityCreateMutation.isPending ? "创建中..." : "创建法体"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">法体列表</h3>
|
||||
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
|
||||
{!legalEntitiesQuery.isLoading ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>简称</TableHead>
|
||||
<TableHead>币种</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{legalEntities.length ? (
|
||||
legalEntities.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.shortName ?? "-"}</TableCell>
|
||||
<TableCell>{item.currencyCode}</TableCell>
|
||||
<TableCell>{renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setConfirmArchive({
|
||||
kind: "legal-entity",
|
||||
id: item.id,
|
||||
label: `${item.code} / ${item.name}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-zinc-500">
|
||||
暂无法体数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cost-centers" className="space-y-6">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
className="select-std"
|
||||
value={costCenterEnabledFilter}
|
||||
onChange={(event) => setCostCenterEnabledFilter(event.target.value as "" | "true" | "false")}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">仅启用</option>
|
||||
<option value="false">仅停用</option>
|
||||
</select>
|
||||
<select
|
||||
className="select-std"
|
||||
value={costCenterLegalEntityFilter}
|
||||
onChange={(event) => setCostCenterLegalEntityFilter(event.target.value)}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
{legalEntities
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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 md:col-span-2">
|
||||
<Label htmlFor="costCenterLegalEntity">所属法体</Label>
|
||||
<select
|
||||
id="costCenterLegalEntity"
|
||||
className="select-std"
|
||||
value={costCenterLegalEntityId}
|
||||
onChange={(event) => {
|
||||
setCostCenterLegalEntityId(event.target.value);
|
||||
setCostCenterParentId("");
|
||||
}}
|
||||
>
|
||||
<option value="">请选择法体</option>
|
||||
{legalEntities
|
||||
.filter((item) => !item.archivedAt)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="costCenterCode">编码</Label>
|
||||
<Input id="costCenterCode" value={costCenterCode} onChange={(event) => setCostCenterCode(event.target.value)} placeholder="RD" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="costCenterName">名称</Label>
|
||||
<Input id="costCenterName" value={costCenterName} onChange={(event) => setCostCenterName(event.target.value)} placeholder="研发中心" />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-3">
|
||||
<Label htmlFor="costCenterParent">上级成本中心(可选)</Label>
|
||||
<select
|
||||
id="costCenterParent"
|
||||
className="select-std"
|
||||
value={costCenterParentId}
|
||||
onChange={(event) => setCostCenterParentId(event.target.value)}
|
||||
disabled={!costCenterLegalEntityId}
|
||||
>
|
||||
<option value="">无</option>
|
||||
{availableParentCostCenters.map((item: CostCenterItem) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
costCenterCreateMutation.isPending ||
|
||||
!costCenterLegalEntityId ||
|
||||
!costCenterCode.trim() ||
|
||||
!costCenterName.trim()
|
||||
}
|
||||
onClick={() => costCenterCreateMutation.mutate()}
|
||||
>
|
||||
{costCenterCreateMutation.isPending ? "创建中..." : "创建成本中心"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">成本中心列表</h3>
|
||||
{costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||
{!costCentersQuery.isLoading ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>所属法体</TableHead>
|
||||
<TableHead>上级</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{costCenters.length ? (
|
||||
costCenters.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.legalEntity ? `${item.legalEntity.code} / ${item.legalEntity.name}` : "-"}</TableCell>
|
||||
<TableCell>{item.parent ? `${item.parent.name}` : "-"}</TableCell>
|
||||
<TableCell>{item.owner ? `${item.owner.name} (${item.owner.employeeNo})` : "-"}</TableCell>
|
||||
<TableCell>{renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setConfirmArchive({
|
||||
kind: "cost-center",
|
||||
id: item.id,
|
||||
label: `${item.code} / ${item.name}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-sm text-zinc-500">
|
||||
暂无成本中心数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="document-categories" className="space-y-6">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
className="select-std"
|
||||
value={documentCategoryEnabledFilter}
|
||||
onChange={(event) =>
|
||||
setDocumentCategoryEnabledFilter(event.target.value as "" | "true" | "false")
|
||||
}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">仅启用</option>
|
||||
<option value="false">仅停用</option>
|
||||
</select>
|
||||
<select
|
||||
className="select-std"
|
||||
value={documentCategoryEntityTypeFilter}
|
||||
onChange={(event) => setDocumentCategoryEntityTypeFilter(event.target.value as "" | DocumentCategoryEntityType)}
|
||||
>
|
||||
<option value="">全部实体</option>
|
||||
{DOCUMENT_ENTITY_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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="documentCategoryCode">编码</Label>
|
||||
<Input id="documentCategoryCode" value={documentCategoryCode} onChange={(event) => setDocumentCategoryCode(event.target.value)} placeholder="purchase_general" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryName">名称</Label>
|
||||
<Input id="documentCategoryName" value={documentCategoryName} onChange={(event) => setDocumentCategoryName(event.target.value)} placeholder="日常采购申请" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryEntityType">实体类型</Label>
|
||||
<select
|
||||
id="documentCategoryEntityType"
|
||||
className="select-std"
|
||||
value={documentCategoryEntityType}
|
||||
onChange={(event) => setDocumentCategoryEntityType(event.target.value as DocumentCategoryEntityType)}
|
||||
>
|
||||
{DOCUMENT_ENTITY_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryPrefix">流水号前缀</Label>
|
||||
<Input id="documentCategoryPrefix" value={documentCategorySequencePrefix} onChange={(event) => setDocumentCategorySequencePrefix(event.target.value)} placeholder="CG" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryNumberLength">流水号位数</Label>
|
||||
<Input id="documentCategoryNumberLength" type="number" min={2} max={8} value={documentCategoryNumberLength} onChange={(event) => setDocumentCategoryNumberLength(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="documentCategoryResetPeriod">重置周期</Label>
|
||||
<select
|
||||
id="documentCategoryResetPeriod"
|
||||
className="select-std"
|
||||
value={documentCategoryResetPeriod}
|
||||
onChange={(event) => setDocumentCategoryResetPeriod(event.target.value as DocumentResetPeriod)}
|
||||
>
|
||||
{RESET_PERIOD_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="documentCategoryWorkflowKey">默认工作流键(可选)</Label>
|
||||
<Input id="documentCategoryWorkflowKey" value={documentCategoryWorkflowKey} onChange={(event) => setDocumentCategoryWorkflowKey(event.target.value)} placeholder="预留字段,不在本轮启用" />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
documentCategoryCreateMutation.isPending ||
|
||||
!documentCategoryCode.trim() ||
|
||||
!documentCategoryName.trim() ||
|
||||
!documentCategorySequencePrefix.trim()
|
||||
}
|
||||
onClick={() => documentCategoryCreateMutation.mutate()}
|
||||
>
|
||||
{documentCategoryCreateMutation.isPending ? "创建中..." : "创建单据分类"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">单据分类列表</h3>
|
||||
{documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||
{!documentCategoriesQuery.isLoading ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>实体类型</TableHead>
|
||||
<TableHead>前缀</TableHead>
|
||||
<TableHead>重置周期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{documentCategories.length ? (
|
||||
documentCategories.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>
|
||||
{DOCUMENT_ENTITY_TYPE_OPTIONS.find((option) => option.value === item.entityType)?.label ?? item.entityType}
|
||||
</TableCell>
|
||||
<TableCell>{item.sequencePrefix}</TableCell>
|
||||
<TableCell>
|
||||
{RESET_PERIOD_OPTIONS.find((option) => option.value === item.resetPeriod)?.label ?? item.resetPeriod}
|
||||
</TableCell>
|
||||
<TableCell>{renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell>
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setConfirmArchive({
|
||||
kind: "document-category",
|
||||
id: item.id,
|
||||
label: `${item.code} / ${item.name}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-sm text-zinc-500">
|
||||
暂无单据分类数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="holiday-calendars" className="space-y-6">
|
||||
<HolidayCalendarTab
|
||||
canManage={canManage}
|
||||
includeArchived={includeArchived}
|
||||
legalEntities={legalEntities}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmArchive)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmArchive(null);
|
||||
}
|
||||
}}
|
||||
title="确认归档"
|
||||
description={confirmArchive ? `归档后将无法继续在新业务中使用:${confirmArchive.label}` : undefined}
|
||||
confirmLabel="确认归档"
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (confirmArchive) {
|
||||
await archiveMutation.mutateAsync(confirmArchive);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ChangePasswordPage } from "../pages/ChangePasswordPage";
|
||||
import { BudgetsPage } from "../pages/BudgetsPage";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { DepartmentsPage } from "../pages/DepartmentsPage";
|
||||
import { MasterDataPage } from "../pages/MasterDataPage";
|
||||
import { DelegationsPage } from "../pages/DelegationsPage";
|
||||
import { ForbiddenPage } from "../pages/ForbiddenPage";
|
||||
import { MobileApprovalsPage } from "../pages/MobileApprovalsPage";
|
||||
@@ -180,6 +181,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/master-data"
|
||||
element={
|
||||
<RequirePermission anyOf={["masterdata.view", "masterdata.manage"]}>
|
||||
<MasterDataPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/approval-rules"
|
||||
element={
|
||||
|
||||
+20
-3
@@ -1,11 +1,12 @@
|
||||
# hyb-oa 实现状态矩阵(P5-P15)
|
||||
# hyb-oa 实现状态矩阵(P5-P16B)
|
||||
|
||||
**快照日期**: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`,共 124 项。
|
||||
**事实源**:`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`(执行合同)
|
||||
**范围**:`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 项。
|
||||
**事实源**:`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/*.csv` 的执行合同职责。
|
||||
- UI 专项合同(如 `ui-refresh-stage7/8`)属于界面治理线,独立维护,不并入本业务主表统计。
|
||||
- `首次规划 CSV` 为该 ID 首次出现的执行合同;`首次完成 CSV` 为三状态首次全 `DONE` 的执行合同。
|
||||
- `规格书章节` 优先引用 `docs/requirements-specification.md`;规格未显式覆盖时标注 `N/A`。
|
||||
|
||||
@@ -142,3 +143,19 @@
|
||||
| P15B-4A | Leave撤回与冻结释放接口 | §17(请假V1治理) | 2026-03-06_14-30-00-p15b-l5-leave-v1.csv | 2026-03-06_14-30-00-p15b-l5-leave-v1.csv | 2026-03-07 | DONE | manual leave flow | 撤回恢复 draft 并释放冻结额度 |
|
||||
| P15B-5 | Leave申请前端模块集成 | §17(请假V1治理) | 2026-03-06_14-30-00-p15b-l5-leave-v1.csv | 2026-03-06_14-30-00-p15b-l5-leave-v1.csv | 2026-03-07 | DONE | client:typecheck/build | 请假页面、菜单、路由与审批详情联动完成 |
|
||||
| P15B-6 | P15B验收联调测试 | §17(请假V1治理) | 2026-03-06_14-30-00-p15b-l5-leave-v1.csv | 2026-03-06_14-30-00-p15b-l5-leave-v1.csv | 2026-03-07 | DONE | regression-all + manual leave flow | 创建/提交/审批/冲突/撤回全链路通过 |
|
||||
|
||||
| P16A-0 | P16A执行合同建档 | §18(主数据核心治理) | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07 | DONE | CSV 校验 | P16A仅建主数据本体不批量改造既有业务 |
|
||||
| P16A-1 | Prisma主数据模型与迁移 | §18(主数据核心治理) | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07 | DONE | prisma:validate + prisma:migrate + prisma:generate | LegalEntity/CostCenter/DocumentCategory 与枚举迁移完成 |
|
||||
| P16A-2 | 权限与种子补齐 | §18(主数据核心治理) | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07 | DONE | server:test + seed:config + seed:dev-users | 新增 masterdata.view/manage 与主数据演示种子 |
|
||||
| P16A-3 | 后端主数据CRUD API | §18(主数据核心治理) | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07 | DONE | server:test + manual master-data flow | `/api/master-data/*` 三类主数据 CRUD/归档与唯一性校验可用 |
|
||||
| P16A-4 | 前端主数据入口与管理页 | §18(主数据核心治理) | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07 | DONE | client:typecheck/build | `MasterDataPage` 单页多 Tab 与菜单路由落地 |
|
||||
| P16A-5 | 矩阵与文档同步 | §18(主数据核心治理) | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07 | DONE | validate-feature-matrix | 状态矩阵范围、事实源与 P16A 条目同步完成 |
|
||||
| P16A-6 | P16A回归与安全收口 | §18(主数据核心治理) | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07_13-47-05-p16a-l5-master-data-core.csv | 2026-03-07 | DONE | regression-all + manual security | 主数据 CRUD/归档/越权负测与既有主流程回归通过 |
|
||||
|
||||
| P16B-0 | 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 | CSV 校验 | P16B仅补节假日历主数据与关闭态算法增强 |
|
||||
| P16B-1 | Prisma日历模型与迁移 | §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 | prisma:validate + prisma:migrate + prisma:generate | HolidayCalendar/HolidayCalendarDay 与法体默认日历关联落地 |
|
||||
| P16B-2 | 日历开关与种子补齐 | §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 + seed:config + seed:dev-users | `leave_holiday_calendar` 双闸开关与演示日历种子完成 |
|
||||
| P16B-3 | 后端日历主数据API | §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 calendar flow | `/api/master-data/holiday-calendars*` 列表/维护/日期覆盖/归档可用 |
|
||||
| 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-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 天验证通过 |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
UI7-0,UI翻新阶段7执行合同建档,创建并校验本轮UI翻新Issue CSV执行合同,CSV校验通过且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-05_18-53-29-ui-refresh-stage7.csv,manual,TODO,TODO,TODO,issues/2026-03-05_18-53-29-ui-refresh-stage7.csv,[],none
|
||||
UI7-0,UI翻新阶段7执行合同建档,创建并校验本轮UI翻新Issue CSV执行合同,CSV校验通过且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-05_18-53-29-ui-refresh-stage7.csv,manual,DONE,DONE,DONE,issues/2026-03-05_18-53-29-ui-refresh-stage7.csv,[],none
|
||||
UI7-1,工作区UI翻新分批提交,将当前累计WIP拆分为4到6个可回滚提交并保持每个提交可构建,git status干净且提交按主题拆分并均可通过构建,cd client && npm run typecheck && npm run build; git status --porcelain,manual,DONE,DONE,DONE,client/**,"[""UI7-0""]",提交信息中文并引用Issue ID
|
||||
UI7-2,Design Tokens对齐(字体与primary色阶),对齐字体来源并修复primary色阶引用不生效问题,字体与按钮hover和focus样式一致且构建通过,cd client && npm run typecheck && npm run build; manual: Tab聚焦按钮与Tabs检查ring与hover,manual,DONE,DONE,DONE,client/index.html|client/src/index.css|client/tailwind.config.js|client/src/components/ui/*.tsx,"[""UI7-0""]",可参考~/shenzhou-business-suite的CSS变量token方式但不强制迁移
|
||||
UI7-3,7.3 修复403与404页面错位色与异常按钮,403与404页面按新版DS统一并明确返回路径,访问/403与未知路径页面样式统一且按钮可用,cd client && npm run build; manual: 登录后访问/403与/does-not-exist核验布局与按钮,manual,DONE,DONE,DONE,client/src/pages/ForbiddenPage.tsx|client/src/pages/NotFoundPage.tsx|client/src/router/index.tsx,"[""UI7-2""]",确认403/404是否需要放入AppShellLayout内
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
UI8-0,UI翻新阶段8验收建档,创建并校验本轮遗留Card组件与边缘文案中文化Issue CSV,CSV校验通过,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_10-09-00-ui-refresh-stage8.csv,manual,TODO,TODO,TODO,issues/2026-03-06_10-09-00-ui-refresh-stage8.csv,[],none
|
||||
UI8-0,UI翻新阶段8验收建档,创建并校验本轮遗留Card组件与边缘文案中文化Issue CSV,CSV校验通过,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_10-09-00-ui-refresh-stage8.csv,manual,DONE,DONE,DONE,issues/2026-03-06_10-09-00-ui-refresh-stage8.csv,[],none
|
||||
UI8-1,清理残余的Card与混用UI,修复Dashboard及15个遗留页面中的shadcn Card引入混用问题及样式调整,全站所有遗留被替换为 card-std 且Typecheck通过,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/**,"[""UI8-0""]",确保剥离 p-6 后的样式无异常
|
||||
UI8-2,下拉框中文化,修复Budgets和Delegations等的选项文案中文化,文案完成对应本地化不影响value传入后端,手动校验下拉框显示,manual,DONE,DONE,DONE,client/src/pages/BudgetsPage.tsx|client/src/pages/DelegationsPage.tsx,"[""UI8-0""]",无
|
||||
|
||||
|
@@ -0,0 +1,5 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
G1-0,G1治理尾项执行合同建档,创建并校验G1治理尾项执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_13-46-55-g1-governance-tail.csv,manual,DONE,DONE,DONE,issues/2026-03-07_13-46-55-g1-governance-tail.csv,[],收口当前活跃治理残项
|
||||
G1-1,UI阶段合同建档状态收口,将UI7-0与UI8-0建档行状态收口为DONE并保持原合同其余内容不变,UI7-0与UI8-0三状态均为DONE,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-05_18-53-29-ui-refresh-stage7.csv && python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-06_10-09-00-ui-refresh-stage8.csv,manual,DONE,DONE,DONE,issues/2026-03-05_18-53-29-ui-refresh-stage7.csv | issues/2026-03-06_10-09-00-ui-refresh-stage8.csv,"[""G1-0""]",仅收口建档行不回填更老历史CSV
|
||||
G1-2,Feature Matrix治理边界澄清,在状态矩阵中明确UI专项合同属于界面治理线不并入业务主表,feature-matrix说明区明确UI治理合同不纳入主表统计,python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/feature-matrix.md | scripts/validate-feature-matrix.py,"[""G1-1""]",不新增UI条目到业务矩阵
|
||||
G1-3,G1一致性复查,复查活跃roadmap与当前主表及CSV状态的一致性,不要求在未提交阶段强制清空工作区,G1与P16A合同校验通过且当前主表与活跃合同事实源一致,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_13-46-55-g1-governance-tail.csv && python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv && python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,issues/2026-03-07_13-46-55-g1-governance-tail.csv | issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv | issues/2026-03-06_20-00-00-p15-p18-roadmap-preflight.csv | docs/feature-matrix.md,"[""G1-2""]",不改业务代码且不要求以提交换取clean working tree
|
||||
|
@@ -0,0 +1,8 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
P16A-0,P16A执行合同建档,创建并校验P16A基础主数据核心执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv,manual,DONE,DONE,DONE,issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv,[],P16A仅建主数据本体不批量改造既有业务
|
||||
P16A-1,Prisma主数据模型与迁移,新增LegalEntity CostCenter DocumentCategory模型与必要枚举索引并生成迁移,Prisma schema与迁移可通过校验并成功生成客户端,cd server && npm run prisma:validate && npx prisma migrate dev --name p16a_master_data_core && npm run prisma:generate,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/prisma/migrations,"[""P16A-0""]",不强制回写既有报销预算表结构
|
||||
P16A-2,权限与种子补齐,新增masterdata.view/manage权限映射并补齐seed-config与seed-dev所需基础主数据,角色权限与默认单据分类及开发演示数据可用,cd server && npm run test && npm run seed:config && npm run seed:dev-users,manual,DONE,DONE,DONE,server/src/constants/permissions.js | client/src/auth/permissions.ts | server/src/scripts/seed-config.js | server/src/scripts/seed-dev.js,"[""P16A-1""]",finance_mgr主配manage finance与ceo具备view
|
||||
P16A-3,后端主数据CRUD API,实现master-data命名空间下的法体 成本中心 单据分类列表 新建 更新 归档接口并补审计与OpenAPI标签,三类主数据API可通过权限与唯一性校验且返回一致数据结构,cd server && npm run test,manual,DONE,DONE,DONE,server/src/routes/masterData.js | server/src/app.js,"[""P16A-2""]",列表接口支持includeArchived enabled legalEntityId entityType最小筛选
|
||||
P16A-4,前端主数据入口与管理页,新增MasterDataPage与API封装并注册/system/master-data菜单与路由,具备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/router/index.tsx | client/src/components/navigation/NavLinks.tsx,"[""P16A-3""]",采用单页多Tab承载法体 成本中心 单据分类
|
||||
P16A-5,矩阵与文档同步,同步feature-matrix范围与事实源并补P16A条目及治理边界说明,feature-matrix校验通过且P16A条目全部可追溯,python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/feature-matrix.md | scripts/validate-feature-matrix.py,"[""P16A-4""]",UI专项合同继续排除在业务矩阵主表外
|
||||
P16A-6,P16A回归与安全收口,完成主数据最小手工流转验证并执行后端前端回归及安全复核,CRUD 权限 归档限制与现有主流程均通过验证,cd server && npm run test && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,none,"[""P16A-5""]",合并前按security-best-practices检查IDOR与越权更新
|
||||
|
@@ -0,0 +1,8 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
P16B-0,P16B执行合同建档,创建并校验P16B节假日历主数据执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv,manual,DONE,DONE,DONE,issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv,[],P16B只建设日历主数据与关闭态增强算法
|
||||
P16B-1,Prisma日历模型与迁移,新增HolidayCalendar与HolidayCalendarDay模型并为LegalEntity补默认日历关联和必要索引,Prisma schema与迁移可通过校验并成功生成客户端,cd server && npm run prisma:validate && npx prisma migrate dev --name p16b_holiday_calendar && npm run prisma:generate,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/prisma/migrations/20260307084423_p16b_holiday_calendar,"[""P16B-0""]",补充LegalEntity默认日历关联并保持请假算法默认不切换
|
||||
P16B-2,日历开关与种子补齐,新增leave_holiday_calendar功能开关及默认记录并补齐演示日历种子,功能开关可在系统页展示且seed后可看到演示日历,cd server && npm run test && npm run seed:config && npm run seed:dev-users,manual,DONE,DONE,DONE,server/src/services/featureFlags/index.js | client/src/api/featureFlags.ts | client/src/pages/FeatureFlagsPage.tsx | server/src/scripts/seed-config.js | server/src/scripts/seed-dev.js | server/.env.example,"[""P16B-1""]",默认保持关闭态并通过Env+在线开关双闸控制
|
||||
P16B-3,后端日历主数据API,实现holiday-calendars列表 新建 更新 批量日期覆盖 归档接口并挂入master-data命名空间,日历与日历日接口支持权限校验 唯一性校验 引用法体校验与批量覆盖,cd server && npm run test,manual,DONE,DONE,DONE,server/src/routes/masterDataHolidayCalendars.js | server/src/app.js | server/src/plugins/auth.js,"[""P16B-2""]",接口范围固定为GET POST PATCH GET days PUT days POST archive
|
||||
P16B-4,前端主数据日历Tab,在MasterDataPage加入日历Tab并补API封装及功能开关显示名,具备masterdata.view/manage的用户可查看与维护日历及日期明细,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/api/masterData.ts | client/src/components/master-data/HolidayCalendarTab.tsx | client/src/pages/MasterDataPage.tsx | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx | client/src/pages/FeatureFlagsPage.tsx | client/src/api/errorMessages.ts,"[""P16B-3""]",继续复用/system/master-data单页多Tab
|
||||
P16B-5,请假日历增强计算器,在leave服务中新增可选日历版请假天数计算器并通过feature flag按法体默认日历读取,关闭开关时结果与现有自然日算法一致 打开后可识别holiday adjusted_workday,cd server && npm run test,manual,DONE,DONE,DONE,server/src/services/leave.js | server/src/routes/leave.js | server/src/services/featureFlags/index.js,"[""P16B-3""]",默认关闭且仅增强requestedDays计算不改账本语义
|
||||
P16B-6,P16B回归与矩阵收口,完成日历主数据手工验证 请假算法双模式验证 并同步feature-matrix事实源,CSV 矩阵 回归与越权负测全部通过,cd server && npm run test && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build && python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv,"[""P16B-4"",""P16B-5""]",已完成finance只读 finance_mgr可维护 默认日历禁止归档与开关双模式验证
|
||||
|
@@ -28,6 +28,8 @@ CSV_FACT_SOURCES = [
|
||||
ROOT / "issues" / "2026-02-13_00-11-56-p14-l5-operationalization.csv",
|
||||
ROOT / "issues" / "2026-03-06_14-30-00-p15a-l5-permission-decouple.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_16-22-07-p16b-l5-holiday-calendar.csv",
|
||||
]
|
||||
|
||||
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$")
|
||||
|
||||
@@ -61,6 +61,10 @@ OCR_TIMEOUT_MS="5000"
|
||||
# ===== 发票验真(P6)=====
|
||||
INVOICE_VERIFICATION_ENABLED="false"
|
||||
# INVOICE_VERIFICATION_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
|
||||
|
||||
# ===== 请假日历增强(P16B)=====
|
||||
LEAVE_HOLIDAY_CALENDAR_ENABLED="false"
|
||||
# LEAVE_HOLIDAY_CALENDAR_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
|
||||
VERIFICATION_PROVIDER="mock" # mock|tax
|
||||
VERIFICATION_POLL_CRON="* * * * *"
|
||||
VERIFICATION_MAX_ATTEMPTS="3"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentCategoryEntityType" AS ENUM ('reimbursement', 'leave', 'purchase_request', 'asset_card', 'general');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "legal_entities" (
|
||||
"id" UUID NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"short_name" TEXT,
|
||||
"currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY',
|
||||
"tax_no" TEXT,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archived_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "legal_entities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cost_centers" (
|
||||
"id" UUID NOT NULL,
|
||||
"legal_entity_id" UUID NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parent_id" UUID,
|
||||
"owner_employee_id" UUID,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archived_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "cost_centers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "document_categories" (
|
||||
"id" UUID NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"entity_type" "DocumentCategoryEntityType" NOT NULL,
|
||||
"sequence_prefix" TEXT NOT NULL,
|
||||
"number_length" INTEGER NOT NULL DEFAULT 4,
|
||||
"reset_period" "DocumentResetPeriod" NOT NULL,
|
||||
"default_workflow_key" TEXT,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archived_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "document_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "legal_entities_code_key" ON "legal_entities"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_legal_entities_status" ON "legal_entities"("enabled", "archived_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_cost_centers_legal_entity_status" ON "cost_centers"("legal_entity_id", "enabled", "archived_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_cost_centers_parent" ON "cost_centers"("parent_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_cost_centers_owner" ON "cost_centers"("owner_employee_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "legal_entity_cost_center_code_ux" ON "cost_centers"("legal_entity_id", "code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "document_categories_code_key" ON "document_categories"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_document_categories_entity_status" ON "document_categories"("entity_type", "enabled", "archived_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cost_centers" ADD CONSTRAINT "cost_centers_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cost_centers" ADD CONSTRAINT "cost_centers_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "cost_centers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cost_centers" ADD CONSTRAINT "cost_centers_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HolidayCalendarDayType" AS ENUM ('holiday', 'workday', 'adjusted_workday');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "employees" ADD COLUMN "legal_entity_id" UUID;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "legal_entities" ADD COLUMN "default_holiday_calendar_id" UUID;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "holiday_calendars" (
|
||||
"id" UUID NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"timezone" VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
|
||||
"legal_entity_id" UUID,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archived_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "holiday_calendars_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "holiday_calendar_days" (
|
||||
"id" UUID NOT NULL,
|
||||
"calendar_id" UUID NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"day_type" "HolidayCalendarDayType" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"note" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "holiday_calendar_days_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "holiday_calendars_code_key" ON "holiday_calendars"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_holiday_calendars_legal_entity_status" ON "holiday_calendars"("legal_entity_id", "enabled", "archived_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_holiday_calendar_days_calendar_date" ON "holiday_calendar_days"("calendar_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "holiday_calendar_day_ux" ON "holiday_calendar_days"("calendar_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employees_legal_entity" ON "employees"("legal_entity_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_legal_entities_default_calendar" ON "legal_entities"("default_holiday_calendar_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "employees" ADD CONSTRAINT "employees_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "legal_entities" ADD CONSTRAINT "legal_entities_default_holiday_calendar_id_fkey" FOREIGN KEY ("default_holiday_calendar_id") REFERENCES "holiday_calendars"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "holiday_calendars" ADD CONSTRAINT "holiday_calendars_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "holiday_calendar_days" ADD CONSTRAINT "holiday_calendar_days_calendar_id_fkey" FOREIGN KEY ("calendar_id") REFERENCES "holiday_calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+137
-3
@@ -90,6 +90,20 @@ enum DocumentResetPeriod {
|
||||
yearly
|
||||
}
|
||||
|
||||
enum DocumentCategoryEntityType {
|
||||
reimbursement
|
||||
leave
|
||||
purchase_request
|
||||
asset_card
|
||||
general
|
||||
}
|
||||
|
||||
enum HolidayCalendarDayType {
|
||||
holiday
|
||||
workday
|
||||
adjusted_workday
|
||||
}
|
||||
|
||||
enum WorkflowInstanceStatus {
|
||||
pending
|
||||
approved
|
||||
@@ -163,6 +177,7 @@ model Employee {
|
||||
phone String @unique
|
||||
email String @unique
|
||||
department_id String @db.Uuid
|
||||
legal_entity_id String? @db.Uuid
|
||||
title String
|
||||
direct_manager_id String? @db.Uuid
|
||||
hire_date DateTime @db.Date
|
||||
@@ -185,9 +200,10 @@ model Employee {
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
|
||||
direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict)
|
||||
subordinates Employee[] @relation("EmployeeToManager")
|
||||
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
|
||||
legal_entity LegalEntity? @relation(fields: [legal_entity_id], references: [id], onDelete: SetNull)
|
||||
direct_manager Employee? @relation("EmployeeToManager", fields: [direct_manager_id], references: [id], onDelete: Restrict)
|
||||
subordinates Employee[] @relation("EmployeeToManager")
|
||||
|
||||
roles EmployeeRole[]
|
||||
|
||||
@@ -203,6 +219,7 @@ model Employee {
|
||||
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
||||
leave_requests LeaveRequest[]
|
||||
leave_ledgers LeaveLedger[]
|
||||
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
||||
|
||||
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
||||
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
||||
@@ -220,6 +237,7 @@ model Employee {
|
||||
password_reset_codes PasswordResetCode[]
|
||||
|
||||
@@index([department_id])
|
||||
@@index([legal_entity_id], map: "idx_employees_legal_entity")
|
||||
@@index([direct_manager_id])
|
||||
@@map("employees")
|
||||
}
|
||||
@@ -371,6 +389,122 @@ model DocumentSequence {
|
||||
@@map("document_sequences")
|
||||
}
|
||||
|
||||
model LegalEntity {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
code String @unique
|
||||
name String
|
||||
short_name String?
|
||||
currency_code String @default("CNY") @db.VarChar(8)
|
||||
tax_no String?
|
||||
default_holiday_calendar_id String? @db.Uuid
|
||||
enabled Boolean @default(true)
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
default_holiday_calendar HolidayCalendar? @relation("LegalEntityDefaultHolidayCalendar", fields: [default_holiday_calendar_id], references: [id], onDelete: SetNull)
|
||||
cost_centers CostCenter[]
|
||||
employees Employee[]
|
||||
holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity")
|
||||
|
||||
@@index([enabled, archived_at], map: "idx_legal_entities_status")
|
||||
@@index([default_holiday_calendar_id], map: "idx_legal_entities_default_calendar")
|
||||
@@map("legal_entities")
|
||||
}
|
||||
|
||||
model CostCenter {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
code String
|
||||
name String
|
||||
parent_id String? @db.Uuid
|
||||
owner_employee_id String? @db.Uuid
|
||||
enabled Boolean @default(true)
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict)
|
||||
parent CostCenter? @relation("CostCenterToParent", fields: [parent_id], references: [id], onDelete: Restrict)
|
||||
children CostCenter[] @relation("CostCenterToParent")
|
||||
owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux")
|
||||
@@index([legal_entity_id, enabled, archived_at], map: "idx_cost_centers_legal_entity_status")
|
||||
@@index([parent_id], map: "idx_cost_centers_parent")
|
||||
@@index([owner_employee_id], map: "idx_cost_centers_owner")
|
||||
@@map("cost_centers")
|
||||
}
|
||||
|
||||
model DocumentCategory {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
code String @unique
|
||||
name String
|
||||
entity_type DocumentCategoryEntityType
|
||||
sequence_prefix String
|
||||
number_length Int @default(4)
|
||||
reset_period DocumentResetPeriod
|
||||
default_workflow_key String?
|
||||
enabled Boolean @default(true)
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
@@index([entity_type, enabled, archived_at], map: "idx_document_categories_entity_status")
|
||||
@@map("document_categories")
|
||||
}
|
||||
|
||||
model HolidayCalendar {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
code String @unique
|
||||
name String
|
||||
timezone String @default("Asia/Shanghai") @db.VarChar(64)
|
||||
legal_entity_id String? @db.Uuid
|
||||
enabled Boolean @default(true)
|
||||
archived_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
legal_entity LegalEntity? @relation("HolidayCalendarLegalEntity", fields: [legal_entity_id], references: [id], onDelete: SetNull)
|
||||
days HolidayCalendarDay[]
|
||||
default_for_legal_entities LegalEntity[] @relation("LegalEntityDefaultHolidayCalendar")
|
||||
|
||||
@@index([legal_entity_id, enabled, archived_at], map: "idx_holiday_calendars_legal_entity_status")
|
||||
@@map("holiday_calendars")
|
||||
}
|
||||
|
||||
model HolidayCalendarDay {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
calendar_id String @db.Uuid
|
||||
date DateTime @db.Date
|
||||
day_type HolidayCalendarDayType
|
||||
name String
|
||||
note String?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
calendar HolidayCalendar @relation(fields: [calendar_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([calendar_id, date], map: "holiday_calendar_day_ux")
|
||||
@@index([calendar_id, date], map: "idx_holiday_calendar_days_calendar_date")
|
||||
@@map("holiday_calendar_days")
|
||||
}
|
||||
|
||||
model ReimbursementForm {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
form_number String? @unique
|
||||
|
||||
@@ -26,6 +26,8 @@ const delegationRoutes = require("./routes/delegations");
|
||||
const auditRoutes = require("./routes/audit");
|
||||
const usersRoutes = require("./routes/users");
|
||||
const departmentsRoutes = require("./routes/departments");
|
||||
const masterDataRoutes = require("./routes/masterData");
|
||||
const masterDataHolidayCalendarsRoutes = require("./routes/masterDataHolidayCalendars");
|
||||
const employeesRoutes = require("./routes/employees");
|
||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||
const configReadRoutes = require("./routes/configRead");
|
||||
@@ -282,6 +284,8 @@ async function buildApp() {
|
||||
await app.register(usersRoutes, { prefix: "/api" });
|
||||
await app.register(employeesRoutes, { prefix: "/api" });
|
||||
await app.register(departmentsRoutes, { prefix: "/api" });
|
||||
await app.register(masterDataRoutes, { prefix: "/api" });
|
||||
await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" });
|
||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||
await app.register(configReadRoutes, { prefix: "/api" });
|
||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -92,6 +92,7 @@ function getConfig() {
|
||||
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
|
||||
|
||||
invoiceVerificationEnabled: process.env.INVOICE_VERIFICATION_ENABLED === "true",
|
||||
leaveHolidayCalendarEnabled: process.env.LEAVE_HOLIDAY_CALENDAR_ENABLED === "true",
|
||||
invoiceVerificationFlagEnv: process.env.INVOICE_VERIFICATION_ENABLED ?? "false",
|
||||
verificationProvider: process.env.VERIFICATION_PROVIDER ?? "mock",
|
||||
verificationPollCron: process.env.VERIFICATION_POLL_CRON ?? "* * * * *",
|
||||
|
||||
@@ -16,6 +16,9 @@ const LEAVE_SELF_SERVICE_PERMISSIONS = [
|
||||
"leave.submit",
|
||||
];
|
||||
|
||||
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"];
|
||||
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
|
||||
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
@@ -68,6 +71,7 @@ const ROLE_PERMISSIONS = {
|
||||
],
|
||||
ceo: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_VIEW_PERMISSIONS,
|
||||
"expense.approve",
|
||||
"expense.transfer",
|
||||
"expense.view_all",
|
||||
@@ -78,6 +82,7 @@ const ROLE_PERMISSIONS = {
|
||||
],
|
||||
finance: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_VIEW_PERMISSIONS,
|
||||
"invoice.manage",
|
||||
"invoice.verify",
|
||||
"report.dashboard.view",
|
||||
@@ -88,6 +93,7 @@ const ROLE_PERMISSIONS = {
|
||||
],
|
||||
finance_mgr: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_MANAGE_PERMISSIONS,
|
||||
"invoice.manage",
|
||||
"invoice.verify",
|
||||
"report.dashboard.view",
|
||||
|
||||
@@ -99,6 +99,7 @@ async function authPlugin(fastify) {
|
||||
status: true,
|
||||
token_version: true,
|
||||
department_id: true,
|
||||
legal_entity_id: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
@@ -126,6 +127,7 @@ async function authPlugin(fastify) {
|
||||
status: employee.status,
|
||||
token_version: employee.token_version,
|
||||
department_id: employee.department_id,
|
||||
legal_entity_id: employee.legal_entity_id ?? null,
|
||||
roleCodes,
|
||||
permissionCodes,
|
||||
};
|
||||
|
||||
+60
-1
@@ -3,9 +3,11 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { resolveActionableStep } = require("../utils/workflow");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
DEFAULT_ATTACHMENT_MIME_TYPES,
|
||||
calculateRequestedDays,
|
||||
calculateRequestedDaysByWorkingCalendar,
|
||||
createLeaveLedgerEntry,
|
||||
formatDateOnly,
|
||||
leaveRangesOverlap,
|
||||
@@ -165,6 +167,61 @@ function parseCreateBody(body) {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRequestedDaysForCreate(fastify, employee, body) {
|
||||
if (!employee?.legal_entity_id) {
|
||||
return body.requestedDays;
|
||||
}
|
||||
|
||||
const featureFlag = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.leaveHolidayCalendar,
|
||||
departmentId: employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
if (!featureFlag.enabled) {
|
||||
return body.requestedDays;
|
||||
}
|
||||
|
||||
const legalEntity = await fastify.prisma.legalEntity.findUnique({
|
||||
where: { id: employee.legal_entity_id },
|
||||
select: {
|
||||
default_holiday_calendar: {
|
||||
select: {
|
||||
id: true,
|
||||
archived_at: true,
|
||||
days: {
|
||||
where: {
|
||||
date: {
|
||||
gte: body.startDate,
|
||||
lte: body.endDate,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
day_type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const calendar = legalEntity?.default_holiday_calendar;
|
||||
if (!calendar || calendar.archived_at) {
|
||||
return body.requestedDays;
|
||||
}
|
||||
|
||||
return calculateRequestedDaysByWorkingCalendar({
|
||||
startDate: body.startDate,
|
||||
endDate: body.endDate,
|
||||
startSession: body.startSession,
|
||||
endSession: body.endSession,
|
||||
calendarDays: calendar.days,
|
||||
});
|
||||
}
|
||||
|
||||
async function collectCreatePayload(request) {
|
||||
if (typeof request.isMultipart === "function" && request.isMultipart()) {
|
||||
const fields = {};
|
||||
@@ -428,6 +485,8 @@ async function leaveRoutes(fastify) {
|
||||
request.employee.id,
|
||||
);
|
||||
|
||||
const requestedDays = await resolveRequestedDaysForCreate(fastify, request.employee, body);
|
||||
|
||||
const leave = await fastify.prisma.leaveRequest.create({
|
||||
data: {
|
||||
owner_id: request.employee.id,
|
||||
@@ -437,7 +496,7 @@ async function leaveRoutes(fastify) {
|
||||
end_date: body.endDate,
|
||||
start_session: body.startSession,
|
||||
end_session: body.endSession,
|
||||
requested_days: body.requestedDays,
|
||||
requested_days: requestedDays,
|
||||
reason: body.reason,
|
||||
attachment_storage_path: attachment?.storagePath ?? null,
|
||||
attachment_original_name: attachment?.originalName ?? null,
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,703 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional();
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const HOLIDAY_DAY_TYPE_VALUES = ["holiday", "workday", "adjusted_workday"];
|
||||
|
||||
const holidayCalendarListQuerySchema = z.object({
|
||||
includeArchived: BOOLEAN_QUERY_SCHEMA,
|
||||
enabled: BOOLEAN_QUERY_SCHEMA,
|
||||
legalEntityId: UUID_SCHEMA.optional(),
|
||||
});
|
||||
|
||||
const holidayCalendarCreateBodySchema = z
|
||||
.object({
|
||||
code: z.string().trim().min(2).max(64),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
timezone: z.string().trim().min(1).max(64).optional(),
|
||||
legalEntityId: UUID_SCHEMA.nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const holidayCalendarPatchBodySchema = holidayCalendarCreateBodySchema.partial();
|
||||
|
||||
const holidayCalendarDaySchema = z
|
||||
.object({
|
||||
date: DATE_ONLY_SCHEMA,
|
||||
dayType: z.enum(HOLIDAY_DAY_TYPE_VALUES),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const holidayCalendarDaysReplaceBodySchema = z
|
||||
.object({
|
||||
items: z.array(holidayCalendarDaySchema).max(500),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseUuid(value) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ id: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseBooleanQueryValue(rawValue) {
|
||||
if (rawValue == null) {
|
||||
return null;
|
||||
}
|
||||
return rawValue === "1" || rawValue === "true";
|
||||
}
|
||||
|
||||
function parseListQuery(query) {
|
||||
const parsed = holidayCalendarListQuerySchema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed.data,
|
||||
includeArchived: parseBooleanQueryValue(parsed.data.includeArchived) ?? false,
|
||||
enabled: parseBooleanQueryValue(parsed.data.enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCreateBody(body) {
|
||||
const parsed = holidayCalendarCreateBodySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parsePatchBody(body) {
|
||||
const parsed = holidayCalendarPatchBodySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw buildValidationError({ body: "empty" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReplaceDaysBody(body) {
|
||||
const parsed = holidayCalendarDaysReplaceBodySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
const seenDates = new Set();
|
||||
for (const item of parsed.data.items) {
|
||||
if (seenDates.has(item.date)) {
|
||||
throw buildValidationError({ date: "duplicate" }, 409);
|
||||
}
|
||||
seenDates.add(item.date);
|
||||
}
|
||||
|
||||
return parsed.data.items;
|
||||
}
|
||||
|
||||
function normalizeCalendarPayload(body) {
|
||||
return {
|
||||
code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined,
|
||||
name: typeof body.name === "string" ? body.name.trim() : undefined,
|
||||
timezone: typeof body.timezone === "string" ? body.timezone.trim() : undefined,
|
||||
legalEntityId: Object.prototype.hasOwnProperty.call(body, "legalEntityId")
|
||||
? body.legalEntityId ?? null
|
||||
: undefined,
|
||||
enabled: body.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHolidayCalendarDay(item) {
|
||||
return {
|
||||
date: item.date,
|
||||
dayType: item.dayType,
|
||||
name: item.name.trim(),
|
||||
note: item.note?.trim() ? item.note.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function toDateOnly(value) {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function mapHolidayCalendar(record) {
|
||||
const defaultCalendarId = record.legal_entity?.default_holiday_calendar_id ?? null;
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
timezone: record.timezone,
|
||||
legalEntityId: record.legal_entity_id ?? null,
|
||||
enabled: Boolean(record.enabled),
|
||||
archivedAt: record.archived_at?.toISOString?.() ?? null,
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
daysCount:
|
||||
typeof record.days_count === "number"
|
||||
? record.days_count
|
||||
: typeof record._count?.days === "number"
|
||||
? record._count.days
|
||||
: 0,
|
||||
isDefaultForLegalEntity: defaultCalendarId === record.id,
|
||||
legalEntity: record.legal_entity
|
||||
? {
|
||||
id: record.legal_entity.id,
|
||||
code: record.legal_entity.code,
|
||||
name: record.legal_entity.name,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapHolidayCalendarDay(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
date: record.date.toISOString().slice(0, 10),
|
||||
dayType: record.day_type,
|
||||
name: record.name,
|
||||
note: record.note ?? null,
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureLegalEntityAvailable(tx, legalEntityId) {
|
||||
if (!legalEntityId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const legalEntity = await tx.legalEntity.findUnique({
|
||||
where: { id: legalEntityId },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
archived_at: true,
|
||||
default_holiday_calendar_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!legalEntity) {
|
||||
throw buildValidationError({ legalEntityId: "not_found" });
|
||||
}
|
||||
|
||||
if (legalEntity.archived_at) {
|
||||
throw buildValidationError({ legalEntityId: "archived" }, 409);
|
||||
}
|
||||
|
||||
return legalEntity;
|
||||
}
|
||||
|
||||
function assertMasterDataViewPermission(fastify, request) {
|
||||
const canView =
|
||||
fastify.hasPermission(request.employee, "masterdata.view") ||
|
||||
fastify.hasPermission(request.employee, "masterdata.manage");
|
||||
|
||||
if (!canView) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: "masterdata.view" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function masterDataHolidayCalendarsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/master-data/holiday-calendars",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
schema: {
|
||||
tags: ["master-data"],
|
||||
summary: "List holiday calendars",
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
assertMasterDataViewPermission(fastify, request);
|
||||
const query = parseListQuery(request.query);
|
||||
|
||||
const items = await fastify.prisma.holidayCalendar.findMany({
|
||||
where: {
|
||||
...(query.includeArchived ? {} : { archived_at: null }),
|
||||
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
||||
...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}),
|
||||
},
|
||||
orderBy: [{ code: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
timezone: true,
|
||||
legal_entity_id: true,
|
||||
enabled: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
legal_entity: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
default_holiday_calendar_id: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { days: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
includeArchived: query.includeArchived,
|
||||
items: items.map(mapHolidayCalendar),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/master-data/holiday-calendars",
|
||||
{
|
||||
preHandler: fastify.requirePermission("masterdata.manage"),
|
||||
schema: {
|
||||
tags: ["master-data"],
|
||||
summary: "Create holiday calendar",
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = normalizeCalendarPayload(parseCreateBody(request.body));
|
||||
|
||||
try {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const legalEntity = await ensureLegalEntityAvailable(tx, body.legalEntityId ?? null);
|
||||
const holidayCalendar = await tx.holidayCalendar.create({
|
||||
data: {
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
timezone: body.timezone ?? "Asia/Shanghai",
|
||||
legal_entity_id: body.legalEntityId ?? null,
|
||||
enabled: body.enabled ?? true,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
timezone: true,
|
||||
legal_entity_id: true,
|
||||
enabled: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
legal_entity: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
default_holiday_calendar_id: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { days: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (legalEntity && !legalEntity.default_holiday_calendar_id) {
|
||||
await tx.legalEntity.update({
|
||||
where: { id: legalEntity.id },
|
||||
data: {
|
||||
default_holiday_calendar_id: holidayCalendar.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
holidayCalendar.legal_entity.default_holiday_calendar_id = holidayCalendar.id;
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "HOLIDAY_CALENDAR_CREATE",
|
||||
targetType: "holiday_calendar",
|
||||
targetId: holidayCalendar.id,
|
||||
extra: {
|
||||
code: holidayCalendar.code,
|
||||
legalEntityId: holidayCalendar.legal_entity_id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return holidayCalendar;
|
||||
});
|
||||
|
||||
return sendData(reply, request, { holidayCalendar: mapHolidayCalendar(result) });
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw buildValidationError({ code: "duplicate" }, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/master-data/holiday-calendars/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("masterdata.manage"),
|
||||
schema: {
|
||||
tags: ["master-data"],
|
||||
summary: "Patch holiday calendar",
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const holidayCalendarId = parseUuid(request.params?.id);
|
||||
const body = normalizeCalendarPayload(parsePatchBody(request.body));
|
||||
|
||||
const current = await fastify.prisma.holidayCalendar.findUnique({
|
||||
where: { id: holidayCalendarId },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
archived_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
if (current.archived_at) {
|
||||
throw buildValidationError({ status: "archived" }, 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
if (body.legalEntityId !== undefined) {
|
||||
await ensureLegalEntityAvailable(tx, body.legalEntityId);
|
||||
}
|
||||
|
||||
const holidayCalendar = await tx.holidayCalendar.update({
|
||||
where: { id: holidayCalendarId },
|
||||
data: {
|
||||
...(body.code !== undefined ? { code: body.code } : {}),
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.timezone !== undefined ? { timezone: body.timezone } : {}),
|
||||
...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}),
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
timezone: true,
|
||||
legal_entity_id: true,
|
||||
enabled: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
legal_entity: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
default_holiday_calendar_id: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { days: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "HOLIDAY_CALENDAR_UPDATE",
|
||||
targetType: "holiday_calendar",
|
||||
targetId: holidayCalendar.id,
|
||||
extra: {
|
||||
code: holidayCalendar.code,
|
||||
legalEntityId: holidayCalendar.legal_entity_id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return holidayCalendar;
|
||||
});
|
||||
|
||||
return sendData(reply, request, { holidayCalendar: mapHolidayCalendar(result) });
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw buildValidationError({ code: "duplicate" }, 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/master-data/holiday-calendars/:id/days",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
schema: {
|
||||
tags: ["master-data"],
|
||||
summary: "Get holiday calendar days",
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
assertMasterDataViewPermission(fastify, request);
|
||||
const holidayCalendarId = parseUuid(request.params?.id);
|
||||
|
||||
const holidayCalendar = await fastify.prisma.holidayCalendar.findUnique({
|
||||
where: { id: holidayCalendarId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!holidayCalendar) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const items = await fastify.prisma.holidayCalendarDay.findMany({
|
||||
where: { calendar_id: holidayCalendarId },
|
||||
orderBy: [{ date: "asc" }, { created_at: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
day_type: true,
|
||||
name: true,
|
||||
note: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, { items: items.map(mapHolidayCalendarDay) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
"/master-data/holiday-calendars/:id/days",
|
||||
{
|
||||
preHandler: fastify.requirePermission("masterdata.manage"),
|
||||
schema: {
|
||||
tags: ["master-data"],
|
||||
summary: "Replace holiday calendar days",
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const holidayCalendarId = parseUuid(request.params?.id);
|
||||
const items = parseReplaceDaysBody(request.body).map(normalizeHolidayCalendarDay);
|
||||
const now = new Date();
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.holidayCalendar.findUnique({
|
||||
where: { id: holidayCalendarId },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
archived_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
if (current.archived_at) {
|
||||
throw buildValidationError({ status: "archived" }, 409);
|
||||
}
|
||||
|
||||
await tx.holidayCalendarDay.deleteMany({
|
||||
where: { calendar_id: holidayCalendarId },
|
||||
});
|
||||
|
||||
if (items.length) {
|
||||
await tx.holidayCalendarDay.createMany({
|
||||
data: items.map((item) => ({
|
||||
calendar_id: holidayCalendarId,
|
||||
date: toDateOnly(item.date),
|
||||
day_type: item.dayType,
|
||||
name: item.name,
|
||||
note: item.note,
|
||||
created_at: now,
|
||||
created_by: request.employee.id,
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "HOLIDAY_CALENDAR_DAYS_REPLACE",
|
||||
targetType: "holiday_calendar",
|
||||
targetId: holidayCalendarId,
|
||||
extra: {
|
||||
count: items.length,
|
||||
code: current.code,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return current;
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
holidayCalendarId: result.id,
|
||||
replacedCount: items.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/master-data/holiday-calendars/:id/archive",
|
||||
{
|
||||
preHandler: fastify.requirePermission("masterdata.manage"),
|
||||
schema: {
|
||||
tags: ["master-data"],
|
||||
summary: "Archive holiday calendar",
|
||||
security: [{ bearerAuth: [] }],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const holidayCalendarId = parseUuid(request.params?.id);
|
||||
const now = new Date();
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.holidayCalendar.findUnique({
|
||||
where: { id: holidayCalendarId },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
timezone: true,
|
||||
legal_entity_id: true,
|
||||
enabled: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
legal_entity: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
default_holiday_calendar_id: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { days: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
if (current.archived_at) {
|
||||
return { holidayCalendar: current, outcome: "already_archived" };
|
||||
}
|
||||
|
||||
const boundLegalEntity = await tx.legalEntity.findFirst({
|
||||
where: {
|
||||
default_holiday_calendar_id: holidayCalendarId,
|
||||
archived_at: null,
|
||||
},
|
||||
select: { id: true, code: true, name: true },
|
||||
});
|
||||
|
||||
if (boundLegalEntity) {
|
||||
throw buildValidationError(
|
||||
{
|
||||
holidayCalendar: "bound_as_default",
|
||||
legalEntityCode: boundLegalEntity.code,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const holidayCalendar = await tx.holidayCalendar.update({
|
||||
where: { id: holidayCalendarId },
|
||||
data: {
|
||||
enabled: false,
|
||||
archived_at: now,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
timezone: true,
|
||||
legal_entity_id: true,
|
||||
enabled: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
legal_entity: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
default_holiday_calendar_id: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { days: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "HOLIDAY_CALENDAR_ARCHIVE",
|
||||
targetType: "holiday_calendar",
|
||||
targetId: holidayCalendar.id,
|
||||
extra: {
|
||||
code: holidayCalendar.code,
|
||||
legalEntityId: holidayCalendar.legal_entity_id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { holidayCalendar, outcome: "archived" };
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
holidayCalendar: mapHolidayCalendar(result.holidayCalendar),
|
||||
outcome: result.outcome,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = masterDataHolidayCalendarsRoutes;
|
||||
@@ -106,6 +106,83 @@ const DEFAULT_LEAVE_WORKFLOW_DEFINITION = {
|
||||
};
|
||||
|
||||
|
||||
const DEFAULT_DOCUMENT_CATEGORIES = [
|
||||
{
|
||||
code: "reimbursement_general",
|
||||
name: "通用报销单",
|
||||
entity_type: "reimbursement",
|
||||
sequence_prefix: "BX",
|
||||
number_length: 4,
|
||||
reset_period: "yearly",
|
||||
default_workflow_key: null,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
code: "leave_request",
|
||||
name: "请假申请单",
|
||||
entity_type: "leave",
|
||||
sequence_prefix: "JQ",
|
||||
number_length: 4,
|
||||
reset_period: "yearly",
|
||||
default_workflow_key: null,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
code: "purchase_general",
|
||||
name: "日常采购申请",
|
||||
entity_type: "purchase_request",
|
||||
sequence_prefix: "CG",
|
||||
number_length: 4,
|
||||
reset_period: "yearly",
|
||||
default_workflow_key: null,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
code: "asset_it",
|
||||
name: "IT 固资台账",
|
||||
entity_type: "asset_card",
|
||||
sequence_prefix: "ZC",
|
||||
number_length: 4,
|
||||
reset_period: "yearly",
|
||||
default_workflow_key: null,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
code: "general_record",
|
||||
name: "通用业务单据",
|
||||
entity_type: "general",
|
||||
sequence_prefix: "GEN",
|
||||
number_length: 4,
|
||||
reset_period: "yearly",
|
||||
default_workflow_key: null,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_FEATURE_FLAGS = [
|
||||
{
|
||||
flag_key: "erp_sync",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "leave_holiday_calendar",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_LEAVE_TYPES = [
|
||||
{
|
||||
code: "annual_leave",
|
||||
@@ -216,6 +293,72 @@ async function seedDocumentSequence(prisma) {
|
||||
});
|
||||
}
|
||||
|
||||
async function seedDocumentCategories(prisma) {
|
||||
const now = new Date();
|
||||
|
||||
for (const item of DEFAULT_DOCUMENT_CATEGORIES) {
|
||||
await prisma.documentCategory.upsert({
|
||||
where: { code: item.code },
|
||||
update: {
|
||||
name: item.name,
|
||||
entity_type: item.entity_type,
|
||||
sequence_prefix: item.sequence_prefix,
|
||||
number_length: item.number_length,
|
||||
reset_period: item.reset_period,
|
||||
default_workflow_key: item.default_workflow_key,
|
||||
enabled: item.enabled,
|
||||
archived_at: null,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
},
|
||||
create: {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
entity_type: item.entity_type,
|
||||
sequence_prefix: item.sequence_prefix,
|
||||
number_length: item.number_length,
|
||||
reset_period: item.reset_period,
|
||||
default_workflow_key: item.default_workflow_key,
|
||||
enabled: item.enabled,
|
||||
archived_at: null,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function seedFeatureFlags(prisma) {
|
||||
const now = new Date();
|
||||
|
||||
for (const item of DEFAULT_FEATURE_FLAGS) {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: item.flag_key },
|
||||
update: {
|
||||
override_mode: item.override_mode,
|
||||
rollout_type: item.rollout_type,
|
||||
department_ids: item.department_ids,
|
||||
note: item.note,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
},
|
||||
create: {
|
||||
flag_key: item.flag_key,
|
||||
override_mode: item.override_mode,
|
||||
rollout_type: item.rollout_type,
|
||||
department_ids: item.department_ids,
|
||||
note: item.note,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function seedLeaveTypes(prisma) {
|
||||
const now = new Date();
|
||||
|
||||
@@ -345,8 +488,10 @@ async function main() {
|
||||
await seedExpenseTypes(prisma);
|
||||
await seedApprovalRules(prisma);
|
||||
await seedDocumentSequence(prisma);
|
||||
await seedDocumentCategories(prisma);
|
||||
await seedWorkflowDefinition(prisma);
|
||||
await seedLeaveTypes(prisma);
|
||||
await seedFeatureFlags(prisma);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Seeded config data:");
|
||||
@@ -357,11 +502,15 @@ async function main() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("- document sequence: reimbursement(reset_period=none)");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- document categories: ${DEFAULT_DOCUMENT_CATEGORIES.length} seeded`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("- workflow definition: reimbursement v1 (1-step role=ceo)");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("- workflow definition: leave v1 (1-step department_manager)");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- leave types: ${DEFAULT_LEAVE_TYPES.length} seeded with default policies`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
|
||||
@@ -33,6 +33,147 @@ async function ensureDepartment(prisma, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureLegalEntity(prisma, payload) {
|
||||
const now = new Date();
|
||||
const existing = await prisma.legalEntity.findUnique({
|
||||
where: { code: payload.code },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return prisma.legalEntity.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: payload.name,
|
||||
short_name: payload.shortName ?? null,
|
||||
currency_code: payload.currencyCode,
|
||||
tax_no: payload.taxNo ?? null,
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.legalEntity.create({
|
||||
data: {
|
||||
code: payload.code,
|
||||
name: payload.name,
|
||||
short_name: payload.shortName ?? null,
|
||||
currency_code: payload.currencyCode,
|
||||
tax_no: payload.taxNo ?? null,
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCostCenter(prisma, payload) {
|
||||
const now = new Date();
|
||||
const existing = await prisma.costCenter.findFirst({
|
||||
where: {
|
||||
legal_entity_id: payload.legalEntityId,
|
||||
code: payload.code,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return prisma.costCenter.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: payload.name,
|
||||
parent_id: payload.parentId ?? null,
|
||||
owner_employee_id: payload.ownerEmployeeId ?? null,
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.costCenter.create({
|
||||
data: {
|
||||
legal_entity_id: payload.legalEntityId,
|
||||
code: payload.code,
|
||||
name: payload.name,
|
||||
parent_id: payload.parentId ?? null,
|
||||
owner_employee_id: payload.ownerEmployeeId ?? null,
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureHolidayCalendar(prisma, payload) {
|
||||
const now = new Date();
|
||||
const existing = await prisma.holidayCalendar.findUnique({
|
||||
where: { code: payload.code },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const calendar = existing
|
||||
? await prisma.holidayCalendar.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: payload.name,
|
||||
timezone: payload.timezone ?? "Asia/Shanghai",
|
||||
legal_entity_id: payload.legalEntityId ?? null,
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
updated_at: now,
|
||||
},
|
||||
})
|
||||
: await prisma.holidayCalendar.create({
|
||||
data: {
|
||||
code: payload.code,
|
||||
name: payload.name,
|
||||
timezone: payload.timezone ?? "Asia/Shanghai",
|
||||
legal_entity_id: payload.legalEntityId ?? null,
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.holidayCalendarDay.deleteMany({
|
||||
where: { calendar_id: calendar.id },
|
||||
});
|
||||
|
||||
if (payload.days?.length) {
|
||||
await prisma.holidayCalendarDay.createMany({
|
||||
data: payload.days.map((item) => ({
|
||||
calendar_id: calendar.id,
|
||||
date: new Date(`${item.date}T00:00:00.000Z`),
|
||||
day_type: item.dayType,
|
||||
name: item.name,
|
||||
note: item.note ?? null,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.setAsDefault && payload.legalEntityId) {
|
||||
await prisma.legalEntity.update({
|
||||
where: { id: payload.legalEntityId },
|
||||
data: {
|
||||
default_holiday_calendar_id: calendar.id,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return calendar;
|
||||
}
|
||||
|
||||
async function ensureEmployee(prisma, payload) {
|
||||
const now = new Date();
|
||||
const passwordHash = await hashPassword(payload.password);
|
||||
@@ -50,6 +191,7 @@ async function ensureEmployee(prisma, payload) {
|
||||
name: payload.name,
|
||||
phone: payload.phone,
|
||||
department_id: payload.departmentId,
|
||||
legal_entity_id: payload.legalEntityId ?? null,
|
||||
title: payload.title,
|
||||
direct_manager_id: payload.directManagerId,
|
||||
hire_date: payload.hireDate,
|
||||
@@ -86,6 +228,7 @@ async function ensureEmployee(prisma, payload) {
|
||||
phone: payload.phone,
|
||||
email: payload.email,
|
||||
department_id: payload.departmentId,
|
||||
legal_entity_id: payload.legalEntityId ?? null,
|
||||
title: payload.title,
|
||||
direct_manager_id: payload.directManagerId,
|
||||
hire_date: payload.hireDate,
|
||||
@@ -141,6 +284,14 @@ async function main() {
|
||||
parent_id: rootDepartment.id,
|
||||
});
|
||||
|
||||
const legalEntity = await ensureLegalEntity(prisma, {
|
||||
code: "HYB_CN",
|
||||
name: "海音达科技有限公司",
|
||||
shortName: "海音达",
|
||||
currencyCode: "CNY",
|
||||
taxNo: null,
|
||||
});
|
||||
|
||||
const ceo = await ensureEmployee(prisma, {
|
||||
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "E0001",
|
||||
name: "CEO",
|
||||
@@ -148,6 +299,7 @@ async function main() {
|
||||
email: process.env.SEED_CEO_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234",
|
||||
departmentId: rootDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "总经理",
|
||||
directManagerId: null,
|
||||
roles: ["ceo", "employee"],
|
||||
@@ -161,6 +313,7 @@ async function main() {
|
||||
email: process.env.SEED_ADMIN_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234",
|
||||
departmentId: managementDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "系统管理员",
|
||||
directManagerId: ceo.id,
|
||||
roles: ["admin", "employee"],
|
||||
@@ -174,6 +327,7 @@ async function main() {
|
||||
email: process.env.SEED_MANAGER_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234",
|
||||
departmentId: rdDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "研发经理",
|
||||
directManagerId: ceo.id,
|
||||
roles: ["manager", "employee"],
|
||||
@@ -187,6 +341,7 @@ async function main() {
|
||||
email: process.env.SEED_EMPLOYEE_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234",
|
||||
departmentId: rdDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "研发工程师",
|
||||
directManagerId: manager.id,
|
||||
roles: ["employee"],
|
||||
@@ -200,6 +355,7 @@ async function main() {
|
||||
email: process.env.SEED_FINANCE_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234",
|
||||
departmentId: financeDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "财务专员",
|
||||
directManagerId: ceo.id,
|
||||
roles: ["finance", "employee"],
|
||||
@@ -213,6 +369,7 @@ async function main() {
|
||||
email: process.env.SEED_FINANCE_MGR_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234",
|
||||
departmentId: financeDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "财务主管",
|
||||
directManagerId: ceo.id,
|
||||
roles: ["finance", "finance_mgr", "employee"],
|
||||
@@ -226,6 +383,7 @@ async function main() {
|
||||
email: process.env.SEED_HR_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_HR_PASSWORD ?? "HrPassword1234",
|
||||
departmentId: hrDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "人事专员",
|
||||
directManagerId: ceo.id,
|
||||
roles: ["hr"],
|
||||
@@ -239,12 +397,51 @@ async function main() {
|
||||
email: process.env.SEED_ADMINISTRATION_EMAIL ?? "[email protected]",
|
||||
password: process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234",
|
||||
departmentId: administrationDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
title: "行政专员",
|
||||
directManagerId: manager.id,
|
||||
roles: ["administration"],
|
||||
hireDate,
|
||||
});
|
||||
|
||||
const holidayCalendar = await ensureHolidayCalendar(prisma, {
|
||||
code: "HYB_CN_STD",
|
||||
name: "海音达默认日历",
|
||||
timezone: "Asia/Shanghai",
|
||||
legalEntityId: legalEntity.id,
|
||||
setAsDefault: true,
|
||||
days: [
|
||||
{ date: "2026-05-04", dayType: "holiday", name: "公司调休假" },
|
||||
{ date: "2026-05-05", dayType: "holiday", name: "公司调休假" },
|
||||
{ date: "2026-05-09", dayType: "adjusted_workday", name: "周末补班" },
|
||||
],
|
||||
});
|
||||
|
||||
await ensureCostCenter(prisma, {
|
||||
legalEntityId: legalEntity.id,
|
||||
code: "MGMT",
|
||||
name: "管理中心",
|
||||
ownerEmployeeId: admin.id,
|
||||
});
|
||||
await ensureCostCenter(prisma, {
|
||||
legalEntityId: legalEntity.id,
|
||||
code: "FIN",
|
||||
name: "财务中心",
|
||||
ownerEmployeeId: financeMgr.id,
|
||||
});
|
||||
await ensureCostCenter(prisma, {
|
||||
legalEntityId: legalEntity.id,
|
||||
code: "HR",
|
||||
name: "人力中心",
|
||||
ownerEmployeeId: hr.id,
|
||||
});
|
||||
await ensureCostCenter(prisma, {
|
||||
legalEntityId: legalEntity.id,
|
||||
code: "RD",
|
||||
name: "研发中心",
|
||||
ownerEmployeeId: manager.id,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Seeded dev users:");
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -263,6 +460,10 @@ async function main() {
|
||||
console.log(`- HR: ${hr.email} / ${process.env.SEED_HR_PASSWORD ?? "HrPassword1234"}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- Administration: ${administration.email} / ${process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234"}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- Legal Entity: ${legalEntity.code} / ${legalEntity.name}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- HolidayCalendar: ${holidayCalendar.code} / ${holidayCalendar.name}`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const FEATURE_FLAG_KEYS = {
|
||||
erpSync: "erp_sync",
|
||||
invoiceVerification: "invoice_verification",
|
||||
leaveHolidayCalendar: "leave_holiday_calendar",
|
||||
};
|
||||
|
||||
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
|
||||
@@ -62,6 +63,10 @@ function resolveEnvGate(config, flagKey) {
|
||||
return Boolean(config.invoiceVerificationEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.leaveHolidayCalendar) {
|
||||
return Boolean(config.leaveHolidayCalendarEnabled);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,100 @@ function calculateRequestedDays({ startDate, endDate, startSession, endSession }
|
||||
return fromHalfDayUnits(endSlot - startSlot + 1);
|
||||
}
|
||||
|
||||
function isWeekendDate(dateValue) {
|
||||
const date = normalizeDateOnly(dateValue);
|
||||
const weekday = date.getUTCDay();
|
||||
return weekday === 0 || weekday === 6;
|
||||
}
|
||||
|
||||
function buildHolidayCalendarDayMap(calendarDays = []) {
|
||||
const map = new Map();
|
||||
for (const item of calendarDays) {
|
||||
const key = formatDateOnly(item.date);
|
||||
if (key) {
|
||||
map.set(key, item.day_type ?? item.dayType ?? null);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function isWorkingDateByCalendar(dateValue, calendarDayMap) {
|
||||
const dateKey = formatDateOnly(dateValue);
|
||||
const configuredType = dateKey ? calendarDayMap.get(dateKey) ?? null : null;
|
||||
|
||||
if (configuredType === "holiday") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (configuredType === "workday" || configuredType === "adjusted_workday") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isWeekendDate(dateValue);
|
||||
}
|
||||
|
||||
function countHalfDayUnitsForDate({ date, startDate, endDate, startSession, endSession }) {
|
||||
const normalizedDate = formatDateOnly(date);
|
||||
const startDateOnly = formatDateOnly(startDate);
|
||||
const endDateOnly = formatDateOnly(endDate);
|
||||
|
||||
if (normalizedDate === startDateOnly && normalizedDate === endDateOnly) {
|
||||
return toHalfDaySlot(endDate, endSession) - toHalfDaySlot(startDate, startSession) + 1;
|
||||
}
|
||||
|
||||
if (normalizedDate === startDateOnly) {
|
||||
return startSession === "pm" ? 1 : 2;
|
||||
}
|
||||
|
||||
if (normalizedDate === endDateOnly) {
|
||||
return endSession === "am" ? 1 : 2;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
function calculateRequestedDaysByWorkingCalendar({
|
||||
startDate,
|
||||
endDate,
|
||||
startSession,
|
||||
endSession,
|
||||
calendarDays = [],
|
||||
}) {
|
||||
const startSlot = toHalfDaySlot(startDate, startSession);
|
||||
const endSlot = toHalfDaySlot(endDate, endSession);
|
||||
|
||||
if (endSlot < startSlot) {
|
||||
throw buildLeaveValidationError({ leave: "invalid_range" });
|
||||
}
|
||||
|
||||
const calendarDayMap = buildHolidayCalendarDayMap(calendarDays);
|
||||
const current = normalizeDateOnly(startDate);
|
||||
const finalDate = normalizeDateOnly(endDate);
|
||||
let cursor = current;
|
||||
let totalUnits = 0;
|
||||
|
||||
while (cursor.getTime() <= finalDate.getTime()) {
|
||||
if (isWorkingDateByCalendar(cursor, calendarDayMap)) {
|
||||
totalUnits += countHalfDayUnitsForDate({
|
||||
date: cursor,
|
||||
startDate,
|
||||
endDate,
|
||||
startSession,
|
||||
endSession,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = new Date(`${formatDateOnly(cursor)}T00:00:00.000Z`);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
if (totalUnits <= 0) {
|
||||
throw buildLeaveValidationError({ leave: "non_working_range" });
|
||||
}
|
||||
|
||||
return fromHalfDayUnits(totalUnits);
|
||||
}
|
||||
|
||||
function leaveRangesOverlap(left, right) {
|
||||
const leftStart = toHalfDaySlot(left.startDate, left.startSession);
|
||||
const leftEnd = toHalfDaySlot(left.endDate, left.endSession);
|
||||
@@ -478,6 +572,7 @@ async function persistLeaveAttachment({ config, employeeId, filePart }) {
|
||||
module.exports = {
|
||||
DEFAULT_ATTACHMENT_MIME_TYPES,
|
||||
calculateRequestedDays,
|
||||
calculateRequestedDaysByWorkingCalendar,
|
||||
createLeaveLedgerEntry,
|
||||
consumeReservedLeave,
|
||||
currentLeavePeriod,
|
||||
|
||||
@@ -21,6 +21,14 @@ describe("Permission Constants", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["hr"]), "leave.approve")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
it("grants master data permissions to finance and finance manager roles", () => {
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "masterdata.view")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "masterdata.manage")).toBe(false);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "masterdata.manage")).toBe(true);
|
||||
expect(hasPermission(resolvePermissionsForRoles(["ceo"]), "masterdata.view")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants hr leave view-all and manage permissions", () => {
|
||||
const hrPermissions = resolvePermissionsForRoles(["hr"]);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("evaluateFeatureFlag", () => {
|
||||
it("returns env_hard_off when env gate is closed", async () => {
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma: buildPrisma(),
|
||||
config: { erpSyncEnabled: false, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: false, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("evaluateFeatureFlag", () => {
|
||||
it("returns missing_flag_record when record does not exist", async () => {
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma: buildPrisma(),
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("evaluateFeatureFlag", () => {
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("evaluateFeatureFlag", () => {
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
@@ -106,14 +106,14 @@ describe("evaluateFeatureFlag", () => {
|
||||
|
||||
const missingDepartment = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
const blockedDepartment = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: "dept-b",
|
||||
strictDepartment: true,
|
||||
@@ -121,7 +121,7 @@ describe("evaluateFeatureFlag", () => {
|
||||
|
||||
const allowedDepartment = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: "dept-a",
|
||||
strictDepartment: true,
|
||||
@@ -144,7 +144,7 @@ describe("evaluateFeatureFlag", () => {
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
strictDepartment: false,
|
||||
});
|
||||
@@ -164,7 +164,7 @@ describe("evaluateFeatureFlag", () => {
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
strictDepartment: true,
|
||||
});
|
||||
@@ -174,6 +174,19 @@ describe("evaluateFeatureFlag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("leave holiday calendar flag", () => {
|
||||
it("uses the dedicated env gate", async () => {
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma: buildPrisma(),
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: false },
|
||||
flagKey: FEATURE_FLAG_KEYS.leaveHolidayCalendar,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("env_hard_off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listFeatureFlags", () => {
|
||||
it("returns all known keys even if part of records are missing", async () => {
|
||||
const prisma = buildPrisma({
|
||||
@@ -188,13 +201,14 @@ describe("listFeatureFlags", () => {
|
||||
invoiceVerificationEnabled: true,
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items.map((item) => item.flagKey).sort()).toEqual([
|
||||
FEATURE_FLAG_KEYS.erpSync,
|
||||
FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
FEATURE_FLAG_KEYS.leaveHolidayCalendar,
|
||||
]);
|
||||
|
||||
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.invoiceVerification);
|
||||
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.leaveHolidayCalendar);
|
||||
expect(missingItem.id).toBeNull();
|
||||
expect(missingItem.effectiveEnabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("FeatureFlags Service", () => {
|
||||
it("returns env_hard_off when config disables it globally", async () => {
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: {},
|
||||
config: { invoiceVerificationEnabled: false },
|
||||
config: { invoiceVerificationEnabled: false, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
});
|
||||
expect(result.enabled).toBe(false);
|
||||
@@ -34,7 +34,7 @@ describe("FeatureFlags Service", () => {
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
config: { invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("FeatureFlags Service", () => {
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
config: { invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId: "dep_B",
|
||||
strictDepartment: true
|
||||
@@ -80,7 +80,7 @@ describe("FeatureFlags Service", () => {
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
config: { invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId: "dep_B",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const {
|
||||
calculateRequestedDays,
|
||||
calculateRequestedDaysByWorkingCalendar,
|
||||
leaveRangesOverlap,
|
||||
summarizeLeaveLedgerEntries,
|
||||
} = require("../../../src/services/leave");
|
||||
@@ -49,6 +50,54 @@ describe("Leave Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateRequestedDaysByWorkingCalendar", () => {
|
||||
it("skips weekend days by default and respects adjusted workdays", () => {
|
||||
expect(
|
||||
calculateRequestedDaysByWorkingCalendar({
|
||||
startDate: "2026-03-20",
|
||||
endDate: "2026-03-22",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
calendarDays: [],
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
expect(
|
||||
calculateRequestedDaysByWorkingCalendar({
|
||||
startDate: "2026-03-20",
|
||||
endDate: "2026-03-22",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
calendarDays: [{ date: "2026-03-21", day_type: "adjusted_workday" }],
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("skips configured holidays inside a weekday span", () => {
|
||||
expect(
|
||||
calculateRequestedDaysByWorkingCalendar({
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-03-17",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
calendarDays: [{ date: "2026-03-17", day_type: "holiday" }],
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects ranges that become fully non-working after calendar adjustment", () => {
|
||||
expect(() =>
|
||||
calculateRequestedDaysByWorkingCalendar({
|
||||
startDate: "2026-03-21",
|
||||
endDate: "2026-03-22",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
calendarDays: [],
|
||||
}),
|
||||
).toThrow(/参数校验失败/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaveRangesOverlap", () => {
|
||||
it("detects overlapping half-day ranges", () => {
|
||||
expect(
|
||||
|
||||
在新工单中引用
屏蔽一个用户