feat(P20T1-1,P20T1-2,P20T1-3): 差旅模型、申请/订单接口与工作流分支

这个提交包含在:
2026-03-10 19:58:46 +08:00
父节点 427ae2db53
当前提交 7b78945112
共修改 19 个文件,包含 2266 行新增和 59 行删除
@@ -0,0 +1,120 @@
-- AlterEnum
ALTER TYPE "DocumentCategoryEntityType" ADD VALUE IF NOT EXISTS 'travel_request';
-- CreateEnum
CREATE TYPE "TravelRequestStatus" AS ENUM ('draft', 'reviewing', 'approved', 'rejected');
-- CreateEnum
CREATE TYPE "TravelTripType" AS ENUM ('domestic', 'international');
-- CreateEnum
CREATE TYPE "TravelSegmentRole" AS ENUM ('outbound', 'return', 'connection');
-- CreateEnum
CREATE TYPE "TravelTransportType" AS ENUM ('flight', 'train', 'car', 'bus', 'other');
-- AlterTable
ALTER TABLE "travel_orders" ADD COLUMN "travel_request_id" UUID;
-- AlterTable
ALTER TABLE "workflow_instances" ADD COLUMN "travel_request_id" UUID;
-- CreateTable
CREATE TABLE "travel_requests" (
"id" UUID NOT NULL,
"request_no" TEXT,
"applicant_employee_id" UUID NOT NULL,
"submitter_id" UUID,
"department_id" UUID NOT NULL,
"legal_entity_id" UUID NOT NULL,
"cost_center_id" UUID NOT NULL,
"budget_pool_id" UUID,
"purpose" TEXT NOT NULL,
"trip_type" "TravelTripType" NOT NULL,
"start_date" DATE NOT NULL,
"end_date" DATE NOT NULL,
"estimated_amount" DECIMAL(14,2) NOT NULL,
"status" "TravelRequestStatus" NOT NULL DEFAULT 'draft',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "travel_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "travel_request_segments" (
"id" UUID NOT NULL,
"travel_request_id" UUID NOT NULL,
"sort_order" INTEGER NOT NULL,
"segment_role" "TravelSegmentRole" NOT NULL,
"transport_type" "TravelTransportType" NOT NULL,
"from_city" TEXT NOT NULL,
"to_city" TEXT NOT NULL,
"depart_at" TIMESTAMP(3) NOT NULL,
"arrive_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "travel_request_segments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "travel_requests_request_no_key" ON "travel_requests"("request_no");
-- CreateIndex
CREATE INDEX "idx_travel_requests_applicant_start" ON "travel_requests"("applicant_employee_id", "start_date");
-- CreateIndex
CREATE INDEX "idx_travel_requests_submitter" ON "travel_requests"("submitter_id");
-- CreateIndex
CREATE INDEX "idx_travel_requests_department_status" ON "travel_requests"("department_id", "status");
-- CreateIndex
CREATE INDEX "idx_travel_requests_legal_cost_center" ON "travel_requests"("legal_entity_id", "cost_center_id");
-- CreateIndex
CREATE INDEX "idx_travel_requests_budget_pool" ON "travel_requests"("budget_pool_id");
-- CreateIndex
CREATE INDEX "idx_travel_requests_status" ON "travel_requests"("status");
-- CreateIndex
CREATE INDEX "idx_travel_request_segments_request_sort" ON "travel_request_segments"("travel_request_id", "sort_order");
-- CreateIndex
CREATE INDEX "travel_orders_travel_request_id_idx" ON "travel_orders"("travel_request_id");
-- CreateIndex
CREATE INDEX "workflow_instances_travel_request_id_idx" ON "workflow_instances"("travel_request_id");
-- AddForeignKey
ALTER TABLE "travel_requests" ADD CONSTRAINT "travel_requests_applicant_employee_id_fkey" FOREIGN KEY ("applicant_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "travel_requests" ADD CONSTRAINT "travel_requests_submitter_id_fkey" FOREIGN KEY ("submitter_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "travel_requests" ADD CONSTRAINT "travel_requests_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "travel_requests" ADD CONSTRAINT "travel_requests_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "travel_requests" ADD CONSTRAINT "travel_requests_cost_center_id_fkey" FOREIGN KEY ("cost_center_id") REFERENCES "cost_centers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "travel_requests" ADD CONSTRAINT "travel_requests_budget_pool_id_fkey" FOREIGN KEY ("budget_pool_id") REFERENCES "budget_pools"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "travel_request_segments" ADD CONSTRAINT "travel_request_segments_travel_request_id_fkey" FOREIGN KEY ("travel_request_id") REFERENCES "travel_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "travel_orders" ADD CONSTRAINT "travel_orders_travel_request_id_fkey" FOREIGN KEY ("travel_request_id") REFERENCES "travel_requests"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workflow_instances" ADD CONSTRAINT "workflow_instances_travel_request_id_fkey" FOREIGN KEY ("travel_request_id") REFERENCES "travel_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+102
查看文件
@@ -52,6 +52,32 @@ enum PurchaseRequestStatus {
cancelled cancelled
} }
enum TravelRequestStatus {
draft
reviewing
approved
rejected
}
enum TravelTripType {
domestic
international
}
enum TravelSegmentRole {
outbound
return
connection
}
enum TravelTransportType {
flight
train
car
bus
other
}
enum AssetCardStatus { enum AssetCardStatus {
in_stock in_stock
in_use in_use
@@ -130,6 +156,7 @@ enum DocumentCategoryEntityType {
reimbursement reimbursement
leave leave
purchase_request purchase_request
travel_request
asset_card asset_card
general general
} }
@@ -199,6 +226,7 @@ model Department {
reimbursement_forms ReimbursementForm[] reimbursement_forms ReimbursementForm[]
leave_requests LeaveRequest[] leave_requests LeaveRequest[]
purchase_requests PurchaseRequest[] purchase_requests PurchaseRequest[]
travel_requests TravelRequest[]
workflow_instances WorkflowInstance[] workflow_instances WorkflowInstance[]
budget_pools BudgetPool[] budget_pools BudgetPool[]
travel_orders TravelOrder[] travel_orders TravelOrder[]
@@ -258,6 +286,8 @@ model Employee {
leave_ledgers LeaveLedger[] leave_ledgers LeaveLedger[]
owned_cost_centers CostCenter[] @relation("CostCenterOwner") owned_cost_centers CostCenter[] @relation("CostCenterOwner")
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant") purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
travel_requests_applicant TravelRequest[] @relation("TravelRequestApplicant")
travel_requests_submitted TravelRequest[] @relation("TravelRequestSubmitter")
purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter") purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter")
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader") purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy") purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy")
@@ -456,6 +486,7 @@ model LegalEntity {
holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity") holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity")
suppliers Supplier[] suppliers Supplier[]
purchase_requests PurchaseRequest[] purchase_requests PurchaseRequest[]
travel_requests TravelRequest[]
asset_cards AssetCard[] asset_cards AssetCard[]
budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity")
@@ -485,6 +516,7 @@ model CostCenter {
owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull) owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
budget_pools BudgetPool[] @relation("BudgetPoolCostCenter") budget_pools BudgetPool[] @relation("BudgetPoolCostCenter")
purchase_requests PurchaseRequest[] purchase_requests PurchaseRequest[]
travel_requests TravelRequest[]
asset_cards AssetCard[] asset_cards AssetCard[]
@@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux") @@unique([legal_entity_id, code], map: "legal_entity_cost_center_code_ux")
@@ -1136,6 +1168,7 @@ model BudgetPool {
cost_center CostCenter? @relation("BudgetPoolCostCenter", fields: [cost_center_id], references: [id], onDelete: SetNull) cost_center CostCenter? @relation("BudgetPoolCostCenter", fields: [cost_center_id], references: [id], onDelete: SetNull)
ledger_entries BudgetLedgerEntry[] ledger_entries BudgetLedgerEntry[]
purchase_requests PurchaseRequest[] purchase_requests PurchaseRequest[]
travel_requests TravelRequest[]
@@index([department_id, status], map: "idx_budget_pools_department_status") @@index([department_id, status], map: "idx_budget_pools_department_status")
@@index([legal_entity_id, cost_center_id, status], map: "idx_budget_pools_scope_status") @@index([legal_entity_id, cost_center_id, status], map: "idx_budget_pools_scope_status")
@@ -1246,6 +1279,69 @@ model IntegrationSyncState {
@@map("integration_sync_states") @@map("integration_sync_states")
} }
model TravelRequest {
id String @id @default(uuid()) @db.Uuid
request_no String? @unique
applicant_employee_id String @db.Uuid
submitter_id String? @db.Uuid
department_id String @db.Uuid
legal_entity_id String @db.Uuid
cost_center_id String @db.Uuid
budget_pool_id String? @db.Uuid
purpose String
trip_type TravelTripType
start_date DateTime @db.Date
end_date DateTime @db.Date
estimated_amount Decimal @db.Decimal(14, 2)
status TravelRequestStatus @default(draft)
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
applicant Employee @relation("TravelRequestApplicant", fields: [applicant_employee_id], references: [id], onDelete: Restrict)
submitter Employee? @relation("TravelRequestSubmitter", fields: [submitter_id], references: [id], onDelete: SetNull)
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict)
cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict)
budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull)
segments TravelRequestSegment[]
travel_orders TravelOrder[]
workflow_instances WorkflowInstance[]
@@index([applicant_employee_id, start_date], map: "idx_travel_requests_applicant_start")
@@index([submitter_id], map: "idx_travel_requests_submitter")
@@index([department_id, status], map: "idx_travel_requests_department_status")
@@index([legal_entity_id, cost_center_id], map: "idx_travel_requests_legal_cost_center")
@@index([budget_pool_id], map: "idx_travel_requests_budget_pool")
@@index([status], map: "idx_travel_requests_status")
@@map("travel_requests")
}
model TravelRequestSegment {
id String @id @default(uuid()) @db.Uuid
travel_request_id String @db.Uuid
sort_order Int
segment_role TravelSegmentRole
transport_type TravelTransportType
from_city String
to_city String
depart_at DateTime
arrive_at DateTime
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
travel_request TravelRequest @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
@@index([travel_request_id, sort_order], map: "idx_travel_request_segments_request_sort")
@@map("travel_request_segments")
}
model TravelOrder { model TravelOrder {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
source String @default("manual") @db.VarChar(32) source String @default("manual") @db.VarChar(32)
@@ -1253,6 +1349,7 @@ model TravelOrder {
employee_id String @db.Uuid employee_id String @db.Uuid
department_id String? @db.Uuid department_id String? @db.Uuid
linked_reimbursement_id String? @db.Uuid linked_reimbursement_id String? @db.Uuid
travel_request_id String? @db.Uuid
occurred_at DateTime? @db.Date occurred_at DateTime? @db.Date
amount Decimal? @db.Decimal(14, 2) amount Decimal? @db.Decimal(14, 2)
currency String @default("CNY") @db.VarChar(8) currency String @default("CNY") @db.VarChar(8)
@@ -1267,10 +1364,12 @@ model TravelOrder {
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict) employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_id], references: [id], onDelete: SetNull) linked_reimbursement ReimbursementForm? @relation(fields: [linked_reimbursement_id], references: [id], onDelete: SetNull)
travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: SetNull)
@@unique([source, external_order_no], map: "travel_orders_source_order_ux") @@unique([source, external_order_no], map: "travel_orders_source_order_ux")
@@index([employee_id, occurred_at], map: "idx_travel_orders_employee_occurred") @@index([employee_id, occurred_at], map: "idx_travel_orders_employee_occurred")
@@index([linked_reimbursement_id]) @@index([linked_reimbursement_id])
@@index([travel_request_id])
@@map("travel_orders") @@map("travel_orders")
} }
@@ -1318,6 +1417,7 @@ model WorkflowInstance {
reimbursement_id String? @db.Uuid reimbursement_id String? @db.Uuid
leave_request_id String? @db.Uuid leave_request_id String? @db.Uuid
purchase_request_id String? @db.Uuid purchase_request_id String? @db.Uuid
travel_request_id String? @db.Uuid
submitter_id String @db.Uuid submitter_id String @db.Uuid
current_status WorkflowInstanceStatus @default(pending) current_status WorkflowInstanceStatus @default(pending)
current_step_id String current_step_id String
@@ -1334,6 +1434,7 @@ model WorkflowInstance {
reimbursement_form ReimbursementForm? @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade) reimbursement_form ReimbursementForm? @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
leave_request LeaveRequest? @relation(fields: [leave_request_id], references: [id], onDelete: Cascade) leave_request LeaveRequest? @relation(fields: [leave_request_id], references: [id], onDelete: Cascade)
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade) purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade)
travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict) submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull) department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
@@ -1348,6 +1449,7 @@ model WorkflowInstance {
@@index([reimbursement_id]) @@index([reimbursement_id])
@@index([leave_request_id]) @@index([leave_request_id])
@@index([purchase_request_id]) @@index([purchase_request_id])
@@index([travel_request_id])
@@index([current_status]) @@index([current_status])
@@index([submitter_id]) @@index([submitter_id])
@@index([context_department_id]) @@index([context_department_id])
+2
查看文件
@@ -44,6 +44,7 @@ const assetMovementsRoutes = require("./routes/assetMovements");
const inventoryChecksRoutes = require("./routes/inventoryChecks"); const inventoryChecksRoutes = require("./routes/inventoryChecks");
const integrationsRoutes = require("./routes/integrations"); const integrationsRoutes = require("./routes/integrations");
const travelOrdersRoutes = require("./routes/travelOrders"); const travelOrdersRoutes = require("./routes/travelOrders");
const travelRequestsRoutes = require("./routes/travelRequests");
const aiRoutes = require("./routes/ai"); const aiRoutes = require("./routes/ai");
const { startJobs } = require("./jobs/jobSystem"); const { startJobs } = require("./jobs/jobSystem");
const { initMetrics } = require("./observability"); const { initMetrics } = require("./observability");
@@ -306,6 +307,7 @@ async function buildApp() {
await app.register(inventoryChecksRoutes, { prefix: "/api" }); await app.register(inventoryChecksRoutes, { prefix: "/api" });
await app.register(integrationsRoutes, { prefix: "/api" }); await app.register(integrationsRoutes, { prefix: "/api" });
await app.register(travelOrdersRoutes, { prefix: "/api" }); await app.register(travelOrdersRoutes, { prefix: "/api" });
await app.register(travelRequestsRoutes, { prefix: "/api" });
await app.register(aiRoutes, { prefix: "/api" }); await app.register(aiRoutes, { prefix: "/api" });
await app.register(exportsRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" });
@@ -3,6 +3,7 @@ const NOTIFICATION_DOMAIN_SCOPES = Object.freeze({
reimbursement: 'reimbursement', reimbursement: 'reimbursement',
leave: 'leave', leave: 'leave',
procurement: 'procurement', procurement: 'procurement',
travel: 'travel',
asset: 'asset', asset: 'asset',
inventory: 'inventory', inventory: 'inventory',
}); });
@@ -17,6 +18,8 @@ const NOTIFICATION_TYPES = Object.freeze({
leaveRejected: 'leave.rejected', leaveRejected: 'leave.rejected',
purchaseRequestApproved: 'purchase_request.approved', purchaseRequestApproved: 'purchase_request.approved',
purchaseRequestRejected: 'purchase_request.rejected', purchaseRequestRejected: 'purchase_request.rejected',
travelRequestApproved: 'travel_request.approved',
travelRequestRejected: 'travel_request.rejected',
assetAssigned: 'asset.assigned', assetAssigned: 'asset.assigned',
assetReturned: 'asset.returned', assetReturned: 'asset.returned',
assetTransferred: 'asset.transferred', assetTransferred: 'asset.transferred',
@@ -33,6 +36,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
NOTIFICATION_TYPES.leaveRejected, NOTIFICATION_TYPES.leaveRejected,
NOTIFICATION_TYPES.purchaseRequestApproved, NOTIFICATION_TYPES.purchaseRequestApproved,
NOTIFICATION_TYPES.purchaseRequestRejected, NOTIFICATION_TYPES.purchaseRequestRejected,
NOTIFICATION_TYPES.travelRequestApproved,
NOTIFICATION_TYPES.travelRequestRejected,
NOTIFICATION_TYPES.assetAssigned, NOTIFICATION_TYPES.assetAssigned,
NOTIFICATION_TYPES.assetReturned, NOTIFICATION_TYPES.assetReturned,
NOTIFICATION_TYPES.assetTransferred, NOTIFICATION_TYPES.assetTransferred,
@@ -49,6 +54,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave, [NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement, [NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement,
[NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement, [NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement,
[NOTIFICATION_TYPES.travelRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.travel,
[NOTIFICATION_TYPES.travelRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.travel,
[NOTIFICATION_TYPES.assetAssigned]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetAssigned]: NOTIFICATION_DOMAIN_SCOPES.asset,
[NOTIFICATION_TYPES.assetReturned]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetReturned]: NOTIFICATION_DOMAIN_SCOPES.asset,
[NOTIFICATION_TYPES.assetTransferred]: NOTIFICATION_DOMAIN_SCOPES.asset, [NOTIFICATION_TYPES.assetTransferred]: NOTIFICATION_DOMAIN_SCOPES.asset,
@@ -60,6 +67,7 @@ const TARGET_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
reimbursement_form: NOTIFICATION_DOMAIN_SCOPES.reimbursement, reimbursement_form: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
leave_request: NOTIFICATION_DOMAIN_SCOPES.leave, leave_request: NOTIFICATION_DOMAIN_SCOPES.leave,
purchase_request: NOTIFICATION_DOMAIN_SCOPES.procurement, purchase_request: NOTIFICATION_DOMAIN_SCOPES.procurement,
travel_request: NOTIFICATION_DOMAIN_SCOPES.travel,
asset_card: NOTIFICATION_DOMAIN_SCOPES.asset, asset_card: NOTIFICATION_DOMAIN_SCOPES.asset,
inventory_check: NOTIFICATION_DOMAIN_SCOPES.inventory, inventory_check: NOTIFICATION_DOMAIN_SCOPES.inventory,
}); });
+32
查看文件
@@ -22,6 +22,12 @@ const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [
"procurement.submit", "procurement.submit",
]; ];
const TRAVEL_SELF_SERVICE_PERMISSIONS = [
"travel.create",
"travel.view_own",
"travel.submit",
];
const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"]; const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"];
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"]; const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"];
@@ -30,6 +36,7 @@ const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
const EMPLOYEE_BASE_PERMISSIONS = [ const EMPLOYEE_BASE_PERMISSIONS = [
...EXPENSE_SELF_SERVICE_PERMISSIONS, ...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS,
...TRAVEL_SELF_SERVICE_PERMISSIONS,
]; ];
const ENTITY_PERMISSION_MAP = { const ENTITY_PERMISSION_MAP = {
@@ -79,6 +86,22 @@ const ENTITY_PERMISSION_MAP = {
approve: "procurement.approve", approve: "procurement.approve",
manage: "procurement.manage", manage: "procurement.manage",
}, },
travel_request: {
create: "travel.create",
view_own: "travel.view_own",
view_all: "travel.view_all",
submit: "travel.submit",
approve: "travel.approve",
manage: "travel.manage",
},
travel: {
create: "travel.create",
view_own: "travel.view_own",
view_all: "travel.view_all",
submit: "travel.submit",
approve: "travel.approve",
manage: "travel.manage",
},
}; };
const ROLE_PERMISSIONS = { const ROLE_PERMISSIONS = {
@@ -96,6 +119,8 @@ const ROLE_PERMISSIONS = {
"leave.approve", "leave.approve",
"procurement.approve", "procurement.approve",
"procurement.view_all", "procurement.view_all",
"travel.approve",
"travel.view_all",
"asset.view_all", "asset.view_all",
"report.dashboard.view", "report.dashboard.view",
"budget.view", "budget.view",
@@ -112,6 +137,8 @@ const ROLE_PERMISSIONS = {
"leave.view_all", "leave.view_all",
"procurement.approve", "procurement.approve",
"procurement.view_all", "procurement.view_all",
"travel.approve",
"travel.view_all",
"asset.view_all", "asset.view_all",
"report.dashboard.view", "report.dashboard.view",
"budget.view", "budget.view",
@@ -127,6 +154,7 @@ const ROLE_PERMISSIONS = {
"expense.finance.pay", "expense.finance.pay",
"expense.view_all", "expense.view_all",
"procurement.view_all", "procurement.view_all",
"travel.view_all",
"asset.view_all", "asset.view_all",
], ],
finance_mgr: [ finance_mgr: [
@@ -146,6 +174,8 @@ const ROLE_PERMISSIONS = {
"expense.finance.override_duplicate", "expense.finance.override_duplicate",
"procurement.view_all", "procurement.view_all",
"procurement.manage", "procurement.manage",
"travel.view_all",
"travel.manage",
"asset.view_all", "asset.view_all",
"asset.manage", "asset.manage",
"asset.inventory", "asset.inventory",
@@ -157,6 +187,8 @@ const ROLE_PERMISSIONS = {
...EMPLOYEE_BASE_PERMISSIONS, ...EMPLOYEE_BASE_PERMISSIONS,
"procurement.view_all", "procurement.view_all",
"procurement.manage", "procurement.manage",
"travel.view_all",
"travel.manage",
"asset.view_all", "asset.view_all",
"asset.manage", "asset.manage",
"asset.transfer", "asset.transfer",
+1
查看文件
@@ -10,6 +10,7 @@ const DOCUMENT_CATEGORY_ENTITY_TYPES = [
"reimbursement", "reimbursement",
"leave", "leave",
"purchase_request", "purchase_request",
"travel_request",
"asset_card", "asset_card",
"general", "general",
]; ];
+90 -35
查看文件
@@ -8,6 +8,7 @@ const { normalizeTravelImportRows } = require("../services/integrations/travel")
const IMPORT_SCHEMA = z.object({ const IMPORT_SCHEMA = z.object({
orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100), orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
}); });
const UUID_SCHEMA = z.string().uuid();
function mapTravelOrder(item) { function mapTravelOrder(item) {
return { return {
@@ -17,6 +18,7 @@ function mapTravelOrder(item) {
employeeId: item.employee_id, employeeId: item.employee_id,
departmentId: item.department_id ?? null, departmentId: item.department_id ?? null,
linkedReimbursementId: item.linked_reimbursement_id ?? null, linkedReimbursementId: item.linked_reimbursement_id ?? null,
travelRequestId: item.travel_request_id ?? null,
occurredAt: item.occurred_at?.toISOString?.()?.slice(0, 10) ?? null, occurredAt: item.occurred_at?.toISOString?.()?.slice(0, 10) ?? null,
amount: item.amount?.toString?.() ?? null, amount: item.amount?.toString?.() ?? null,
currency: item.currency, currency: item.currency,
@@ -26,6 +28,15 @@ function mapTravelOrder(item) {
}; };
} }
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
async function travelOrdersRoutes(fastify) { async function travelOrdersRoutes(fastify) {
fastify.post( fastify.post(
"/travel/orders/import", "/travel/orders/import",
@@ -34,36 +45,30 @@ async function travelOrdersRoutes(fastify) {
}, },
async (request, reply) => { async (request, reply) => {
if (!fastify.config.travelSyncEnabled) { if (!fastify.config.travelSyncEnabled) {
throw new AppError({ throw buildValidationError({ integration: "travel_sync_disabled" }, 409);
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { integration: "travel_sync_disabled" },
});
} }
const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) { if (!parsed.success) {
throw new AppError({ throw buildValidationError(parsed.error.flatten());
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
} }
const rows = normalizeTravelImportRows(parsed.data.orders); const rows = normalizeTravelImportRows(parsed.data.orders);
if (!rows.length) { if (!rows.length) {
throw new AppError({ throw buildValidationError({ orders: "empty_after_normalization" });
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { orders: "empty_after_normalization" },
});
} }
const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId)));
const employees = await fastify.prisma.employee.findMany({ const travelRequestIds = Array.from(
new Set(
rows
.filter((item) => item.hasTravelRequestId && item.travelRequestId)
.map((item) => item.travelRequestId),
),
).filter((item) => UUID_SCHEMA.safeParse(item).success);
const [employees, travelRequests] = await Promise.all([
fastify.prisma.employee.findMany({
where: { where: {
id: { in: employeeIds }, id: { in: employeeIds },
status: "active", status: "active",
@@ -72,11 +77,24 @@ async function travelOrdersRoutes(fastify) {
id: true, id: true,
department_id: true, department_id: true,
}, },
}); }),
const employeeMap = new Map(employees.map((item) => [item.id, item])); travelRequestIds.length
? fastify.prisma.travelRequest.findMany({
where: { id: { in: travelRequestIds } },
select: {
id: true,
applicant_employee_id: true,
},
})
: Promise.resolve([]),
]);
const created = []; const employeeMap = new Map(employees.map((item) => [item.id, item]));
const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item]));
const imported = [];
const skipped = []; const skipped = [];
let linkedTravelRequestCount = 0;
for (const row of rows) { for (const row of rows) {
const employee = employeeMap.get(row.employeeId); const employee = employeeMap.get(row.employeeId);
@@ -89,14 +107,31 @@ async function travelOrdersRoutes(fastify) {
continue; continue;
} }
const upserted = await fastify.prisma.travelOrder.upsert({ let nextTravelRequestId;
where: { if (row.hasTravelRequestId && row.travelRequestId) {
source_external_order_no: { const travelRequest = travelRequestMap.get(row.travelRequestId);
if (!travelRequest) {
skipped.push({
source: row.source, source: row.source,
external_order_no: row.externalOrderNo, externalOrderNo: row.externalOrderNo,
}, reason: "travel_request_not_found",
}, });
update: { continue;
}
if (travelRequest.applicant_employee_id !== row.employeeId) {
skipped.push({
source: row.source,
externalOrderNo: row.externalOrderNo,
reason: "travel_request_employee_mismatch",
});
continue;
}
nextTravelRequestId = travelRequest.id;
} else if (row.hasTravelRequestId && !row.travelRequestId) {
nextTravelRequestId = undefined;
}
const updateData = {
employee_id: row.employeeId, employee_id: row.employeeId,
department_id: employee.department_id, department_id: employee.department_id,
linked_reimbursement_id: row.linkedReimbursementId, linked_reimbursement_id: row.linkedReimbursementId,
@@ -106,13 +141,18 @@ async function travelOrdersRoutes(fastify) {
status: "imported", status: "imported",
raw_payload: row.rawPayload, raw_payload: row.rawPayload,
updated_by: request.employee.id, updated_by: request.employee.id,
}, };
create: { if (nextTravelRequestId !== undefined) {
updateData.travel_request_id = nextTravelRequestId;
}
const createData = {
source: row.source, source: row.source,
external_order_no: row.externalOrderNo, external_order_no: row.externalOrderNo,
employee_id: row.employeeId, employee_id: row.employeeId,
department_id: employee.department_id, department_id: employee.department_id,
linked_reimbursement_id: row.linkedReimbursementId, linked_reimbursement_id: row.linkedReimbursementId,
travel_request_id: nextTravelRequestId ?? null,
occurred_at: row.occurredAt, occurred_at: row.occurredAt,
amount: row.amount, amount: row.amount,
currency: row.currency, currency: row.currency,
@@ -120,10 +160,23 @@ async function travelOrdersRoutes(fastify) {
raw_payload: row.rawPayload, raw_payload: row.rawPayload,
created_by: request.employee.id, created_by: request.employee.id,
updated_by: request.employee.id, updated_by: request.employee.id,
};
const upserted = await fastify.prisma.travelOrder.upsert({
where: {
source_external_order_no: {
source: row.source,
external_order_no: row.externalOrderNo,
}, },
},
update: updateData,
create: createData,
}); });
created.push(upserted); if (upserted.travel_request_id) {
linkedTravelRequestCount += 1;
}
imported.push(upserted);
} }
await fastify.prisma.auditLog.create({ await fastify.prisma.auditLog.create({
@@ -133,7 +186,8 @@ async function travelOrdersRoutes(fastify) {
targetType: "travel_order", targetType: "travel_order",
extra: { extra: {
requested: rows.length, requested: rows.length,
createdOrUpdated: created.length, createdOrUpdated: imported.length,
linkedTravelRequestCount,
skipped, skipped,
}, },
}), }),
@@ -141,9 +195,10 @@ async function travelOrdersRoutes(fastify) {
return sendData(reply, request, { return sendData(reply, request, {
requested: rows.length, requested: rows.length,
imported: created.length, imported: imported.length,
linkedTravelRequestCount,
skipped, skipped,
items: created.map(mapTravelOrder), items: imported.map(mapTravelOrder),
}); });
}, },
); );
文件差异内容过多而无法显示 加载差异
+166
查看文件
@@ -458,6 +458,15 @@ function getWorkflowEntityMeta(entityType, entityRecord, entityId) {
}; };
} }
if (normalizedEntityType === "travel_request") {
return {
label: "差旅申请",
reference: entityRecord?.request_no ?? entityId,
targetType: "travel_request",
detailPath: `/travel-requests?id=${entityId}`,
};
}
return { return {
label: "审批单", label: "审批单",
reference: entityId, reference: entityId,
@@ -525,6 +534,18 @@ async function loadPurchaseNotifyContext(tx, purchaseRequestId) {
}); });
} }
async function loadTravelNotifyContext(tx, travelRequestId) {
return tx.travelRequest.findUnique({
where: { id: travelRequestId },
select: {
id: true,
request_no: true,
applicant_employee_id: true,
submitter_id: true,
},
});
}
async function loadWorkflowNotifyContext(tx, entityType, entityId) { async function loadWorkflowNotifyContext(tx, entityType, entityId) {
const normalizedEntityType = entityType === 'procurement' ? 'purchase_request' : entityType; const normalizedEntityType = entityType === 'procurement' ? 'purchase_request' : entityType;
@@ -552,6 +573,14 @@ async function loadWorkflowNotifyContext(tx, entityType, entityId) {
}; };
} }
if (normalizedEntityType === 'travel_request') {
const record = await loadTravelNotifyContext(tx, entityId);
return {
record,
recipientEmployeeIds: uniq([record?.applicant_employee_id, record?.submitter_id]),
};
}
return { return {
record: null, record: null,
recipientEmployeeIds: [], recipientEmployeeIds: [],
@@ -565,12 +594,14 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved; if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved; if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved; if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved;
} }
if (outcome === 'rejected') { if (outcome === 'rejected') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected; if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected; if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected; if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected;
} }
if (outcome === 'returned') { if (outcome === 'returned') {
@@ -973,6 +1004,50 @@ async function workflowRoutes(fastify) {
}, },
}, },
}, },
travel_request: {
select: {
id: true,
request_no: true,
status: true,
purpose: true,
trip_type: true,
start_date: true,
end_date: true,
estimated_amount: true,
applicant: {
select: {
id: true,
employee_no: true,
name: true,
},
},
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
},
},
segments: {
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
select: {
id: true,
sort_order: true,
segment_role: true,
transport_type: true,
from_city: true,
to_city: true,
},
},
},
},
}, },
}, },
}, },
@@ -1166,6 +1241,47 @@ async function workflowRoutes(fastify) {
: null, : null,
} }
: null, : null,
travelRequest: task.workflow_instance?.travel_request
? {
id: task.workflow_instance.travel_request.id,
requestNo: task.workflow_instance.travel_request.request_no ?? null,
status: task.workflow_instance.travel_request.status,
purpose: task.workflow_instance.travel_request.purpose,
tripType: task.workflow_instance.travel_request.trip_type,
startDate: formatDateOnly(task.workflow_instance.travel_request.start_date),
endDate: formatDateOnly(task.workflow_instance.travel_request.end_date),
estimatedAmount: toMoneyString(task.workflow_instance.travel_request.estimated_amount),
applicant: task.workflow_instance.travel_request.applicant
? {
id: task.workflow_instance.travel_request.applicant.id,
employeeNo: task.workflow_instance.travel_request.applicant.employee_no,
name: task.workflow_instance.travel_request.applicant.name,
}
: null,
legalEntity: task.workflow_instance.travel_request.legal_entity
? {
id: task.workflow_instance.travel_request.legal_entity.id,
code: task.workflow_instance.travel_request.legal_entity.code,
name: task.workflow_instance.travel_request.legal_entity.name,
}
: null,
costCenter: task.workflow_instance.travel_request.cost_center
? {
id: task.workflow_instance.travel_request.cost_center.id,
code: task.workflow_instance.travel_request.cost_center.code,
name: task.workflow_instance.travel_request.cost_center.name,
}
: null,
segments: task.workflow_instance.travel_request.segments.map((item) => ({
id: item.id,
sortOrder: item.sort_order,
segmentRole: item.segment_role,
transportType: item.transport_type,
fromCity: item.from_city,
toCity: item.to_city,
})),
}
: null,
allowedActions: listAllowedActionsForTask(request, task, actionable), allowedActions: listAllowedActionsForTask(request, task, actionable),
history: historyItems.map((item) => ({ history: historyItems.map((item) => ({
id: item.id, id: item.id,
@@ -1477,6 +1593,21 @@ async function workflowRoutes(fastify) {
"Failed to enqueue purchase approval ERP events", "Failed to enqueue purchase approval ERP events",
); );
} }
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
await confirmBudgetForEntity(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "approved_confirm_travel_budget",
});
} }
await tx.workflowHistory.create({ await tx.workflowHistory.create({
@@ -1520,6 +1651,7 @@ async function workflowRoutes(fastify) {
nextTaskId: null, nextTaskId: null,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null, purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "approved" : null,
}; };
}); });
@@ -1645,6 +1777,21 @@ async function workflowRoutes(fastify) {
actorId: request.employee.id, actorId: request.employee.id,
note: "rejected_release_purchase_budget", note: "rejected_release_purchase_budget",
}); });
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "rejected_release_travel_budget",
});
} }
await tx.workflowHistory.create({ await tx.workflowHistory.create({
@@ -1688,6 +1835,7 @@ async function workflowRoutes(fastify) {
workflowInstanceId: workflowInstance.id, workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null, purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "rejected" : null,
}; };
}); });
@@ -1813,6 +1961,21 @@ async function workflowRoutes(fastify) {
actorId: request.employee.id, actorId: request.employee.id,
note: "returned_release_purchase_budget", note: "returned_release_purchase_budget",
}); });
} else if (workflowInstance.entity_type === "travel_request") {
await tx.travelRequest.update({
where: { id: workflowInstance.entity_id },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await releaseReservedBudgetIfAny(tx, {
entityType: "travel_request",
entityId: workflowInstance.entity_id,
actorId: request.employee.id,
note: "returned_release_travel_budget",
});
} }
await tx.workflowHistory.create({ await tx.workflowHistory.create({
@@ -1856,6 +2019,7 @@ async function workflowRoutes(fastify) {
workflowInstanceId: workflowInstance.id, workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "draft" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "draft" : null, purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "draft" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "draft" : null,
}; };
}); });
@@ -2012,4 +2176,6 @@ module.exports = workflowRoutes;
module.exports.__testables = { module.exports.__testables = {
handlePurchaseApprovalErpSync, handlePurchaseApprovalErpSync,
listAllowedActionsForTask, listAllowedActionsForTask,
getWorkflowEntityMeta,
resolveWorkflowOutcomeNotificationType,
}; };
+55
查看文件
@@ -118,6 +118,19 @@ const DEFAULT_PURCHASE_WORKFLOW_DEFINITION = {
], ],
}; };
const DEFAULT_TRAVEL_WORKFLOW_DEFINITION = {
steps: [
{
id: "travel_approval_manager",
name: "直属经理审批",
type: "approval",
assignee_rule: {
type: "department_manager",
},
},
],
};
const DEFAULT_DOCUMENT_CATEGORIES = [ const DEFAULT_DOCUMENT_CATEGORIES = [
{ {
code: "reimbursement_general", code: "reimbursement_general",
@@ -149,6 +162,16 @@ const DEFAULT_DOCUMENT_CATEGORIES = [
default_workflow_key: null, default_workflow_key: null,
enabled: true, enabled: true,
}, },
{
code: "TR_STANDARD",
name: "标准差旅申请",
entity_type: "travel_request",
sequence_prefix: "CL",
number_length: 4,
reset_period: "yearly",
default_workflow_key: null,
enabled: true,
},
{ {
code: "ASSET_CARD_DEFAULT", code: "ASSET_CARD_DEFAULT",
name: "固定资产卡片", name: "固定资产卡片",
@@ -529,6 +552,38 @@ async function seedWorkflowDefinition(prisma) {
created_by: null, created_by: null,
}, },
}); });
await prisma.workflowDefinition.updateMany({
where: {
entity_type: "travel_request",
},
data: {
is_active: false,
},
});
await prisma.workflowDefinition.upsert({
where: {
entity_type_version: {
entity_type: "travel_request",
version: 1,
},
},
update: {
name: "差旅申请审批流程(V1)",
definition_json: DEFAULT_TRAVEL_WORKFLOW_DEFINITION,
is_active: true,
created_by: null,
},
create: {
name: "差旅申请审批流程(V1)",
entity_type: "travel_request",
definition_json: DEFAULT_TRAVEL_WORKFLOW_DEFINITION,
version: 1,
is_active: true,
created_by: null,
},
});
} }
async function main() { async function main() {
+11
查看文件
@@ -39,6 +39,17 @@ const DOCUMENT_TYPE_CONFIG = {
resetPeriod: "yearly", resetPeriod: "yearly",
}, },
}, },
travel_request: {
defaultCategoryCode: "TR_STANDARD",
preferLegacySequence: false,
fallback: {
prefix: "CL",
dateFormat: "YYYYMMDD",
separator: "-",
numberLength: 4,
resetPeriod: "yearly",
},
},
asset_card: { asset_card: {
defaultCategoryCode: "ASSET_CARD_DEFAULT", defaultCategoryCode: "ASSET_CARD_DEFAULT",
preferLegacySequence: false, preferLegacySequence: false,
@@ -18,6 +18,9 @@ function normalizeTravelImportRows(rows) {
? new Date(`${String(item.occurredAt).slice(0, 10)}T00:00:00.000Z`) ? new Date(`${String(item.occurredAt).slice(0, 10)}T00:00:00.000Z`)
: null; : null;
const hasTravelRequestId = Object.prototype.hasOwnProperty.call(item ?? {}, "travelRequestId");
const rawTravelRequestId = String(item?.travelRequestId ?? "").trim();
return { return {
source, source,
externalOrderNo, externalOrderNo,
@@ -26,6 +29,8 @@ function normalizeTravelImportRows(rows) {
item?.linkedReimbursementId && String(item.linkedReimbursementId).trim() item?.linkedReimbursementId && String(item.linkedReimbursementId).trim()
? String(item.linkedReimbursementId).trim() ? String(item.linkedReimbursementId).trim()
: null, : null,
travelRequestId: rawTravelRequestId || null,
hasTravelRequestId,
amount: Number.isFinite(amount) ? amount.toFixed(2) : null, amount: Number.isFinite(amount) ? amount.toFixed(2) : null,
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY", currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
occurredAt, occurredAt,
@@ -44,6 +44,14 @@ describe("Permission Constants", () => {
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "procurement.manage")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["administration"]), "procurement.manage")).toBe(true);
}); });
it("grants travel permissions to configured roles", () => {
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.create")).toBe(true);
expect(hasPermission(resolvePermissionsForRoles(["employee"]), "travel.submit")).toBe(true);
expect(hasPermission(resolvePermissionsForRoles(["manager"]), "travel.approve")).toBe(true);
expect(hasPermission(resolvePermissionsForRoles(["finance"]), "travel.approve")).toBe(false);
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "travel.manage")).toBe(true);
expect(hasPermission(resolvePermissionsForRoles(["administration"]), "travel.manage")).toBe(true);
});
it("grants ai.assist only to finance manager and admin", () => { it("grants ai.assist only to finance manager and admin", () => {
expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "ai.assist")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "ai.assist")).toBe(true);
@@ -142,6 +142,28 @@ describe("documentSequence service", () => {
}); });
it("falls back to default travel category code for travel requests", async () => {
const prisma = buildPrisma({
categoryMap: {
TR_STANDARD: {
code: "TR_STANDARD",
entity_type: "travel_request",
sequence_prefix: "CL",
number_length: 4,
reset_period: "yearly",
enabled: true,
archived_at: null,
},
},
});
const config = await resolveDocumentSequenceConfig(prisma, { documentType: "travel_request" });
expect(config.source).toBe("document_category");
expect(config.documentCategoryCode).toBe("TR_STANDARD");
expect(config.prefix).toBe("CL");
});
it("falls back to default asset category code for asset cards", async () => { it("falls back to default asset category code for asset cards", async () => {
const prisma = buildPrisma({ const prisma = buildPrisma({
categoryMap: { categoryMap: {
@@ -11,3 +11,9 @@ describe("resolveEntityPermission", () => {
expect(resolveEntityPermission("procurement", "manage")).toBe("procurement.manage"); expect(resolveEntityPermission("procurement", "manage")).toBe("procurement.manage");
}); });
}); });
it("maps travel_request approvals to travel permissions", () => {
expect(resolveEntityPermission("travel_request", "approve")).toBe("travel.approve");
expect(resolveEntityPermission("travel_request", "submit")).toBe("travel.submit");
expect(resolveEntityPermission("travel", "manage")).toBe("travel.manage");
});
@@ -0,0 +1,203 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const travelOrdersRoutes = require("../../../src/routes/travelOrders");
function buildPrisma({ employees = [], travelRequests = [], upsertImpl } = {}) {
return {
employee: {
findMany: vi.fn().mockResolvedValue(employees),
},
travelRequest: {
findMany: vi.fn().mockResolvedValue(travelRequests),
},
travelOrder: {
upsert: vi.fn().mockImplementation(upsertImpl ?? (async ({ create }) => ({
id: "order-1",
source: create.source,
external_order_no: create.external_order_no,
employee_id: create.employee_id,
department_id: create.department_id,
linked_reimbursement_id: create.linked_reimbursement_id,
travel_request_id: create.travel_request_id,
occurred_at: create.occurred_at,
amount: create.amount,
currency: create.currency,
status: create.status,
created_at: new Date("2026-03-10T00:00:00.000Z"),
updated_at: new Date("2026-03-10T00:00:00.000Z"),
}))),
},
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit-1" }),
},
};
}
async function buildApp({ prisma }) {
const app = Fastify();
app.decorate("prisma", prisma);
app.decorate("config", { travelSyncEnabled: true });
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = { id: "actor-1", permissionCodes: ["integration.manage"] };
});
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode));
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(travelOrdersRoutes, { prefix: "/api" });
return app;
}
describe("travelOrders routes", () => {
it("imports rows with legal travelRequestId linkage", async () => {
const prisma = buildPrisma({
employees: [{ id: "employee-1", department_id: "dept-1" }],
travelRequests: [{ id: "11111111-1111-4111-8111-111111111111", applicant_employee_id: "employee-1" }],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: "POST",
url: "/api/travel/orders/import",
headers: { authorization: "Bearer test" },
payload: {
orders: [
{
source: "ctrip",
externalOrderNo: "EXT-001",
employeeId: "employee-1",
travelRequestId: "11111111-1111-4111-8111-111111111111",
amount: "1888.00",
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
imported: 1,
linkedTravelRequestCount: 1,
}),
);
expect(response.json().data.items[0].travelRequestId).toBe("11111111-1111-4111-8111-111111111111");
await app.close();
});
it("skips rows when travel request employee mismatches", async () => {
const prisma = buildPrisma({
employees: [{ id: "employee-1", department_id: "dept-1" }],
travelRequests: [{ id: "11111111-1111-4111-8111-111111111111", applicant_employee_id: "employee-2" }],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: "POST",
url: "/api/travel/orders/import",
headers: { authorization: "Bearer test" },
payload: {
orders: [{ source: "ctrip", externalOrderNo: "EXT-002", employeeId: "employee-1", travelRequestId: "11111111-1111-4111-8111-111111111111" }],
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.imported).toBe(0);
expect(response.json().data.skipped).toEqual([
expect.objectContaining({ reason: "travel_request_employee_mismatch" }),
]);
await app.close();
});
it("skips rows when travel request does not exist", async () => {
const prisma = buildPrisma({
employees: [{ id: "employee-1", department_id: "dept-1" }],
travelRequests: [],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: "POST",
url: "/api/travel/orders/import",
headers: { authorization: "Bearer test" },
payload: {
orders: [{ source: "ctrip", externalOrderNo: "EXT-003", employeeId: "employee-1", travelRequestId: "11111111-1111-4111-8111-111111111111" }],
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.imported).toBe(0);
expect(response.json().data.skipped).toEqual([
expect.objectContaining({ reason: "travel_request_not_found" }),
]);
await app.close();
});
it("preserves existing travel linkage when new row omits travelRequestId", async () => {
const prisma = buildPrisma({
employees: [{ id: "employee-1", department_id: "dept-1" }],
travelRequests: [],
upsertImpl: async ({ create, update }) => {
expect(update.travel_request_id).toBeUndefined();
return {
id: "order-2",
source: create.source,
external_order_no: create.external_order_no,
employee_id: create.employee_id,
department_id: create.department_id,
linked_reimbursement_id: create.linked_reimbursement_id,
travel_request_id: "existing-travel-request",
occurred_at: create.occurred_at,
amount: create.amount,
currency: create.currency,
status: "imported",
created_at: new Date("2026-03-10T00:00:00.000Z"),
updated_at: new Date("2026-03-10T00:00:00.000Z"),
};
},
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: "POST",
url: "/api/travel/orders/import",
headers: { authorization: "Bearer test" },
payload: {
orders: [{ source: "ctrip", externalOrderNo: "EXT-004", employeeId: "employee-1", amount: "500.00" }],
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items[0].travelRequestId).toBe("existing-travel-request");
await app.close();
});
});
@@ -0,0 +1,200 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const travelRequestsRoutes = require("../../../src/routes/travelRequests");
function buildTravelRequestRecord({
id = "11111111-1111-4111-8111-111111111111",
applicantId = "employee-1",
submitterId = null,
status = "draft",
} = {}) {
const now = new Date("2026-03-10T00:00:00.000Z");
return {
id,
request_no: status === "draft" ? null : "CL-20260310-0001",
applicant_employee_id: applicantId,
submitter_id: submitterId,
department_id: "dept-1",
legal_entity_id: "legal-1",
cost_center_id: "cc-1",
budget_pool_id: null,
purpose: "上海客户拜访",
trip_type: "domestic",
start_date: new Date("2026-03-15T00:00:00.000Z"),
end_date: new Date("2026-03-17T00:00:00.000Z"),
estimated_amount: "1280.00",
status,
created_at: now,
updated_at: now,
applicant: {
id: applicantId,
employee_no: "E001",
name: "张三",
},
submitter: submitterId
? {
id: submitterId,
employee_no: "E009",
name: "李四",
}
: null,
department: { id: "dept-1", name: "销售部" },
legal_entity: { id: "legal-1", code: "HY", name: "海音达" },
cost_center: { id: "cc-1", code: "CC001", name: "销售中心", legal_entity_id: "legal-1" },
budget_pool: null,
segments: [
{
id: "seg-1",
sort_order: 1,
segment_role: "outbound",
transport_type: "flight",
from_city: "深圳",
to_city: "上海",
depart_at: new Date("2026-03-15T01:00:00.000Z"),
arrive_at: new Date("2026-03-15T03:30:00.000Z"),
created_at: now,
updated_at: now,
},
],
travel_orders: [],
workflow_instances: [],
};
}
function buildPrisma({ items = [], detail = null } = {}) {
return {
travelRequest: {
findMany: vi.fn().mockResolvedValue(items),
count: vi.fn().mockResolvedValue(items.length),
findUnique: vi.fn().mockResolvedValue(detail),
},
};
}
async function buildApp({ employee, prisma }) {
const app = Fastify();
app.decorate("prisma", prisma);
app.decorate("config", { appTimezone: "Asia/Shanghai" });
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({
code: "AUTH_001",
message: "未登录",
statusCode: 401,
});
}
request.employee = employee;
});
app.decorate("hasPermission", (actor, permissionCode) => {
return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode);
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { permission: permissionCode },
});
}
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(travelRequestsRoutes, { prefix: "/api" });
return app;
}
describe("travelRequests routes", () => {
it("lists only own records when actor lacks view_all", async () => {
const prisma = buildPrisma({
items: [buildTravelRequestRecord()],
});
const app = await buildApp({
employee: { id: "employee-1", department_id: "dept-1", permissionCodes: ["travel.view_own"] },
prisma,
});
const response = await app.inject({
method: "GET",
url: "/api/travel-requests?mine=0&page=1&pageSize=10",
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
mine: true,
total: 1,
}),
);
expect(prisma.travelRequest.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [
{ applicant_employee_id: "employee-1" },
{ submitter_id: "employee-1" },
],
}),
}),
);
await app.close();
});
it("allows owner to fetch detail and blocks outsiders", async () => {
const detail = buildTravelRequestRecord();
const ownerApp = await buildApp({
employee: { id: "employee-1", department_id: "dept-1", permissionCodes: ["travel.view_own"] },
prisma: buildPrisma({ detail }),
});
const outsiderApp = await buildApp({
employee: { id: "employee-9", department_id: "dept-9", permissionCodes: ["travel.view_own"] },
prisma: buildPrisma({ detail }),
});
const ownerResponse = await ownerApp.inject({
method: "GET",
url: `/api/travel-requests/${detail.id}`,
headers: { authorization: "Bearer test" },
});
const outsiderResponse = await outsiderApp.inject({
method: "GET",
url: `/api/travel-requests/${detail.id}`,
headers: { authorization: "Bearer test" },
});
expect(ownerResponse.statusCode).toBe(200);
expect(ownerResponse.json().data).toEqual(
expect.objectContaining({
id: detail.id,
purpose: detail.purpose,
permissions: expect.objectContaining({ canEditDraft: true }),
}),
);
expect(outsiderResponse.statusCode).toBe(403);
expect(outsiderResponse.json().error.code).toBe("PERM_001");
await ownerApp.close();
await outsiderApp.close();
});
});
@@ -42,6 +42,19 @@ describe("Workflow Route Permissions", () => {
expect(actions).toEqual(["approve", "reject", "return"]); expect(actions).toEqual(["approve", "reject", "return"]);
}); });
it("allows travel approvers to act on travel tasks", () => {
const actions = listAllowedActionsForTask(
{ employee: { permissionCodes: ["travel.approve"] } },
{
status: "pending",
workflow_instance: { entity_type: "travel_request" },
},
true,
);
expect(actions).toEqual(["approve", "reject", "return"]);
});
it("keeps transfer action limited to reimbursement tasks", () => { it("keeps transfer action limited to reimbursement tasks", () => {
const reimbursementActions = listAllowedActionsForTask( const reimbursementActions = listAllowedActionsForTask(
{ employee: { permissionCodes: ["expense.approve", "expense.transfer"] } }, { employee: { permissionCodes: ["expense.approve", "expense.transfer"] } },
@@ -0,0 +1,33 @@
const { NOTIFICATION_TYPES } = require("../../../src/constants/notificationTypes");
const workflowRoutes = require("../../../src/routes/workflow");
describe("workflow travel helpers", () => {
const { getWorkflowEntityMeta, resolveWorkflowOutcomeNotificationType } = workflowRoutes.__testables;
it("builds travel entity meta with deep-link detail path", () => {
const meta = getWorkflowEntityMeta(
"travel_request",
{ request_no: "CL-20260310-0001" },
"11111111-1111-4111-8111-111111111111",
);
expect(meta).toEqual(
expect.objectContaining({
label: "差旅申请",
reference: "CL-20260310-0001",
targetType: "travel_request",
detailPath: "/travel-requests?id=11111111-1111-4111-8111-111111111111",
}),
);
});
it("resolves approved and rejected notification types for travel workflows", () => {
expect(resolveWorkflowOutcomeNotificationType("travel_request", "approved")).toBe(
NOTIFICATION_TYPES.travelRequestApproved,
);
expect(resolveWorkflowOutcomeNotificationType("travel_request", "rejected")).toBe(
NOTIFICATION_TYPES.travelRequestRejected,
);
expect(resolveWorkflowOutcomeNotificationType("travel_request", "returned")).toBeNull();
});
});