diff --git a/server/prisma/migrations/20260310173221_p20_travel_1_request_intake/migration.sql b/server/prisma/migrations/20260310173221_p20_travel_1_request_intake/migration.sql new file mode 100644 index 00000000..7c5ca594 --- /dev/null +++ b/server/prisma/migrations/20260310173221_p20_travel_1_request_intake/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1e913e55..f52c3b0d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -52,6 +52,32 @@ enum PurchaseRequestStatus { 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 { in_stock in_use @@ -130,6 +156,7 @@ enum DocumentCategoryEntityType { reimbursement leave purchase_request + travel_request asset_card general } @@ -199,6 +226,7 @@ model Department { reimbursement_forms ReimbursementForm[] leave_requests LeaveRequest[] purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] workflow_instances WorkflowInstance[] budget_pools BudgetPool[] travel_orders TravelOrder[] @@ -258,6 +286,8 @@ model Employee { leave_ledgers LeaveLedger[] owned_cost_centers CostCenter[] @relation("CostCenterOwner") 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_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader") purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy") @@ -456,6 +486,7 @@ model LegalEntity { holiday_calendars HolidayCalendar[] @relation("HolidayCalendarLegalEntity") suppliers Supplier[] purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] asset_cards AssetCard[] budget_pools BudgetPool[] @relation("BudgetPoolLegalEntity") @@ -485,6 +516,7 @@ model CostCenter { owner_employee Employee? @relation("CostCenterOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull) budget_pools BudgetPool[] @relation("BudgetPoolCostCenter") purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] asset_cards AssetCard[] @@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) ledger_entries BudgetLedgerEntry[] purchase_requests PurchaseRequest[] + travel_requests TravelRequest[] @@index([department_id, status], map: "idx_budget_pools_department_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") } +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 { id String @id @default(uuid()) @db.Uuid source String @default("manual") @db.VarChar(32) @@ -1253,6 +1349,7 @@ model TravelOrder { employee_id String @db.Uuid department_id String? @db.Uuid linked_reimbursement_id String? @db.Uuid + travel_request_id String? @db.Uuid occurred_at DateTime? @db.Date amount Decimal? @db.Decimal(14, 2) currency String @default("CNY") @db.VarChar(8) @@ -1267,10 +1364,12 @@ model TravelOrder { employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict) department Department? @relation(fields: [department_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") @@index([employee_id, occurred_at], map: "idx_travel_orders_employee_occurred") @@index([linked_reimbursement_id]) + @@index([travel_request_id]) @@map("travel_orders") } @@ -1318,6 +1417,7 @@ model WorkflowInstance { reimbursement_id String? @db.Uuid leave_request_id String? @db.Uuid purchase_request_id String? @db.Uuid + travel_request_id String? @db.Uuid submitter_id String @db.Uuid current_status WorkflowInstanceStatus @default(pending) current_step_id String @@ -1334,6 +1434,7 @@ model WorkflowInstance { reimbursement_form ReimbursementForm? @relation(fields: [reimbursement_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) + travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: Cascade) submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict) department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull) @@ -1348,6 +1449,7 @@ model WorkflowInstance { @@index([reimbursement_id]) @@index([leave_request_id]) @@index([purchase_request_id]) + @@index([travel_request_id]) @@index([current_status]) @@index([submitter_id]) @@index([context_department_id]) diff --git a/server/src/app.js b/server/src/app.js index 42a72dc3..060725d4 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -44,6 +44,7 @@ const assetMovementsRoutes = require("./routes/assetMovements"); const inventoryChecksRoutes = require("./routes/inventoryChecks"); const integrationsRoutes = require("./routes/integrations"); const travelOrdersRoutes = require("./routes/travelOrders"); +const travelRequestsRoutes = require("./routes/travelRequests"); const aiRoutes = require("./routes/ai"); const { startJobs } = require("./jobs/jobSystem"); const { initMetrics } = require("./observability"); @@ -306,6 +307,7 @@ async function buildApp() { await app.register(inventoryChecksRoutes, { prefix: "/api" }); await app.register(integrationsRoutes, { prefix: "/api" }); await app.register(travelOrdersRoutes, { prefix: "/api" }); + await app.register(travelRequestsRoutes, { prefix: "/api" }); await app.register(aiRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" }); diff --git a/server/src/constants/notificationTypes.js b/server/src/constants/notificationTypes.js index 35aca5b5..1334fda0 100644 --- a/server/src/constants/notificationTypes.js +++ b/server/src/constants/notificationTypes.js @@ -3,6 +3,7 @@ const NOTIFICATION_DOMAIN_SCOPES = Object.freeze({ reimbursement: 'reimbursement', leave: 'leave', procurement: 'procurement', + travel: 'travel', asset: 'asset', inventory: 'inventory', }); @@ -17,6 +18,8 @@ const NOTIFICATION_TYPES = Object.freeze({ leaveRejected: 'leave.rejected', purchaseRequestApproved: 'purchase_request.approved', purchaseRequestRejected: 'purchase_request.rejected', + travelRequestApproved: 'travel_request.approved', + travelRequestRejected: 'travel_request.rejected', assetAssigned: 'asset.assigned', assetReturned: 'asset.returned', assetTransferred: 'asset.transferred', @@ -33,6 +36,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([ NOTIFICATION_TYPES.leaveRejected, NOTIFICATION_TYPES.purchaseRequestApproved, NOTIFICATION_TYPES.purchaseRequestRejected, + NOTIFICATION_TYPES.travelRequestApproved, + NOTIFICATION_TYPES.travelRequestRejected, NOTIFICATION_TYPES.assetAssigned, NOTIFICATION_TYPES.assetReturned, NOTIFICATION_TYPES.assetTransferred, @@ -49,6 +54,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ [NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave, [NOTIFICATION_TYPES.purchaseRequestApproved]: 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.assetReturned]: 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, leave_request: NOTIFICATION_DOMAIN_SCOPES.leave, purchase_request: NOTIFICATION_DOMAIN_SCOPES.procurement, + travel_request: NOTIFICATION_DOMAIN_SCOPES.travel, asset_card: NOTIFICATION_DOMAIN_SCOPES.asset, inventory_check: NOTIFICATION_DOMAIN_SCOPES.inventory, }); diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index ad8ba09e..42c27674 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -22,6 +22,12 @@ const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [ "procurement.submit", ]; +const TRAVEL_SELF_SERVICE_PERMISSIONS = [ + "travel.create", + "travel.view_own", + "travel.submit", +]; + const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"]; const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"]; @@ -30,6 +36,7 @@ const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"]; const EMPLOYEE_BASE_PERMISSIONS = [ ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, + ...TRAVEL_SELF_SERVICE_PERMISSIONS, ]; const ENTITY_PERMISSION_MAP = { @@ -79,6 +86,22 @@ const ENTITY_PERMISSION_MAP = { approve: "procurement.approve", 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 = { @@ -96,6 +119,8 @@ const ROLE_PERMISSIONS = { "leave.approve", "procurement.approve", "procurement.view_all", + "travel.approve", + "travel.view_all", "asset.view_all", "report.dashboard.view", "budget.view", @@ -112,6 +137,8 @@ const ROLE_PERMISSIONS = { "leave.view_all", "procurement.approve", "procurement.view_all", + "travel.approve", + "travel.view_all", "asset.view_all", "report.dashboard.view", "budget.view", @@ -127,6 +154,7 @@ const ROLE_PERMISSIONS = { "expense.finance.pay", "expense.view_all", "procurement.view_all", + "travel.view_all", "asset.view_all", ], finance_mgr: [ @@ -146,6 +174,8 @@ const ROLE_PERMISSIONS = { "expense.finance.override_duplicate", "procurement.view_all", "procurement.manage", + "travel.view_all", + "travel.manage", "asset.view_all", "asset.manage", "asset.inventory", @@ -157,6 +187,8 @@ const ROLE_PERMISSIONS = { ...EMPLOYEE_BASE_PERMISSIONS, "procurement.view_all", "procurement.manage", + "travel.view_all", + "travel.manage", "asset.view_all", "asset.manage", "asset.transfer", diff --git a/server/src/routes/masterData.js b/server/src/routes/masterData.js index 322b6093..bc534da2 100644 --- a/server/src/routes/masterData.js +++ b/server/src/routes/masterData.js @@ -10,6 +10,7 @@ const DOCUMENT_CATEGORY_ENTITY_TYPES = [ "reimbursement", "leave", "purchase_request", + "travel_request", "asset_card", "general", ]; diff --git a/server/src/routes/travelOrders.js b/server/src/routes/travelOrders.js index 6efa906c..5a883af3 100644 --- a/server/src/routes/travelOrders.js +++ b/server/src/routes/travelOrders.js @@ -8,6 +8,7 @@ const { normalizeTravelImportRows } = require("../services/integrations/travel") const IMPORT_SCHEMA = z.object({ orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100), }); +const UUID_SCHEMA = z.string().uuid(); function mapTravelOrder(item) { return { @@ -17,6 +18,7 @@ function mapTravelOrder(item) { employeeId: item.employee_id, departmentId: item.department_id ?? null, linkedReimbursementId: item.linked_reimbursement_id ?? null, + travelRequestId: item.travel_request_id ?? null, occurredAt: item.occurred_at?.toISOString?.()?.slice(0, 10) ?? null, amount: item.amount?.toString?.() ?? null, 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) { fastify.post( "/travel/orders/import", @@ -34,49 +45,56 @@ async function travelOrdersRoutes(fastify) { }, async (request, reply) => { if (!fastify.config.travelSyncEnabled) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { integration: "travel_sync_disabled" }, - }); + throw buildValidationError({ integration: "travel_sync_disabled" }, 409); } const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: parsed.error.flatten(), - }); + throw buildValidationError(parsed.error.flatten()); } const rows = normalizeTravelImportRows(parsed.data.orders); if (!rows.length) { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 400, - details: { orders: "empty_after_normalization" }, - }); + throw buildValidationError({ orders: "empty_after_normalization" }); } const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); - const employees = await fastify.prisma.employee.findMany({ - where: { - id: { in: employeeIds }, - status: "active", - }, - select: { - id: true, - department_id: true, - }, - }); - const employeeMap = new Map(employees.map((item) => [item.id, item])); + 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 created = []; + const [employees, travelRequests] = await Promise.all([ + fastify.prisma.employee.findMany({ + where: { + id: { in: employeeIds }, + status: "active", + }, + select: { + id: true, + department_id: true, + }, + }), + travelRequestIds.length + ? fastify.prisma.travelRequest.findMany({ + where: { id: { in: travelRequestIds } }, + select: { + id: true, + applicant_employee_id: true, + }, + }) + : Promise.resolve([]), + ]); + + const employeeMap = new Map(employees.map((item) => [item.id, item])); + const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item])); + + const imported = []; const skipped = []; + let linkedTravelRequestCount = 0; for (const row of rows) { const employee = employeeMap.get(row.employeeId); @@ -89,6 +107,61 @@ async function travelOrdersRoutes(fastify) { continue; } + let nextTravelRequestId; + if (row.hasTravelRequestId && row.travelRequestId) { + const travelRequest = travelRequestMap.get(row.travelRequestId); + if (!travelRequest) { + skipped.push({ + source: row.source, + externalOrderNo: row.externalOrderNo, + reason: "travel_request_not_found", + }); + 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, + department_id: employee.department_id, + linked_reimbursement_id: row.linkedReimbursementId, + occurred_at: row.occurredAt, + amount: row.amount, + currency: row.currency, + status: "imported", + raw_payload: row.rawPayload, + updated_by: request.employee.id, + }; + if (nextTravelRequestId !== undefined) { + updateData.travel_request_id = nextTravelRequestId; + } + + const createData = { + source: row.source, + external_order_no: row.externalOrderNo, + employee_id: row.employeeId, + department_id: employee.department_id, + linked_reimbursement_id: row.linkedReimbursementId, + travel_request_id: nextTravelRequestId ?? null, + occurred_at: row.occurredAt, + amount: row.amount, + currency: row.currency, + status: "imported", + raw_payload: row.rawPayload, + created_by: request.employee.id, + updated_by: request.employee.id, + }; + const upserted = await fastify.prisma.travelOrder.upsert({ where: { source_external_order_no: { @@ -96,34 +169,14 @@ async function travelOrdersRoutes(fastify) { external_order_no: row.externalOrderNo, }, }, - update: { - employee_id: row.employeeId, - department_id: employee.department_id, - linked_reimbursement_id: row.linkedReimbursementId, - occurred_at: row.occurredAt, - amount: row.amount, - currency: row.currency, - status: "imported", - raw_payload: row.rawPayload, - updated_by: request.employee.id, - }, - create: { - source: row.source, - external_order_no: row.externalOrderNo, - employee_id: row.employeeId, - department_id: employee.department_id, - linked_reimbursement_id: row.linkedReimbursementId, - occurred_at: row.occurredAt, - amount: row.amount, - currency: row.currency, - status: "imported", - raw_payload: row.rawPayload, - created_by: request.employee.id, - updated_by: request.employee.id, - }, + update: updateData, + create: createData, }); - created.push(upserted); + if (upserted.travel_request_id) { + linkedTravelRequestCount += 1; + } + imported.push(upserted); } await fastify.prisma.auditLog.create({ @@ -133,7 +186,8 @@ async function travelOrdersRoutes(fastify) { targetType: "travel_order", extra: { requested: rows.length, - createdOrUpdated: created.length, + createdOrUpdated: imported.length, + linkedTravelRequestCount, skipped, }, }), @@ -141,9 +195,10 @@ async function travelOrdersRoutes(fastify) { return sendData(reply, request, { requested: rows.length, - imported: created.length, + imported: imported.length, + linkedTravelRequestCount, skipped, - items: created.map(mapTravelOrder), + items: imported.map(mapTravelOrder), }); }, ); diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js new file mode 100644 index 00000000..ebe7fe5f --- /dev/null +++ b/server/src/routes/travelRequests.js @@ -0,0 +1,1165 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { buildAuditLogData } = require("../utils/audit"); +const { parseDateOnlyString } = require("../utils/date"); +const { sendData } = require("../utils/reply"); +const { issueDocumentNumber } = require("../services/documentSequence"); +const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); +const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); +const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow"); +const { sendNotificationEmailSafe } = require("../services/email"); +const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); +const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); +const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); + +const UUID_SCHEMA = z.string().uuid(); +const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional(); +const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(?:\.\d{1,2})?$/); +const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +const DATE_TIME_SCHEMA = z.string().min(1).max(64); +const TRAVEL_STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected"]); +const TRIP_TYPE_SCHEMA = z.enum(["domestic", "international"]); +const SEGMENT_ROLE_SCHEMA = z.enum(["outbound", "return", "connection"]); +const TRANSPORT_TYPE_SCHEMA = z.enum(["flight", "train", "car", "bus", "other"]); + +const TRAVEL_LIST_QUERY_SCHEMA = z.object({ + status: TRAVEL_STATUS_SCHEMA.optional(), + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + mine: BOOLEAN_QUERY_SCHEMA, + tripType: TRIP_TYPE_SCHEMA.optional(), + legalEntityId: UUID_SCHEMA.optional(), + costCenterId: UUID_SCHEMA.optional(), +}); + +const SEGMENT_BODY_SCHEMA = z + .object({ + segmentRole: SEGMENT_ROLE_SCHEMA, + transportType: TRANSPORT_TYPE_SCHEMA, + fromCity: z.string().trim().min(1).max(120), + toCity: z.string().trim().min(1).max(120), + departAt: DATE_TIME_SCHEMA, + arriveAt: DATE_TIME_SCHEMA, + }) + .strict(); + +const TRAVEL_CREATE_BODY_SCHEMA = z + .object({ + purpose: z.string().trim().min(1).max(500), + tripType: TRIP_TYPE_SCHEMA, + startDate: DATE_ONLY_SCHEMA, + endDate: DATE_ONLY_SCHEMA, + legalEntityId: UUID_SCHEMA, + costCenterId: UUID_SCHEMA, + estimatedAmount: z.union([MONEY_STRING_SCHEMA, z.number().min(0)]), + segments: z.array(SEGMENT_BODY_SCHEMA).min(1).max(20), + }) + .strict(); + +const TRAVEL_PATCH_BODY_SCHEMA = TRAVEL_CREATE_BODY_SCHEMA.partial(); + +const TRAVEL_SUMMARY_SELECT = { + id: true, + request_no: true, + applicant_employee_id: true, + submitter_id: true, + department_id: true, + legal_entity_id: true, + cost_center_id: true, + budget_pool_id: true, + purpose: true, + trip_type: true, + start_date: true, + end_date: true, + estimated_amount: true, + status: true, + created_at: true, + updated_at: true, + applicant: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + submitter: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + department: { + select: { + id: true, + name: true, + }, + }, + legal_entity: { + select: { + id: true, + code: true, + name: true, + }, + }, + cost_center: { + select: { + id: true, + code: true, + name: true, + legal_entity_id: true, + }, + }, + budget_pool: { + select: { + id: true, + code: true, + name: true, + }, + }, +}; + +const TRAVEL_DETAIL_SELECT = { + ...TRAVEL_SUMMARY_SELECT, + 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, + depart_at: true, + arrive_at: true, + created_at: true, + updated_at: true, + }, + }, + travel_orders: { + orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], + select: { + id: true, + source: true, + external_order_no: true, + employee_id: true, + linked_reimbursement_id: true, + occurred_at: true, + amount: true, + currency: true, + status: true, + created_at: true, + updated_at: true, + }, + }, + workflow_instances: { + where: { + entity_type: "travel_request", + current_status: "pending", + }, + orderBy: { created_at: "desc" }, + take: 1, + select: { + id: true, + current_step_id: true, + current_status: true, + workflow_tasks: { + where: { status: "pending" }, + orderBy: { created_at: "asc" }, + take: 1, + select: { + id: true, + step_id: true, + step_name: true, + assignee_id: true, + assignee_role: true, + }, + }, + }, + }, +}; + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function parseBooleanQueryValue(rawValue) { + if (rawValue == null) { + return null; + } + return rawValue === "1" || rawValue === "true"; +} + +function parseListQuery(query) { + const parsed = TRAVEL_LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + return { + status: parsed.data.status, + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + mine: parseBooleanQueryValue(parsed.data.mine) ?? true, + tripType: parsed.data.tripType, + legalEntityId: parsed.data.legalEntityId ?? null, + costCenterId: parsed.data.costCenterId ?? null, + }; +} + +function parseTravelRequestId(value) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw buildValidationError({ id: "invalid_uuid" }); + } + return parsed.data; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return parsed.data; +} + +function parseDateTimeValue(value, field) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw buildValidationError({ [field]: "invalid_datetime" }); + } + return parsed; +} + +function decimalInputToString(value, { field, min = 0 }) { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric)) { + throw buildValidationError({ [field]: "invalid_decimal" }); + } + if (numeric < min) { + throw buildValidationError({ [field]: min === 0 ? "must_be_non_negative" : "must_be_positive" }); + } + return numeric.toFixed(2); +} + +function normalizeSegments(segments) { + return segments.map((item, index) => { + const departAt = parseDateTimeValue(item.departAt, `segments.${index}.departAt`); + const arriveAt = parseDateTimeValue(item.arriveAt, `segments.${index}.arriveAt`); + if (arriveAt < departAt) { + throw buildValidationError({ [`segments.${index}.arriveAt`]: "must_be_after_departAt" }); + } + + return { + sortOrder: index + 1, + segmentRole: item.segmentRole, + transportType: item.transportType, + fromCity: item.fromCity.trim(), + toCity: item.toCity.trim(), + departAt, + arriveAt, + }; + }); +} + +function ensureDateRangeValid(startDate, endDate) { + if (endDate < startDate) { + throw buildValidationError({ endDate: "must_be_after_startDate" }); + } +} + +async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) { + const [legalEntity, costCenter] = await Promise.all([ + tx.legalEntity.findUnique({ + where: { id: legalEntityId }, + select: { id: true, code: true, name: true, enabled: true, archived_at: true }, + }), + tx.costCenter.findUnique({ + where: { id: costCenterId }, + select: { + id: true, + code: true, + name: true, + legal_entity_id: true, + enabled: true, + archived_at: true, + }, + }), + ]); + + if (!legalEntity || !legalEntity.enabled || legalEntity.archived_at) { + throw buildValidationError({ legalEntityId: "not_found_or_disabled" }, 409); + } + if (!costCenter || !costCenter.enabled || costCenter.archived_at) { + throw buildValidationError({ costCenterId: "not_found_or_disabled" }, 409); + } + if (costCenter.legal_entity_id !== legalEntityId) { + throw buildValidationError({ costCenterId: "legal_entity_mismatch" }, 409); + } + + return { legalEntity, costCenter }; +} + +function toIsoDate(value) { + return value?.toISOString?.()?.slice(0, 10) ?? null; +} + +function toIsoDateTime(value) { + return value?.toISOString?.() ?? null; +} + +function moneyToFixed(value) { + if (value == null) { + return "0.00"; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00"; +} + +function mapEmployeeBasic(employee) { + if (!employee) { + return null; + } + + return { + id: employee.id, + employeeNo: employee.employee_no, + name: employee.name, + }; +} + +function mapTravelSegment(item) { + return { + id: item.id, + sortOrder: item.sort_order, + segmentRole: item.segment_role, + transportType: item.transport_type, + fromCity: item.from_city, + toCity: item.to_city, + departAt: toIsoDateTime(item.depart_at), + arriveAt: toIsoDateTime(item.arrive_at), + createdAt: toIsoDateTime(item.created_at), + updatedAt: toIsoDateTime(item.updated_at), + }; +} + +function mapTravelOrder(item) { + return { + id: item.id, + source: item.source, + externalOrderNo: item.external_order_no, + employeeId: item.employee_id, + linkedReimbursementId: item.linked_reimbursement_id ?? null, + occurredAt: toIsoDate(item.occurred_at), + amount: moneyToFixed(item.amount), + currency: item.currency, + status: item.status, + createdAt: toIsoDateTime(item.created_at), + updatedAt: toIsoDateTime(item.updated_at), + }; +} + +function mapTravelRequestSummary(item) { + return { + id: item.id, + requestNo: item.request_no ?? null, + applicantEmployeeId: item.applicant_employee_id, + submitterId: item.submitter_id ?? null, + departmentId: item.department_id, + legalEntityId: item.legal_entity_id, + costCenterId: item.cost_center_id, + budgetPoolId: item.budget_pool_id ?? null, + purpose: item.purpose, + tripType: item.trip_type, + startDate: toIsoDate(item.start_date), + endDate: toIsoDate(item.end_date), + estimatedAmount: moneyToFixed(item.estimated_amount), + status: item.status, + createdAt: toIsoDateTime(item.created_at), + updatedAt: toIsoDateTime(item.updated_at), + applicant: mapEmployeeBasic(item.applicant), + submitter: mapEmployeeBasic(item.submitter), + department: item.department + ? { + id: item.department.id, + name: item.department.name, + } + : null, + legalEntity: item.legal_entity + ? { + id: item.legal_entity.id, + code: item.legal_entity.code, + name: item.legal_entity.name, + } + : null, + costCenter: item.cost_center + ? { + id: item.cost_center.id, + code: item.cost_center.code, + name: item.cost_center.name, + legalEntityId: item.cost_center.legal_entity_id, + } + : null, + budgetPool: item.budget_pool + ? { + id: item.budget_pool.id, + code: item.budget_pool.code, + name: item.budget_pool.name, + } + : null, + }; +} + +function mapTravelRequestDetail(item, request) { + const summary = mapTravelRequestSummary(item); + const isApplicant = item.applicant_employee_id === request.employee.id; + const canManage = request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("travel.manage"); + + return { + ...summary, + segments: item.segments.map(mapTravelSegment), + travelOrders: item.travel_orders.map(mapTravelOrder), + pendingWorkflow: item.workflow_instances[0] + ? { + instanceId: item.workflow_instances[0].id, + currentStatus: item.workflow_instances[0].current_status, + currentStepId: item.workflow_instances[0].current_step_id, + task: item.workflow_instances[0].workflow_tasks[0] + ? { + id: item.workflow_instances[0].workflow_tasks[0].id, + stepId: item.workflow_instances[0].workflow_tasks[0].step_id, + stepName: item.workflow_instances[0].workflow_tasks[0].step_name, + assigneeId: item.workflow_instances[0].workflow_tasks[0].assignee_id ?? null, + assigneeRole: item.workflow_instances[0].workflow_tasks[0].assignee_role ?? null, + } + : null, + } + : null, + permissions: { + canEditDraft: item.status === "draft" && isApplicant, + canSubmit: item.status === "draft" && isApplicant, + canWithdraw: item.status === "reviewing" && isApplicant, + canManage, + }, + }; +} + +function ensureTravelReadPermission(fastify, request) { + const canViewOwn = fastify.hasPermission(request.employee, "travel.view_own"); + const canViewAll = + fastify.hasPermission(request.employee, "travel.view_all") || + fastify.hasPermission(request.employee, "travel.manage"); + + if (!canViewOwn && !canViewAll) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + return { canViewOwn, canViewAll }; +} + +function canReadTravelRequest(request, travelRequest) { + if (!travelRequest) { + return false; + } + + if ( + request.employee.permissionCodes.includes("*") || + request.employee.permissionCodes.includes("travel.view_all") || + request.employee.permissionCodes.includes("travel.manage") + ) { + return true; + } + + return ( + travelRequest.applicant_employee_id === request.employee.id || + travelRequest.submitter_id === request.employee.id + ); +} + +function ensureDraftOwner(request, travelRequest, detailsKey = "travelRequest") { + if (travelRequest.applicant_employee_id !== request.employee.id) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { [detailsKey]: "only_applicant" }, + }); + } +} + +async function loadTravelRequestForUpdate(tx, travelRequestId) { + const rows = await tx.$queryRaw` + SELECT id + FROM "travel_requests" + WHERE id = ${travelRequestId}::uuid + FOR UPDATE + `; + + if (!Array.isArray(rows) || rows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const item = await tx.travelRequest.findUnique({ + where: { id: travelRequestId }, + select: { + id: true, + request_no: true, + applicant_employee_id: true, + submitter_id: true, + department_id: true, + legal_entity_id: true, + cost_center_id: true, + budget_pool_id: true, + purpose: true, + trip_type: true, + start_date: true, + end_date: true, + estimated_amount: true, + status: true, + segments: { + orderBy: [{ sort_order: "asc" }, { created_at: "asc" }], + select: { id: true }, + }, + }, + }); + + if (!item) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + return item; +} + +async function loadReadableTravelRequest(tx, travelRequestId, request) { + const travelRequest = await tx.travelRequest.findUnique({ + where: { id: travelRequestId }, + select: TRAVEL_DETAIL_SELECT, + }); + + if (!travelRequest) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + if (!canReadTravelRequest(request, travelRequest)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + return travelRequest; +} + +async function createTravelRequestsRoutes(fastify) { + fastify.get( + "/travel-requests", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const visibility = ensureTravelReadPermission(fastify, request); + const query = parseListQuery(request.query); + const mine = visibility.canViewAll ? query.mine : true; + const skip = (query.page - 1) * query.pageSize; + + const where = { + ...(query.status ? { status: query.status } : {}), + ...(query.tripType ? { trip_type: query.tripType } : {}), + ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), + ...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}), + ...(mine + ? { + OR: [ + { applicant_employee_id: request.employee.id }, + { submitter_id: request.employee.id }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.travelRequest.findMany({ + where, + orderBy: [{ created_at: "desc" }], + skip, + take: query.pageSize, + select: TRAVEL_SUMMARY_SELECT, + }), + fastify.prisma.travelRequest.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + mine, + items: items.map(mapTravelRequestSummary), + }); + }, + ); + + fastify.post( + "/travel-requests", + { + preHandler: fastify.requirePermission("travel.create"), + }, + async (request, reply) => { + const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body); + const startDate = parseDateOnlyString(body.startDate); + const endDate = parseDateOnlyString(body.endDate); + ensureDateRangeValid(startDate, endDate); + const estimatedAmount = decimalInputToString(body.estimatedAmount, { + field: "estimatedAmount", + min: 0, + }); + const segments = normalizeSegments(body.segments); + + const created = await fastify.prisma.$transaction(async (tx) => { + await ensureFinancialDimensions(tx, { + legalEntityId: body.legalEntityId, + costCenterId: body.costCenterId, + }); + + const travelRequest = await tx.travelRequest.create({ + data: { + applicant_employee_id: request.employee.id, + submitter_id: null, + department_id: request.employee.department_id, + legal_entity_id: body.legalEntityId, + cost_center_id: body.costCenterId, + purpose: body.purpose, + trip_type: body.tripType, + start_date: startDate, + end_date: endDate, + estimated_amount: estimatedAmount, + created_by: request.employee.id, + updated_by: request.employee.id, + segments: { + create: segments.map((item) => ({ + sort_order: item.sortOrder, + segment_role: item.segmentRole, + transport_type: item.transportType, + from_city: item.fromCity, + to_city: item.toCity, + depart_at: item.departAt, + arrive_at: item.arriveAt, + created_by: request.employee.id, + updated_by: request.employee.id, + })), + }, + }, + select: TRAVEL_DETAIL_SELECT, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_REQUEST_CREATED", + targetType: "travel_request", + targetId: travelRequest.id, + extra: { + tripType: travelRequest.trip_type, + startDate: toIsoDate(travelRequest.start_date), + endDate: toIsoDate(travelRequest.end_date), + estimatedAmount: moneyToFixed(travelRequest.estimated_amount), + segmentCount: travelRequest.segments.length, + }, + }), + }); + + return travelRequest; + }); + + return sendData(reply, request, mapTravelRequestDetail(created, request)); + }, + ); + + fastify.get( + "/travel-requests/:id", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensureTravelReadPermission(fastify, request); + const travelRequestId = parseTravelRequestId(request.params?.id); + const detail = await loadReadableTravelRequest(fastify.prisma, travelRequestId, request); + return sendData(reply, request, mapTravelRequestDetail(detail, request)); + }, + ); + + fastify.patch( + "/travel-requests/:id", + { + preHandler: fastify.requirePermission("travel.create"), + }, + async (request, reply) => { + const travelRequestId = parseTravelRequestId(request.params?.id); + const body = parseBody(TRAVEL_PATCH_BODY_SCHEMA, request.body); + if (Object.keys(body).length === 0) { + throw buildValidationError({ body: "empty_patch" }); + } + + let response = null; + + await fastify.prisma.$transaction(async (tx) => { + const locked = await loadTravelRequestForUpdate(tx, travelRequestId); + ensureDraftOwner(request, locked); + if (locked.status !== "draft") { + throw buildValidationError({ status: "only_draft_editable" }, 409); + } + + const nextStartDate = Object.prototype.hasOwnProperty.call(body, "startDate") + ? parseDateOnlyString(body.startDate) + : locked.start_date; + const nextEndDate = Object.prototype.hasOwnProperty.call(body, "endDate") + ? parseDateOnlyString(body.endDate) + : locked.end_date; + ensureDateRangeValid(nextStartDate, nextEndDate); + + const nextLegalEntityId = body.legalEntityId ?? locked.legal_entity_id; + const nextCostCenterId = body.costCenterId ?? locked.cost_center_id; + await ensureFinancialDimensions(tx, { + legalEntityId: nextLegalEntityId, + costCenterId: nextCostCenterId, + }); + + const normalizedSegments = Object.prototype.hasOwnProperty.call(body, "segments") + ? normalizeSegments(body.segments) + : null; + + await tx.travelRequest.update({ + where: { id: travelRequestId }, + data: { + ...(Object.prototype.hasOwnProperty.call(body, "purpose") ? { purpose: body.purpose } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "tripType") ? { trip_type: body.tripType } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "startDate") ? { start_date: nextStartDate } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "endDate") ? { end_date: nextEndDate } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "legalEntityId") + ? { legal_entity_id: body.legalEntityId, budget_pool_id: null } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, "costCenterId") + ? { cost_center_id: body.costCenterId, budget_pool_id: null } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, "estimatedAmount") + ? { + estimated_amount: decimalInputToString(body.estimatedAmount, { + field: "estimatedAmount", + min: 0, + }), + budget_pool_id: null, + } + : {}), + updated_by: request.employee.id, + }, + }); + + if (normalizedSegments) { + await tx.travelRequestSegment.deleteMany({ + where: { travel_request_id: travelRequestId }, + }); + if (normalizedSegments.length) { + await tx.travelRequestSegment.createMany({ + data: normalizedSegments.map((item) => ({ + travel_request_id: travelRequestId, + sort_order: item.sortOrder, + segment_role: item.segmentRole, + transport_type: item.transportType, + from_city: item.fromCity, + to_city: item.toCity, + depart_at: item.departAt, + arrive_at: item.arriveAt, + created_by: request.employee.id, + updated_by: request.employee.id, + })), + }); + } + } + + const updated = await tx.travelRequest.findUnique({ + where: { id: travelRequestId }, + select: TRAVEL_DETAIL_SELECT, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_REQUEST_UPDATED", + targetType: "travel_request", + targetId: travelRequestId, + extra: { + fields: Object.keys(body), + segmentCount: updated?.segments.length ?? locked.segments.length, + }, + }), + }); + + response = updated; + }); + + return sendData(reply, request, mapTravelRequestDetail(response, request)); + }, + ); + + fastify.post( + "/travel-requests/:id/submit", + { + preHandler: fastify.requirePermission("travel.submit"), + }, + async (request, reply) => { + const travelRequestId = parseTravelRequestId(request.params?.id); + let response = null; + const emailTasks = []; + + await fastify.prisma.$transaction(async (tx) => { + const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId); + ensureDraftOwner(request, travelRequest); + if (travelRequest.status !== "draft") { + throw buildValidationError({ status: "only_draft_submittable" }, 409); + } + if (!travelRequest.segments.length) { + throw buildValidationError({ segments: "required" }, 409); + } + + const workflowDefinition = await tx.workflowDefinition.findFirst({ + where: { entity_type: "travel_request", is_active: true }, + orderBy: { version: "desc" }, + select: { + id: true, + definition_json: true, + }, + }); + if (!workflowDefinition) { + throw buildValidationError({ workflow: "missing_definition" }, 409); + } + + const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); + const { actionable, skipped } = await resolveActionableStep({ + tx, + steps: workflowSteps, + startIndex: 0, + ownerId: travelRequest.applicant_employee_id, + logger: fastify.log, + }); + if (!actionable) { + throw buildValidationError({ workflow: "no_actionable_approver" }, 409); + } + + let requestNo = travelRequest.request_no; + if (!requestNo) { + const issued = await issueDocumentNumber(tx, { + documentType: "travel_request", + actorId: request.employee.id, + timeZone: fastify.config.appTimezone, + }); + requestNo = issued.documentNumber; + } + + const resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, { + legalEntityId: travelRequest.legal_entity_id, + costCenterId: travelRequest.cost_center_id, + effectiveDate: travelRequest.start_date, + }); + const selectedBudgetPoolId = resolvedBudgetPool?.id ?? null; + + await tx.travelRequest.update({ + where: { id: travelRequestId }, + data: { + request_no: requestNo, + submitter_id: request.employee.id, + budget_pool_id: selectedBudgetPoolId, + status: "reviewing", + updated_by: request.employee.id, + }, + }); + + const budgetReservation = await reserveBudgetForEntity(tx, { + budgetPoolId: selectedBudgetPoolId, + entityType: "travel_request", + entityId: travelRequestId, + amount: travelRequest.estimated_amount, + actorId: request.employee.id, + note: `submit_reserve:${requestNo ?? travelRequestId}`, + }); + + const workflowInstance = await tx.workflowInstance.create({ + data: { + workflow_id: workflowDefinition.id, + entity_type: "travel_request", + entity_id: travelRequestId, + travel_request_id: travelRequestId, + submitter_id: request.employee.id, + current_status: "pending", + current_step_id: actionable.step.id, + context_data: { + owner_id: travelRequest.applicant_employee_id, + department_id: travelRequest.department_id, + workflow_steps_snapshot: workflowSteps, + legal_entity_id: travelRequest.legal_entity_id, + cost_center_id: travelRequest.cost_center_id, + request_no: requestNo, + trip_type: travelRequest.trip_type, + }, + context_department_id: travelRequest.department_id, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { id: true }, + }); + + for (const skippedStep of skipped) { + await tx.workflowHistory.create({ + data: { + instance_id: workflowInstance.id, + actor_id: request.employee.id, + action: "skip", + payload: { + step_id: skippedStep.step.id, + reason: skippedStep.reason, + owner_id: travelRequest.applicant_employee_id, + depth_checked: skippedStep.depthChecked, + }, + }, + }); + } + + const workflowTask = await tx.workflowTask.create({ + data: { + instance_id: workflowInstance.id, + step_id: actionable.step.id, + step_name: actionable.step.name ?? actionable.step.id, + assignee_id: actionable.assigneeId, + assignee_role: actionable.assigneeRole, + }, + select: { + id: true, + assignee_id: true, + assignee_role: true, + }, + }); + + await tx.workflowHistory.create({ + data: { + instance_id: workflowInstance.id, + task_id: workflowTask.id, + actor_id: request.employee.id, + action: "start", + payload: { + step_id: actionable.step.id, + skipped_step_count: skipped.length, + }, + }, + }); + + const recipients = await resolveRecipientsForAssignee(tx, { + assigneeId: workflowTask.assignee_id, + assigneeRole: workflowTask.assignee_role, + }); + const title = `【待办】差旅申请 ${requestNo} 待审批`; + const body = `差旅申请 ${requestNo} 已提交,请及时处理。`; + + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: NOTIFICATION_TYPES.workflowTaskAssigned, + title, + body, + targetType: "workflow_task", + targetId: workflowTask.id, + }); + + await enqueueImNotifications(tx, { + config: fastify.config, + logger: fastify.log, + recipientEmployeeIds: recipients, + type: NOTIFICATION_TYPES.workflowTaskAssigned, + title, + body, + targetType: "workflow_task", + targetId: workflowTask.id, + targetPath: `/workflow/tasks/${workflowTask.id}`, + entityType: "travel_request", + entityId: travelRequestId, + actorId: request.employee.id, + }); + + emailTasks.push({ + type: NOTIFICATION_TYPES.workflowTaskAssigned, + recipientEmployeeIds: recipients, + subject: title, + body, + targetPath: `/workflow/tasks/${workflowTask.id}`, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_REQUEST_SUBMITTED", + targetType: "travel_request", + targetId: travelRequestId, + extra: { + requestNo, + estimatedAmount: moneyToFixed(travelRequest.estimated_amount), + workflowInstanceId: workflowInstance.id, + workflowTaskId: workflowTask.id, + budgetPoolId: selectedBudgetPoolId, + budgetReservation, + }, + }), + }); + + response = { + travelRequestId, + requestNo, + status: "reviewing", + workflowInstanceId: workflowInstance.id, + currentTaskId: workflowTask.id, + budgetPoolId: selectedBudgetPoolId, + budgetReservation, + }; + }); + + for (const emailTask of emailTasks) { + void sendNotificationEmailSafe({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + ...emailTask, + }); + } + + return sendData(reply, request, response); + }, + ); + + fastify.post( + "/travel-requests/:id/withdraw", + { + preHandler: fastify.requirePermission("travel.submit"), + }, + async (request, reply) => { + const travelRequestId = parseTravelRequestId(request.params?.id); + let response = null; + + await fastify.prisma.$transaction(async (tx) => { + const locked = await loadTravelRequestForUpdate(tx, travelRequestId); + ensureDraftOwner(request, locked); + if (locked.status !== "reviewing") { + throw buildValidationError({ status: "only_reviewing_withdrawable" }, 409); + } + + const workflowInstance = await tx.workflowInstance.findFirst({ + where: { + entity_type: "travel_request", + entity_id: travelRequestId, + current_status: "pending", + }, + orderBy: { created_at: "desc" }, + select: { id: true }, + }); + + if (workflowInstance) { + await tx.workflowTask.updateMany({ + where: { + instance_id: workflowInstance.id, + status: "pending", + }, + data: { + status: "cancelled", + completed_at: new Date(), + completed_by: request.employee.id, + }, + }); + + await tx.workflowInstance.update({ + where: { id: workflowInstance.id }, + data: { + current_status: "cancelled", + completed_at: new Date(), + updated_by: request.employee.id, + }, + }); + + await tx.workflowHistory.create({ + data: { + instance_id: workflowInstance.id, + actor_id: request.employee.id, + action: "withdraw", + payload: { + source: "travel_request_withdraw", + }, + }, + }); + } + + const budgetRelease = await releaseReservedBudgetIfAny(tx, { + entityType: "travel_request", + entityId: travelRequestId, + actorId: request.employee.id, + note: "withdraw_release_travel_budget", + }); + + await tx.travelRequest.update({ + where: { id: travelRequestId }, + data: { + status: "draft", + updated_by: request.employee.id, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "TRAVEL_REQUEST_WITHDRAWN", + targetType: "travel_request", + targetId: travelRequestId, + extra: { + workflowInstanceId: workflowInstance?.id ?? null, + budgetRelease, + }, + }), + }); + + response = { + travelRequestId, + status: "draft", + outcome: "withdrawn", + workflowInstanceId: workflowInstance?.id ?? null, + budgetRelease, + }; + }); + + return sendData(reply, request, response); + }, + ); +} + +module.exports = createTravelRequestsRoutes; +module.exports.__testables = { + normalizeSegments, + canReadTravelRequest, + ensureDateRangeValid, + mapTravelRequestSummary, +}; diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 89e6704b..7bf6500d 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -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 { label: "审批单", 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) { 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 { record: null, recipientEmployeeIds: [], @@ -565,12 +594,14 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) { if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved; if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved; if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved; + if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved; } if (outcome === 'rejected') { if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected; if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected; if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected; + if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected; } 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, + 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), history: historyItems.map((item) => ({ id: item.id, @@ -1477,6 +1593,21 @@ async function workflowRoutes(fastify) { "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({ @@ -1520,6 +1651,7 @@ async function workflowRoutes(fastify) { nextTaskId: null, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "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, 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({ @@ -1688,6 +1835,7 @@ async function workflowRoutes(fastify) { workflowInstanceId: workflowInstance.id, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "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, 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({ @@ -1856,6 +2019,7 @@ async function workflowRoutes(fastify) { workflowInstanceId: workflowInstance.id, reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "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 = { handlePurchaseApprovalErpSync, listAllowedActionsForTask, + getWorkflowEntityMeta, + resolveWorkflowOutcomeNotificationType, }; diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 51eb5da0..57e040fa 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -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 = [ { code: "reimbursement_general", @@ -149,6 +162,16 @@ const DEFAULT_DOCUMENT_CATEGORIES = [ default_workflow_key: null, 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", name: "固定资产卡片", @@ -529,6 +552,38 @@ async function seedWorkflowDefinition(prisma) { 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() { diff --git a/server/src/services/documentSequence.js b/server/src/services/documentSequence.js index f4844ec5..febbd7a6 100644 --- a/server/src/services/documentSequence.js +++ b/server/src/services/documentSequence.js @@ -39,6 +39,17 @@ const DOCUMENT_TYPE_CONFIG = { resetPeriod: "yearly", }, }, + travel_request: { + defaultCategoryCode: "TR_STANDARD", + preferLegacySequence: false, + fallback: { + prefix: "CL", + dateFormat: "YYYYMMDD", + separator: "-", + numberLength: 4, + resetPeriod: "yearly", + }, + }, asset_card: { defaultCategoryCode: "ASSET_CARD_DEFAULT", preferLegacySequence: false, diff --git a/server/src/services/integrations/travel.js b/server/src/services/integrations/travel.js index 30b4513f..41946c65 100644 --- a/server/src/services/integrations/travel.js +++ b/server/src/services/integrations/travel.js @@ -18,6 +18,9 @@ function normalizeTravelImportRows(rows) { ? new Date(`${String(item.occurredAt).slice(0, 10)}T00:00:00.000Z`) : null; + const hasTravelRequestId = Object.prototype.hasOwnProperty.call(item ?? {}, "travelRequestId"); + const rawTravelRequestId = String(item?.travelRequestId ?? "").trim(); + return { source, externalOrderNo, @@ -26,6 +29,8 @@ function normalizeTravelImportRows(rows) { item?.linkedReimbursementId && String(item.linkedReimbursementId).trim() ? String(item.linkedReimbursementId).trim() : null, + travelRequestId: rawTravelRequestId || null, + hasTravelRequestId, amount: Number.isFinite(amount) ? amount.toFixed(2) : null, currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY", occurredAt, diff --git a/server/tests/unit/constants/permissions.test.js b/server/tests/unit/constants/permissions.test.js index 466b2b90..1e47797f 100644 --- a/server/tests/unit/constants/permissions.test.js +++ b/server/tests/unit/constants/permissions.test.js @@ -44,6 +44,14 @@ describe("Permission Constants", () => { 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", () => { expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "ai.assist")).toBe(true); diff --git a/server/tests/unit/documentSequence.service.test.js b/server/tests/unit/documentSequence.service.test.js index 3e80ef07..5c476a04 100644 --- a/server/tests/unit/documentSequence.service.test.js +++ b/server/tests/unit/documentSequence.service.test.js @@ -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 () => { const prisma = buildPrisma({ categoryMap: { diff --git a/server/tests/unit/permissions.entity.test.js b/server/tests/unit/permissions.entity.test.js index 1e076985..beb78c69 100644 --- a/server/tests/unit/permissions.entity.test.js +++ b/server/tests/unit/permissions.entity.test.js @@ -11,3 +11,9 @@ describe("resolveEntityPermission", () => { 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"); +}); diff --git a/server/tests/unit/routes/travelOrders.test.js b/server/tests/unit/routes/travelOrders.test.js new file mode 100644 index 00000000..d4209bc7 --- /dev/null +++ b/server/tests/unit/routes/travelOrders.test.js @@ -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(); + }); +}); diff --git a/server/tests/unit/routes/travelRequests.test.js b/server/tests/unit/routes/travelRequests.test.js new file mode 100644 index 00000000..b747a606 --- /dev/null +++ b/server/tests/unit/routes/travelRequests.test.js @@ -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(); + }); +}); diff --git a/server/tests/unit/routes/workflow.permissions.test.js b/server/tests/unit/routes/workflow.permissions.test.js index 5b6aa08f..f1927a0f 100644 --- a/server/tests/unit/routes/workflow.permissions.test.js +++ b/server/tests/unit/routes/workflow.permissions.test.js @@ -42,6 +42,19 @@ describe("Workflow Route Permissions", () => { 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", () => { const reimbursementActions = listAllowedActionsForTask( { employee: { permissionCodes: ["expense.approve", "expense.transfer"] } }, diff --git a/server/tests/unit/routes/workflow.travel.test.js b/server/tests/unit/routes/workflow.travel.test.js new file mode 100644 index 00000000..08e40c44 --- /dev/null +++ b/server/tests/unit/routes/workflow.travel.test.js @@ -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(); + }); +});