feat(P20T1-1,P20T1-2,P20T1-3): 差旅模型、申请/订单接口与工作流分支
这个提交包含在:
@@ -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;
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
文件差异内容过多而无法显示
加载差异
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户