feat(P11-P12): 落地治理开关与移动端MVP(G0-G6/H1/M1-M4)
- 新增 Feature Flag 模型、评估服务与管理 API(erp_sync/invoice_verification)\n- 完成 ERP 与验真灰度收口(路由/服务/作业统一判定)\n- 补齐 pay 与手动 sync 的 department_id 灰度上下文\n- 增加管理后台开关页、PWA 基础设施与移动拍照/审批页面\n- 增加回归脚本、安全基线与压测脚本,更新文档与执行合同\n\nIssue: G0,G1,G2,G3,G4,G5,G6,H1,M1,M2,M3,M4
这个提交包含在:
+4316
-1
文件差异内容过多而无法显示
加载差异
+2
-1
@@ -32,6 +32,7 @@
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.1.0"
|
||||
"vite": "^5.1.0",
|
||||
"vite-plugin-pwa": "^0.20.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "hyb-oa 移动工作台",
|
||||
"short_name": "hyb-oa",
|
||||
"description": "海音达工作台移动端入口,支持离线拍照上传与移动审批。",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#2563eb",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 68 B |
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 68 B |
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 68 B |
@@ -0,0 +1,47 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type FeatureFlagKey = "erp_sync" | "invoice_verification";
|
||||
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
|
||||
export type FeatureFlagRolloutType = "global" | "department_allowlist";
|
||||
|
||||
export type FeatureFlagItem = {
|
||||
id: string | null;
|
||||
flagKey: FeatureFlagKey;
|
||||
overrideMode: FeatureFlagOverrideMode;
|
||||
rolloutType: FeatureFlagRolloutType;
|
||||
departmentIds: string[];
|
||||
note: string;
|
||||
version: number;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
envEnabled: boolean;
|
||||
effectiveEnabled: boolean;
|
||||
effectiveReason: string;
|
||||
matchedDepartmentId: string | null;
|
||||
requiresDepartment: boolean;
|
||||
};
|
||||
|
||||
export type FeatureFlagListResponse = {
|
||||
items: FeatureFlagItem[];
|
||||
};
|
||||
|
||||
export type FeatureFlagUpdatePayload = {
|
||||
overrideMode: FeatureFlagOverrideMode;
|
||||
rolloutType: FeatureFlagRolloutType;
|
||||
departmentIds: string[];
|
||||
note: string;
|
||||
expectedVersion: number;
|
||||
};
|
||||
|
||||
export async function fetchFeatureFlags() {
|
||||
return apiRequestData<FeatureFlagListResponse>("/api/system/feature-flags");
|
||||
}
|
||||
|
||||
export async function updateFeatureFlag(flagKey: FeatureFlagKey, payload: FeatureFlagUpdatePayload) {
|
||||
return apiRequestData<{ item: FeatureFlagItem }>(`/api/system/feature-flags/${flagKey}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,16 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
label: "票据中心",
|
||||
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
|
||||
},
|
||||
{
|
||||
to: "/mobile/capture",
|
||||
label: "移动拍照",
|
||||
anyOf: ["invoice.upload", "invoice.manage"],
|
||||
},
|
||||
{
|
||||
to: "/mobile/approvals",
|
||||
label: "移动审批",
|
||||
anyOf: ["expense.approve"],
|
||||
},
|
||||
{
|
||||
to: "/budgets",
|
||||
label: "预算管理",
|
||||
@@ -56,6 +66,11 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
label: "审批规则",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{
|
||||
to: "/system/feature-flags",
|
||||
label: "功能开关",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{ to: "/settings/profile", label: "个人设置" },
|
||||
];
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
import { queryClient } from "./query/queryClient";
|
||||
import { AppRouter } from "./router";
|
||||
import { registerPwaIfEnabled } from "./pwa";
|
||||
import "./index.css";
|
||||
|
||||
registerPwaIfEnabled();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export type MobileUploadQueueItem = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
dataUrl: string;
|
||||
ownerEmployeeId: string | null;
|
||||
};
|
||||
|
||||
const DB_NAME = "hyb-oa-mobile";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "invoice_upload_queue";
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function getDatabase() {
|
||||
if (!dbPromise) {
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
|
||||
});
|
||||
}
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function toDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("file_reader_invalid_result"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error("file_reader_failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fromDataUrl(dataUrl: string, fallbackFileName: string, fallbackMimeType = "application/octet-stream") {
|
||||
const match = dataUrl.match(/^data:(.*?);base64,(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error("invalid_data_url");
|
||||
}
|
||||
|
||||
const mimeType = match[1] || fallbackMimeType;
|
||||
const base64 = match[2];
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return new File([bytes], fallbackFileName, { type: mimeType });
|
||||
}
|
||||
|
||||
async function withStore<T>(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise<T>) {
|
||||
const db = await getDatabase();
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, mode);
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
executor(store)
|
||||
.then((result) => {
|
||||
tx.oncomplete = () => resolve(result);
|
||||
tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed"));
|
||||
})
|
||||
.catch((error) => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
function requestPromise<T>(request: IDBRequest<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed"));
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueInvoiceUpload(file: File, ownerEmployeeId: string | null = null) {
|
||||
const dataUrl = await toDataUrl(file);
|
||||
const item: MobileUploadQueueItem = {
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
createdAt: new Date().toISOString(),
|
||||
dataUrl,
|
||||
ownerEmployeeId,
|
||||
};
|
||||
|
||||
await withStore("readwrite", async (store) => {
|
||||
await requestPromise(store.put(item));
|
||||
return true;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function listQueuedInvoiceUploads() {
|
||||
return withStore("readonly", async (store) => {
|
||||
const records = (await requestPromise(store.getAll())) as MobileUploadQueueItem[];
|
||||
return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeQueuedInvoiceUpload(id: string) {
|
||||
await withStore("readwrite", async (store) => {
|
||||
await requestPromise(store.delete(id));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function queuedItemToFile(item: MobileUploadQueueItem) {
|
||||
return fromDataUrl(item.dataUrl, item.fileName, item.mimeType);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchDepartments } from "../api/departments";
|
||||
import {
|
||||
fetchFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
type FeatureFlagItem,
|
||||
type FeatureFlagOverrideMode,
|
||||
type FeatureFlagRolloutType,
|
||||
} from "../api/featureFlags";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
|
||||
const OVERRIDE_MODE_OPTIONS: Array<{ value: FeatureFlagOverrideMode; label: string }> = [
|
||||
{ value: "force_off", label: "强制关闭" },
|
||||
{ value: "inherit", label: "继承Env" },
|
||||
{ value: "force_on", label: "强制开启" },
|
||||
];
|
||||
|
||||
const ROLLOUT_TYPE_OPTIONS: Array<{ value: FeatureFlagRolloutType; label: string }> = [
|
||||
{ value: "global", label: "全量" },
|
||||
{ value: "department_allowlist", label: "部门白名单" },
|
||||
];
|
||||
|
||||
function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "erp_sync") return "ERP 同步";
|
||||
if (flagKey === "invoice_verification") return "发票验真";
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
function dedupeIds(items: string[]) {
|
||||
return Array.from(new Set(items.filter(Boolean)));
|
||||
}
|
||||
|
||||
type DraftState = {
|
||||
overrideMode: FeatureFlagOverrideMode;
|
||||
rolloutType: FeatureFlagRolloutType;
|
||||
departmentIds: string[];
|
||||
note: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
function buildDraft(item: FeatureFlagItem): DraftState {
|
||||
return {
|
||||
overrideMode: item.overrideMode,
|
||||
rolloutType: item.rolloutType,
|
||||
departmentIds: item.departmentIds,
|
||||
note: item.note ?? "",
|
||||
version: item.version,
|
||||
};
|
||||
}
|
||||
|
||||
export function FeatureFlagsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState("");
|
||||
const [drafts, setDrafts] = useState<Record<string, DraftState>>({});
|
||||
|
||||
const flagsQuery = useQuery({
|
||||
queryKey: ["feature-flags"],
|
||||
queryFn: () => fetchFeatureFlags(),
|
||||
});
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
queryKey: ["departments", "feature-flags", "active"],
|
||||
queryFn: () => fetchDepartments({ includeArchived: false }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (flagKey: string) => {
|
||||
const draft = drafts[flagKey];
|
||||
if (!draft) {
|
||||
throw new Error("未找到待提交草稿");
|
||||
}
|
||||
|
||||
return updateFeatureFlag(flagKey as "erp_sync" | "invoice_verification", {
|
||||
overrideMode: draft.overrideMode,
|
||||
rolloutType: draft.rolloutType,
|
||||
departmentIds:
|
||||
draft.rolloutType === "department_allowlist" ? dedupeIds(draft.departmentIds) : [],
|
||||
note: draft.note.trim(),
|
||||
expectedVersion: draft.version,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["feature-flags"] });
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "更新开关失败");
|
||||
},
|
||||
});
|
||||
|
||||
const departmentsById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of departmentsQuery.data?.items ?? []) {
|
||||
map.set(item.id, item.name);
|
||||
}
|
||||
return map;
|
||||
}, [departmentsQuery.data?.items]);
|
||||
|
||||
const items = flagsQuery.data?.items ?? [];
|
||||
|
||||
const ensureDraft = (item: FeatureFlagItem) => {
|
||||
if (drafts[item.flagKey]) {
|
||||
return drafts[item.flagKey];
|
||||
}
|
||||
return buildDraft(item);
|
||||
};
|
||||
|
||||
const patchDraft = (flagKey: string, updater: (prev: DraftState) => DraftState) => {
|
||||
setDrafts((prev) => {
|
||||
const base = prev[flagKey];
|
||||
const next = updater(base ?? buildDraft(items.find((item) => item.flagKey === flagKey)!));
|
||||
return {
|
||||
...prev,
|
||||
[flagKey]: next,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="功能开关"
|
||||
subtitle="统一治理 ERP 与验真灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<Card className="space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">开关列表</h3>
|
||||
|
||||
{flagsQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
||||
|
||||
{flagsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(flagsQuery.error)
|
||||
? `${flagsQuery.error.code}: ${mapApiErrorMessage(
|
||||
flagsQuery.error.code,
|
||||
flagsQuery.error.details,
|
||||
flagsQuery.error.message,
|
||||
)}`
|
||||
: "加载功能开关失败"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!flagsQuery.isLoading && !flagsQuery.isError ? (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table data-testid="feature-flags-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>功能</TableHead>
|
||||
<TableHead>当前状态</TableHead>
|
||||
<TableHead>覆盖策略</TableHead>
|
||||
<TableHead>灰度范围</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead className="w-40">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const draft = ensureDraft(item);
|
||||
const isPending = updateMutation.isPending && updateMutation.variables === item.flagKey;
|
||||
|
||||
return (
|
||||
<TableRow key={item.flagKey} data-testid={`feature-flag-row-${item.flagKey}`}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-slate-900">{toDisplayName(item.flagKey)}</div>
|
||||
<div className="font-mono text-xs text-slate-500">{item.flagKey}</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
<div>
|
||||
Env:
|
||||
<span className={item.envEnabled ? "text-emerald-600" : "text-red-600"}>
|
||||
{item.envEnabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Effective:
|
||||
<span className={item.effectiveEnabled ? "text-emerald-600" : "text-red-600"}>
|
||||
{item.effectiveEnabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">reason: {item.effectiveReason}</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="space-y-2 text-sm">
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-slate-500">override</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
value={draft.overrideMode}
|
||||
onChange={(event) =>
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
...prev,
|
||||
overrideMode: event.target.value as FeatureFlagOverrideMode,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{OVERRIDE_MODE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-slate-500">rollout</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
value={draft.rolloutType}
|
||||
onChange={(event) =>
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
...prev,
|
||||
rolloutType: event.target.value as FeatureFlagRolloutType,
|
||||
departmentIds:
|
||||
event.target.value === "department_allowlist"
|
||||
? prev.departmentIds
|
||||
: [],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{ROLLOUT_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{draft.rolloutType === "department_allowlist" ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
const selected = event.target.value;
|
||||
if (!selected) return;
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
...prev,
|
||||
departmentIds: dedupeIds([...prev.departmentIds, selected]),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">添加部门</option>
|
||||
{(departmentsQuery.data?.items ?? []).map((department) => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="max-h-24 overflow-auto rounded border border-slate-200 p-2 text-xs">
|
||||
{draft.departmentIds.length ? (
|
||||
draft.departmentIds.map((departmentId) => (
|
||||
<div
|
||||
key={departmentId}
|
||||
className="mb-1 flex items-center justify-between rounded bg-slate-50 px-2 py-1"
|
||||
>
|
||||
<span>{departmentsById.get(departmentId) ?? departmentId}</span>
|
||||
<button
|
||||
className="text-red-600"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
...prev,
|
||||
departmentIds: prev.departmentIds.filter((id) => id !== departmentId),
|
||||
}))
|
||||
}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-slate-500">暂无白名单部门</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">全量</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
placeholder="填写本次变更说明"
|
||||
value={draft.note}
|
||||
onChange={(event) =>
|
||||
patchDraft(item.flagKey, (prev) => ({
|
||||
...prev,
|
||||
note: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">version: {draft.version}</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
data-testid={`feature-flag-save-${item.flagKey}`}
|
||||
disabled={isPending}
|
||||
onClick={() => updateMutation.mutate(item.flagKey)}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
data-testid={`feature-flag-reset-${item.flagKey}`}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[item.flagKey]: buildDraft(item),
|
||||
}))
|
||||
}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import {
|
||||
approveWorkflowTask,
|
||||
fetchWorkflowApprovers,
|
||||
fetchWorkflowTaskDetail,
|
||||
fetchWorkflowTasks,
|
||||
rejectWorkflowTask,
|
||||
returnWorkflowTask,
|
||||
transferWorkflowTask,
|
||||
} from "../api/workflow";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
type MobileAction = "approve" | "reject" | "return" | "transfer";
|
||||
|
||||
export function MobileApprovalsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTaskId, setSelectedTaskId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [transferToApproverId, setTransferToApproverId] = useState("");
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["mobile", "workflow", "tasks"],
|
||||
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 30 }),
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(selectedTaskId),
|
||||
queryKey: ["mobile", "workflow", "task-detail", selectedTaskId],
|
||||
queryFn: () => fetchWorkflowTaskDetail(selectedTaskId),
|
||||
});
|
||||
|
||||
const approversQuery = useQuery({
|
||||
enabled: detailQuery.data?.allowedActions?.includes("transfer") ?? false,
|
||||
queryKey: ["mobile", "workflow", "approvers"],
|
||||
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }),
|
||||
});
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: async (action: MobileAction) => {
|
||||
if (!selectedTaskId) {
|
||||
throw new Error("请选择任务");
|
||||
}
|
||||
|
||||
if (action === "approve") {
|
||||
return approveWorkflowTask(selectedTaskId);
|
||||
}
|
||||
|
||||
if (action === "reject") {
|
||||
if (!comment.trim()) {
|
||||
throw new Error("驳回必须填写意见");
|
||||
}
|
||||
return rejectWorkflowTask(selectedTaskId, comment.trim());
|
||||
}
|
||||
|
||||
if (action === "return") {
|
||||
if (!comment.trim()) {
|
||||
throw new Error("退回必须填写意见");
|
||||
}
|
||||
return returnWorkflowTask(selectedTaskId, comment.trim());
|
||||
}
|
||||
|
||||
if (!transferToApproverId) {
|
||||
throw new Error("请选择转审目标审批人");
|
||||
}
|
||||
if (transferReason.trim().length < 10) {
|
||||
throw new Error("转审理由至少 10 个字");
|
||||
}
|
||||
|
||||
return transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim());
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }),
|
||||
]);
|
||||
setComment("");
|
||||
setTransferReason("");
|
||||
setTransferToApproverId("");
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : "动作执行失败");
|
||||
},
|
||||
});
|
||||
|
||||
const pendingTasks = tasksQuery.data?.items ?? [];
|
||||
const selectedTask = detailQuery.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="移动审批" subtitle="小屏快速处理待办任务。" />
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<Card className="space-y-3 p-4" data-testid="mobile-approvals-list">
|
||||
<h3 className="text-base font-semibold text-slate-900">待办任务</h3>
|
||||
|
||||
{tasksQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
||||
|
||||
{!tasksQuery.isLoading && pendingTasks.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无待办任务。</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{pendingTasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm ${
|
||||
selectedTaskId === task.id
|
||||
? "border-primary bg-primary-50 text-primary"
|
||||
: "border-slate-200 bg-white text-slate-800"
|
||||
}`}
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="font-medium">{task.stepName}</div>
|
||||
<div className="text-xs text-slate-500">任务ID: {task.id}</div>
|
||||
<div className="text-xs text-slate-500">状态: {task.status}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||
<h3 className="text-base font-semibold text-slate-900">任务详情与动作</h3>
|
||||
|
||||
{!selectedTaskId ? <p className="text-sm text-slate-500">请先选择任务。</p> : null}
|
||||
|
||||
{selectedTaskId && detailQuery.isLoading ? (
|
||||
<p className="text-sm text-slate-500">加载任务详情中...</p>
|
||||
) : null}
|
||||
|
||||
{selectedTask && (
|
||||
<>
|
||||
<div className="rounded-lg border border-slate-200 p-3 text-sm">
|
||||
<div>任务状态:{selectedTask.task.status}</div>
|
||||
<div>步骤:{selectedTask.task.stepName}</div>
|
||||
<div>报销单号:{selectedTask.reimbursement?.formNumber ?? "-"}</div>
|
||||
<div>金额:{selectedTask.reimbursement?.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{selectedTask.reimbursement?.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-comment"
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="驳回/退回意见"
|
||||
rows={3}
|
||||
value={comment}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => actionMutation.mutate("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => actionMutation.mutate("reject")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => actionMutation.mutate("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3 text-sm">
|
||||
<p className="font-medium text-slate-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
value={transferToApproverId}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||
<option key={approver.id} value={approver.id}>
|
||||
{approver.name} ({approver.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { uploadInvoice } from "../api/invoicePool";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import {
|
||||
enqueueInvoiceUpload,
|
||||
listQueuedInvoiceUploads,
|
||||
queuedItemToFile,
|
||||
removeQueuedInvoiceUpload,
|
||||
type MobileUploadQueueItem,
|
||||
} from "../mobile/uploadQueue";
|
||||
|
||||
function formatTime(value: string) {
|
||||
try {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyNetworkError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return error.status >= 500;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function MobileCapturePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
|
||||
|
||||
const queueQuery = useQuery({
|
||||
queryKey: ["mobile", "invoice-upload-queue"],
|
||||
queryFn: () => listQueuedInvoiceUploads(),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => uploadInvoice(file, "vat"),
|
||||
onSuccess: () => {
|
||||
setSelectedFile(null);
|
||||
setError("");
|
||||
setMessage("上传成功");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : "上传失败");
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: async (items: MobileUploadQueueItem[]) => {
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const file = queuedItemToFile(item);
|
||||
await uploadInvoice(file, "vat");
|
||||
await removeQueuedInvoiceUpload(item.id);
|
||||
succeeded += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { succeeded, failed };
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
|
||||
setError("");
|
||||
setMessage(`队列同步完成:成功 ${result.succeeded},失败 ${result.failed}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "队列同步失败");
|
||||
},
|
||||
});
|
||||
|
||||
const queuedItems = queueQuery.data ?? [];
|
||||
|
||||
const queuedCountLabel = useMemo(() => {
|
||||
if (!queuedItems.length) return "无待上传项";
|
||||
return `待上传 ${queuedItems.length} 项`;
|
||||
}, [queuedItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => {
|
||||
setIsOnline(true);
|
||||
if (queuedItems.length) {
|
||||
syncMutation.mutate(queuedItems);
|
||||
}
|
||||
};
|
||||
|
||||
const onOffline = () => {
|
||||
setIsOnline(false);
|
||||
};
|
||||
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("offline", onOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("offline", onOffline);
|
||||
};
|
||||
}, [queuedItems, syncMutation]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="移动拍照上传"
|
||||
subtitle="支持离线缓存与联网自动重放。"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
<Card className="space-y-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">拍照/选择文件</h3>
|
||||
<span className={isOnline ? "text-emerald-600" : "text-orange-600"}>
|
||||
{isOnline ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
accept="image/*,application/pdf"
|
||||
capture="environment"
|
||||
className="w-full text-sm"
|
||||
data-testid="mobile-capture-file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
setSelectedFile(file);
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div className="text-sm text-slate-600">
|
||||
当前文件:{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="mobile-capture-upload"
|
||||
disabled={uploadMutation.isPending || !selectedFile}
|
||||
onClick={async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setMessage("");
|
||||
setError("");
|
||||
|
||||
if (!isOnline) {
|
||||
await enqueueInvoiceUpload(selectedFile);
|
||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
|
||||
setSelectedFile(null);
|
||||
setMessage("当前离线,已加入待上传队列");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadMutation.mutateAsync(selectedFile);
|
||||
} catch (err) {
|
||||
if (isLikelyNetworkError(err)) {
|
||||
await enqueueInvoiceUpload(selectedFile);
|
||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
|
||||
setSelectedFile(null);
|
||||
setError("");
|
||||
setMessage("上传失败,已加入离线队列等待重试");
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{uploadMutation.isPending ? "上传中..." : "立即上传"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
data-testid="mobile-capture-sync-queue"
|
||||
disabled={!queuedItems.length || syncMutation.isPending || !isOnline}
|
||||
onClick={() => syncMutation.mutate(queuedItems)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{syncMutation.isPending ? "同步中..." : "同步队列"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-3 p-4" data-testid="mobile-capture-queue">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">离线上传队列</h3>
|
||||
<span className="text-sm text-slate-500">{queuedCountLabel}</span>
|
||||
</div>
|
||||
|
||||
{queueQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
||||
|
||||
{!queueQuery.isLoading && queuedItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无待上传文件。</p>
|
||||
) : null}
|
||||
|
||||
{queuedItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<div className="font-medium text-slate-800">{item.fileName}</div>
|
||||
<div className="text-slate-500">创建时间:{formatTime(item.createdAt)}</div>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
data-testid={`mobile-capture-remove-${item.id}`}
|
||||
onClick={async () => {
|
||||
await removeQueuedInvoiceUpload(item.id);
|
||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
export function registerPwaIfEnabled() {
|
||||
if (import.meta.env.DEV) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_PWA_ENABLED === "false") {
|
||||
return;
|
||||
}
|
||||
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onOfflineReady() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,12 +3,15 @@ import { AppShellLayout } from "../layouts/AppShellLayout";
|
||||
import { AuthLayout } from "../layouts/AuthLayout";
|
||||
import { AuditLogsPage } from "../pages/AuditLogsPage";
|
||||
import { ApprovalRulesPage } from "../pages/ApprovalRulesPage";
|
||||
import { FeatureFlagsPage } from "../pages/FeatureFlagsPage";
|
||||
import { ChangePasswordPage } from "../pages/ChangePasswordPage";
|
||||
import { BudgetsPage } from "../pages/BudgetsPage";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { DepartmentsPage } from "../pages/DepartmentsPage";
|
||||
import { DelegationsPage } from "../pages/DelegationsPage";
|
||||
import { ForbiddenPage } from "../pages/ForbiddenPage";
|
||||
import { MobileApprovalsPage } from "../pages/MobileApprovalsPage";
|
||||
import { MobileCapturePage } from "../pages/MobileCapturePage";
|
||||
import { InvoicePoolPage } from "../pages/InvoicePoolPage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
@@ -111,6 +114,24 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/capture"
|
||||
element={
|
||||
<RequirePermission anyOf={["invoice.upload", "invoice.manage"]}>
|
||||
<MobileCapturePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/approvals"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.approve"]}>
|
||||
<MobileApprovalsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
|
||||
<Route
|
||||
@@ -158,6 +179,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/feature-flags"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
<FeatureFlagsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="settings/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
第三方依赖
+1
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
+49
-1
@@ -1,8 +1,56 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
disable: false,
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["pwa-192x192.png", "pwa-512x512.png", "pwa-maskable-512x512.png"],
|
||||
manifest: {
|
||||
name: "hyb-oa 移动工作台",
|
||||
short_name: "hyb-oa",
|
||||
description: "海音达工作台移动端入口,支持离线拍照上传与移动审批。",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#2563eb",
|
||||
icons: [
|
||||
{
|
||||
src: "pwa-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-maskable-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "/index.html",
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,webmanifest}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
||||
+16
-4
@@ -1,8 +1,8 @@
|
||||
# hyb-oa 实现状态矩阵(P5-P10)
|
||||
# hyb-oa 实现状态矩阵(P5-P12)
|
||||
|
||||
**快照日期**:2026-02-10
|
||||
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1`,共 80 项。
|
||||
**事实源**:`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`(执行合同)
|
||||
**快照日期**:2026-02-11
|
||||
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4`,共 92 项。
|
||||
**事实源**:`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/*.csv` 的执行合同职责。
|
||||
@@ -93,3 +93,15 @@
|
||||
| P10M1 | invoicePool二阶段拆分(读路径) | §16(维护性治理) | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10 | DONE | node --check invoicePool 主/子模块 | none |
|
||||
| P10M2 | invoicePool二阶段拆分回归与基线更新 | §16(回归与文档) | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10 | DONE | server/scripts/regression-verification.sh + regression-workflow-actions.sh | none |
|
||||
| P10G1 | 矩阵与执行合同同步到P10 | §16(状态治理) | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10_10-35-30-p10-production-hardening.csv | 2026-02-10 | DONE | scripts/check-minimal.sh | 覆盖 P5-P10 全量 |
|
||||
| G0 | P11/P12执行合同建档 | §16(执行合同治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual | none |
|
||||
| G1 | Feature Flag模型与评估服务 | §16(灰度治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | cd server && npm run prisma:validate | none |
|
||||
| G2 | 开关管理API与审计 | §16(灰度治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | node --check server/src/routes/featureFlags.js | none |
|
||||
| G3 | ERP灰度接入(pay+手动sync) | §16(外部集成灰度) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/regression-integrations.sh | 补齐department_id上下文 |
|
||||
| G4 | 验真开关统一收口(路由+服务+作业) | §16(验真灰度) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/regression-verification.sh | 判定统一收口 |
|
||||
| G5 | 管理后台开关页 | §16(运营治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | client:typecheck/build | none |
|
||||
| G6 | 回归脚本与文档收口 | §16(回归工程化) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/regression-feature-flags.sh | none |
|
||||
| H1 | 安全与压测门槛 | §16(非功能治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | server/scripts/security-baseline.sh + benchmark-dashboard.sh | none |
|
||||
| M1 | PWA基础设施 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | client:typecheck/build | none |
|
||||
| M2 | 移动拍照与离线上传队列 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | iOS前台恢复重放 |
|
||||
| M3 | 移动审批快捷处理 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | none |
|
||||
| M4 | P12验收与收口 | §16(状态治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | docs/mobile/pwa-regression-checklist.md | none |
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# PWA 回归清单(P12)
|
||||
|
||||
## 前置条件
|
||||
- `client` 已完成构建:`cd client && npm run build`
|
||||
- 浏览器支持 PWA(Chrome/Safari 最新版)
|
||||
- 服务端 API 可用
|
||||
|
||||
## 场景 A:安装与离线壳
|
||||
1. 打开系统首页,确认可出现安装入口(浏览器原生 UI)。
|
||||
2. 安装到桌面后重新打开,确认可进入系统壳页面。
|
||||
3. 断网后刷新,确认仍可打开壳页面(离线壳生效)。
|
||||
|
||||
期望:离线壳可访问,不应白屏崩溃。
|
||||
|
||||
## 场景 B:移动拍照离线上传
|
||||
1. 访问 `移动拍照` 页面。
|
||||
2. 断网,选择图片文件,点击“立即上传”。
|
||||
3. 确认提示“已加入待上传队列”。
|
||||
4. 恢复网络,点击“同步队列”或等待自动重放。
|
||||
5. 进入票据中心确认文件已上传。
|
||||
|
||||
期望:离线缓存成功,联网可重放成功。
|
||||
|
||||
## 场景 C:移动审批快捷动作
|
||||
1. 使用审批角色访问 `移动审批` 页面。
|
||||
2. 选择待办任务,依次测试 `通过` / `驳回` / `退回` / `转审`。
|
||||
3. 返回待办列表与详情页确认状态同步刷新。
|
||||
|
||||
期望:动作闭环,错误提示可读。
|
||||
|
||||
## 场景 D:权限与边界
|
||||
1. 非审批角色访问 `移动审批`,应被拒绝(403页)。
|
||||
2. 非上传角色访问 `移动拍照`,应被拒绝(403页)。
|
||||
|
||||
期望:权限守卫生效。
|
||||
|
||||
## 备注
|
||||
- iOS 场景仅保证前台恢复后的重放,不承诺后台持续同步。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 非功能基线(P10.5)
|
||||
|
||||
更新时间:2026-02-11
|
||||
|
||||
## 安全基线
|
||||
- 使用 `server/scripts/security-baseline.sh` 检查关键安全配置与核心路由语法。
|
||||
- 发布前要求:
|
||||
- `JWT_SECRET` 非默认值。
|
||||
- `ERP_SYNC_ENABLED` / `INVOICE_VERIFICATION_ENABLED` 通过 Feature Flag 治理,默认保持关闭。
|
||||
- `npm audit --omit=dev --audit-level=high` 输出需评审并记录处理策略。
|
||||
|
||||
## 性能基线(Dashboard)
|
||||
- 使用 `server/scripts/benchmark-dashboard.sh` 对以下端点做基线采样:
|
||||
- `/api/dashboard/expense-overview`
|
||||
- `/api/dashboard/trends`
|
||||
- `/api/dashboard/top-categories`
|
||||
- `/api/dashboard/approval-efficiency`
|
||||
- 默认采样次数:`ITERATIONS=10`。
|
||||
- 建议阈值(测试环境):
|
||||
- 平均响应(avg)< 500ms
|
||||
- 最大响应(max)< 1500ms
|
||||
|
||||
## 回滚原则
|
||||
- 若非功能指标异常,先执行配置回滚而非代码回滚:
|
||||
1. 将 `erp_sync` 和 `invoice_verification` 设为 `force_off`。
|
||||
2. 必要时将 env 硬闸 `ERP_SYNC_ENABLED=false`、`INVOICE_VERIFICATION_ENABLED=false`。
|
||||
- 保留审计与历史数据,不做 destructive 数据回滚。
|
||||
@@ -0,0 +1,13 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
G0,P11/P12执行合同建档,创建并校验P11-P12执行合同CSV且冻结依赖关系,CSV通过校验且字段满足规范,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-11-p11-p12-governance-mobile.csv,manual,DONE,DONE,DONE,issues/2026-02-11-p11-p12-governance-mobile.csv,[],none
|
||||
G1,Feature Flag模型与评估服务,新增feature_flags数据模型并实现统一评估服务与env硬闸融合,支持erp_sync/invoice_verification两键评估且默认fail-closed,cd server && npm run prisma:validate && node --check src/services/featureFlags/index.js && node --check src/config.js,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/prisma/migrations/* | server/src/services/featureFlags/index.js | server/src/config.js | server/.env.example,"[""G0""]",默认两键force_off作为首期安全基线
|
||||
G2,开关管理API与审计,新增system.settings可访问的Feature Flag查询与更新API并写审计,GET/PUT接口可用且变更具备审计记录,cd server && node --check src/routes/featureFlags.js && node --check src/app.js,manual,DONE,DONE,DONE,server/src/routes/featureFlags.js | server/src/app.js | server/src/routes/audit.js | server/src/utils/audit.js,"[""G1""]",并发更新使用expectedVersion乐观锁
|
||||
G3,ERP灰度接入(pay+手动sync),pay自动入队与手动ERP同步统一走feature flag并按department_id灰度,pay成功语义不变且非灰度部门阻断手动sync并跳过自动入队,cd server && ./scripts/regression-integrations.sh,manual,DONE,DONE,DONE,server/src/routes/reimbursements.js | server/src/routes/integrations.js | server/src/services/integrations/erp.js | server/src/services/featureFlags/index.js,"[""G2""]",需补齐reimbursement.department_id查询字段
|
||||
G4,验真开关统一收口(路由+服务+作业),验真判定统一收敛到services/verification/index并覆盖路由与worker,verify/retry/worker均按统一判定执行且关闭时落verification_disabled_by_flag,cd server && ./scripts/regression-verification.sh,manual,DONE,DONE,DONE,server/src/routes/invoicePool/verification.js | server/src/services/verification/index.js | server/src/jobs/verificationJob.js | server/src/jobs/pgBossJobs.js | server/src/services/verification/tax.js | server/src/services/featureFlags/index.js,"[""G2""]",移除分散的config.invoiceVerificationEnabled直接判断
|
||||
G5,管理后台开关页,新增Feature Flags管理页面并接入权限与导航,system.settings角色可查看并更新开关且前端构建通过,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/FeatureFlagsPage.tsx | client/src/api/featureFlags.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx,"[""G2""]",展示effective状态与灰度范围
|
||||
G6,回归脚本与文档收口,新增feature flag回归脚本并接入总回归与README和矩阵,脚本可重复执行且check-minimal保持通过,cd server && ./scripts/regression-feature-flags.sh && cd /home/shunli/Projects/hyb-oa && ./scripts/check-minimal.sh,manual,DONE,DONE,DONE,server/scripts/regression-feature-flags.sh | server/scripts/regression-all.sh | server/README.md | docs/feature-matrix.md,"[""G3"",""G4"",""G5""]",回滚通过RUN_FEATURE_FLAGS_REGRESSION=0
|
||||
H1,安全与压测门槛,新增安全基线检查与dashboard压测脚本并形成文档基线,脚本输出可复用且阈值说明可追溯,cd server && ./scripts/security-baseline.sh && ./scripts/benchmark-dashboard.sh,manual,DONE,DONE,DONE,server/scripts/security-baseline.sh | server/scripts/benchmark-dashboard.sh | docs/ops/non-functional-baseline.md,"[""G6""]",默认先观测不阻断
|
||||
M1,PWA基础设施,在现有client启用PWA并新增manifest与SW注册开关,支持安装与离线壳打开且构建通过,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/vite.config.ts | client/src/main.tsx | client/public/manifest.webmanifest,"[""H1""]",默认仅缓存静态壳与离线fallback
|
||||
M2,移动拍照与离线上传队列,新增移动拍照上传页及indexeddb离线重放队列,断网缓存并在联网后自动重放上传成功,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/MobileCapturePage.tsx | client/src/mobile/uploadQueue.ts | client/src/api/invoicePool.ts | client/src/router/index.tsx,"[""M1""]",iOS只保证前台恢复后重放
|
||||
M3,移动审批快捷处理,新增移动审批列表与详情快捷动作页复用workflow接口,移动端可完成approve/reject/return/transfer闭环,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/MobileApprovalsPage.tsx | client/src/api/workflow.ts | client/src/router/index.tsx,"[""M1""]",保持现有权限守卫策略
|
||||
M4,P12验收与收口,补充PWA手工回归清单并同步矩阵与执行合同状态,checklist完整且P12状态可审计追溯,manual: 按 docs/mobile/pwa-regression-checklist.md 执行离线上传与移动审批全流程,manual,DONE,DONE,DONE,docs/mobile/pwa-regression-checklist.md | docs/feature-matrix.md | issues/2026-02-11-p11-p12-governance-mobile.csv,"[""M2"",""M3""]",无自动化时必须写可复现手工步骤
|
||||
|
@@ -28,6 +28,7 @@ echo "=== [2/4] Server Static Checks ==="
|
||||
node --check src/routes/dashboard.js
|
||||
node --check src/routes/budgets.js
|
||||
node --check src/routes/integrations.js
|
||||
node --check src/routes/featureFlags.js
|
||||
node --check src/routes/travelOrders.js
|
||||
node --check src/routes/notifications.js
|
||||
|
||||
@@ -40,6 +41,7 @@ echo "=== [2/4] Server Static Checks ==="
|
||||
|
||||
node --check src/services/ocr/index.js
|
||||
node --check src/services/verification/index.js
|
||||
node --check src/services/featureFlags/index.js
|
||||
node --check src/services/integrations/outbox.js
|
||||
node --check src/services/integrations/erp.js
|
||||
node --check src/services/integrations/dispatcher.js
|
||||
@@ -53,6 +55,9 @@ echo "=== [2/4] Server Static Checks ==="
|
||||
bash -n scripts/regression-dashboard.sh
|
||||
bash -n scripts/regression-budget.sh
|
||||
bash -n scripts/regression-integrations.sh
|
||||
bash -n scripts/regression-feature-flags.sh
|
||||
bash -n scripts/security-baseline.sh
|
||||
bash -n scripts/benchmark-dashboard.sh
|
||||
bash -n scripts/regression-all.sh
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ CSV_FACT_SOURCES = [
|
||||
ROOT / "issues" / "2026-02-09_23-30-00-p6-p8-remaining-work-master.csv",
|
||||
ROOT / "issues" / "2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv",
|
||||
ROOT / "issues" / "2026-02-10_10-35-30-p10-production-hardening.csv",
|
||||
ROOT / "issues" / "2026-02-11-p11-p12-governance-mobile.csv",
|
||||
]
|
||||
|
||||
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z]*$")
|
||||
|
||||
@@ -57,6 +57,7 @@ OCR_TIMEOUT_MS="5000"
|
||||
|
||||
# ===== 发票验真(P6)=====
|
||||
INVOICE_VERIFICATION_ENABLED="false"
|
||||
# INVOICE_VERIFICATION_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
|
||||
VERIFICATION_PROVIDER="mock" # mock|tax
|
||||
VERIFICATION_POLL_CRON="* * * * *"
|
||||
VERIFICATION_MAX_ATTEMPTS="3"
|
||||
@@ -73,6 +74,7 @@ TENCENT_OCR_REGION="ap-beijing"
|
||||
# ===== 预算与集成(P7-P8)=====
|
||||
BUDGET_CONTROL_MODE="warn" # warn|block
|
||||
ERP_SYNC_ENABLED="false"
|
||||
# ERP_SYNC_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
|
||||
ERP_SYNC_PROVIDER="mock" # mock|http
|
||||
ERP_SYNC_ENDPOINT=""
|
||||
ERP_SYNC_TIMEOUT_MS="5000"
|
||||
|
||||
+10
@@ -459,6 +459,14 @@ npm run job:ocr-once
|
||||
- `TRAVEL_SYNC_ENABLED=false`
|
||||
- `IM_NOTIFIER_ENABLED=false`
|
||||
|
||||
### 9.1.1 Feature Flag 治理(P11)
|
||||
- 新增统一管理 API:
|
||||
- `GET /api/system/feature-flags`
|
||||
- `PUT /api/system/feature-flags/:key`
|
||||
- 首期纳管键:`erp_sync`、`invoice_verification`。
|
||||
- 优先级规则:`Env硬闸` > `DB在线灰度`。即使 DB 放开,若 Env 为 false 仍会关闭。
|
||||
- 部门灰度模式:`rolloutType=department_allowlist` 时,仅白名单部门生效。
|
||||
|
||||
建议:先在测试组织灰度开启,再逐步扩大范围。
|
||||
|
||||
### 9.2 新增 API 摘要
|
||||
@@ -489,6 +497,7 @@ cd /home/shunli/Projects/hyb-oa/server
|
||||
./scripts/regression-dashboard.sh
|
||||
./scripts/regression-budget.sh
|
||||
./scripts/regression-integrations.sh
|
||||
./scripts/regression-feature-flags.sh
|
||||
```
|
||||
|
||||
可通过总回归开关启用:
|
||||
@@ -498,6 +507,7 @@ RUN_VERIFICATION_REGRESSION=1 \
|
||||
RUN_DASHBOARD_REGRESSION=1 \
|
||||
RUN_BUDGET_REGRESSION=1 \
|
||||
RUN_INTEGRATIONS_REGRESSION=1 \
|
||||
RUN_FEATURE_FLAGS_REGRESSION=1 \
|
||||
./scripts/regression-all.sh
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE "feature_flags" (
|
||||
"id" UUID NOT NULL,
|
||||
"flag_key" VARCHAR(64) NOT NULL,
|
||||
"override_mode" VARCHAR(16) NOT NULL DEFAULT 'force_off',
|
||||
"rollout_type" VARCHAR(32) NOT NULL DEFAULT 'global',
|
||||
"department_ids" JSONB,
|
||||
"note" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "feature_flags_flag_key_key" ON "feature_flags"("flag_key");
|
||||
CREATE INDEX "idx_feature_flags_key" ON "feature_flags"("flag_key");
|
||||
|
||||
INSERT INTO "feature_flags" (
|
||||
"id",
|
||||
"flag_key",
|
||||
"override_mode",
|
||||
"rollout_type",
|
||||
"department_ids",
|
||||
"note",
|
||||
"version",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
) VALUES
|
||||
('6eb0ff4b-912a-4d19-8e4f-c2365604a200'::uuid, 'erp_sync', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('6eb0ff4b-912a-4d19-8e4f-c2365604a201'::uuid, 'invoice_verification', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
@@ -608,6 +608,24 @@ model BudgetLedgerEntry {
|
||||
@@map("budget_ledger_entries")
|
||||
}
|
||||
|
||||
model FeatureFlag {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
flag_key String @unique @db.VarChar(64)
|
||||
override_mode String @default("force_off") @db.VarChar(16)
|
||||
rollout_type String @default("global") @db.VarChar(32)
|
||||
department_ids Json?
|
||||
note String?
|
||||
version Int @default(1)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
@@index([flag_key], map: "idx_feature_flags_key")
|
||||
@@map("feature_flags")
|
||||
}
|
||||
|
||||
model IntegrationOutbox {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
event_type String @db.VarChar(64)
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-benchmark-dashboard}"
|
||||
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-finance_mgr@hyinda.com}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-FinanceMgrPassword1234}"
|
||||
ITERATIONS="${ITERATIONS:-10}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[ERROR] jq not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
login() {
|
||||
local out_file="$1"
|
||||
local status
|
||||
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
|
||||
-d "{\"identifier\":\"$ADMIN_IDENTIFIER\",\"password\":\"$ADMIN_PASSWORD\"}")"
|
||||
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "[ERROR] login failed: HTTP $status" >&2
|
||||
cat "$out_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -er '.data.accessToken' "$out_file"
|
||||
}
|
||||
|
||||
benchmark_endpoint() {
|
||||
local endpoint="$1"
|
||||
local token="$2"
|
||||
|
||||
local total_ms=0
|
||||
local max_ms=0
|
||||
local min_ms=999999
|
||||
|
||||
for _ in $(seq 1 "$ITERATIONS"); do
|
||||
local body_file="$TMP_DIR/$(basename "$endpoint").json"
|
||||
local elapsed
|
||||
elapsed="$(curl -sS -o "$body_file" -w "%{time_total}" \
|
||||
-H "authorization: Bearer $token" \
|
||||
"$BASE_URL$endpoint")"
|
||||
|
||||
local elapsed_ms
|
||||
elapsed_ms="$(python3 - <<PY
|
||||
value = float("$elapsed")
|
||||
print(int(round(value * 1000)))
|
||||
PY
|
||||
)"
|
||||
|
||||
total_ms=$((total_ms + elapsed_ms))
|
||||
if (( elapsed_ms > max_ms )); then
|
||||
max_ms=$elapsed_ms
|
||||
fi
|
||||
if (( elapsed_ms < min_ms )); then
|
||||
min_ms=$elapsed_ms
|
||||
fi
|
||||
done
|
||||
|
||||
local avg_ms=$((total_ms / ITERATIONS))
|
||||
echo "$endpoint avg=${avg_ms}ms min=${min_ms}ms max=${max_ms}ms iterations=$ITERATIONS"
|
||||
}
|
||||
|
||||
echo "[1/2] Login"
|
||||
TOKEN="$(login "$TMP_DIR/login.json")"
|
||||
|
||||
echo "[2/2] Dashboard endpoint benchmarks"
|
||||
benchmark_endpoint "/api/dashboard/expense-overview" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/trends" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/top-categories" "$TOKEN"
|
||||
benchmark_endpoint "/api/dashboard/approval-efficiency" "$TOKEN"
|
||||
|
||||
echo "[OK] Dashboard benchmark completed"
|
||||
@@ -10,6 +10,7 @@ RUN_VERIFICATION_REGRESSION="${RUN_VERIFICATION_REGRESSION:-0}"
|
||||
RUN_DASHBOARD_REGRESSION="${RUN_DASHBOARD_REGRESSION:-0}"
|
||||
RUN_BUDGET_REGRESSION="${RUN_BUDGET_REGRESSION:-0}"
|
||||
RUN_INTEGRATIONS_REGRESSION="${RUN_INTEGRATIONS_REGRESSION:-0}"
|
||||
RUN_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}"
|
||||
|
||||
echo "=== [1/7] Smoke Test ==="
|
||||
"$SCRIPT_DIR/smoke-test.sh"
|
||||
@@ -95,4 +96,13 @@ else
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_FEATURE_FLAGS_REGRESSION" == "1" ]]; then
|
||||
echo "=== [12/12] Feature Flags Regression ==="
|
||||
"$SCRIPT_DIR/regression-feature-flags.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [12/12] Feature Flags Regression (skipped, RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "[OK] All regressions passed"
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-feature-flags}"
|
||||
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
|
||||
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "[ERROR] jq not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
local body_file="${4:-}"
|
||||
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
||||
if [[ -n "$body_file" && -f "$body_file" ]]; then
|
||||
cat "$body_file" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local out_file="$3"
|
||||
|
||||
local status
|
||||
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
|
||||
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
|
||||
assert_status "$status" "200" "login" "$out_file"
|
||||
jq -er '.data.accessToken' "$out_file"
|
||||
}
|
||||
|
||||
api_json() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local token="$3"
|
||||
local out_file="$4"
|
||||
local body="${5:-}"
|
||||
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token" \
|
||||
-H "content-type: application/json" \
|
||||
-d "$body"
|
||||
else
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/4] Login finance_mgr"
|
||||
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
|
||||
|
||||
echo "[2/4] List feature flags"
|
||||
status="$(api_json GET "$BASE_URL/api/system/feature-flags" "$TOKEN" "$TMP_DIR/list.json")"
|
||||
assert_status "$status" "200" "feature-flags list" "$TMP_DIR/list.json"
|
||||
jq -e '.data.items | length >= 2' "$TMP_DIR/list.json" >/dev/null
|
||||
|
||||
ERP_VERSION="$(jq -er '.data.items[] | select(.flagKey=="erp_sync") | .version' "$TMP_DIR/list.json")"
|
||||
|
||||
echo "[3/4] Update erp_sync to force_off/global"
|
||||
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/update.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"regression-force-off\",\"expectedVersion\":$ERP_VERSION}")"
|
||||
assert_status "$status" "200" "feature-flags update" "$TMP_DIR/update.json"
|
||||
jq -e '.data.item.flagKey == "erp_sync"' "$TMP_DIR/update.json" >/dev/null
|
||||
jq -e '.data.item.overrideMode == "force_off"' "$TMP_DIR/update.json" >/dev/null
|
||||
|
||||
echo "[4/4] Verify stale version conflict"
|
||||
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/conflict.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"stale-version\",\"expectedVersion\":$ERP_VERSION}")"
|
||||
if [[ "$status" != "409" ]]; then
|
||||
echo "[ERROR] expected version conflict 409 but got $status" >&2
|
||||
cat "$TMP_DIR/conflict.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] Feature flags regression passed"
|
||||
@@ -292,6 +292,50 @@ run_outbox_once() {
|
||||
npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log"
|
||||
}
|
||||
|
||||
set_erp_flag() {
|
||||
local mode="$1"
|
||||
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("./src/prismaClient");
|
||||
|
||||
(async () => {
|
||||
const mode = process.env.MODE;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "erp_sync" },
|
||||
create: {
|
||||
flag_key: "erp_sync",
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-integrations",
|
||||
},
|
||||
update: {
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-integrations",
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[0/6] Ensure ERP feature flag force_on"
|
||||
set_erp_flag "force_on"
|
||||
|
||||
assert_mode_semantics() {
|
||||
local mode="$1"
|
||||
local state_json="$2"
|
||||
@@ -373,3 +417,6 @@ run_mode_case "timeout"
|
||||
|
||||
echo "[6/6] Done"
|
||||
echo "[OK] Integrations regression passed"
|
||||
|
||||
echo "[post] Reset ERP feature flag force_off"
|
||||
set_erp_flag "force_off"
|
||||
|
||||
@@ -153,6 +153,50 @@ const { getConfig } = require('./src/config');
|
||||
NODE
|
||||
}
|
||||
|
||||
set_verification_flag() {
|
||||
local mode="$1"
|
||||
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("./src/prismaClient");
|
||||
|
||||
(async () => {
|
||||
const mode = process.env.MODE;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "invoice_verification" },
|
||||
create: {
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-verification",
|
||||
},
|
||||
update: {
|
||||
override_mode: mode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "regression-verification",
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[0/6] Ensure verification feature flag force_on"
|
||||
set_verification_flag "force_on"
|
||||
|
||||
echo "[1/6] Login finance_mgr"
|
||||
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
|
||||
|
||||
@@ -184,3 +228,6 @@ if [[ "$status" != "409" ]]; then
|
||||
fi
|
||||
|
||||
echo "[OK] Verification regression passed"
|
||||
|
||||
echo "[post] Reset verification feature flag force_off"
|
||||
set_verification_flag "force_off"
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "[1/3] Check critical env defaults"
|
||||
if [[ "${JWT_SECRET:-}" == "change_me" ]]; then
|
||||
echo "[WARN] JWT_SECRET should not be change_me in non-dev environments"
|
||||
fi
|
||||
|
||||
if [[ "${ERP_SYNC_ENABLED:-false}" != "false" ]]; then
|
||||
echo "[WARN] ERP_SYNC_ENABLED is not false; ensure feature-flag governance is enabled"
|
||||
fi
|
||||
|
||||
if [[ "${INVOICE_VERIFICATION_ENABLED:-false}" != "false" ]]; then
|
||||
echo "[WARN] INVOICE_VERIFICATION_ENABLED is not false; ensure rollout is controlled"
|
||||
fi
|
||||
|
||||
echo "[2/3] Basic static hardening checks"
|
||||
node --check src/routes/auth.js
|
||||
node --check src/routes/integrations.js
|
||||
node --check src/routes/invoicePool/verification.js
|
||||
node --check src/services/featureFlags/index.js
|
||||
|
||||
echo "[3/3] Dependency audit (non-blocking)"
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm audit --omit=dev --audit-level=high || true
|
||||
fi
|
||||
|
||||
echo "[OK] Security baseline script completed"
|
||||
@@ -28,6 +28,7 @@ const employeesRoutes = require("./routes/employees");
|
||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||
const configReadRoutes = require("./routes/configRead");
|
||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||
const featureFlagsRoutes = require("./routes/featureFlags");
|
||||
const notificationsRoutes = require("./routes/notifications");
|
||||
const exportsRoutes = require("./routes/exports");
|
||||
const dashboardRoutes = require("./routes/dashboard");
|
||||
@@ -229,6 +230,7 @@ async function buildApp() {
|
||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||
await app.register(configReadRoutes, { prefix: "/api" });
|
||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||
await app.register(dashboardRoutes, { prefix: "/api" });
|
||||
await app.register(budgetsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -91,6 +91,7 @@ function getConfig() {
|
||||
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
|
||||
|
||||
invoiceVerificationEnabled: process.env.INVOICE_VERIFICATION_ENABLED === "true",
|
||||
invoiceVerificationFlagEnv: process.env.INVOICE_VERIFICATION_ENABLED ?? "false",
|
||||
verificationProvider: process.env.VERIFICATION_PROVIDER ?? "mock",
|
||||
verificationPollCron: process.env.VERIFICATION_POLL_CRON ?? "* * * * *",
|
||||
verificationMaxAttempts: Number(process.env.VERIFICATION_MAX_ATTEMPTS ?? 3),
|
||||
@@ -102,6 +103,7 @@ function getConfig() {
|
||||
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
|
||||
|
||||
erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true",
|
||||
erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false",
|
||||
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
|
||||
erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "",
|
||||
erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000),
|
||||
|
||||
@@ -5,6 +5,10 @@ const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
||||
const { runOcrOnce } = require("./ocrJob");
|
||||
const { runVerificationForRecord } = require("./verificationJob");
|
||||
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
|
||||
const {
|
||||
FEATURE_FLAG_KEYS,
|
||||
evaluateFeatureFlag,
|
||||
} = require("../services/featureFlags");
|
||||
|
||||
const JOB_OCR_TICK = "ocr.tick";
|
||||
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
|
||||
@@ -267,7 +271,14 @@ async function startPgBossJobs(fastify) {
|
||||
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
|
||||
}
|
||||
|
||||
if (fastify.config.invoiceVerificationEnabled) {
|
||||
const verificationScheduling = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
if (verificationScheduling.enabled) {
|
||||
await boss.schedule(JOB_INVOICE_VERIFY, fastify.config.verificationPollCron, null, {
|
||||
tz: fastify.config.appTimezone,
|
||||
singletonKey: JOB_INVOICE_VERIFY,
|
||||
@@ -288,7 +299,8 @@ async function startPgBossJobs(fastify) {
|
||||
schema: fastify.config.pgBossSchema,
|
||||
ocrProvider: fastify.config.ocrProvider,
|
||||
ocrCron: fastify.config.ocrPollCron,
|
||||
verificationEnabled: fastify.config.invoiceVerificationEnabled,
|
||||
verificationEnabled: verificationScheduling.enabled,
|
||||
verificationFlagReason: verificationScheduling.reason,
|
||||
verificationCron: fastify.config.verificationPollCron,
|
||||
delegationCron: fastify.config.delegationRevokeCron,
|
||||
invoiceArchiveCron: fastify.config.invoiceArchiveCron,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const { verifyInvoiceByProvider } = require("../services/verification");
|
||||
const {
|
||||
evaluateVerificationAccess,
|
||||
verifyInvoiceByProvider,
|
||||
} = require("../services/verification");
|
||||
|
||||
function toResultStatus(status) {
|
||||
if (["passed", "failed", "error"].includes(status)) {
|
||||
@@ -32,6 +35,16 @@ async function runVerificationForRecord(prisma, config, recordId) {
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
status: true,
|
||||
owner_employee: {
|
||||
select: {
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -54,6 +67,37 @@ async function runVerificationForRecord(prisma, config, recordId) {
|
||||
return { skipped: true, reason: "status_not_pending" };
|
||||
}
|
||||
|
||||
const departmentId =
|
||||
record.invoice.linked_reimbursement?.department_id ??
|
||||
record.invoice.owner_employee?.department_id ??
|
||||
null;
|
||||
|
||||
const access = await evaluateVerificationAccess({
|
||||
prisma: tx,
|
||||
config,
|
||||
departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!access.enabled) {
|
||||
await tx.invoiceVerificationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: {
|
||||
status: "error",
|
||||
error_code: "verification_disabled_by_flag",
|
||||
error_message: access.reason,
|
||||
completed_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
status: "error",
|
||||
durationMs: 0,
|
||||
resultCode: "verification_disabled_by_flag",
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const verification = await verifyInvoiceByProvider({
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const {
|
||||
FEATURE_FLAG_KEY_LIST,
|
||||
OVERRIDE_MODE_LIST,
|
||||
ROLLOUT_TYPE_LIST,
|
||||
evaluateFeatureFlag,
|
||||
listFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
} = require("../services/featureFlags");
|
||||
|
||||
const KEY_PARAM_SCHEMA = z.object({
|
||||
key: z.enum(FEATURE_FLAG_KEY_LIST),
|
||||
});
|
||||
|
||||
const UPDATE_BODY_SCHEMA = z.object({
|
||||
overrideMode: z.enum(OVERRIDE_MODE_LIST),
|
||||
rolloutType: z.enum(ROLLOUT_TYPE_LIST),
|
||||
departmentIds: z.array(z.string().uuid()).default([]),
|
||||
note: z.string().trim().max(500).optional().default(""),
|
||||
expectedVersion: z.coerce.number().int().min(1),
|
||||
});
|
||||
|
||||
function parseKey(params) {
|
||||
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data.key;
|
||||
}
|
||||
|
||||
function parseUpdateBody(body) {
|
||||
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { departmentIds: "rollout_type_global_disallows_department_ids" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function featureFlagsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/system/feature-flags",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const items = await listFeatureFlags(fastify.prisma, fastify.config);
|
||||
return sendData(reply, request, { items });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
"/system/feature-flags/:key",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const key = parseKey(request.params);
|
||||
const body = parseUpdateBody(request.body);
|
||||
|
||||
const result = await updateFeatureFlag(fastify.prisma, {
|
||||
flagKey: key,
|
||||
overrideMode: body.overrideMode,
|
||||
rolloutType: body.rolloutType,
|
||||
departmentIds: body.departmentIds,
|
||||
note: body.note,
|
||||
expectedVersion: body.expectedVersion,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
|
||||
if (result.status === "not_found" || !result.current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === "version_conflict") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
featureFlag: "version_conflict",
|
||||
expectedVersion: body.expectedVersion,
|
||||
currentVersion: result.current.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status !== "updated" || !result.updated) {
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
targetType: "feature_flag",
|
||||
targetId: result.updated.id,
|
||||
extra: {
|
||||
flagKey: key,
|
||||
from: {
|
||||
overrideMode: result.current.overrideMode,
|
||||
rolloutType: result.current.rolloutType,
|
||||
departmentIds: result.current.departmentIds,
|
||||
note: result.current.note,
|
||||
version: result.current.version,
|
||||
},
|
||||
to: {
|
||||
overrideMode: result.updated.overrideMode,
|
||||
rolloutType: result.updated.rolloutType,
|
||||
departmentIds: result.updated.departmentIds,
|
||||
note: result.updated.note,
|
||||
version: result.updated.version,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const effective = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: key,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
...result.updated,
|
||||
envEnabled: effective.envEnabled,
|
||||
effectiveEnabled: effective.enabled,
|
||||
effectiveReason: effective.reason,
|
||||
matchedDepartmentId: effective.matchedDepartmentId,
|
||||
requiresDepartment: effective.requiresDepartment,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = featureFlagsRoutes;
|
||||
@@ -5,6 +5,7 @@ const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
|
||||
|
||||
async function integrationsRoutes(fastify) {
|
||||
@@ -14,15 +15,6 @@ async function integrationsRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("integration.manage"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!fastify.config.erpSyncEnabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { integration: "erp_sync_disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
@@ -42,6 +34,7 @@ async function integrationsRoutes(fastify) {
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
@@ -67,6 +60,27 @@ async function integrationsRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!erpGate.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = buildErpPayload({ reimbursement });
|
||||
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
@@ -85,6 +99,9 @@ async function integrationsRoutes(fastify) {
|
||||
extra: {
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
featureFlagReason: erpGate.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,12 +3,87 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../../utils/AppError");
|
||||
const { sendData } = require("../../utils/reply");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { getProvider, isVerificationEnabled } = require("../../services/verification");
|
||||
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
|
||||
|
||||
const { mapVerificationRecord } = require("./shared/mappers");
|
||||
const { parseVerificationListQuery } = require("./shared/schemas");
|
||||
const { triggerVerificationJob } = require("./shared/helpers");
|
||||
|
||||
function throwVerificationDisabled(reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled", featureFlag: reason },
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
|
||||
const invoice = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
owner_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const departmentId =
|
||||
invoice.linked_reimbursement?.department_id ??
|
||||
invoice.owner_employee?.department_id ??
|
||||
request.employee.department_id ??
|
||||
null;
|
||||
|
||||
return {
|
||||
invoice,
|
||||
departmentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId }) {
|
||||
const context = await resolveVerificationDepartmentContext(tx, request, invoiceId);
|
||||
const evaluation = await evaluateVerificationAccess({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
departmentId: context.departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!evaluation.enabled) {
|
||||
throwVerificationDisabled(evaluation.reason);
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
evaluation,
|
||||
};
|
||||
}
|
||||
|
||||
function registerInvoiceVerificationRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/invoice-pool/:id/verify",
|
||||
@@ -16,15 +91,6 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
@@ -54,24 +120,12 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
},
|
||||
const { invoice, departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
||||
tx,
|
||||
fastify,
|
||||
request,
|
||||
invoiceId,
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const latest = await tx.invoiceVerificationRecord.findFirst({
|
||||
where: { invoice_id: invoiceId },
|
||||
@@ -116,6 +170,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
invoiceCode: invoice.invoice_code ?? null,
|
||||
invoiceNumber: invoice.invoice_number ?? null,
|
||||
totalAmount: invoice.total_amount?.toString?.() ?? null,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
requested_by: request.employee.id,
|
||||
requested_at: new Date(now),
|
||||
@@ -135,6 +191,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
extra: {
|
||||
verificationId: created.id,
|
||||
provider,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -142,6 +200,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
return {
|
||||
verificationId: created.id,
|
||||
provider,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -157,6 +217,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
extra: {
|
||||
verificationId: response.verificationId,
|
||||
status: trigger.result?.status ?? null,
|
||||
departmentId: response.departmentId,
|
||||
featureFlagReason: response.featureFlagReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -259,15 +321,6 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
@@ -282,6 +335,13 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
const provider = getProvider(fastify.config);
|
||||
|
||||
const created = await fastify.prisma.$transaction(async (tx) => {
|
||||
const { departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
||||
tx,
|
||||
fastify,
|
||||
request,
|
||||
invoiceId,
|
||||
});
|
||||
|
||||
const latest = await tx.invoiceVerificationRecord.findFirst({
|
||||
where: { invoice_id: invoiceId },
|
||||
orderBy: { created_at: "desc" },
|
||||
@@ -344,6 +404,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
requested_at: new Date(),
|
||||
request_payload: {
|
||||
retryOfId: latest.id,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -361,6 +423,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
verificationId: createdRecord.id,
|
||||
retryOfId: latest.id,
|
||||
attempts,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
resolveActionableStep,
|
||||
@@ -1691,7 +1692,7 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, status, form_number, owner_id, submitter_id,
|
||||
SELECT id, status, form_number, owner_id, submitter_id, department_id,
|
||||
payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
@@ -1772,6 +1773,7 @@ async function reimbursementRoutes(fastify) {
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
@@ -1779,7 +1781,18 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
if (refreshed && fastify.config.erpSyncEnabled) {
|
||||
if (refreshed) {
|
||||
const departmentId =
|
||||
refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null;
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (erpGate.enabled) {
|
||||
await enqueueIntegrationEvent(tx, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
dedupeKey: refreshed.id,
|
||||
@@ -1787,6 +1800,21 @@ async function reimbursementRoutes(fastify) {
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
} else {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: refreshed.id,
|
||||
extra: {
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
const FEATURE_FLAG_KEYS = {
|
||||
erpSync: "erp_sync",
|
||||
invoiceVerification: "invoice_verification",
|
||||
};
|
||||
|
||||
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
|
||||
const OVERRIDE_MODE_LIST = Object.freeze(["inherit", "force_on", "force_off"]);
|
||||
const ROLLOUT_TYPE_LIST = Object.freeze(["global", "department_allowlist"]);
|
||||
|
||||
function isKnownFeatureFlagKey(flagKey) {
|
||||
return FEATURE_FLAG_KEY_LIST.includes(flagKey);
|
||||
}
|
||||
|
||||
function normalizeOverrideMode(value) {
|
||||
const mode = String(value ?? "force_off").trim().toLowerCase();
|
||||
if (OVERRIDE_MODE_LIST.includes(mode)) {
|
||||
return mode;
|
||||
}
|
||||
return "force_off";
|
||||
}
|
||||
|
||||
function normalizeRolloutType(value) {
|
||||
const type = String(value ?? "global").trim().toLowerCase();
|
||||
if (ROLLOUT_TYPE_LIST.includes(type)) {
|
||||
return type;
|
||||
}
|
||||
return "global";
|
||||
}
|
||||
|
||||
function sanitizeDepartmentIds(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const id of value) {
|
||||
if (typeof id !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.has(trimmed)) {
|
||||
seen.add(trimmed);
|
||||
items.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function resolveEnvGate(config, flagKey) {
|
||||
if (flagKey === FEATURE_FLAG_KEYS.erpSync) {
|
||||
return Boolean(config.erpSyncEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.invoiceVerification) {
|
||||
return Boolean(config.invoiceVerificationEnabled);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapFlagRecord(record) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const departmentIds = sanitizeDepartmentIds(record.department_ids);
|
||||
const overrideMode = normalizeOverrideMode(record.override_mode);
|
||||
const rolloutType = normalizeRolloutType(record.rollout_type);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
flagKey: record.flag_key,
|
||||
overrideMode,
|
||||
rolloutType,
|
||||
departmentIds,
|
||||
note: record.note ?? "",
|
||||
version: Number(record.version ?? 1),
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
createdBy: record.created_by ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: record.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultRecord(flagKey) {
|
||||
return {
|
||||
id: null,
|
||||
flagKey,
|
||||
overrideMode: "force_off",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
createdAt: null,
|
||||
createdBy: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
function withEvaluation(base, evaluation) {
|
||||
return {
|
||||
...base,
|
||||
envEnabled: evaluation.envEnabled,
|
||||
effectiveEnabled: evaluation.enabled,
|
||||
effectiveReason: evaluation.reason,
|
||||
matchedDepartmentId: evaluation.matchedDepartmentId,
|
||||
requiresDepartment: evaluation.requiresDepartment,
|
||||
};
|
||||
}
|
||||
|
||||
async function getFlagRecord(prisma, flagKey) {
|
||||
const row = await prisma.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
return mapFlagRecord(row);
|
||||
}
|
||||
|
||||
async function evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey,
|
||||
departmentId = null,
|
||||
strictDepartment = true,
|
||||
}) {
|
||||
const envEnabled = resolveEnvGate(config, flagKey);
|
||||
const normalizedDepartmentId =
|
||||
typeof departmentId === "string" && departmentId.trim() ? departmentId.trim() : null;
|
||||
|
||||
if (!envEnabled) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "env_hard_off",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await getFlagRecord(prisma, flagKey);
|
||||
if (!record) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "missing_flag_record",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!OVERRIDE_MODE_LIST.includes(record.overrideMode)) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "invalid_override_mode",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!ROLLOUT_TYPE_LIST.includes(record.rolloutType)) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "invalid_rollout_type",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.overrideMode === "force_off") {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "flag_force_off",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const modeEnabled = record.overrideMode === "force_on" || record.overrideMode === "inherit";
|
||||
if (!modeEnabled) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "mode_not_enabled",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.rolloutType === "global") {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason: record.overrideMode === "inherit" ? "env_inherit_global_on" : "flag_force_on_global",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const allowlist = record.departmentIds;
|
||||
if (!allowlist.length) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_allowlist_empty",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalizedDepartmentId) {
|
||||
if (strictDepartment) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_missing",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason: "department_deferred",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
const matched = allowlist.includes(normalizedDepartmentId);
|
||||
if (!matched) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_not_allowed",
|
||||
envEnabled,
|
||||
matchedDepartmentId: normalizedDepartmentId,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason:
|
||||
record.overrideMode === "inherit"
|
||||
? "env_inherit_department_allowed"
|
||||
: "flag_force_on_department_allowed",
|
||||
envEnabled,
|
||||
matchedDepartmentId: normalizedDepartmentId,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function listFeatureFlags(prisma, config) {
|
||||
const rows = await prisma.featureFlag.findMany({
|
||||
where: {
|
||||
flag_key: { in: FEATURE_FLAG_KEY_LIST },
|
||||
},
|
||||
orderBy: { flag_key: "asc" },
|
||||
});
|
||||
|
||||
const recordMap = new Map(rows.map((row) => [row.flag_key, mapFlagRecord(row)]));
|
||||
|
||||
const items = [];
|
||||
for (const flagKey of FEATURE_FLAG_KEY_LIST) {
|
||||
const base = recordMap.get(flagKey) ?? buildDefaultRecord(flagKey);
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey,
|
||||
departmentId: null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
items.push(withEvaluation(base, evaluation));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function updateFeatureFlag(prisma, input) {
|
||||
const flagKey = String(input.flagKey ?? "").trim();
|
||||
if (!isKnownFeatureFlagKey(flagKey)) {
|
||||
return {
|
||||
status: "not_found",
|
||||
current: null,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const overrideMode = normalizeOverrideMode(input.overrideMode);
|
||||
const rolloutType = normalizeRolloutType(input.rolloutType);
|
||||
const departmentIds =
|
||||
rolloutType === "department_allowlist" ? sanitizeDepartmentIds(input.departmentIds) : [];
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const currentRow = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
if (!currentRow) {
|
||||
return {
|
||||
status: "not_found",
|
||||
current: null,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const current = mapFlagRecord(currentRow);
|
||||
|
||||
if (Number(input.expectedVersion) !== current.version) {
|
||||
return {
|
||||
status: "version_conflict",
|
||||
current,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updateResult = await tx.featureFlag.updateMany({
|
||||
where: {
|
||||
id: current.id,
|
||||
version: current.version,
|
||||
},
|
||||
data: {
|
||||
override_mode: overrideMode,
|
||||
rollout_type: rolloutType,
|
||||
department_ids: departmentIds,
|
||||
note: input.note ?? "",
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
updated_by: input.actorId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (updateResult.count !== 1) {
|
||||
const latest = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
return {
|
||||
status: "version_conflict",
|
||||
current: mapFlagRecord(latest),
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedRow = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
return {
|
||||
status: "updated",
|
||||
current,
|
||||
updated: mapFlagRecord(updatedRow),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FEATURE_FLAG_KEYS,
|
||||
FEATURE_FLAG_KEY_LIST,
|
||||
OVERRIDE_MODE_LIST,
|
||||
ROLLOUT_TYPE_LIST,
|
||||
isKnownFeatureFlagKey,
|
||||
sanitizeDepartmentIds,
|
||||
evaluateFeatureFlag,
|
||||
listFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { runMockVerification } = require("./mock");
|
||||
const { runTaxVerification } = require("./tax");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
|
||||
|
||||
function getProvider(config) {
|
||||
return String(config.verificationProvider ?? "mock").trim().toLowerCase();
|
||||
@@ -9,6 +10,28 @@ function isVerificationEnabled(config) {
|
||||
return Boolean(config.invoiceVerificationEnabled);
|
||||
}
|
||||
|
||||
async function evaluateVerificationAccess({
|
||||
prisma,
|
||||
config,
|
||||
departmentId = null,
|
||||
strictDepartment = true,
|
||||
}) {
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId,
|
||||
strictDepartment,
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: evaluation.enabled,
|
||||
reason: evaluation.reason,
|
||||
matchedDepartmentId: evaluation.matchedDepartmentId,
|
||||
envEnabled: evaluation.envEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyInvoiceByProvider({ config, invoice }) {
|
||||
const provider = getProvider(config);
|
||||
|
||||
@@ -33,5 +56,6 @@ async function verifyInvoiceByProvider({ config, invoice }) {
|
||||
module.exports = {
|
||||
getProvider,
|
||||
isVerificationEnabled,
|
||||
evaluateVerificationAccess,
|
||||
verifyInvoiceByProvider,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
async function runTaxVerification({ config, invoice }) {
|
||||
if (!config.invoiceVerificationEnabled) {
|
||||
return {
|
||||
status: "error",
|
||||
resultCode: "verification_disabled",
|
||||
resultMessage: "invoice verification disabled",
|
||||
payload: {
|
||||
provider: "tax",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
resultCode: "tax_provider_not_configured",
|
||||
|
||||
在新工单中引用
屏蔽一个用户