From ceacc2f98d0149ed1dae54e307db714703945c2f Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 23 Mar 2026 11:54:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BB=84=E7=BB=87=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=9B=98=E7=82=B9=E4=B8=8E=E8=B5=84=E4=BA=A7=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 15 + README.md | 3 +- .../migration.sql | 184 +++ backend/prisma/schema.prisma | 164 ++- .../backfillImportedEquipmentLocations.js | 326 +++++ .../exportAdminManualReviewWorkbook.js | 439 ++++++ .../scripts/importConfirmedEquipmentToLive.js | 771 +++++++++++ backend/scripts/locationNormalization.js | 133 ++ .../prepareEquipmentMigrationDryRun.js | 1200 +++++++++++++++++ backend/src/controllers/batchController.ts | 11 +- .../src/controllers/batchImportController.ts | 11 +- .../src/controllers/departmentController.ts | 391 +++++- .../src/controllers/equipmentController.ts | 165 ++- backend/src/controllers/exportController.ts | 9 +- .../src/controllers/inventoryController.ts | 133 ++ backend/src/controllers/personController.ts | 11 +- .../controllers/publicInventoryController.ts | 189 +++ backend/src/index.ts | 4 + backend/src/routes/departments.ts | 2 + backend/src/routes/inventory.ts | 25 + backend/src/routes/publicInventory.ts | 22 + backend/src/services/hrSyncService.ts | 1042 ++++++++++---- backend/src/services/inventoryService.ts | 1127 ++++++++++++++++ backend/src/utils/organization.ts | 22 + docs/2026-03-19_组织架构改造交接.md | 149 ++ docs/2026-03-19_项目总交接.md | 332 +++++ docs/2026-03-23_新对话完整交接.md | 718 ++++++++++ docs/2026-03-23_资产迁移推进记录.md | 104 ++ docs/组织同步源表结构说明.md | 572 ++++++++ docs/资产盘点与组织同步联合方案.md | 1188 ++++++++++++++++ frontend/nginx.conf | 6 + frontend/src/api/department.ts | 13 +- frontend/src/api/inventory.ts | 68 + frontend/src/api/publicInventory.ts | 105 ++ .../src/components/CameraScannerModal.vue | 20 +- frontend/src/layouts/MainLayout.vue | 61 +- frontend/src/main.ts | 9 + frontend/src/router/index.ts | 32 +- frontend/src/style.css | 93 +- frontend/src/types/index.ts | 321 ++++- frontend/src/views/Login.vue | 182 +-- frontend/src/views/borrow/BorrowList.vue | 58 +- frontend/src/views/borrow/BorrowScan.vue | 66 +- frontend/src/views/borrow/ReturnScan.vue | 48 +- frontend/src/views/dashboard/Dashboard.vue | 28 +- .../src/views/equipment/EquipmentForm.vue | 2 +- .../src/views/equipment/EquipmentList.vue | 89 +- .../src/views/inventory/InventoryCreate.vue | 329 +++++ .../src/views/inventory/InventoryDetail.vue | 736 ++++++++++ .../src/views/inventory/InventoryList.vue | 286 ++++ .../views/inventory/PublicInventoryTask.vue | 794 +++++++++++ .../notifications/NotificationCenter.vue | 15 +- frontend/src/views/settings/Settings.vue | 37 +- .../components/AssetTypeManagement.vue | 4 +- .../components/DepartmentManagement.vue | 876 +++++++----- .../settings/components/HrSyncManagement.vue | 112 +- .../settings/components/OperationLogList.vue | 97 +- .../settings/components/PersonManagement.vue | 296 +++- .../components/StorageLocationManagement.vue | 4 +- frontend/src/views/statistics/Statistics.vue | 49 +- 前端视觉规范.md | 12 +- 前端视觉重构台账.md | 841 ++---------- 62 files changed, 13283 insertions(+), 1868 deletions(-) create mode 100644 backend/prisma/migrations/20260318_inventory_and_business_department_sync/migration.sql create mode 100644 backend/scripts/backfillImportedEquipmentLocations.js create mode 100644 backend/scripts/exportAdminManualReviewWorkbook.js create mode 100644 backend/scripts/importConfirmedEquipmentToLive.js create mode 100644 backend/scripts/locationNormalization.js create mode 100644 backend/scripts/prepareEquipmentMigrationDryRun.js create mode 100644 backend/src/controllers/inventoryController.ts create mode 100644 backend/src/controllers/publicInventoryController.ts create mode 100644 backend/src/routes/inventory.ts create mode 100644 backend/src/routes/publicInventory.ts create mode 100644 backend/src/services/inventoryService.ts create mode 100644 backend/src/utils/organization.ts create mode 100644 docs/2026-03-19_组织架构改造交接.md create mode 100644 docs/2026-03-19_项目总交接.md create mode 100644 docs/2026-03-23_新对话完整交接.md create mode 100644 docs/2026-03-23_资产迁移推进记录.md create mode 100644 docs/组织同步源表结构说明.md create mode 100644 docs/资产盘点与组织同步联合方案.md create mode 100644 frontend/src/api/inventory.ts create mode 100644 frontend/src/api/publicInventory.ts create mode 100644 frontend/src/views/inventory/InventoryCreate.vue create mode 100644 frontend/src/views/inventory/InventoryDetail.vue create mode 100644 frontend/src/views/inventory/InventoryList.vue create mode 100644 frontend/src/views/inventory/PublicInventoryTask.vue diff --git a/.gitignore b/.gitignore index c13428f..0d3001a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,18 @@ frontend/vite.config.js frontend/vite.config.d.ts frontend/public/branding/custom-logo.* frontend/public/branding/custom-favicon.* +backend/*.log +frontend/*.log +error.log +temp_migration_output/ +8.xlsx +~$*.xlsx +行政手动补录清单*.xlsx +地址标准化候选.csv +01-login-page.png +02-dashboard-overview.png +03-equipment-list.png +04-borrow-management.png +05-settings-page.png +nul +{ diff --git a/README.md b/README.md index a63897d..b1b2ec1 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,9 @@ HR_SYNC_EXCLUDED_NAME_KEYWORDS= - `HR_SYNC_ENABLED=true` 时,后端会启动每日定时同步 - `HR_SYNC_SCHEDULE` 建议使用 `03:00` -- `HR_SYNC_ALLOWED_STATUS_VALUES` 用来限定“哪些源状态算正式员工”,当前建议 `3` +- `HR_SYNC_ALLOWED_STATUS_VALUES` 用来标记“哪些源状态算正式员工”,当前建议 `3` - `HR_SYNC_INACTIVE_STATUS_VALUES` 用来标记源系统里的非在职状态码 +- 当前组织同步默认以 `hr_employee_register` 为主口径,`hr_employee` 只用于补充姓名、手机、邮箱、岗位等字段 - `HR_SYNC_EXCLUDED_DEPARTMENTS` 用来排除供应商、快递公司等外部对象 - `HR_SYNC_EXCLUDED_NAME_KEYWORDS` 可选,用来额外排除名字特征 - 如果源库是较老版本 SQL Server,建议 `SQLSERVER_ENCRYPT=false` diff --git a/backend/prisma/migrations/20260318_inventory_and_business_department_sync/migration.sql b/backend/prisma/migrations/20260318_inventory_and_business_department_sync/migration.sql new file mode 100644 index 0000000..b93d691 --- /dev/null +++ b/backend/prisma/migrations/20260318_inventory_and_business_department_sync/migration.sql @@ -0,0 +1,184 @@ +ALTER TABLE `person` + ADD COLUMN `source_department_name` VARCHAR(100) NULL AFTER `source_dept_ids`, + ADD COLUMN `source_main_department_name` VARCHAR(100) NULL AFTER `source_department_name`, + ADD COLUMN `department_sync_bucket` VARCHAR(20) NULL AFTER `source_main_department_name`; + +CREATE INDEX `person_department_sync_bucket_idx` + ON `person`(`department_sync_bucket`); + +CREATE TABLE `inventory_task` ( + `id` INT NOT NULL AUTO_INCREMENT, + `title` VARCHAR(150) NOT NULL, + `description` TEXT NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'active', + `public_token` VARCHAR(120) NOT NULL, + `scope_department_id` INT NULL, + `scope_department_name` VARCHAR(100) NULL, + `scope_asset_type_id` INT NULL, + `scope_asset_type_name` VARCHAR(100) NULL, + `scope_snapshot_json` TEXT NULL, + `participants_count` INT NOT NULL DEFAULT 0, + `items_count` INT NOT NULL DEFAULT 0, + `completed_count` INT NOT NULL DEFAULT 0, + `exception_count` INT NOT NULL DEFAULT 0, + `unassigned_count` INT NOT NULL DEFAULT 0, + `created_by` INT NOT NULL, + `closed_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `inventory_task_public_token_key`(`public_token`), + INDEX `inventory_task_status_created_at_idx`(`status`, `created_at`), + INDEX `inventory_task_scope_department_id_status_idx`(`scope_department_id`, `status`), + INDEX `inventory_task_scope_asset_type_id_status_idx`(`scope_asset_type_id`, `status`), + INDEX `inventory_task_created_by_idx`(`created_by`), + PRIMARY KEY (`id`), + CONSTRAINT `inventory_task_scope_department_id_fkey` + FOREIGN KEY (`scope_department_id`) REFERENCES `department`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_scope_asset_type_id_fkey` + FOREIGN KEY (`scope_asset_type_id`) REFERENCES `asset_type`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_created_by_fkey` + FOREIGN KEY (`created_by`) REFERENCES `user`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `inventory_task_participant` ( + `id` INT NOT NULL AUTO_INCREMENT, + `task_id` INT NOT NULL, + `person_id` INT NULL, + `person_name` VARCHAR(50) NOT NULL, + `person_nickname` VARCHAR(50) NOT NULL, + `person_source_userid` VARCHAR(100) NULL, + `department_id` INT NULL, + `department_name` VARCHAR(100) NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'pending', + `confirmed_at` DATETIME(3) NULL, + `last_action_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `inventory_task_participant_task_id_person_id_key`(`task_id`, `person_id`), + INDEX `inventory_task_participant_task_id_status_idx`(`task_id`, `status`), + INDEX `inventory_task_participant_task_id_person_id_idx`(`task_id`, `person_id`), + INDEX `inventory_task_participant_task_id_person_nickname_idx`(`task_id`, `person_nickname`), + INDEX `inventory_task_participant_department_id_idx`(`department_id`), + PRIMARY KEY (`id`), + CONSTRAINT `inventory_task_participant_task_id_fkey` + FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_participant_person_id_fkey` + FOREIGN KEY (`person_id`) REFERENCES `person`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_participant_department_id_fkey` + FOREIGN KEY (`department_id`) REFERENCES `department`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `inventory_task_item` ( + `id` INT NOT NULL AUTO_INCREMENT, + `task_id` INT NOT NULL, + `participant_id` INT NULL, + `equipment_id` INT NOT NULL, + `equipment_code` VARCHAR(50) NOT NULL, + `equipment_name` VARCHAR(100) NOT NULL, + `equipment_model` VARCHAR(100) NULL, + `equipment_type` VARCHAR(50) NOT NULL, + `asset_type_id` INT NULL, + `asset_type_name` VARCHAR(100) NULL, + `snapshot_department_id` INT NULL, + `snapshot_department_name` VARCHAR(100) NULL, + `snapshot_person_id` INT NULL, + `snapshot_person_name` VARCHAR(50) NULL, + `snapshot_person_nickname` VARCHAR(50) NULL, + `snapshot_status` VARCHAR(20) NULL, + `source_borrow_record_id` INT NULL, + `is_unassigned` BOOLEAN NOT NULL DEFAULT false, + `check_status` VARCHAR(20) NOT NULL DEFAULT 'pending', + `checked_by_participant_id` INT NULL, + `checked_at` DATETIME(3) NULL, + `exception_type` VARCHAR(30) NULL, + `exception_note` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `inventory_task_item_task_id_equipment_id_key`(`task_id`, `equipment_id`), + INDEX `inventory_task_item_task_id_participant_id_check_status_idx`(`task_id`, `participant_id`, `check_status`), + INDEX `inventory_task_item_task_id_equipment_id_idx`(`task_id`, `equipment_id`), + INDEX `inventory_task_item_task_id_is_unassigned_idx`(`task_id`, `is_unassigned`), + INDEX `inventory_task_item_task_id_check_status_idx`(`task_id`, `check_status`), + INDEX `inventory_task_item_checked_by_participant_id_idx`(`checked_by_participant_id`), + INDEX `inventory_task_item_snapshot_department_id_idx`(`snapshot_department_id`), + INDEX `inventory_task_item_snapshot_person_id_idx`(`snapshot_person_id`), + PRIMARY KEY (`id`), + CONSTRAINT `inventory_task_item_task_id_fkey` + FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_item_participant_id_fkey` + FOREIGN KEY (`participant_id`) REFERENCES `inventory_task_participant`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_item_checked_by_participant_id_fkey` + FOREIGN KEY (`checked_by_participant_id`) REFERENCES `inventory_task_participant`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_item_equipment_id_fkey` + FOREIGN KEY (`equipment_id`) REFERENCES `equipment`(`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_item_asset_type_id_fkey` + FOREIGN KEY (`asset_type_id`) REFERENCES `asset_type`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_item_snapshot_department_id_fkey` + FOREIGN KEY (`snapshot_department_id`) REFERENCES `department`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_item_snapshot_person_id_fkey` + FOREIGN KEY (`snapshot_person_id`) REFERENCES `person`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `inventory_task_action` ( + `id` INT NOT NULL AUTO_INCREMENT, + `task_id` INT NOT NULL, + `participant_id` INT NULL, + `item_id` INT NULL, + `action_type` VARCHAR(40) NOT NULL, + `action_payload` TEXT NULL, + `ip_address` VARCHAR(50) NULL, + `user_agent` VARCHAR(255) NULL, + `operator_user_id` INT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `inventory_task_action_task_id_created_at_idx`(`task_id`, `created_at`), + INDEX `inventory_task_action_participant_id_idx`(`participant_id`), + INDEX `inventory_task_action_item_id_idx`(`item_id`), + INDEX `inventory_task_action_operator_user_id_idx`(`operator_user_id`), + PRIMARY KEY (`id`), + CONSTRAINT `inventory_task_action_task_id_fkey` + FOREIGN KEY (`task_id`) REFERENCES `inventory_task`(`id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_action_participant_id_fkey` + FOREIGN KEY (`participant_id`) REFERENCES `inventory_task_participant`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_action_item_id_fkey` + FOREIGN KEY (`item_id`) REFERENCES `inventory_task_item`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `inventory_task_action_operator_user_id_fkey` + FOREIGN KEY (`operator_user_id`) REFERENCES `user`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a239e8a..3a908e9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -20,7 +20,9 @@ model AssetType { created_at DateTime @default(now()) updated_at DateTime @updatedAt - equipments Equipment[] + equipments Equipment[] + inventoryTasks InventoryTask[] + inventoryTaskItems InventoryTaskItem[] @@index([status]) @@index([short_name]) @@ -43,9 +45,12 @@ model Department { parent Department? @relation("DepartmentHierarchy", fields: [parent_id], references: [id]) children Department[] @relation("DepartmentHierarchy") - equipments Equipment[] - mainPersons Person[] @relation("PersonMainDepartment") - personDepartments PersonDepartment[] + equipments Equipment[] + mainPersons Person[] @relation("PersonMainDepartment") + personDepartments PersonDepartment[] + inventoryTasks InventoryTask[] + inventoryTaskParticipants InventoryTaskParticipant[] + inventoryTaskItems InventoryTaskItem[] @relation("InventoryTaskItemSnapshotDepartment") @@index([status]) @@index([parent_id]) @@ -94,6 +99,7 @@ model Equipment { disposalRecords DisposalRecord[] // 维修记录关联 maintenanceRecords MaintenanceRecord[] + inventoryTaskItems InventoryTaskItem[] @@index([equipment_code]) @@index([equipment_type]) @@ -160,6 +166,8 @@ model User { maintenance_records MaintenanceRecord[] notifications UserNotification[] processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy") + inventory_tasks_created InventoryTask[] @relation("InventoryTaskCreatedBy") + inventory_actions InventoryTaskAction[] @relation("InventoryTaskActionOperator") @@index([role]) @@index([status]) @@ -197,21 +205,27 @@ model Person { source_system String? @db.VarChar(30) source_main_department_id Int? source_dept_ids String? @db.Text + source_department_name String? @db.VarChar(100) + source_main_department_name String? @db.VarChar(100) + department_sync_bucket String? @db.VarChar(20) manager_source_userid String? @db.VarChar(100) source_employee_status String? @db.VarChar(30) last_synced_at DateTime? created_at DateTime @default(now()) updated_at DateTime @updatedAt - borrow_records BorrowRecord[] - mainDepartment Department? @relation("PersonMainDepartment", fields: [main_department_id], references: [id]) - personDepartments PersonDepartment[] + borrow_records BorrowRecord[] + mainDepartment Department? @relation("PersonMainDepartment", fields: [main_department_id], references: [id]) + personDepartments PersonDepartment[] + inventoryTaskParticipants InventoryTaskParticipant[] + inventoryTaskItems InventoryTaskItem[] @relation("InventoryTaskItemSnapshotPerson") @@index([nickname]) @@index([real_name]) @@index([status]) @@index([main_department_id]) @@index([source_system]) + @@index([department_sync_bucket]) @@index([last_synced_at]) @@map("person") } @@ -344,6 +358,142 @@ model UserNotification { @@map("user_notification") } +model InventoryTask { + id Int @id @default(autoincrement()) + title String @db.VarChar(150) + description String? @db.Text + status String @default("active") @db.VarChar(20) + public_token String @unique @db.VarChar(120) + scope_department_id Int? + scope_department_name String? @db.VarChar(100) + scope_asset_type_id Int? + scope_asset_type_name String? @db.VarChar(100) + scope_snapshot_json String? @db.Text + participants_count Int @default(0) + items_count Int @default(0) + completed_count Int @default(0) + exception_count Int @default(0) + unassigned_count Int @default(0) + created_by Int + closed_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + scopeDepartment Department? @relation(fields: [scope_department_id], references: [id]) + scopeAssetType AssetType? @relation(fields: [scope_asset_type_id], references: [id]) + creator User @relation("InventoryTaskCreatedBy", fields: [created_by], references: [id]) + participants InventoryTaskParticipant[] + items InventoryTaskItem[] + actions InventoryTaskAction[] + + @@index([status, created_at]) + @@index([scope_department_id, status]) + @@index([scope_asset_type_id, status]) + @@index([created_by]) + @@map("inventory_task") +} + +model InventoryTaskParticipant { + id Int @id @default(autoincrement()) + task_id Int + person_id Int? + person_name String @db.VarChar(50) + person_nickname String @db.VarChar(50) + person_source_userid String? @db.VarChar(100) + department_id Int? + department_name String? @db.VarChar(100) + status String @default("pending") @db.VarChar(20) + confirmed_at DateTime? + last_action_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade) + person Person? @relation(fields: [person_id], references: [id], onDelete: SetNull) + department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull) + assigned_items InventoryTaskItem[] @relation("InventoryTaskItemAssignee") + checked_items InventoryTaskItem[] @relation("InventoryTaskItemChecker") + actions InventoryTaskAction[] + + @@unique([task_id, person_id]) + @@index([task_id, status]) + @@index([task_id, person_id]) + @@index([task_id, person_nickname]) + @@index([department_id]) + @@map("inventory_task_participant") +} + +model InventoryTaskItem { + id Int @id @default(autoincrement()) + task_id Int + participant_id Int? + equipment_id Int + equipment_code String @db.VarChar(50) + equipment_name String @db.VarChar(100) + equipment_model String? @db.VarChar(100) + equipment_type String @db.VarChar(50) + asset_type_id Int? + asset_type_name String? @db.VarChar(100) + snapshot_department_id Int? + snapshot_department_name String? @db.VarChar(100) + snapshot_person_id Int? + snapshot_person_name String? @db.VarChar(50) + snapshot_person_nickname String? @db.VarChar(50) + snapshot_status String? @db.VarChar(20) + source_borrow_record_id Int? + is_unassigned Boolean @default(false) + check_status String @default("pending") @db.VarChar(20) + checked_by_participant_id Int? + checked_at DateTime? + exception_type String? @db.VarChar(30) + exception_note String? @db.Text + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade) + participant InventoryTaskParticipant? @relation("InventoryTaskItemAssignee", fields: [participant_id], references: [id], onDelete: SetNull) + checked_by_participant InventoryTaskParticipant? @relation("InventoryTaskItemChecker", fields: [checked_by_participant_id], references: [id], onDelete: SetNull) + equipment Equipment @relation(fields: [equipment_id], references: [id], onDelete: Cascade) + assetType AssetType? @relation(fields: [asset_type_id], references: [id], onDelete: SetNull) + snapshotDepartment Department? @relation("InventoryTaskItemSnapshotDepartment", fields: [snapshot_department_id], references: [id], onDelete: SetNull) + snapshotPerson Person? @relation("InventoryTaskItemSnapshotPerson", fields: [snapshot_person_id], references: [id], onDelete: SetNull) + actions InventoryTaskAction[] + + @@unique([task_id, equipment_id]) + @@index([task_id, participant_id, check_status]) + @@index([task_id, equipment_id]) + @@index([task_id, is_unassigned]) + @@index([task_id, check_status]) + @@index([checked_by_participant_id]) + @@index([snapshot_department_id]) + @@index([snapshot_person_id]) + @@map("inventory_task_item") +} + +model InventoryTaskAction { + id Int @id @default(autoincrement()) + task_id Int + participant_id Int? + item_id Int? + action_type String @db.VarChar(40) + action_payload String? @db.Text + ip_address String? @db.VarChar(50) + user_agent String? @db.VarChar(255) + operator_user_id Int? + created_at DateTime @default(now()) + + task InventoryTask @relation(fields: [task_id], references: [id], onDelete: Cascade) + participant InventoryTaskParticipant? @relation(fields: [participant_id], references: [id], onDelete: SetNull) + item InventoryTaskItem? @relation(fields: [item_id], references: [id], onDelete: SetNull) + operator User? @relation("InventoryTaskActionOperator", fields: [operator_user_id], references: [id], onDelete: SetNull) + + @@index([task_id, created_at]) + @@index([participant_id]) + @@index([item_id]) + @@index([operator_user_id]) + @@map("inventory_task_action") +} + model MountHistory { id Int @id @default(autoincrement()) diff --git a/backend/scripts/backfillImportedEquipmentLocations.js b/backend/scripts/backfillImportedEquipmentLocations.js new file mode 100644 index 0000000..c27b7a0 --- /dev/null +++ b/backend/scripts/backfillImportedEquipmentLocations.js @@ -0,0 +1,326 @@ +let PrismaClient + +try { + ;({ PrismaClient } = require('@prisma/client')) +} catch { + ;({ PrismaClient } = require('/app/node_modules/@prisma/client')) +} + +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') +const { resolveStorageLocationName } = require('./locationNormalization') + +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') +const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe' +const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133' +const SSH_HOSTKEY = + process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA' +const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || '' +const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node' + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const readJsonl = (filePath) => + fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + +const ensureOutputDir = () => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const outputDir = path.join(OUTPUT_ROOT, `location_backfill_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const resolveLatestMigrationDir = () => { + const candidates = fs + .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_')) + .map((entry) => entry.name) + .sort() + + if (!candidates.length) { + throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`) + } + + return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) +} + +const parseArgs = () => { + const args = process.argv.slice(2) + const options = { + inputDir: '' + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--input-dir') { + options.inputDir = args[index + 1] || '' + index += 1 + } + } + + return options +} + +const resolveInputDir = (inputDirArg) => { + if (!inputDirArg) { + return resolveLatestMigrationDir() + } + + const resolved = path.isAbsolute(inputDirArg) + ? inputDirArg + : path.resolve(process.cwd(), inputDirArg) + + if (!fs.existsSync(resolved)) { + throw new Error(`指定的输入目录不存在: ${resolved}`) + } + + return resolved +} + +const buildPayload = (records) => { + const importableRecords = records.filter( + (record) => record.bucket === 'import-ready' || record.bucket === 'import-without-owner' + ) + const grouped = new Map() + + for (const record of importableRecords) { + const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() + if (!legacyCode) { + continue + } + + if (!grouped.has(legacyCode)) { + grouped.set(legacyCode, []) + } + + grouped.get(legacyCode).push(record) + } + + const duplicateCodes = new Set( + [...grouped.entries()].filter(([, items]) => items.length > 1).map(([legacyCode]) => legacyCode) + ) + + const updates = [] + for (const record of importableRecords) { + const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() + if (!legacyCode || duplicateCodes.has(legacyCode)) { + continue + } + + const resolvedLocationName = normalizeText( + record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue + ) + + if (!resolvedLocationName || !record.locationMatch) { + continue + } + + updates.push({ + legacyCode, + rawLocation: normalizeText(record.addressRaw), + resolvedLocationName, + sourceRow: record.sourceRow + }) + } + + return updates +} + +const runRemoteNodeScript = (script) => { + if (!fs.existsSync(PLINK_PATH)) { + throw new Error(`未找到 plink.exe: ${PLINK_PATH}`) + } + + if (!SSH_PASSWORD) { + throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量') + } + + const result = spawnSync( + PLINK_PATH, + ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], + { + encoding: 'utf8', + input: script, + maxBuffer: 64 * 1024 * 1024, + windowsHide: true + } + ) + + if (result.error) { + throw result.error + } + + if (result.status !== 0) { + throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`) + } + + const stdout = normalizeText(result.stdout) + if (!stdout) { + throw new Error('远程脚本返回为空') + } + + return JSON.parse(stdout) +} + +const buildRemoteScript = (payload) => { + const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') + + return ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8')) +;(async () => { + const locationNames = [...new Set(payload.map((item) => item.resolvedLocationName))] + const [locations, equipmentsBefore] = await Promise.all([ + prisma.storageLocation.findMany({ + where: { status: 'active', name: { in: locationNames } }, + select: { id: true, name: true, code: true } + }), + prisma.equipment.findMany({ + where: { equipment_code: { in: payload.map((item) => item.legacyCode) } }, + select: { + id: true, + equipment_code: true, + equipment_name: true, + storage_location_id: true, + storageLocation: { select: { id: true, name: true } } + } + }) + ]) + + const locationByName = new Map(locations.map((item) => [item.name, item])) + const equipmentByCode = new Map(equipmentsBefore.map((item) => [item.equipment_code, item])) + + const skipped = [] + const planned = [] + + for (const item of payload) { + const equipment = equipmentByCode.get(item.legacyCode) + if (!equipment) { + skipped.push({ ...item, reason: 'equipment_not_found' }) + continue + } + + if (equipment.storage_location_id) { + skipped.push({ + ...item, + reason: 'already_has_storage_location', + current_storage_location: equipment.storageLocation?.name || '' + }) + continue + } + + const location = locationByName.get(item.resolvedLocationName) + if (!location) { + skipped.push({ ...item, reason: 'storage_location_not_found' }) + continue + } + + planned.push({ + equipmentId: equipment.id, + legacyCode: item.legacyCode, + equipmentName: equipment.equipment_name, + rawLocation: item.rawLocation, + resolvedLocationName: item.resolvedLocationName, + storageLocationId: location.id + }) + } + + const updated = [] + for (const item of planned) { + const result = await prisma.equipment.update({ + where: { id: item.equipmentId }, + data: { storage_location_id: item.storageLocationId }, + select: { + id: true, + equipment_code: true, + storage_location_id: true, + storageLocation: { select: { id: true, name: true } } + } + }) + updated.push(result) + } + + const verification = { + nullStorageLocationCount: await prisma.equipment.count({ where: { storage_location_id: null } }), + updatedCount: updated.length + } + + console.log(JSON.stringify({ + payloadCount: payload.length, + plannedCount: planned.length, + updatedCount: updated.length, + skippedCount: skipped.length, + planned, + skipped, + verification + })) + + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` +} + +const run = () => { + const options = parseArgs() + const inputDir = resolveInputDir(options.inputDir) + const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl') + if (!fs.existsSync(normalizedPath)) { + throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`) + } + + const normalizedRecords = readJsonl(normalizedPath) + const payload = buildPayload(normalizedRecords) + if (!payload.length) { + throw new Error('没有可回填的地址记录') + } + + const outputDir = ensureOutputDir() + writeJson(path.join(outputDir, 'planned_payload.json'), { + inputDir, + payloadCount: payload.length, + payload + }) + + const result = runRemoteNodeScript(buildRemoteScript(payload)) + writeJson(path.join(outputDir, 'result.json'), result) + + console.log( + JSON.stringify( + { + outputDir, + inputDir, + payloadCount: payload.length, + result + }, + null, + 2 + ) + ) +} + +try { + run() +} catch (error) { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) +} diff --git a/backend/scripts/exportAdminManualReviewWorkbook.js b/backend/scripts/exportAdminManualReviewWorkbook.js new file mode 100644 index 0000000..955d800 --- /dev/null +++ b/backend/scripts/exportAdminManualReviewWorkbook.js @@ -0,0 +1,439 @@ +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') + +const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) + +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') +const DEFAULT_OUTPUT = path.resolve(__dirname, '../../行政手动补录清单.xlsx') +const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe' +const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133' +const SSH_HOSTKEY = + process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA' +const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || '' +const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node' + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const parseArgs = () => { + const args = process.argv.slice(2) + const options = { + inputDir: '', + importBackupDir: '', + output: DEFAULT_OUTPUT + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--input-dir') { + options.inputDir = args[index + 1] || '' + index += 1 + } else if (arg === '--import-backup-dir') { + options.importBackupDir = args[index + 1] || '' + index += 1 + } else if (arg === '--output') { + options.output = args[index + 1] || DEFAULT_OUTPUT + index += 1 + } + } + + return options +} + +const resolveLatestDir = (prefix) => { + const candidates = fs + .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) + .map((entry) => entry.name) + .sort() + + if (!candidates.length) { + throw new Error(`未找到目录前缀 ${prefix} 的输出结果`) + } + + return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) +} + +const resolvePathArg = (value, fallbackPrefix) => { + if (!value) { + return resolveLatestDir(fallbackPrefix) + } + + const resolved = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value) + if (!fs.existsSync(resolved)) { + throw new Error(`路径不存在: ${resolved}`) + } + return resolved +} + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) + +const readJsonl = (filePath) => + fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + +const parseDescription = (description) => { + const result = {} + const normalized = normalizeText(description) + if (!normalized) { + return result + } + + for (const part of normalized.split(';')) { + const trimmed = normalizeText(part) + if (!trimmed) { + continue + } + + const splitIndex = trimmed.indexOf('=') + if (splitIndex === -1) { + continue + } + + const key = normalizeText(trimmed.slice(0, splitIndex)) + const value = normalizeText(trimmed.slice(splitIndex + 1)) + if (key) { + result[key] = value + } + } + + return result +} + +const withEditableFields = (row, fields) => ({ + ...row, + ...fields +}) + +const buildPendingRows = (records) => + records + .filter((record) => record.bucket === 'pending-review') + .map((record) => + withEditableFields( + { + source_row: record.sourceRow, + legacy_asset_code: normalizeText(record.assetCodeRaw), + asset_name: normalizeText(record.assetNameRaw), + source_status: normalizeText(record.currentStatusRaw), + raw_owner_name: normalizeText(record.ownerRaw), + raw_department: normalizeText(record.departmentRaw), + normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''), + raw_location: normalizeText(record.addressRaw), + pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '', + suggested_action: + Array.isArray(record.pendingReasons) && record.pendingReasons.includes('unmapped_department') + ? '先确认业务部门映射' + : '人工核对后再补录' + }, + { + 行政填写_目标部门: '', + 行政填写_目标员工: '', + 行政填写_标准地址: '', + 行政填写_资产状态: '', + 行政填写_备注: '' + } + ) + ) + +const flattenDuplicateRows = (duplicateDetails) => + duplicateDetails.flatMap((item) => + item.items.map((detail, index) => ({ + legacy_code: item.legacyCode, + conflict_index: index + 1, + bucket: detail.bucket, + source_row: detail.sourceRow, + asset_name: detail.assetName, + owner: detail.owner, + department: detail.department, + source_status: detail.sourceStatus, + address: detail.address, + unit_price: detail.unitPrice, + purchase_date: detail.purchaseDate, + suggested_action: '同一个资产编码对应了多条记录,先人工确认保留哪一条' + })) + ) + +const runRemoteNodeScript = (script) => { + if (!fs.existsSync(PLINK_PATH)) { + throw new Error(`未找到 plink.exe: ${PLINK_PATH}`) + } + + if (!SSH_PASSWORD) { + throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量') + } + + const result = spawnSync( + PLINK_PATH, + ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], + { + encoding: 'utf8', + input: script, + maxBuffer: 32 * 1024 * 1024, + windowsHide: true + } + ) + + if (result.error) { + throw result.error + } + + if (result.status !== 0) { + throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`) + } + + const stdout = normalizeText(result.stdout) + if (!stdout) { + throw new Error('远程脚本返回为空') + } + + return JSON.parse(stdout) +} + +const buildRemoteNullLocationScript = () => ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +;(async () => { + const rows = await prisma.equipment.findMany({ + where: { storage_location_id: null }, + orderBy: [{ status: 'asc' }, { equipment_code: 'asc' }], + select: { + equipment_code: true, + equipment_name: true, + equipment_type: true, + status: true, + unit_price: true, + purchase_date: true, + description: true, + department: { select: { name: true } } + } + }) + + process.stdout.write(JSON.stringify(rows)) + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` + +const buildOwnerManualRows = (records, duplicateCodeSet) => + records + .filter( + (record) => + record.bucket === 'import-without-owner' && + record.statusKind === 'assigned' && + normalizeText(record.assetCodeRaw) && + !duplicateCodeSet.has(normalizeText(record.assetCodeRaw).toUpperCase()) + ) + .map((record) => + withEditableFields( + { + source_row: record.sourceRow, + legacy_asset_code: normalizeText(record.assetCodeRaw).toUpperCase(), + asset_name: normalizeText(record.assetNameRaw), + source_status: normalizeText(record.currentStatusRaw), + raw_owner_name: normalizeText(record.ownerRaw), + raw_department: normalizeText(record.departmentRaw), + normalized_department: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''), + raw_location: normalizeText(record.addressRaw), + pending_reason: Array.isArray(record.pendingReasons) ? record.pendingReasons.join('|') : '', + suggested_action: '资产已导入,但当前使用人还没落到系统里;请行政核对后补登记使用人' + }, + { + 行政填写_目标员工: '', + 行政填写_备注: '' + } + ) + ) + +const buildOwnerAliasSummaryRows = (records, duplicateCodeSet) => { + const grouped = new Map() + + for (const record of records) { + const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() + const ownerName = normalizeText(record.ownerRaw) + if ( + record.bucket !== 'import-without-owner' || + record.statusKind !== 'assigned' || + !legacyCode || + duplicateCodeSet.has(legacyCode) || + !ownerName + ) { + continue + } + + const key = ownerName + if (!grouped.has(key)) { + grouped.set(key, { + owner_name: ownerName, + asset_count: 0, + departments: new Set(), + pending_reasons: new Set(), + asset_codes: [] + }) + } + + const current = grouped.get(key) + current.asset_count += 1 + current.departments.add(normalizeText(record.departmentRaw)) + for (const reason of Array.isArray(record.pendingReasons) ? record.pendingReasons : []) { + current.pending_reasons.add(reason) + } + current.asset_codes.push(legacyCode) + } + + return [...grouped.values()] + .map((item) => + withEditableFields( + { + owner_name: item.owner_name, + asset_count: item.asset_count, + departments: [...item.departments].filter(Boolean).join('、'), + pending_reasons: [...item.pending_reasons].filter(Boolean).join('|'), + sample_asset_codes: item.asset_codes.slice(0, 10).join('、'), + suggested_action: '先核对该花名对应的当前在职员工,再统一补挂名下资产' + }, + { + 行政填写_目标员工: '', + 行政填写_备注: '' + } + ) + ) + .sort((left, right) => right.asset_count - left.asset_count || left.owner_name.localeCompare(right.owner_name, 'zh-CN')) +} + +const buildNullLocationRows = (rows) => + rows.map((row) => { + const metadata = parseDescription(row.description) + return withEditableFields( + { + source_row: metadata.source_row || '', + legacy_asset_code: normalizeText(row.equipment_code), + asset_name: normalizeText(row.equipment_name), + equipment_type: normalizeText(row.equipment_type), + current_status: normalizeText(row.status), + current_department: normalizeText(row.department?.name || ''), + raw_department: metadata.raw_department || '', + raw_owner: metadata.raw_owner || '', + raw_location: metadata.raw_location || '', + purchase_date: row.purchase_date ? String(row.purchase_date).slice(0, 10) : '', + unit_price: normalizeText(row.unit_price), + suggested_action: '资产已导入,但没有标准地址;请行政确认后补齐标准存放地点' + }, + { + 行政填写_标准地址: '', + 行政填写_备注: '' + } + ) + }) + +const appendSheet = (workbook, name, rows) => { + const safeRows = rows.length ? rows : [{ message: '当前没有数据' }] + const sheet = XLSX.utils.json_to_sheet(safeRows) + XLSX.utils.book_append_sheet(workbook, sheet, name) +} + +const writeWorkbookWithFallback = (workbook, outputPath) => { + try { + XLSX.writeFile(workbook, outputPath) + return outputPath + } catch (error) { + if (error && error.code === 'EBUSY') { + const parsed = path.parse(outputPath) + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const fallbackPath = path.join(parsed.dir, `${parsed.name}-${timestamp}${parsed.ext}`) + XLSX.writeFile(workbook, fallbackPath) + return fallbackPath + } + + throw error + } +} + +const run = () => { + const options = parseArgs() + const inputDir = resolvePathArg(options.inputDir, 'migration_dry_run_') + const importBackupDir = resolvePathArg(options.importBackupDir, 'live_import_backup_') + const outputPath = path.isAbsolute(options.output) + ? options.output + : path.resolve(process.cwd(), options.output) + + const normalizedRecords = readJsonl(path.join(inputDir, 'normalized_assets.jsonl')) + const missingCode = readJson(path.join(importBackupDir, 'excluded_missing_code_rows.json')) + const duplicateCodes = readJson(path.join(importBackupDir, 'excluded_duplicate_codes.json')) + const plannedSummary = readJson(path.join(importBackupDir, 'planned_import_summary.json')) + const liveNullLocationRows = runRemoteNodeScript(buildRemoteNullLocationScript()) + + const duplicateCodeSet = new Set(duplicateCodes.duplicateDetails.map((item) => item.legacyCode)) + const pendingRows = buildPendingRows(normalizedRecords) + const ownerManualRows = buildOwnerManualRows(normalizedRecords, duplicateCodeSet) + const ownerAliasSummaryRows = buildOwnerAliasSummaryRows(normalizedRecords, duplicateCodeSet) + const flattenedDuplicateRows = flattenDuplicateRows(duplicateCodes.duplicateDetails) + const nullLocationRows = buildNullLocationRows(liveNullLocationRows) + + const workbook = XLSX.utils.book_new() + + appendSheet(workbook, '概览', [ + { 项目: 'dry-run 待人工确认', 数量: pendingRows.length }, + { 项目: '缺少资产编码', 数量: missingCode.excludedMissingCodeCount || 0 }, + { 项目: '重复资产编码(编码数)', 数量: duplicateCodes.excludedDuplicateCodeCount || 0 }, + { 项目: '重复资产编码(记录数)', 数量: duplicateCodes.excludedDuplicateRecordCount || 0 }, + { 项目: '已导入待补使用人', 数量: ownerManualRows.length }, + { 项目: '待核对历史花名', 数量: ownerAliasSummaryRows.length }, + { 项目: 'live 待补标准地址', 数量: nullLocationRows.length }, + { 项目: '本轮已导入总数', 数量: plannedSummary.finalImportCount || 0 }, + { 项目: '其中已导入不带使用人', 数量: plannedSummary.finalImportWithoutOwnerCount || 0 } + ]) + appendSheet(workbook, '填写说明', [ + { 工作表: '待确认部门', 说明: '只在“行政填写_目标部门 / 目标员工 / 标准地址 / 资产状态”里填写需要修正的值。' }, + { 工作表: '待补使用人', 说明: '在“行政填写_目标员工”里填写当前在职员工姓名或花名。' }, + { 工作表: '花名核对', 说明: '同一个历史花名如果能统一对应到当前员工,优先在这里填写。' }, + { 工作表: '待补地址', 说明: '在“行政填写_标准地址”里填写系统里的标准地址名。' }, + { 工作表: '输出用途', 说明: '填写完成后可生成 migration_manual_overrides.json,再重新 dry-run / 导入。' } + ]) + appendSheet(workbook, '待确认部门', pendingRows) + appendSheet(workbook, '缺少编码', missingCode.rows || []) + appendSheet(workbook, '重复编码', flattenedDuplicateRows) + appendSheet(workbook, '待补使用人', ownerManualRows) + appendSheet(workbook, '花名核对', ownerAliasSummaryRows) + appendSheet(workbook, '待补地址', nullLocationRows) + + const finalOutputPath = writeWorkbookWithFallback(workbook, outputPath) + + console.log( + JSON.stringify( + { + requestedOutputPath: outputPath, + outputPath: finalOutputPath, + summary: { + pendingReview: pendingRows.length, + missingCode: missingCode.excludedMissingCodeCount || 0, + duplicateCodeCount: duplicateCodes.excludedDuplicateCodeCount || 0, + duplicateRecordCount: duplicateCodes.excludedDuplicateRecordCount || 0, + ownerManual: ownerManualRows.length, + ownerAliasSummary: ownerAliasSummaryRows.length, + nullLocation: nullLocationRows.length + } + }, + null, + 2 + ) + ) +} + +try { + run() +} catch (error) { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) +} diff --git a/backend/scripts/importConfirmedEquipmentToLive.js b/backend/scripts/importConfirmedEquipmentToLive.js new file mode 100644 index 0000000..2bc987b --- /dev/null +++ b/backend/scripts/importConfirmedEquipmentToLive.js @@ -0,0 +1,771 @@ +let PrismaClient + +try { + ;({ PrismaClient } = require('@prisma/client')) +} catch { + ;({ PrismaClient } = require('/app/node_modules/@prisma/client')) +} + +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') +const { resolveStorageLocationName } = require('./locationNormalization') + +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') +const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe' +const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133' +const SSH_HOSTKEY = + process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA' +const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || '' +const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node' + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const parseArgs = () => { + const args = process.argv.slice(2) + const options = { + inputDir: '', + keepExisting: false, + includeOwnerless: false + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--input-dir') { + options.inputDir = args[index + 1] || '' + index += 1 + } else if (arg === '--keep-existing') { + options.keepExisting = true + } else if (arg === '--include-ownerless') { + options.includeOwnerless = true + } + } + + return options +} + +const ensureOutputDir = (prefix) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const resolveLatestMigrationDir = () => { + const candidates = fs + .readdirSync(OUTPUT_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('migration_dry_run_')) + .map((entry) => entry.name) + .sort() + + if (!candidates.length) { + throw new Error(`未找到 dry-run 输出目录: ${OUTPUT_ROOT}`) + } + + return path.join(OUTPUT_ROOT, candidates[candidates.length - 1]) +} + +const resolveInputDir = (inputDirArg) => { + if (!inputDirArg) { + return resolveLatestMigrationDir() + } + + const resolved = path.isAbsolute(inputDirArg) + ? inputDirArg + : path.resolve(process.cwd(), inputDirArg) + + if (!fs.existsSync(resolved)) { + throw new Error(`指定的输入目录不存在: ${resolved}`) + } + + return resolved +} + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const readJsonl = (filePath) => + fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + +const parseDate = (value) => { + const normalized = normalizeText(value) + if (!normalized || normalized === '#REF!') { + return null + } + + const directDate = new Date(normalized) + if (!Number.isNaN(directDate.getTime())) { + return directDate.toISOString() + } + + return null +} + +const buildDescription = (record) => { + const parts = [ + ['legacy_asset_code', record.assetCodeRaw || ''], + ['source_row', record.sourceRow], + ['source_status', record.currentStatusRaw || ''], + ['raw_owner', record.ownerRaw || ''], + ['raw_department', record.departmentRaw || ''], + ['raw_location', record.addressRaw || ''], + ['raw_category', record.assetCategoryRaw || ''] + ] + + return parts + .map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`) + .join('; ') +} + +const collectDuplicateCodeDetails = (records) => { + const grouped = new Map() + + for (const record of records) { + const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() + if (!legacyCode) { + continue + } + + if (!grouped.has(legacyCode)) { + grouped.set(legacyCode, []) + } + + grouped.get(legacyCode).push({ + bucket: normalizeText(record.bucket), + sourceRow: record.sourceRow, + assetName: normalizeText(record.assetNameRaw), + owner: normalizeText(record.ownerRaw), + department: normalizeText(record.departmentRaw), + resolvedType: normalizeText(record.assetTypeResolvedName || record.assetTypeName), + address: normalizeText(record.addressRaw), + purchaseDate: normalizeText(record.purchaseDateParsed), + unitPrice: normalizeText(record.unitPriceParsed), + sourceStatus: normalizeText(record.currentStatusRaw), + statusKind: normalizeText(record.statusKind), + pendingReasons: Array.isArray(record.pendingReasons) ? record.pendingReasons : [] + }) + } + + return [...grouped.entries()] + .filter(([, items]) => items.length > 1) + .map(([legacyCode, items]) => ({ legacyCode, items })) +} + +const partitionImportCandidates = (records) => { + const missingCodeRows = [] + const validCodeRecords = [] + + for (const record of records) { + const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() + if (!legacyCode) { + missingCodeRows.push({ + bucket: normalizeText(record.bucket), + sourceRow: record.sourceRow, + assetName: normalizeText(record.assetNameRaw), + owner: normalizeText(record.ownerRaw), + department: normalizeText(record.departmentRaw), + currentStatus: normalizeText(record.currentStatusRaw), + address: normalizeText(record.addressRaw) + }) + continue + } + + validCodeRecords.push(record) + } + + const duplicateDetails = collectDuplicateCodeDetails(validCodeRecords) + const duplicateCodes = new Set(duplicateDetails.map((item) => item.legacyCode)) + const filteredRecords = validCodeRecords.filter( + (record) => !duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase()) + ) + const duplicateRows = validCodeRecords + .filter((record) => duplicateCodes.has(normalizeText(record.assetCodeRaw).toUpperCase())) + .map((record) => ({ + bucket: normalizeText(record.bucket), + legacyCode: normalizeText(record.assetCodeRaw).toUpperCase(), + sourceRow: record.sourceRow, + assetName: normalizeText(record.assetNameRaw), + owner: normalizeText(record.ownerRaw), + department: normalizeText(record.departmentRaw), + currentStatus: normalizeText(record.currentStatusRaw) + })) + + return { + filteredRecords, + missingCodeRows, + duplicateRows, + duplicateDetails + } +} + +const buildImportPayload = (records) => { + const payload = [] + + for (const record of records) { + const legacyCode = normalizeText(record.assetCodeRaw).toUpperCase() + const importMode = record.bucket === 'import-without-owner' ? 'without_owner' : 'with_owner' + const statusKind = normalizeText(record.statusKind) + const pendingReasons = Array.isArray(record.pendingReasons) ? record.pendingReasons : [] + const matchedPerson = record.ownerMatch?.matchedPerson || null + + if (importMode === 'with_owner' && !matchedPerson?.id) { + throw new Error(`发现未匹配 owner 的 import-ready 记录,sourceRow=${record.sourceRow} code=${legacyCode}`) + } + + if (importMode === 'without_owner' && !['assigned', 'unassigned'].includes(statusKind)) { + throw new Error( + `import-without-owner 记录状态异常,无法安全导入,sourceRow=${record.sourceRow} code=${legacyCode}` + ) + } + + const descriptionExtras = [`migration_bucket=${record.bucket}`] + if (importMode === 'without_owner') { + descriptionExtras.push(`owner_pending_reasons=${pendingReasons.join('|') || 'none'}`) + descriptionExtras.push(`owner_resolution=manual_followup_required`) + } + + payload.push({ + legacyCode, + importMode, + sourceStatusKind: statusKind, + unresolvedOwnerReasons: pendingReasons.join('|'), + rawOwnerName: normalizeText(record.ownerRaw), + equipmentName: normalizeText(record.assetNameRaw), + equipmentModel: normalizeText(record.equipment_model || record.modelRaw) || null, + equipmentType: normalizeText(record.assetTypeResolvedName || record.assetTypeName), + departmentName: normalizeText(record.departmentResolvedName || record.departmentNormalized || ''), + storageLocationName: record.locationMatch + ? normalizeText(record.locationResolvedName || resolveStorageLocationName(record.addressRaw).normalizedValue) + : '', + unitPrice: Number(record.unitPriceParsed || 0), + purchaseDate: normalizeText(record.purchaseDateParsed) || null, + supplier: '', + description: [buildDescription(record), ...descriptionExtras].join('; '), + ownerPersonId: matchedPerson?.id || null, + ownerNickname: normalizeText(matchedPerson?.nickname || ''), + ownerRealName: normalizeText(matchedPerson?.real_name || ''), + borrowTime: parseDate(record.claimedAtRaw) || parseDate(record.purchaseDateParsed) || null, + sourceRow: record.sourceRow + }) + } + + return payload +} + +const runRemoteNodeScript = (script) => { + if (!fs.existsSync(PLINK_PATH)) { + throw new Error(`未找到 plink.exe: ${PLINK_PATH}`) + } + + if (!SSH_PASSWORD) { + throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量') + } + + const result = spawnSync( + PLINK_PATH, + ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], + { + encoding: 'utf8', + input: script, + maxBuffer: 64 * 1024 * 1024, + windowsHide: true + } + ) + + if (result.error) { + throw result.error + } + + if (result.status !== 0) { + throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`) + } + + const stdout = normalizeText(result.stdout) + if (!stdout) { + throw new Error('远程脚本返回为空') + } + + return JSON.parse(stdout) +} + +const buildRemoteBackupScript = () => ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +;(async () => { + const [ + equipment, + borrowRecords, + maintenanceRecords, + disposalRecords, + mountHistories, + inventoryTasks, + inventoryTaskParticipants, + inventoryTaskItems, + inventoryTaskActions + ] = await Promise.all([ + prisma.equipment.findMany({ orderBy: { id: 'asc' } }), + prisma.borrowRecord.findMany({ orderBy: { id: 'asc' } }), + prisma.maintenanceRecord.findMany({ orderBy: { id: 'asc' } }), + prisma.disposalRecord.findMany({ orderBy: { id: 'asc' } }), + prisma.mountHistory.findMany({ orderBy: { id: 'asc' } }), + prisma.inventoryTask.findMany({ orderBy: { id: 'asc' } }), + prisma.inventoryTaskParticipant.findMany({ orderBy: { id: 'asc' } }), + prisma.inventoryTaskItem.findMany({ orderBy: { id: 'asc' } }), + prisma.inventoryTaskAction.findMany({ orderBy: { id: 'asc' } }) + ]) + + console.log(JSON.stringify({ + generatedAt: new Date().toISOString(), + counts: { + equipment: equipment.length, + borrowRecords: borrowRecords.length, + maintenanceRecords: maintenanceRecords.length, + disposalRecords: disposalRecords.length, + mountHistories: mountHistories.length, + inventoryTasks: inventoryTasks.length, + inventoryTaskParticipants: inventoryTaskParticipants.length, + inventoryTaskItems: inventoryTaskItems.length, + inventoryTaskActions: inventoryTaskActions.length + }, + equipment, + borrowRecords, + maintenanceRecords, + disposalRecords, + mountHistories, + inventoryTasks, + inventoryTaskParticipants, + inventoryTaskItems, + inventoryTaskActions + })) + + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` + +const buildRemoteWipeScript = () => ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +;(async () => { + const before = { + equipment: await prisma.equipment.count(), + borrowRecords: await prisma.borrowRecord.count(), + maintenanceRecords: await prisma.maintenanceRecord.count(), + disposalRecords: await prisma.disposalRecord.count(), + mountHistories: await prisma.mountHistory.count(), + inventoryTasks: await prisma.inventoryTask.count(), + inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(), + inventoryTaskItems: await prisma.inventoryTaskItem.count(), + inventoryTaskActions: await prisma.inventoryTaskAction.count() + } + + await prisma.$transaction(async (tx) => { + await tx.inventoryTaskAction.deleteMany({}) + await tx.inventoryTaskItem.deleteMany({}) + await tx.inventoryTaskParticipant.deleteMany({}) + await tx.inventoryTask.deleteMany({}) + await tx.maintenanceRecord.deleteMany({}) + await tx.disposalRecord.deleteMany({}) + await tx.mountHistory.deleteMany({}) + await tx.borrowRecord.deleteMany({}) + await tx.equipment.deleteMany({}) + }) + + const after = { + equipment: await prisma.equipment.count(), + borrowRecords: await prisma.borrowRecord.count(), + maintenanceRecords: await prisma.maintenanceRecord.count(), + disposalRecords: await prisma.disposalRecord.count(), + mountHistories: await prisma.mountHistory.count(), + inventoryTasks: await prisma.inventoryTask.count(), + inventoryTaskParticipants: await prisma.inventoryTaskParticipant.count(), + inventoryTaskItems: await prisma.inventoryTaskItem.count(), + inventoryTaskActions: await prisma.inventoryTaskAction.count() + } + + console.log(JSON.stringify({ before, after })) + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` + +const buildRemoteImportScript = (payload, keepExisting) => { + const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') + + return ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8')) +const keepExisting = ${keepExisting ? 'true' : 'false'} + +const normalizeText = (value) => String(value == null ? '' : value).replace(/\\r/g, ' ').replace(/\\n/g, ' ').replace(/\\s+/g, ' ').trim() +const toDateOrNull = (value) => { + const normalized = normalizeText(value) + if (!normalized) return null + const parsed = new Date(normalized) + return Number.isNaN(parsed.getTime()) ? null : parsed +} +const calculateValues = (unitPrice, remainingQuantity) => { + const normalizedUnitPrice = Number(unitPrice || 0) + const totalValue = normalizedUnitPrice + const borrowedQuantity = Math.max(1 - Number(remainingQuantity || 0), 0) + return { + totalValue, + borrowedValue: normalizedUnitPrice * borrowedQuantity, + availableValue: normalizedUnitPrice * Number(remainingQuantity || 0) + } +} + +;(async () => { + const [assetTypes, departments, locations] = await Promise.all([ + prisma.assetType.findMany({ where: { status: 'active' }, select: { id: true, name: true } }), + prisma.department.findMany({ + where: { status: 'active', source_system: 'ding_hr_business' }, + select: { id: true, name: true } + }), + prisma.storageLocation.findMany({ where: { status: 'active' }, select: { id: true, name: true } }) + ]) + + const assetTypeMap = new Map(assetTypes.map((item) => [item.name, item])) + const departmentMap = new Map(departments.map((item) => [item.name, item])) + const locationMap = new Map(locations.map((item) => [item.name, item])) + + const duplicatePayloadCodes = new Set() + const payloadCodes = new Set() + for (const item of payload) { + if (payloadCodes.has(item.legacyCode)) { + duplicatePayloadCodes.add(item.legacyCode) + } + payloadCodes.add(item.legacyCode) + } + if (duplicatePayloadCodes.size > 0) { + throw new Error('导入 payload 中存在重复资产编码: ' + [...duplicatePayloadCodes].join(', ')) + } + + const existingCodes = await prisma.equipment.findMany({ + where: { equipment_code: { in: [...payloadCodes] } }, + select: { equipment_code: true } + }) + if (!keepExisting && existingCodes.length > 0) { + throw new Error('线上已存在同名资产编码: ' + existingCodes.map((item) => item.equipment_code).join(', ')) + } + const existingCodeSet = new Set(existingCodes.map((item) => item.equipment_code)) + + const results = [] + const skippedExisting = [] + + for (const item of payload) { + if (existingCodeSet.has(item.legacyCode)) { + skippedExisting.push({ + legacyCode: item.legacyCode, + importMode: item.importMode, + reason: 'already_exists' + }) + continue + } + + const assetType = assetTypeMap.get(item.equipmentType) + if (!assetType) { + throw new Error('资产类型不存在: ' + item.equipmentType + ' code=' + item.legacyCode) + } + + const department = item.departmentName ? departmentMap.get(item.departmentName) || null : null + if (item.departmentName && !department) { + throw new Error('业务部门不存在: ' + item.departmentName + ' code=' + item.legacyCode) + } + + const location = item.storageLocationName ? locationMap.get(item.storageLocationName) || null : null + if (item.storageLocationName && !location) { + throw new Error('存放地点不存在: ' + item.storageLocationName + ' code=' + item.legacyCode) + } + + let person = null + if (item.importMode === 'with_owner') { + person = await prisma.person.findFirst({ + where: { + id: item.ownerPersonId, + status: 'active', + source_system: 'ding_hr' + }, + select: { + id: true, + real_name: true, + nickname: true, + id_number: true + } + }) + + if (!person) { + throw new Error('使用人不存在或不再是 active: personId=' + item.ownerPersonId + ' code=' + item.legacyCode) + } + } + + const unitPrice = Number(item.unitPrice || 0) + const purchaseDate = toDateOrNull(item.purchaseDate) + const borrowTime = toDateOrNull(item.borrowTime) || new Date() + + let equipmentStatus = '可用' + let remainingQuantity = 1 + let borrowRecordNeeded = false + let borrowNotes = 'Excel迁移导入;在职长期' + + if (item.importMode === 'with_owner') { + equipmentStatus = '已借出' + remainingQuantity = 0 + borrowRecordNeeded = true + } else if (item.sourceStatusKind === 'assigned') { + equipmentStatus = '已借出' + remainingQuantity = 0 + borrowNotes = 'Excel迁移导入;当前使用人待行政补录' + } else if (item.sourceStatusKind === 'unassigned') { + equipmentStatus = '可用' + remainingQuantity = 1 + } else { + throw new Error('ownerless 资产状态异常: ' + item.sourceStatusKind + ' code=' + item.legacyCode) + } + + const values = calculateValues(unitPrice, remainingQuantity) + + const created = await prisma.$transaction(async (tx) => { + const equipment = await tx.equipment.create({ + data: { + equipment_code: item.legacyCode, + equipment_name: item.equipmentName, + equipment_model: normalizeText(item.equipmentModel) || null, + equipment_type: assetType.name, + code_generation_mode: 'manual', + status: equipmentStatus, + total_quantity: 1, + remaining_quantity: remainingQuantity, + unit_price: unitPrice, + total_value: values.totalValue, + borrowed_value: values.borrowedValue, + available_value: values.availableValue, + total_value_with_children: values.totalValue, + description: normalizeText(item.description) || null, + purchase_date: purchaseDate, + supplier: normalizeText(item.supplier) || null, + storage_location_id: location?.id, + asset_type_id: assetType.id, + department_id: department?.id, + mount_status: 'none' + } + }) + + if (borrowRecordNeeded && person) { + await tx.borrowRecord.create({ + data: { + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + person_id: person.id, + person_name: person.real_name, + person_nickname: person.nickname, + person_identifier: person.id_number, + borrow_time: borrowTime, + expected_return_time: null, + is_long_term: true, + return_time: null, + status: '已借出', + return_condition: '正常', + notes: borrowNotes + } + }) + } + + return { + id: equipment.id, + equipment_code: equipment.equipment_code, + importMode: item.importMode, + status: equipment.status, + person_id: person?.id || null, + person_nickname: person?.nickname || null + } + }) + + results.push(created) + } + + const verification = { + equipment: await prisma.equipment.count(), + borrowRecords: await prisma.borrowRecord.count(), + activeBorrow: await prisma.borrowRecord.count({ where: { status: '已借出' } }), + importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: [...payloadCodes] } } }), + importedWithOwnerCount: results.filter((item) => item.importMode === 'with_owner').length, + importedWithoutOwnerCount: results.filter((item) => item.importMode === 'without_owner').length, + skippedExistingCount: skippedExisting.length + } + + console.log(JSON.stringify({ + imported: results.length, + verification, + skippedExisting, + sample: results.slice(0, 10) + })) + + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` +} + +const buildRemoteVerifyScript = (payload) => { + const payloadBase64 = Buffer.from( + JSON.stringify(payload.map((item) => item.legacyCode)), + 'utf8' + ).toString('base64') + + return ` +let PrismaClient +try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) } +const prisma = new PrismaClient() +const codes = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8')) +;(async () => { + const [equipment, borrowRecords, activeBorrow, importedEquipments] = await Promise.all([ + prisma.equipment.count(), + prisma.borrowRecord.count(), + prisma.borrowRecord.count({ where: { status: '已借出' } }), + prisma.equipment.findMany({ + where: { equipment_code: { in: codes } }, + orderBy: { equipment_code: 'asc' }, + take: 20, + select: { + equipment_code: true, + equipment_name: true, + equipment_type: true, + status: true, + remaining_quantity: true, + department: { select: { name: true } }, + storageLocation: { select: { name: true } } + } + }) + ]) + + console.log(JSON.stringify({ + equipment, + borrowRecords, + activeBorrow, + importedCodeCount: await prisma.equipment.count({ where: { equipment_code: { in: codes } } }), + sample: importedEquipments + })) + + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { await prisma.$disconnect() } catch {} + process.exit(1) +}) +` +} + +const run = () => { + const options = parseArgs() + const inputDir = resolveInputDir(options.inputDir) + const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl') + + if (!fs.existsSync(normalizedPath)) { + throw new Error(`未找到 normalized_assets.jsonl: ${normalizedPath}`) + } + + const normalizedRecords = readJsonl(normalizedPath) + const importReadyRecords = normalizedRecords.filter((record) => record.bucket === 'import-ready') + const importWithoutOwnerRecords = options.includeOwnerless + ? normalizedRecords.filter((record) => record.bucket === 'import-without-owner') + : [] + const importCandidates = [...importReadyRecords, ...importWithoutOwnerRecords] + + if (!importCandidates.length) { + throw new Error('没有可导入的确认记录') + } + + const partitioned = partitionImportCandidates(importCandidates) + const payload = buildImportPayload(partitioned.filteredRecords) + const backupDir = ensureOutputDir('live_import_backup') + + writeJson(path.join(backupDir, 'excluded_missing_code_rows.json'), { + excludedMissingCodeCount: partitioned.missingCodeRows.length, + rows: partitioned.missingCodeRows + }) + writeJson(path.join(backupDir, 'excluded_duplicate_codes.json'), { + excludedDuplicateCodeCount: partitioned.duplicateDetails.length, + excludedDuplicateRecordCount: partitioned.duplicateRows.length, + duplicateDetails: partitioned.duplicateDetails, + excludedRows: partitioned.duplicateRows + }) + writeJson(path.join(backupDir, 'planned_import_summary.json'), { + inputDir, + importReadyCount: importReadyRecords.length, + importWithoutOwnerCount: importWithoutOwnerRecords.length, + selectedCandidateCount: importCandidates.length, + excludedMissingCodeCount: partitioned.missingCodeRows.length, + excludedDuplicateRecordCount: partitioned.duplicateRows.length, + excludedDuplicateCodeCount: partitioned.duplicateDetails.length, + finalImportCount: payload.length, + finalImportReadyCount: payload.filter((item) => item.importMode === 'with_owner').length, + finalImportWithoutOwnerCount: payload.filter((item) => item.importMode === 'without_owner').length, + keepExisting: options.keepExisting, + includeOwnerless: options.includeOwnerless + }) + writeJson(path.join(backupDir, 'planned_payload.json'), { + payloadCount: payload.length, + payload + }) + + const backup = runRemoteNodeScript(buildRemoteBackupScript()) + writeJson(path.join(backupDir, 'pre_import_live_backup.json'), backup) + + let wipeResult = { skipped: true } + if (!options.keepExisting) { + wipeResult = runRemoteNodeScript(buildRemoteWipeScript()) + writeJson(path.join(backupDir, 'wipe_result.json'), wipeResult) + } + + const importResult = runRemoteNodeScript(buildRemoteImportScript(payload, options.keepExisting)) + writeJson(path.join(backupDir, 'import_result.json'), importResult) + + const verification = runRemoteNodeScript(buildRemoteVerifyScript(payload)) + writeJson(path.join(backupDir, 'post_import_verification.json'), verification) + + console.log( + JSON.stringify( + { + backupDir, + inputDir, + importedCount: importResult.imported, + wipeResult, + importResult, + verification + }, + null, + 2 + ) + ) +} + +try { + run() +} catch (error) { + console.error(error && error.stack ? error.stack : String(error)) + process.exit(1) +} diff --git a/backend/scripts/locationNormalization.js b/backend/scripts/locationNormalization.js new file mode 100644 index 0000000..5b59cbf --- /dev/null +++ b/backend/scripts/locationNormalization.js @@ -0,0 +1,133 @@ +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() + +const STORAGE_LOCATION_ALIASES = { + 天安: '常州-天安', + 天安平面: '常州-天安', + 天安3D: '常州-天安', + 天安二楼分销: '常州-天安', + 二楼分销: '常州-天安', + 天安策划: '常州-天安', + 天安二楼: '常州-天安', + 天安直播间: '常州-天安', + 天安东直播间: '常州-天安', + 天安东南直播间: '常州-天安', + 天安东小直播间: '常州-天安', + '天安-大龙虾': '常州-天安', + 天安库存: '常州-天安', + 天安数据机: '常州-天安', + 天安3楼: '常州-天安', + 天安3楼机房: '常州-天安', + 天安影棚: '常州-天安', + 天安三楼经管胖大海: '常州-天安', + 影棚: '常州-天安', + 摄影棚: '常州-天安', + 摄影中心: '常州-天安', + 摄影中心监控: '常州-天安', + 二楼外贸: '常州-天安', + 横林: '常州-仓库', + 横林仓: '常州-仓库', + 横林二仓: '常州-仓库', + 横林客退: '常州-仓库', + 客退: '常州-仓库', + 常州临时仓: '常州-仓库', + 崔桥工厂仓: '常州-仓库', + 崔桥仓: '常州-仓库', + 欧伯娜崔桥工厂: '常州-仓库', + 欧伯娜工厂: '常州-仓库', + 崔桥工厂欧伯娜: '常州-仓库', + 新苏北仓: '常州-仓库', + 常州: '常州-天安', + 杭州: '杭州-万通中心', + 杭州宋达: '杭州-万通中心', + 南京: '南京-苏宁园区', + 南京运营网盘: '南京-苏宁园区', + 南京机房远程数据机: '南京-苏宁园区', + '10号楼1楼机房监控': '南京-苏宁园区', + '10号楼1楼监控室': '南京-苏宁园区', + '10号楼1楼配电间监控': '南京-苏宁园区', + '10号楼3楼机房': '南京-苏宁园区', + 佛山3D: '佛山', + 佛山平面: '佛山', + 佛山建模: '佛山', + 广东佛山: '佛山', + 顺德仓: '佛山' +} + +const resolveStorageLocationName = (rawValue) => { + const normalized = normalizeText(rawValue) + if (!normalized) { + return { + rawValue: normalized, + normalizedValue: '', + rule: '' + } + } + + const aliased = STORAGE_LOCATION_ALIASES[normalized] + if (aliased) { + return { + rawValue: normalized, + normalizedValue: aliased, + rule: 'alias' + } + } + + return { + rawValue: normalized, + normalizedValue: normalized, + rule: 'direct' + } +} + +const resolveStorageLocationMatch = (rawValue, locationByName) => { + const normalized = normalizeText(rawValue) + if (!normalized) { + return { + rawValue: '', + normalizedValue: '', + match: null, + rule: '' + } + } + + const directMatch = locationByName.get(normalizeLookupKey(normalized)) || null + if (directMatch) { + return { + rawValue: normalized, + normalizedValue: directMatch.name, + match: directMatch, + rule: 'direct' + } + } + + const resolved = resolveStorageLocationName(rawValue) + if (!resolved.normalizedValue) { + return { + rawValue: normalized, + normalizedValue: '', + match: null, + rule: '' + } + } + + const aliasMatch = locationByName.get(normalizeLookupKey(resolved.normalizedValue)) || null + return { + rawValue: normalized, + normalizedValue: resolved.normalizedValue, + match: aliasMatch, + rule: resolved.rule + } +} + +module.exports = { + STORAGE_LOCATION_ALIASES, + resolveStorageLocationName, + resolveStorageLocationMatch +} diff --git a/backend/scripts/prepareEquipmentMigrationDryRun.js b/backend/scripts/prepareEquipmentMigrationDryRun.js new file mode 100644 index 0000000..081aa46 --- /dev/null +++ b/backend/scripts/prepareEquipmentMigrationDryRun.js @@ -0,0 +1,1200 @@ +let PrismaClient + +try { + ;({ PrismaClient } = require('@prisma/client')) +} catch { + ;({ PrismaClient } = require('/app/node_modules/@prisma/client')) +} + +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') + +const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')) +const { + STORAGE_LOCATION_ALIASES, + resolveStorageLocationMatch +} = require('./locationNormalization') + +const WORKBOOK_PATH = path.resolve(__dirname, '../../8.xlsx') +const SHEET_NAME = '📜固定资产明细' +const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output') +const DEFAULT_MANUAL_OVERRIDES_PATH = path.resolve(__dirname, '../../migration_manual_overrides.json') + +const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe' +const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133' +const SSH_HOSTKEY = + process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA' +const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || '' +const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node' + +const OWNER_SEPARATORS = [',', ',', '、', ';', ';', '/', '|', '|'] +const DEPARTMENT_SEPARATORS = [...OWNER_SEPARATORS] +const ASSET_TYPE_SEPARATORS = [...OWNER_SEPARATORS] + +const ASSET_TYPE_ALIASES = { + 笔记本电脑: '笔记本' +} + +const DEPARTMENT_ALIASES = { + '3D视觉(广东)': '视觉中心', + '3D视觉(常州)': '视觉中心', + 事业一部: '运营中心A', + 事业二部: '运营中心A', + 事业三部: '运营中心A', + 事业四部: '运营中心A', + 策划一部: '品牌策划中心', + 策划二部: '品牌策划中心', + 策划三部: '品牌策划中心', + 策划四部: '品牌策划中心', + 策划五部: '品牌策划中心', + 策划六部: '品牌策划中心', + 客服二部: '客服中心', + 客服三部: '客服中心', + 客服四部: '客服中心', + 客服五部: '客服中心', + 客服六部: '客服中心', + 品牌营销策划中心: '品牌策划中心', + 品牌创意视觉中心: '视觉中心', + 品牌供应链中心: '供应链中心', + 智管信息中心: '效率部(懂业务的技术部)', + 总账组: '财务中心', + 结算组: '财务中心', + 视觉一部: '视觉中心', + 视觉二部: '视觉中心', + 视觉三部: '视觉中心', + 视觉四部: '视觉中心', + 视觉五部: '视觉中心', + 视觉六部: '视觉中心', + '基本盘 采购部': '供应链中心', + '基本盘 核价组': '供应链中心' +} + +const OWNER_ONLY_PENDING_REASONS = new Set([ + 'owner_not_in_active_synced_roster', + 'owner_ambiguous', + 'owner_ambiguous_even_after_department', + 'multi_owner_cell', + 'assigned_but_owner_blank', + 'unassigned_but_owner_present' +]) + +const HEADER_MAP = { + assetCode: '资产编码', + currentStatus: '当前状态', + assetName: '资产名称', + assetCategory: '资产分类', + department: '部门', + owner: '当前使用员工', + address: '地址', + cpu: 'cpu', + motherboard: '主板', + memory: '内存', + ssd: '固态硬盘', + hdd: '机械硬盘', + gpu: '显卡', + arrivalDate: '到货时间', + unitPrice: '采购单价(元)', + model: '规格型号', + claimedAt: '领用时间', + maintenanceRecord: '维修记录', + assetStatusUpdate: '资产状态更新', + parentRecord: '父记录' +} + +const CSV_IMPORT_HEADERS = [ + 'equipment_name', + 'equipment_type', + 'equipment_model', + 'department', + 'storage_location', + 'unit_price', + 'total_quantity', + 'supplier', + 'purchase_date', + 'description' +] + +const parseArgs = () => { + const args = process.argv.slice(2) + const options = { + workbookPath: WORKBOOK_PATH, + overridesPath: '' + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--workbook') { + options.workbookPath = args[index + 1] || WORKBOOK_PATH + index += 1 + } else if (arg === '--overrides') { + options.overridesPath = args[index + 1] || '' + index += 1 + } + } + + return options +} + +const normalizeText = (value) => + String(value == null ? '' : value) + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + +const normalizeLookupKey = (value) => normalizeText(value).toUpperCase() + +const isBlank = (value) => normalizeText(value) === '' + +const resolvePathArg = (value, fallbackPath = '') => { + const target = normalizeText(value || fallbackPath) + if (!target) { + return '' + } + + return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target) +} + +const normalizeAssetTypeName = (value) => { + const normalized = normalizeText(value) + return ASSET_TYPE_ALIASES[normalized] || normalized +} + +const normalizeDepartmentName = (value) => { + const normalized = normalizeText(value) + return DEPARTMENT_ALIASES[normalized] || normalized +} + +const splitBySeparators = (value, separators) => { + const normalized = normalizeText(value) + if (!normalized) { + return [] + } + + let tokens = [normalized] + for (const separator of separators) { + tokens = tokens.flatMap((token) => token.split(separator)) + } + + return [...new Set(tokens.map((token) => normalizeText(token)).filter(Boolean))] +} + +const parseStatus = (value) => { + const normalized = normalizeText(value) + + if (!normalized) { + return 'unknown' + } + + if (normalized.includes('已领用')) { + return 'assigned' + } + + if (normalized.includes('未领用')) { + return 'unassigned' + } + + if (normalized.includes('报废')) { + return 'scrapped' + } + + return 'unknown' +} + +const isScrappedRow = (record) => { + const category = normalizeText(record.assetCategoryRaw) + const statusRaw = normalizeText(record.currentStatusRaw) + return category === '已报废' || statusRaw.includes('报废') +} + +const hasMultiOwner = (owner) => { + const normalized = normalizeText(owner) + if (!normalized) { + return false + } + + return OWNER_SEPARATORS.some((separator) => normalized.includes(separator)) +} + +const parseUnitPrice = (value) => { + const normalized = normalizeText(value).replace(/,/g, '') + if (!normalized || normalized === '#REF!') { + return '' + } + + const parsed = Number(normalized) + if (!Number.isFinite(parsed)) { + return '' + } + + return parsed % 1 === 0 ? String(parsed) : parsed.toFixed(2).replace(/\.00$/, '') +} + +const formatDate = (value) => { + const normalized = normalizeText(value) + if (!normalized || normalized === '#REF!') { + return '' + } + + const directDate = new Date(normalized) + if (!Number.isNaN(directDate.getTime())) { + return directDate.toISOString().slice(0, 10) + } + + return normalized +} + +const buildDescription = (record) => { + const parts = [ + ['legacy_asset_code', record.assetCodeRaw || ''], + ['source_row', record.sourceRow], + ['source_status', record.currentStatusRaw || ''], + ['raw_owner', record.ownerRaw || ''], + ['raw_department', record.departmentRaw || ''], + ['raw_location', record.addressRaw || ''], + ['raw_category', record.assetCategoryRaw || ''] + ] + + return parts + .map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`) + .join('; ') +} + +const escapeCsv = (value) => { + const stringValue = String(value == null ? '' : value) + if (/[,"\n]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"` + } + return stringValue +} + +const writeCsv = (filePath, headers, rows) => { + const lines = [headers.map(escapeCsv).join(',')] + + for (const row of rows) { + lines.push(headers.map((header) => escapeCsv(row[header] ?? '')).join(',')) + } + + fs.writeFileSync(filePath, `\uFEFF${lines.join('\n')}`) +} + +const writeJson = (filePath, value) => { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +const writeJsonl = (filePath, rows) => { + const content = rows.map((row) => JSON.stringify(row)).join('\n') + fs.writeFileSync(filePath, `${content}${content ? '\n' : ''}`) +} + +const buildEmptyManualOverrides = () => ({ + departmentAliases: {}, + locationAliases: {}, + ownerAliases: {}, + assetOverrides: {} +}) + +const normalizeOverrideMap = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + const result = {} + for (const [key, raw] of Object.entries(value)) { + const normalizedKey = normalizeText(key) + if (!normalizedKey) { + continue + } + + result[normalizedKey] = normalizeText(raw) + } + + return result +} + +const normalizeAssetOverrideMap = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + const result = {} + for (const [key, rawOverride] of Object.entries(value)) { + const normalizedKey = normalizeText(key).toUpperCase() + if (!normalizedKey || !rawOverride || typeof rawOverride !== 'object' || Array.isArray(rawOverride)) { + continue + } + + const normalizedOverride = {} + for (const [fieldKey, fieldValue] of Object.entries(rawOverride)) { + normalizedOverride[fieldKey] = normalizeText(fieldValue) + } + + result[normalizedKey] = normalizedOverride + } + + return result +} + +const loadManualOverrides = (overridesPathArg) => { + const resolvedPath = resolvePathArg(overridesPathArg, DEFAULT_MANUAL_OVERRIDES_PATH) + if (!resolvedPath || !fs.existsSync(resolvedPath)) { + return { + path: '', + data: buildEmptyManualOverrides(), + summary: { + departmentAliases: 0, + locationAliases: 0, + ownerAliases: 0, + assetOverrides: 0 + } + } + } + + const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) + const data = { + departmentAliases: normalizeOverrideMap(parsed.departmentAliases), + locationAliases: normalizeOverrideMap(parsed.locationAliases), + ownerAliases: normalizeOverrideMap(parsed.ownerAliases), + assetOverrides: normalizeAssetOverrideMap(parsed.assetOverrides) + } + + return { + path: resolvedPath, + data, + summary: { + departmentAliases: Object.keys(data.departmentAliases).length, + locationAliases: Object.keys(data.locationAliases).length, + ownerAliases: Object.keys(data.ownerAliases).length, + assetOverrides: Object.keys(data.assetOverrides).length + } + } +} + +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key) + +const resolveOverrideValue = (assetOverride, key, fallbackValue) => + hasOwn(assetOverride, key) ? normalizeText(assetOverride[key]) : fallbackValue + +const applyManualOverridesToRecord = (record, manualOverridesData) => { + const assetCode = normalizeText(record.assetCodeRaw).toUpperCase() + const assetOverride = manualOverridesData.assetOverrides[assetCode] || {} + const ownerAliasValue = + manualOverridesData.ownerAliases[normalizeText(record.ownerRaw)] ?? record.ownerRaw + const departmentAliasValue = + manualOverridesData.departmentAliases[normalizeText(record.departmentRaw)] ?? record.departmentRaw + const locationAliasValue = + manualOverridesData.locationAliases[normalizeText(record.addressRaw)] ?? record.addressRaw + + return { + ...record, + currentStatusRaw: resolveOverrideValue( + assetOverride, + 'currentStatus', + resolveOverrideValue(assetOverride, 'sourceStatus', record.currentStatusRaw) + ), + assetNameRaw: resolveOverrideValue(assetOverride, 'assetName', record.assetNameRaw), + assetCategoryRaw: resolveOverrideValue(assetOverride, 'assetCategory', record.assetCategoryRaw), + departmentRaw: resolveOverrideValue(assetOverride, 'department', departmentAliasValue), + ownerRaw: resolveOverrideValue(assetOverride, 'owner', ownerAliasValue), + addressRaw: resolveOverrideValue(assetOverride, 'location', locationAliasValue), + arrivalDateRaw: resolveOverrideValue( + assetOverride, + 'arrivalDate', + resolveOverrideValue(assetOverride, 'purchaseDate', record.arrivalDateRaw) + ), + unitPriceRaw: resolveOverrideValue(assetOverride, 'unitPrice', record.unitPriceRaw), + modelRaw: resolveOverrideValue(assetOverride, 'model', record.modelRaw), + claimedAtRaw: resolveOverrideValue(assetOverride, 'claimedAt', record.claimedAtRaw) + } +} + +const ensureOutputDir = () => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) + const outputDir = path.join(OUTPUT_ROOT, `migration_dry_run_${timestamp}`) + fs.mkdirSync(outputDir, { recursive: true }) + return outputDir +} + +const loadWorkbookRows = (workbookPath, manualOverridesData) => { + const workbook = XLSX.readFile(workbookPath, { raw: false }) + const sheet = workbook.Sheets[SHEET_NAME] + + if (!sheet) { + throw new Error(`未找到工作表: ${SHEET_NAME}`) + } + + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' }) + if (!rows.length) { + throw new Error('工作表为空') + } + + const headerRow = rows[0].map((cell) => normalizeText(cell)) + const headerIndex = {} + headerRow.forEach((header, index) => { + headerIndex[header] = index + }) + + const missingHeaders = Object.values(HEADER_MAP).filter((header) => headerIndex[header] == null) + if (missingHeaders.length) { + throw new Error(`工作表缺少列: ${missingHeaders.join(', ')}`) + } + + const normalizedRows = [] + + for (let index = 1; index < rows.length; index += 1) { + const row = rows[index] + const sourceRow = index + 1 + const sourceRecord = { + sourceRow, + assetCodeRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCode]]), + currentStatusRaw: normalizeText(row[headerIndex[HEADER_MAP.currentStatus]]), + assetNameRaw: normalizeText(row[headerIndex[HEADER_MAP.assetName]]), + assetCategoryRaw: normalizeText(row[headerIndex[HEADER_MAP.assetCategory]]), + departmentRaw: normalizeText(row[headerIndex[HEADER_MAP.department]]), + ownerRaw: normalizeText(row[headerIndex[HEADER_MAP.owner]]), + addressRaw: normalizeText(row[headerIndex[HEADER_MAP.address]]), + cpuRaw: normalizeText(row[headerIndex[HEADER_MAP.cpu]]), + motherboardRaw: normalizeText(row[headerIndex[HEADER_MAP.motherboard]]), + memoryRaw: normalizeText(row[headerIndex[HEADER_MAP.memory]]), + ssdRaw: normalizeText(row[headerIndex[HEADER_MAP.ssd]]), + hddRaw: normalizeText(row[headerIndex[HEADER_MAP.hdd]]), + gpuRaw: normalizeText(row[headerIndex[HEADER_MAP.gpu]]), + arrivalDateRaw: normalizeText(row[headerIndex[HEADER_MAP.arrivalDate]]), + unitPriceRaw: normalizeText(row[headerIndex[HEADER_MAP.unitPrice]]), + modelRaw: normalizeText(row[headerIndex[HEADER_MAP.model]]), + claimedAtRaw: normalizeText(row[headerIndex[HEADER_MAP.claimedAt]]), + maintenanceRecordRaw: normalizeText(row[headerIndex[HEADER_MAP.maintenanceRecord]]), + assetStatusUpdateRaw: normalizeText(row[headerIndex[HEADER_MAP.assetStatusUpdate]]), + parentRecordRaw: normalizeText(row[headerIndex[HEADER_MAP.parentRecord]]) + } + + const significantValues = Object.values(sourceRecord).filter( + (value) => !isBlank(value) && value !== sourceRow + ) + if (!significantValues.length) { + continue + } + + const record = applyManualOverridesToRecord(sourceRecord, manualOverridesData) + + normalizedRows.push({ + ...record, + statusKind: parseStatus(record.currentStatusRaw), + assetTypeName: normalizeAssetTypeName(record.assetNameRaw), + unitPriceParsed: parseUnitPrice(record.unitPriceRaw), + purchaseDateParsed: formatDate(record.arrivalDateRaw), + ownerHasMultipleNames: hasMultiOwner(record.ownerRaw) + }) + } + + return { + sheetName: SHEET_NAME, + workbookPath, + headerRow, + rows: normalizedRows + } +} + +const buildRemoteSnapshotScript = () => ` +let PrismaClient + +try { + ;({ PrismaClient } = require('@prisma/client')) +} catch { + ;({ PrismaClient } = require('/app/node_modules/@prisma/client')) +} + +const prisma = new PrismaClient() + +;(async () => { + const [assetTypes, departments, storageLocations, persons] = await Promise.all([ + prisma.assetType.findMany({ + where: { status: 'active' }, + orderBy: [{ name: 'asc' }], + select: { + id: true, + name: true, + short_name: true, + status: true, + _count: { select: { equipments: true } } + } + }), + prisma.department.findMany({ + where: { status: 'active', source_system: 'ding_hr_business' }, + orderBy: [{ name: 'asc' }], + select: { + id: true, + name: true, + source_group_id: true, + source_system: true, + status: true + } + }), + prisma.storageLocation.findMany({ + where: { status: 'active' }, + orderBy: [{ name: 'asc' }], + select: { + id: true, + name: true, + code: true, + status: true + } + }), + prisma.person.findMany({ + where: { status: 'active', source_system: 'ding_hr' }, + orderBy: [{ id: 'asc' }], + select: { + id: true, + nickname: true, + real_name: true, + department: true, + source_system: true, + source_userid: true, + mainDepartment: { + select: { + id: true, + name: true + } + }, + personDepartments: { + select: { + department: { + select: { + id: true, + name: true + } + } + } + } + } + }) + ]) + + process.stdout.write(JSON.stringify({ assetTypes, departments, storageLocations, persons })) + await prisma.$disconnect() +})().catch(async (error) => { + console.error(error && error.stack ? error.stack : String(error)) + try { + await prisma.$disconnect() + } catch {} + process.exit(1) +}) +` + +const readLiveSnapshot = () => { + if (!fs.existsSync(PLINK_PATH)) { + throw new Error(`未找到 plink.exe: ${PLINK_PATH}`) + } + + if (!SSH_PASSWORD) { + throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量') + } + + const remoteScript = buildRemoteSnapshotScript() + const result = spawnSync( + PLINK_PATH, + ['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND], + { + encoding: 'utf8', + input: remoteScript, + maxBuffer: 20 * 1024 * 1024, + windowsHide: true + } + ) + + if (result.error) { + throw result.error + } + + if (result.status !== 0) { + throw new Error(`读取 live 快照失败: ${result.stderr || result.stdout}`) + } + + const stdout = (result.stdout || '').trim() + if (!stdout) { + throw new Error('live 快照返回为空') + } + + return JSON.parse(stdout) +} + +const buildLookupMap = (items, keyGetter) => { + const map = new Map() + for (const item of items) { + const key = normalizeLookupKey(keyGetter(item)) + if (!key) { + continue + } + if (!map.has(key)) { + map.set(key, []) + } + map.get(key).push(item) + } + return map +} + +const resolveReferenceMatch = ({ rawValue, referenceMap, normalizeValue, separators = [] }) => { + const normalizedValue = normalizeValue(rawValue) + const directMatch = referenceMap.get(normalizeLookupKey(normalizedValue)) || null + + if (directMatch) { + return { + normalizedValue: directMatch.name, + match: directMatch, + rule: normalizedValue === normalizeText(rawValue) ? 'direct' : 'alias' + } + } + + const parts = splitBySeparators(rawValue, separators) + if (!parts.length) { + return { + normalizedValue, + match: null, + rule: '' + } + } + + const uniqueMatches = new Map() + for (const part of parts) { + const normalizedPart = normalizeValue(part) + const match = referenceMap.get(normalizeLookupKey(normalizedPart)) || null + if (match) { + uniqueMatches.set(normalizeLookupKey(match.name), match) + } + } + + if (uniqueMatches.size === 1) { + const match = [...uniqueMatches.values()][0] + return { + normalizedValue: match.name, + match, + rule: 'composite_parts' + } + } + + return { + normalizedValue, + match: null, + rule: '' + } +} + +const collectPersonDepartments = (person) => { + const values = new Set() + + if (!isBlank(person.department)) { + values.add(normalizeText(person.department)) + } + + if (person.mainDepartment?.name) { + values.add(normalizeText(person.mainDepartment.name)) + } + + for (const relation of person.personDepartments || []) { + if (relation.department?.name) { + values.add(normalizeText(relation.department.name)) + } + } + + return [...values] +} + +const buildPersonIndexes = (persons) => { + const byNickname = buildLookupMap(persons, (person) => person.nickname) + const byRealName = buildLookupMap(persons, (person) => person.real_name) + const enriched = persons.map((person) => ({ + ...person, + departmentNames: collectPersonDepartments(person) + })) + const byId = new Map(enriched.map((person) => [person.id, person])) + + return { + persons: enriched, + byId, + byNickname, + byRealName + } +} + +const buildUniqueCandidates = (personIndexes, ownerName) => { + const nicknameMatches = personIndexes.byNickname.get(normalizeLookupKey(ownerName)) || [] + const realNameMatches = personIndexes.byRealName.get(normalizeLookupKey(ownerName)) || [] + const merged = [...nicknameMatches, ...realNameMatches] + const uniqueIds = new Set() + const unique = [] + + for (const candidate of merged) { + if (uniqueIds.has(candidate.id)) { + continue + } + uniqueIds.add(candidate.id) + unique.push(personIndexes.byId.get(candidate.id) || candidate) + } + + return unique +} + +const matchOwner = (record, personIndexes) => { + if (record.ownerHasMultipleNames) { + return { + status: 'pending', + reason: 'multi_owner_cell', + candidates: [], + matchedPerson: null, + matchRule: '', + confidence: 'low' + } + } + + if (isBlank(record.ownerRaw)) { + return { + status: 'blank', + reason: '', + candidates: [], + matchedPerson: null, + matchRule: '', + confidence: '' + } + } + + const candidates = buildUniqueCandidates(personIndexes, record.ownerRaw) + if (!candidates.length) { + return { + status: 'pending', + reason: 'owner_not_in_active_synced_roster', + candidates: [], + matchedPerson: null, + matchRule: '', + confidence: 'low' + } + } + + if (candidates.length === 1) { + return { + status: 'matched', + reason: '', + candidates, + matchedPerson: candidates[0], + matchRule: normalizeLookupKey(candidates[0].nickname) === normalizeLookupKey(record.ownerRaw) ? 'nickname_exact' : 'real_name_exact', + confidence: 'high' + } + } + + const departmentKey = normalizeLookupKey(normalizeDepartmentName(record.departmentNormalized || record.departmentRaw)) + if (departmentKey) { + const departmentScoped = candidates.filter((candidate) => + candidate.departmentNames.some((name) => normalizeLookupKey(name) === departmentKey) + ) + + if (departmentScoped.length === 1) { + return { + status: 'matched', + reason: '', + candidates: departmentScoped, + matchedPerson: departmentScoped[0], + matchRule: 'name_plus_department', + confidence: 'medium' + } + } + + if (departmentScoped.length > 1) { + return { + status: 'pending', + reason: 'owner_ambiguous_even_after_department', + candidates: departmentScoped, + matchedPerson: null, + matchRule: '', + confidence: 'low' + } + } + } + + return { + status: 'pending', + reason: 'owner_ambiguous', + candidates, + matchedPerson: null, + matchRule: '', + confidence: 'low' + } +} + +const resolveDepartmentMatch = (record, refs, ownerMatch) => { + const directResolution = resolveReferenceMatch({ + rawValue: record.departmentRaw, + referenceMap: refs.departmentByName, + normalizeValue: normalizeDepartmentName, + separators: DEPARTMENT_SEPARATORS + }) + + if (directResolution.match) { + return directResolution + } + + if (ownerMatch.status === 'matched' && ownerMatch.matchedPerson) { + const uniqueMatches = new Map() + + for (const departmentName of ownerMatch.matchedPerson.departmentNames || []) { + const normalizedDepartmentName = normalizeDepartmentName(departmentName) + const match = refs.departmentByName.get(normalizeLookupKey(normalizedDepartmentName)) || null + if (match) { + uniqueMatches.set(normalizeLookupKey(match.name), match) + } + } + + if (uniqueMatches.size === 1) { + const match = [...uniqueMatches.values()][0] + return { + normalizedValue: match.name, + match, + rule: 'matched_owner_business_department' + } + } + } + + return directResolution +} + +const resolveAssetTypeMatch = (record, refs) => + resolveReferenceMatch({ + rawValue: record.assetNameRaw, + referenceMap: refs.assetTypeByName, + normalizeValue: normalizeAssetTypeName, + separators: ASSET_TYPE_SEPARATORS + }) + +const buildReadyImportRow = (record) => ({ + equipment_name: record.assetNameRaw, + equipment_type: record.assetTypeResolvedName || record.assetTypeName, + equipment_model: record.modelRaw, + department: record.departmentMatch ? record.departmentResolvedName || record.departmentNormalized : '', + storage_location: record.locationMatch ? record.locationResolvedName || '' : '', + unit_price: record.unitPriceParsed, + total_quantity: '1', + supplier: '', + purchase_date: record.purchaseDateParsed, + description: buildDescription(record) +}) + +const classifyRecord = (record, refs, personIndexes) => { + const reasons = [] + const ownerMatch = matchOwner(record, personIndexes) + const assetTypeResolution = resolveAssetTypeMatch(record, refs) + const departmentResolution = resolveDepartmentMatch(record, refs, ownerMatch) + const locationResolution = resolveStorageLocationMatch(record.addressRaw, refs.locationByName) + + const enriched = { + ...record, + assetTypeResolvedName: assetTypeResolution.normalizedValue || record.assetTypeName, + assetTypeResolutionRule: assetTypeResolution.rule || '', + assetTypeMatch: assetTypeResolution.match || null, + departmentNormalized: normalizeDepartmentName(record.departmentRaw), + departmentResolvedName: departmentResolution.normalizedValue || normalizeDepartmentName(record.departmentRaw), + departmentResolutionRule: departmentResolution.rule || '', + departmentMatch: departmentResolution.match || null, + locationResolvedName: locationResolution.normalizedValue || '', + locationResolutionRule: locationResolution.rule || '', + locationMatch: locationResolution.match || null, + ownerMatch, + bucket: '', + pendingReasons: [] + } + + if (isBlank(record.assetNameRaw)) { + enriched.bucket = 'discard-no-import' + enriched.pendingReasons = ['missing_asset_name'] + return enriched + } + + if (isScrappedRow(record)) { + enriched.bucket = 'discard-no-import' + enriched.pendingReasons = ['scrapped_source_row'] + return enriched + } + + if (!enriched.assetTypeMatch) { + reasons.push('unmapped_asset_type') + } + + if (!isBlank(record.departmentRaw) && !enriched.departmentMatch) { + reasons.push('unmapped_department') + } + + if (ownerMatch.status === 'pending') { + reasons.push(ownerMatch.reason) + } + + if (record.statusKind === 'unknown') { + reasons.push('unknown_source_status') + } + + if (record.statusKind === 'assigned' && ownerMatch.status === 'blank') { + reasons.push('assigned_but_owner_blank') + } + + if (record.statusKind === 'unassigned' && ownerMatch.status === 'matched') { + reasons.push('unassigned_but_owner_present') + } + + const hasOnlyOwnerReasons = reasons.length > 0 && reasons.every((reason) => OWNER_ONLY_PENDING_REASONS.has(reason)) + + if (reasons.length) { + if (hasOnlyOwnerReasons) { + enriched.bucket = 'import-without-owner' + enriched.pendingReasons = reasons + return enriched + } + + enriched.bucket = reasons.includes('scrapped_source_row') ? 'discard-no-import' : 'pending-review' + enriched.pendingReasons = reasons + return enriched + } + + if (record.statusKind === 'unassigned' && ownerMatch.status === 'blank') { + enriched.bucket = 'import-without-owner' + return enriched + } + + if (record.statusKind === 'assigned' && ownerMatch.status === 'matched') { + enriched.bucket = 'import-ready' + return enriched + } + + enriched.bucket = 'pending-review' + enriched.pendingReasons = ['unhandled_bucket_case'] + return enriched +} + +const buildReferenceMaps = (snapshot) => ({ + assetTypeByName: new Map(snapshot.assetTypes.map((item) => [normalizeLookupKey(item.name), item])), + departmentByName: new Map(snapshot.departments.map((item) => [normalizeLookupKey(item.name), item])), + locationByName: new Map(snapshot.storageLocations.map((item) => [normalizeLookupKey(item.name), item])) +}) + +const summarizeBuckets = (classifiedRecords) => { + const summary = { + total: classifiedRecords.length, + importReady: 0, + importWithoutOwner: 0, + pendingReview: 0, + discardNoImport: 0 + } + + for (const record of classifiedRecords) { + if (record.bucket === 'import-ready') { + summary.importReady += 1 + } else if (record.bucket === 'import-without-owner') { + summary.importWithoutOwner += 1 + } else if (record.bucket === 'pending-review') { + summary.pendingReview += 1 + } else if (record.bucket === 'discard-no-import') { + summary.discardNoImport += 1 + } + } + + return summary +} + +const buildPendingRow = (record) => { + const hasOwnerIssue = + record.pendingReasons.includes('owner_not_in_active_synced_roster') || + record.pendingReasons.includes('owner_ambiguous') || + record.pendingReasons.includes('owner_ambiguous_even_after_department') || + record.pendingReasons.includes('multi_owner_cell') + const hasDepartmentIssue = record.pendingReasons.includes('unmapped_department') + const hasStatusIssue = record.pendingReasons.includes('unknown_source_status') + + let suggestedAction = '补齐映射或手动修正后再导入' + if (hasDepartmentIssue && hasOwnerIssue) { + suggestedAction = '先确认业务部门映射;owner 暂不自动建立,待行政补充' + } else if (hasDepartmentIssue) { + suggestedAction = '补齐业务部门映射后再导入' + } else if (hasStatusIssue) { + suggestedAction = '补齐资产状态后再导入' + } else if (hasOwnerIssue) { + suggestedAction = '行政核对 owner 后再导入' + } + + return { + source_row: record.sourceRow, + legacy_asset_code: record.assetCodeRaw, + asset_name: record.assetNameRaw, + source_status: record.currentStatusRaw, + raw_owner_name: record.ownerRaw, + raw_department: record.departmentRaw, + normalized_department: record.departmentResolvedName || record.departmentNormalized || '', + raw_location: record.addressRaw, + raw_asset_category: record.assetCategoryRaw, + pending_reason: record.pendingReasons.join('|'), + suggested_action: suggestedAction + } +} + +const buildDiscardRow = (record) => ({ + source_row: record.sourceRow, + legacy_asset_code: record.assetCodeRaw, + asset_name: record.assetNameRaw, + discard_reason: record.pendingReasons.join('|'), + source_status: record.currentStatusRaw, + raw_asset_category: record.assetCategoryRaw, + notes: record.assetStatusUpdateRaw || record.maintenanceRecordRaw || '' +}) + +const buildOwnerResolutionRow = (record) => ({ + source_row: record.sourceRow, + legacy_asset_code: record.assetCodeRaw, + source_status: record.currentStatusRaw, + asset_name: record.assetNameRaw, + raw_owner_name: record.ownerRaw, + raw_department: record.departmentRaw, + matched_person_id: record.ownerMatch.matchedPerson?.id || '', + matched_person_real_name: record.ownerMatch.matchedPerson?.real_name || '', + matched_person_nickname: record.ownerMatch.matchedPerson?.nickname || '', + match_rule: record.ownerMatch.matchRule || '', + confidence: record.ownerMatch.confidence || '', + next_action: + record.bucket === 'import-ready' + ? '后续 owner 关系可建' + : record.bucket === 'import-without-owner' + ? '先导入资产,不建立 owner,后续由行政补充' + : record.ownerMatch.status === 'blank' + ? '无需 owner' + : '待人工核对' +}) + +const run = () => { + const options = parseArgs() + const outputDir = ensureOutputDir() + const manualOverrides = loadManualOverrides(options.overridesPath) + const workbookPath = resolvePathArg(options.workbookPath) + if (!fs.existsSync(workbookPath)) { + throw new Error(`工作簿不存在: ${workbookPath}`) + } + + const workbook = loadWorkbookRows(workbookPath, manualOverrides.data) + const liveSnapshot = readLiveSnapshot() + const refs = buildReferenceMaps(liveSnapshot) + const personIndexes = buildPersonIndexes(liveSnapshot.persons) + + const classifiedRecords = workbook.rows.map((record) => classifyRecord(record, refs, personIndexes)) + const summary = summarizeBuckets(classifiedRecords) + + const importWithoutOwnerRows = classifiedRecords + .filter((record) => record.bucket === 'import-without-owner') + .map((record) => buildReadyImportRow(record)) + + const importReadyRows = classifiedRecords + .filter((record) => record.bucket === 'import-ready') + .map((record) => buildReadyImportRow(record)) + + const pendingRows = classifiedRecords + .filter((record) => record.bucket === 'pending-review') + .map(buildPendingRow) + + const discardRows = classifiedRecords + .filter((record) => record.bucket === 'discard-no-import') + .map(buildDiscardRow) + + const ownerResolutionRows = classifiedRecords + .filter((record) => record.statusKind === 'assigned' || !isBlank(record.ownerRaw)) + .map(buildOwnerResolutionRow) + + const manifest = { + workbookPath, + sheetName: workbook.sheetName, + generatedAt: new Date().toISOString(), + outputDir, + sourceRowCount: workbook.rows.length, + summary, + referenceCounts: { + assetTypes: liveSnapshot.assetTypes.length, + departments: liveSnapshot.departments.length, + storageLocations: liveSnapshot.storageLocations.length, + activePersons: liveSnapshot.persons.length + }, + rulesVersion: 'v4-ownerless-import-and-manual-overrides', + importerFields: CSV_IMPORT_HEADERS, + typeAliases: ASSET_TYPE_ALIASES, + departmentAliases: DEPARTMENT_ALIASES, + storageLocationAliases: STORAGE_LOCATION_ALIASES, + manualOverrides: { + path: manualOverrides.path || null, + summary: manualOverrides.summary + } + } + + writeJson(path.join(outputDir, 'live_reference_snapshot.json'), liveSnapshot) + writeJson(path.join(outputDir, 'migration_manifest.json'), manifest) + writeJsonl(path.join(outputDir, 'normalized_assets.jsonl'), classifiedRecords) + writeCsv(path.join(outputDir, 'assets_import_without_owner.csv'), CSV_IMPORT_HEADERS, importWithoutOwnerRows) + writeCsv(path.join(outputDir, 'assets_import_ready.csv'), CSV_IMPORT_HEADERS, importReadyRows) + writeCsv( + path.join(outputDir, 'pending_review.csv'), + [ + 'source_row', + 'legacy_asset_code', + 'asset_name', + 'source_status', + 'raw_owner_name', + 'raw_department', + 'normalized_department', + 'raw_location', + 'raw_asset_category', + 'pending_reason', + 'suggested_action' + ], + pendingRows + ) + writeCsv( + path.join(outputDir, 'discard_no_import.csv'), + ['source_row', 'legacy_asset_code', 'asset_name', 'discard_reason', 'source_status', 'raw_asset_category', 'notes'], + discardRows + ) + writeCsv( + path.join(outputDir, 'owner_resolution.csv'), + [ + 'source_row', + 'legacy_asset_code', + 'source_status', + 'asset_name', + 'raw_owner_name', + 'raw_department', + 'matched_person_id', + 'matched_person_real_name', + 'matched_person_nickname', + 'match_rule', + 'confidence', + 'next_action' + ], + ownerResolutionRows + ) + + console.log( + JSON.stringify( + { + outputDir, + summary, + files: { + liveSnapshot: 'live_reference_snapshot.json', + manifest: 'migration_manifest.json', + normalized: 'normalized_assets.jsonl', + importWithoutOwner: 'assets_import_without_owner.csv', + importReady: 'assets_import_ready.csv', + pendingReview: 'pending_review.csv', + discard: 'discard_no_import.csv', + ownerResolution: 'owner_resolution.csv' + } + }, + null, + 2 + ) + ) +} + +try { + run() +} catch (error) { + console.error(error?.stack ? error.stack : String(error)) + process.exit(1) +} diff --git a/backend/src/controllers/batchController.ts b/backend/src/controllers/batchController.ts index 8ba5c3d..296efe7 100644 --- a/backend/src/controllers/batchController.ts +++ b/backend/src/controllers/batchController.ts @@ -3,6 +3,7 @@ import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' import { calculateEquipmentValues, syncMountedChildrenState } from '../utils/assetValue' +import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' const buildBorrowNotes = (notes?: string, isLongTerm?: boolean) => { const longTerm = Boolean(isLongTerm) @@ -309,8 +310,14 @@ export const batchTransfer = async (req: Request, res: Response) => { return errorResponse(res, '请选择要转移的资产') } - const department = await prisma.department.findUnique({ where: { id: Number(department_id) } }) - if (!department || department.status !== 'active') { + const department = await prisma.department.findFirst({ + where: { + id: Number(department_id), + status: 'active', + ...buildSyncedBusinessDepartmentWhere() + } + }) + if (!department) { return errorResponse(res, '目标部门不存在或已停用') } diff --git a/backend/src/controllers/batchImportController.ts b/backend/src/controllers/batchImportController.ts index 60caf5e..7ed62ca 100644 --- a/backend/src/controllers/batchImportController.ts +++ b/backend/src/controllers/batchImportController.ts @@ -3,6 +3,7 @@ import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { generateEquipmentCode } from '../utils/equipmentCode' import { calculateEquipmentValues } from '../utils/assetValue' +import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' interface PersonImportData { nickname: string @@ -43,7 +44,9 @@ export const batchImportPersons = async (req: Request, res: Response) => { } const activeDepartments = await prisma.department.findMany({ - where: { status: 'active' }, + where: { + AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }] + }, select: { id: true, name: true } }) const departmentMap = new Map(activeDepartments.map((department) => [department.name, department.id])) @@ -141,7 +144,11 @@ export const batchImportEquipments = async (req: Request, res: Response) => { // 预加载资产类型、部门、地址映射 const [assetTypes, departments, locations] = await Promise.all([ prisma.assetType.findMany({ where: { status: 'active' } }), - prisma.department.findMany({ where: { status: 'active' } }), + prisma.department.findMany({ + where: { + AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }] + } + }), prisma.storageLocation.findMany({ where: { status: 'active' } }) ]) diff --git a/backend/src/controllers/departmentController.ts b/backend/src/controllers/departmentController.ts index 88a3e3e..5cfc448 100644 --- a/backend/src/controllers/departmentController.ts +++ b/backend/src/controllers/departmentController.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +import { buildBusinessDepartmentWhere } from '../utils/organization' const baseSelect = { id: true, @@ -20,6 +21,46 @@ const departmentCollator = new Intl.Collator('zh-CN', { sensitivity: 'base' }) +const personCollator = new Intl.Collator('zh-CN', { + numeric: true, + sensitivity: 'base' +}) + +const resolveBusinessDepartmentIdsForPerson = ( + person: { + main_department_id: number | null + personDepartments: Array<{ + department: { + id: number + } | null + }> + }, + departmentIdSet: Set +) => { + const ids = new Set() + + if (person.main_department_id && departmentIdSet.has(person.main_department_id)) { + ids.add(person.main_department_id) + } + + person.personDepartments.forEach((item) => { + if (item.department && departmentIdSet.has(item.department.id)) { + ids.add(item.department.id) + } + }) + + return [...ids] +} + +const hasAnyLinkedDepartment = (person: { + main_department_id: number | null + personDepartments: Array<{ + department: { + id: number + } | null + }> +}) => Boolean(person.main_department_id) || person.personDepartments.some((item) => Boolean(item.department)) + const buildDepartmentLevelMap = (departments: Array<{ id: number; parent_id: number | null }>) => { const byId = new Map(departments.map((dept) => [dept.id, dept])) const cache = new Map() @@ -113,11 +154,247 @@ const buildDepartmentTree = ( return (childrenMap.get(null) || []).map(buildNode) } +const buildOrganizationTree = ( + departments: Array<{ + id: number + name: string + status: string + parent_id: number | null + source_system?: string | null + created_at: Date + updated_at: Date + last_synced_at?: Date | null + source_group_id?: number | null + source_parent_group_id?: number | null + _count: { equipments: number; children: number; personDepartments: number } + }>, + people: Array<{ + id: number + nickname: string + real_name: string + id_number: string | null + department: string | null + main_department_id: number | null + position: string | null + phone: string | null + email: string | null + status: string + remarks: string | null + source_userid: string | null + source_system: string | null + source_main_department_id: number | null + source_dept_ids: string | null + source_department_name: string | null + source_main_department_name: string | null + department_sync_bucket: string | null + manager_source_userid: string | null + source_employee_status: string | null + last_synced_at: Date | null + created_at: Date + updated_at: Date + mainDepartment: { + id: number + name: string + status: string + parent_id: number | null + source_group_id: number | null + source_system: string | null + last_synced_at: Date | null + } | null + personDepartments: Array<{ + department: { + id: number + name: string + status: string + parent_id: number | null + source_group_id: number | null + source_system: string | null + last_synced_at: Date | null + } | null + }> + }> +) => { + const compareDepartments = ( + a: typeof departments[number], + b: typeof departments[number] + ) => { + if (a.status !== b.status) { + return a.status === 'active' ? -1 : 1 + } + + const aHasChildren = a._count.children > 0 + const bHasChildren = b._count.children > 0 + if (aHasChildren !== bHasChildren) { + return aHasChildren ? -1 : 1 + } + + const aInUse = a._count.personDepartments > 0 || a._count.equipments > 0 + const bInUse = b._count.personDepartments > 0 || b._count.equipments > 0 + if (aInUse !== bInUse) { + return aInUse ? -1 : 1 + } + + const aSynced = Boolean(a.source_system) + const bSynced = Boolean(b.source_system) + if (aSynced !== bSynced) { + return aSynced ? -1 : 1 + } + + if (a._count.personDepartments !== b._count.personDepartments) { + return b._count.personDepartments - a._count.personDepartments + } + + if (a._count.equipments !== b._count.equipments) { + return b._count.equipments - a._count.equipments + } + + return departmentCollator.compare(a.name, b.name) + } + + const comparePeople = (a: typeof people[number], b: typeof people[number]) => { + if (a.status !== b.status) { + return a.status === 'active' ? -1 : 1 + } + + const aSynced = Boolean(a.source_system) + const bSynced = Boolean(b.source_system) + if (aSynced !== bSynced) { + return aSynced ? -1 : 1 + } + + return personCollator.compare(`${a.nickname} ${a.real_name}`, `${b.nickname} ${b.real_name}`) + } + + const childrenMap = new Map() + for (const department of departments) { + const siblings = childrenMap.get(department.parent_id) || [] + siblings.push(department) + childrenMap.set(department.parent_id, siblings) + } + + childrenMap.forEach((siblings, key) => { + childrenMap.set(key, [...siblings].sort(compareDepartments)) + }) + + const departmentIdSet = new Set(departments.map((department) => department.id)) + const peopleByDepartmentId = new Map() + const unassignedPeople: typeof people = [] + + for (const person of people) { + const departmentIds = resolveBusinessDepartmentIdsForPerson(person, departmentIdSet) + + if (departmentIds.length === 0) { + if (!hasAnyLinkedDepartment(person)) { + unassignedPeople.push(person) + } + continue + } + + departmentIds.forEach((departmentId) => { + const bucket = peopleByDepartmentId.get(departmentId) || [] + bucket.push(person) + peopleByDepartmentId.set(departmentId, bucket) + }) + } + + peopleByDepartmentId.forEach((bucket, departmentId) => { + peopleByDepartmentId.set(departmentId, [...bucket].sort(comparePeople)) + }) + + const serializeOrganizationPerson = (person: typeof people[number]) => { + const departments = Array.from( + new Map( + person.personDepartments + .map((item) => item.department) + .filter( + ( + department + ): department is NonNullable<(typeof person.personDepartments)[number]['department']> => Boolean(department) + ) + .map((department) => [department.id, department] as const) + ).values() + ) + + return { + id: person.id, + nickname: person.nickname, + real_name: person.real_name, + id_number: person.id_number, + department: person.mainDepartment?.name || person.department || departments[0]?.name || null, + main_department_id: person.main_department_id, + main_department: person.mainDepartment + ? { + id: person.mainDepartment.id, + name: person.mainDepartment.name, + status: person.mainDepartment.status, + parent_id: person.mainDepartment.parent_id, + source_group_id: person.mainDepartment.source_group_id, + source_system: person.mainDepartment.source_system, + last_synced_at: person.mainDepartment.last_synced_at + } + : null, + departments, + department_names: departments.map((department) => department.name), + position: person.position, + phone: person.phone, + email: person.email, + status: person.status, + remarks: person.remarks, + source_userid: person.source_userid, + source_system: person.source_system, + source_main_department_id: person.source_main_department_id, + source_dept_ids: person.source_dept_ids, + source_department_name: person.source_department_name, + source_main_department_name: person.source_main_department_name, + department_sync_bucket: person.department_sync_bucket, + manager_source_userid: person.manager_source_userid, + source_employee_status: person.source_employee_status, + last_synced_at: person.last_synced_at, + is_synced: Boolean(person.source_system), + created_at: person.created_at, + updated_at: person.updated_at + } + } + + const normalizedUnassignedPeople = [...unassignedPeople].sort(comparePeople).map(serializeOrganizationPerson) + + const buildNode = (department: typeof departments[number]): any => { + const normalizedPeople = (peopleByDepartmentId.get(department.id) || []).map(serializeOrganizationPerson) + + const childNodes = (childrenMap.get(department.id) || []).map(buildNode) + const totalPersonCount = + normalizedPeople.length + childNodes.reduce((sum: number, child: any) => sum + child.total_person_count, 0) + + return { + ...department, + equipment_count: department._count.equipments, + children_count: department._count.children, + person_count: department._count.personDepartments, + direct_person_count: normalizedPeople.length, + total_person_count: totalPersonCount, + people: normalizedPeople, + children: childNodes + } + } + + const tree = (childrenMap.get(null) || []).map(buildNode) + + return { + departments: tree, + unassigned_people: normalizedUnassignedPeople + } +} + // 获取启用的部门列表(下拉选项,含层级信息) export const getDepartmentOptions = async (_req: Request, res: Response) => { try { const departments = await prisma.department.findMany({ - where: { status: 'active' }, + where: { + AND: [ + buildBusinessDepartmentWhere(), + { status: 'active' } + ] + }, orderBy: [{ parent_id: 'asc' }, { name: 'asc' }], select: { ...baseSelect, @@ -144,6 +421,7 @@ export const getDepartmentOptions = async (_req: Request, res: Response) => { export const getDepartmentTree = async (_req: Request, res: Response) => { try { const departments = await prisma.department.findMany({ + where: buildBusinessDepartmentWhere(), orderBy: [{ parent_id: 'asc' }, { name: 'asc' }], select: { ...baseSelect, @@ -161,17 +439,101 @@ export const getDepartmentTree = async (_req: Request, res: Response) => { } } +export const getOrganizationTree = async (_req: Request, res: Response) => { + try { + const [departments, allPeople] = await Promise.all([ + prisma.department.findMany({ + where: buildBusinessDepartmentWhere(), + orderBy: [{ parent_id: 'asc' }, { name: 'asc' }], + select: { + ...baseSelect, + _count: { + select: { equipments: true, children: true, personDepartments: true } + } + } + }), + prisma.person.findMany({ + orderBy: [{ status: 'asc' }, { nickname: 'asc' }, { real_name: 'asc' }], + include: { + mainDepartment: { + select: { + id: true, + name: true, + status: true, + parent_id: true, + source_group_id: true, + source_system: true, + last_synced_at: true + } + }, + personDepartments: { + select: { + department: { + select: { + id: true, + name: true, + status: true, + parent_id: true, + source_group_id: true, + source_system: true, + last_synced_at: true + } + } + } + } + } + }) + ]) + + const businessDepartmentIdSet = new Set(departments.map((department) => department.id)) + const people = allPeople.filter((person) => { + const businessDepartmentIds = resolveBusinessDepartmentIdsForPerson(person, businessDepartmentIdSet) + if (businessDepartmentIds.length > 0) { + return true + } + + return !hasAnyLinkedDepartment(person) + }) + + const tree = buildOrganizationTree(departments, people as any) + const activePersonTotal = people.filter((person) => person.status === 'active').length + const syncedPersonTotal = people.filter((person) => Boolean(person.source_system)).length + + successResponse( + res, + { + ...tree, + summary: { + department_total: departments.length, + active_department_total: departments.filter((department) => department.status === 'active').length, + person_total: people.length, + active_person_total: activePersonTotal, + inactive_person_total: people.length - activePersonTotal, + synced_person_total: syncedPersonTotal, + local_person_total: people.length - syncedPersonTotal, + unassigned_person_total: tree.unassigned_people.length + } + }, + '获取成功' + ) + } catch (error: any) { + errorResponse(res, error.message || '获取组织架构失败') + } +} + // 获取部门列表(分页) export const getDepartmentList = async (req: Request, res: Response) => { try { const { page = 1, pageSize = 10, keyword, status } = req.query - const where: any = {} + const where: any = { + AND: [buildBusinessDepartmentWhere()] + } if (status) { - where.status = status + where.AND.push({ status }) } if (keyword) { - where.name = { contains: keyword as string } + where.AND.push({ name: { contains: keyword as string } }) } const skip = (Number(page) - 1) * Number(pageSize) @@ -224,8 +586,11 @@ export const getDepartmentList = async (req: Request, res: Response) => { const validateParentDepartment = async (parentId: number | null | undefined, currentId?: number): Promise => { if (!parentId) return null - const parent = await prisma.department.findUnique({ - where: { id: parentId }, + const parent = await prisma.department.findFirst({ + where: { + id: parentId, + ...buildBusinessDepartmentWhere() + }, select: { id: true, parent_id: true, status: true } }) @@ -306,8 +671,11 @@ export const updateDepartment = async (req: Request, res: Response) => { const { id } = req.params const { name, status, parent_id } = req.body as { name?: string; status?: string; parent_id?: number | null } - const existing = await prisma.department.findUnique({ - where: { id: Number(id) }, + const existing = await prisma.department.findFirst({ + where: { + id: Number(id), + ...buildBusinessDepartmentWhere() + }, include: { _count: { select: { children: true } } } @@ -362,8 +730,11 @@ export const deleteDepartment = async (req: Request, res: Response) => { try { const { id } = req.params - const existing = await prisma.department.findUnique({ - where: { id: Number(id) }, + const existing = await prisma.department.findFirst({ + where: { + id: Number(id), + ...buildBusinessDepartmentWhere() + }, include: { _count: { select: { equipments: true, children: true, mainPersons: true, personDepartments: true } diff --git a/backend/src/controllers/equipmentController.ts b/backend/src/controllers/equipmentController.ts index 5587594..a3920e5 100644 --- a/backend/src/controllers/equipmentController.ts +++ b/backend/src/controllers/equipmentController.ts @@ -9,6 +9,7 @@ import { sumBundleChildrenValue, syncMountedChildrenState } from '../utils/assetValue' +import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' type EquipmentEntryMode = 'single' | 'parent' | 'child' @@ -61,6 +62,33 @@ const includeStorageLocation = { } } +const activeBorrowSelect = { + borrow_records: { + where: { + status: '已借出' as const + }, + orderBy: [{ borrow_time: 'desc' as const }, { id: 'desc' as const }], + take: 1, + select: { + id: true, + person_id: true, + person_name: true, + person_nickname: true, + person_identifier: true, + borrow_time: true, + status: true, + person: { + select: { + id: true, + nickname: true, + real_name: true, + id_number: true + } + } + } + } +} + const includeEquipmentRelations = { ...includeStorageLocation, parent: { @@ -81,9 +109,11 @@ const includeEquipmentRelations = { equipment_type: true, unit_price: true, status: true, - mount_status: true + mount_status: true, + ...activeBorrowSelect } - } + }, + ...activeBorrowSelect } const toOptionalNumber = (value: unknown) => { @@ -92,6 +122,51 @@ const toOptionalNumber = (value: unknown) => { return Number.isNaN(numericValue) ? null : numericValue } +const mapCurrentBorrow = (borrowRecords: any[] | undefined) => { + const record = borrowRecords?.[0] + if (!record) { + return null + } + + return { + id: record.id, + person_id: record.person_id, + person_name: record.person_name, + person_nickname: record.person_nickname, + person_identifier: record.person_identifier, + borrow_time: record.borrow_time, + status: record.status, + person: record.person + ? { + id: record.person.id, + nickname: record.person.nickname, + real_name: record.person.real_name, + id_number: record.person.id_number + } + : null + } +} + +const serializeEquipmentListItem = (equipment: any) => { + const children = Array.isArray(equipment.children) + ? equipment.children.map((child: any) => { + const { borrow_records, ...restChild } = child + return { + ...restChild, + current_borrow: mapCurrentBorrow(borrow_records) + } + }) + : [] + + const { borrow_records, ...restEquipment } = equipment + + return { + ...restEquipment, + children, + current_borrow: mapCurrentBorrow(borrow_records) + } +} + const normalizeEntryMode = (value: unknown): EquipmentEntryMode => { if (value === 'parent' || value === 'child') return value return 'single' @@ -137,6 +212,30 @@ const ensureAssetTypeExists = async (client: any, equipmentType?: string, assetT return assetType } +const ensureBusinessDepartmentExists = async (client: any, departmentId?: number | null) => { + if (!departmentId) { + return null + } + + const department = await client.department.findFirst({ + where: { + id: departmentId, + status: 'active', + ...buildSyncedBusinessDepartmentWhere() + }, + select: { + id: true, + name: true + } + }) + + if (!department) { + throw new Error('所选使用部门不存在或已停用') + } + + return department +} + const resolveEquipmentCode = async ( client: any, codeGenerationMode: 'auto' | 'manual', @@ -197,6 +296,7 @@ const createEquipmentRecord = async ( const assetTypeId = toOptionalNumber(input.asset_type_id) await ensureLocationExists(client, locationId) + await ensureBusinessDepartmentExists(client, departmentId) const assetType = await ensureAssetTypeExists(client, input.equipment_type, assetTypeId) const codeGenerationMode = input.code_generation_mode === 'manual' ? 'manual' : 'auto' @@ -267,10 +367,58 @@ export const getEquipmentList = async (req: Request, res: Response) => { } if (keyword) { + const normalizedKeyword = String(keyword).trim() const ownSearchConditions = [ - { equipment_name: { contains: keyword as string } }, - { equipment_code: { contains: keyword as string } }, - { equipment_model: { contains: keyword as string } } + { equipment_name: { contains: normalizedKeyword } }, + { equipment_code: { contains: normalizedKeyword } }, + { equipment_model: { contains: normalizedKeyword } }, + { + department: { + is: { + name: { contains: normalizedKeyword } + } + } + }, + { + storageLocation: { + is: { + name: { contains: normalizedKeyword } + } + } + }, + { + borrow_records: { + some: { + status: '已借出', + OR: [ + { person_nickname: { contains: normalizedKeyword } }, + { person_name: { contains: normalizedKeyword } }, + { person_identifier: { contains: normalizedKeyword } }, + { + person: { + is: { + nickname: { contains: normalizedKeyword } + } + } + }, + { + person: { + is: { + real_name: { contains: normalizedKeyword } + } + } + }, + { + person: { + is: { + id_number: { contains: normalizedKeyword } + } + } + } + ] + } + } + } ] where.OR = hasParentFilter @@ -335,6 +483,7 @@ export const getEquipmentList = async (req: Request, res: Response) => { orderBy: { created_at: 'desc' }, include: { ...includeStorageLocation, + ...activeBorrowSelect, parent: { select: { id: true, @@ -351,7 +500,8 @@ export const getEquipmentList = async (req: Request, res: Response) => { equipment_type: true, unit_price: true, status: true, - mount_status: true + mount_status: true, + ...activeBorrowSelect } } } @@ -362,7 +512,7 @@ export const getEquipmentList = async (req: Request, res: Response) => { successResponse( res, { - list, + list: list.map(serializeEquipmentListItem), total, page: Number(page), pageSize: Number(pageSize) @@ -601,6 +751,7 @@ export const updateEquipment = async (req: Request, res: Response) => { if (department_id !== undefined) { targetDeptId = department_id ? Number(department_id) : null } + await ensureBusinessDepartmentExists(prisma, targetDeptId) // 处理资产类型ID let targetTypeId = currentEquipment.asset_type_id diff --git a/backend/src/controllers/exportController.ts b/backend/src/controllers/exportController.ts index a2af3b7..3a78c38 100644 --- a/backend/src/controllers/exportController.ts +++ b/backend/src/controllers/exportController.ts @@ -7,6 +7,7 @@ import { getEquipmentMountedChildrenValue, sumSnapshotChildrenValue } from '../utils/assetValue' +import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' // eslint-disable-next-line @typescript-eslint/no-var-requires const ExcelJS = require('exceljs') @@ -16,7 +17,9 @@ export const generateImportTemplate = async (req: Request, res: Response) => { // 从数据库获取所有启用的选项数据 const [departments, assetTypes, storageLocations] = await Promise.all([ prisma.department.findMany({ - where: { status: 'active' }, + where: { + AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }] + }, select: { name: true }, orderBy: { name: 'asc' } }), @@ -371,7 +374,9 @@ export const generatePersonTemplate = async (req: Request, res: Response) => { try { // 从数据库获取所有启用的部门 const departments = await prisma.department.findMany({ - where: { status: 'active' }, + where: { + AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }] + }, select: { name: true }, orderBy: { name: 'asc' } }) diff --git a/backend/src/controllers/inventoryController.ts b/backend/src/controllers/inventoryController.ts new file mode 100644 index 0000000..f32d33f --- /dev/null +++ b/backend/src/controllers/inventoryController.ts @@ -0,0 +1,133 @@ +import type { Request, Response } from 'express' +import { errorResponse, successResponse } from '../utils/response' +import { + assignUnassignedTaskItem, + closeInventoryTask, + createInventoryTask, + getInventoryTaskDetail, + getTaskUnassignedItems, + listInventoryTasks, + previewInventoryTask +} from '../services/inventoryService' + +const toOptionalNumber = (value: unknown) => { + if (value === undefined || value === null || value === '') return null + const numericValue = Number(value) + return Number.isNaN(numericValue) ? null : numericValue +} + +export const previewInventoryTaskController = async (req: Request, res: Response) => { + try { + const result = await previewInventoryTask({ + departmentId: toOptionalNumber(req.body?.department_id), + assetTypeId: toOptionalNumber(req.body?.asset_type_id) + }) + successResponse(res, result, '预览成功') + } catch (error: any) { + errorResponse(res, error.message || '预览盘点任务失败', 422) + } +} + +export const createInventoryTaskController = async (req: Request, res: Response) => { + try { + const currentUserId = Number(res.locals.user?.id) + if (!Number.isInteger(currentUserId) || currentUserId <= 0) { + return errorResponse(res, '未获取到当前登录用户', 401) + } + + const result = await createInventoryTask( + { + title: req.body?.title, + description: req.body?.description, + department_id: toOptionalNumber(req.body?.department_id), + asset_type_id: toOptionalNumber(req.body?.asset_type_id) + }, + currentUserId + ) + + successResponse(res, result, '创建成功') + } catch (error: any) { + errorResponse(res, error.message || '创建盘点任务失败', 422) + } +} + +export const getInventoryTaskListController = async (req: Request, res: Response) => { + try { + const page = Math.max(1, Number(req.query.page) || 1) + const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize) || 10)) + const result = await listInventoryTasks(page, pageSize) + successResponse(res, result, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取盘点任务列表失败', 500) + } +} + +export const getInventoryTaskDetailController = async (req: Request, res: Response) => { + try { + const taskId = Number(req.params.id) + if (!Number.isInteger(taskId) || taskId <= 0) { + return errorResponse(res, '盘点任务不存在', 404) + } + + const result = await getInventoryTaskDetail(taskId) + successResponse(res, result, '获取成功') + } catch (error: any) { + const message = error.message || '获取盘点任务详情失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 500) + } +} + +export const closeInventoryTaskController = async (req: Request, res: Response) => { + try { + const taskId = Number(req.params.id) + const currentUserId = Number(res.locals.user?.id) + if (!Number.isInteger(taskId) || taskId <= 0) { + return errorResponse(res, '盘点任务不存在', 404) + } + if (!Number.isInteger(currentUserId) || currentUserId <= 0) { + return errorResponse(res, '未获取到当前登录用户', 401) + } + + await closeInventoryTask(taskId, currentUserId) + successResponse(res, null, '任务已关闭') + } catch (error: any) { + const message = error.message || '关闭盘点任务失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 500) + } +} + +export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => { + try { + const taskId = Number(req.params.id) + if (!Number.isInteger(taskId) || taskId <= 0) { + return errorResponse(res, '盘点任务不存在', 404) + } + + const list = await getTaskUnassignedItems(taskId) + successResponse(res, list, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '获取无人归属资产失败', 500) + } +} + +export const assignInventoryTaskUnassignedController = async (req: Request, res: Response) => { + try { + const taskId = Number(req.params.id) + const itemId = Number(req.params.itemId) + const personId = Number(req.body?.person_id) + + if (!Number.isInteger(taskId) || taskId <= 0 || !Number.isInteger(itemId) || itemId <= 0) { + return errorResponse(res, '待处理资产不存在', 404) + } + + if (!Number.isInteger(personId) || personId <= 0) { + return errorResponse(res, '目标人员不能为空', 422) + } + + await assignUnassignedTaskItem(taskId, itemId, personId) + successResponse(res, null, '分配成功') + } catch (error: any) { + const message = error.message || '分配无人归属资产失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 422) + } +} diff --git a/backend/src/controllers/personController.ts b/backend/src/controllers/personController.ts index e04aa9d..e3dc28c 100644 --- a/backend/src/controllers/personController.ts +++ b/backend/src/controllers/personController.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' const departmentSelect = { id: true, @@ -89,6 +90,9 @@ const sanitizePerson = (person: any) => { source_system: person.source_system, source_main_department_id: person.source_main_department_id, source_dept_ids: person.source_dept_ids, + source_department_name: person.source_department_name, + source_main_department_name: person.source_main_department_name, + department_sync_bucket: person.department_sync_bucket, manager_source_userid: person.manager_source_userid, source_employee_status: person.source_employee_status, last_synced_at: person.last_synced_at, @@ -142,7 +146,8 @@ const resolveDepartmentByName = async (departmentName: string | null) => { return prisma.department.findFirst({ where: { name: departmentName, - status: 'active' + status: 'active', + ...buildSyncedBusinessDepartmentWhere() }, select: { id: true, @@ -244,7 +249,7 @@ export const createPerson = async (req: Request, res: Response) => { if (normalizedIdNumber) { const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } }) if (exists) { - return errorResponse(res, '证件编号已存在', 409) + return errorResponse(res, '工号已存在', 409) } } @@ -309,7 +314,7 @@ export const updatePerson = async (req: Request, res: Response) => { if (normalizedIdNumber && normalizedIdNumber !== person.id_number) { const exists = await prisma.person.findUnique({ where: { id_number: normalizedIdNumber } }) if (exists) { - return errorResponse(res, '证件编号已存在', 409) + return errorResponse(res, '工号已存在', 409) } } diff --git a/backend/src/controllers/publicInventoryController.ts b/backend/src/controllers/publicInventoryController.ts new file mode 100644 index 0000000..9d249ae --- /dev/null +++ b/backend/src/controllers/publicInventoryController.ts @@ -0,0 +1,189 @@ +import type { Request, Response } from 'express' +import { errorResponse, successResponse } from '../utils/response' +import { + confirmInventoryParticipant, + getInventoryTaskPublicInfo, + getParticipantItems, + getParticipantTaskContext, + searchTaskParticipants, + submitInventoryException, + submitInventoryScan +} from '../services/inventoryService' + +const INVENTORY_SESSION_KEY = 'inventoryParticipant' + +const getInventorySession = (req: Request) => ((req.session as any)?.[INVENTORY_SESSION_KEY] || null) as + | { + taskId: number + participantId: number + publicToken: string + } + | null + +const setInventorySession = ( + req: Request, + payload: { + taskId: number + participantId: number + publicToken: string + } +) => { + ;(req.session as any)[INVENTORY_SESSION_KEY] = payload +} + +const clearInventorySession = (req: Request) => { + if ((req.session as any)?.[INVENTORY_SESSION_KEY]) { + delete (req.session as any)[INVENTORY_SESSION_KEY] + } +} + +const ensureInventorySession = async (req: Request) => { + const session = getInventorySession(req) + if (!session) { + throw new Error('盘点会话不存在,请重新确认身份') + } + + const taskContext = await getParticipantTaskContext(session.taskId, session.participantId) + if (taskContext.task.public_token !== session.publicToken) { + clearInventorySession(req) + throw new Error('盘点会话已失效,请重新确认身份') + } + + return { + session, + ...taskContext + } +} + +export const getPublicInventoryTaskController = async (req: Request, res: Response) => { + try { + const data = await getInventoryTaskPublicInfo(req.params.token) + successResponse(res, data, '获取成功') + } catch (error: any) { + const message = error.message || '获取盘点任务失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 500) + } +} + +export const searchPublicInventoryParticipantsController = async (req: Request, res: Response) => { + try { + const keyword = typeof req.query.keyword === 'string' ? req.query.keyword : '' + const list = await searchTaskParticipants(req.params.token, keyword) + successResponse(res, list, '获取成功') + } catch (error: any) { + const message = error.message || '搜索盘点人员失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 500) + } +} + +export const confirmPublicInventoryParticipantController = async (req: Request, res: Response) => { + try { + const participantId = Number(req.body?.participant_id) + if (!Number.isInteger(participantId) || participantId <= 0) { + return errorResponse(res, '请选择盘点人员', 422) + } + + const data = await confirmInventoryParticipant(req.params.token, participantId) + setInventorySession(req, { + taskId: data.task_id, + participantId: data.participant_id, + publicToken: req.params.token + }) + successResponse(res, data, '确认成功') + } catch (error: any) { + const message = error.message || '确认盘点身份失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 422) + } +} + +export const getPublicInventoryMeController = async (req: Request, res: Response) => { + try { + const { task, participant } = await ensureInventorySession(req) + successResponse( + res, + { + task: { + id: task.id, + title: task.title, + status: task.status, + scope_department_name: task.scope_department_name, + scope_asset_type_name: task.scope_asset_type_name, + completed_count: task.completed_count, + exception_count: task.exception_count, + items_count: task.items_count, + closed_at: task.closed_at + }, + participant + }, + '获取成功' + ) + } catch (error: any) { + const message = error.message || '获取盘点会话失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 401) + } +} + +export const getPublicInventoryItemsController = async (req: Request, res: Response) => { + try { + const { task, participant } = await ensureInventorySession(req) + const data = await getParticipantItems(task.id, participant.id) + successResponse(res, data, '获取成功') + } catch (error: any) { + const message = error.message || '获取盘点资产失败' + errorResponse(res, message, message.includes('不存在') ? 404 : 401) + } +} + +export const submitPublicInventoryScanController = async (req: Request, res: Response) => { + try { + const { task, participant } = await ensureInventorySession(req) + if (task.status === 'closed') { + return errorResponse(res, '盘点任务已关闭', 403) + } + + const equipmentCode = String(req.body?.equipment_code || '').trim() + if (!equipmentCode) { + return errorResponse(res, '资产编码不能为空', 422) + } + + const data = await submitInventoryScan(task.id, participant.id, equipmentCode) + successResponse(res, data, '盘点成功') + } catch (error: any) { + const message = error.message || '提交盘点结果失败' + errorResponse( + res, + message, + message.includes('盘点范围') || message.includes('不能为空') ? 422 : message.includes('不存在') ? 404 : 401 + ) + } +} + +export const submitPublicInventoryExceptionController = async (req: Request, res: Response) => { + try { + const { task, participant } = await ensureInventorySession(req) + if (task.status === 'closed') { + return errorResponse(res, '盘点任务已关闭', 403) + } + + const itemId = Number(req.params.itemId) + if (!Number.isInteger(itemId) || itemId <= 0) { + return errorResponse(res, '盘点资产不存在', 404) + } + + await submitInventoryException( + task.id, + participant.id, + itemId, + String(req.body?.exception_type || ''), + req.body?.exception_note + ) + successResponse(res, null, '异常已提交') + } catch (error: any) { + const message = error.message || '提交盘点异常失败' + errorResponse( + res, + message, + message.includes('不存在') ? 404 : message.includes('无效') ? 422 : 401 + ) + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index caa21fa..80732bf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,6 +25,8 @@ import exportRoutes from './routes/export' import searchRoutes from './routes/search' import syncRoutes from './routes/sync' import notificationRoutes from './routes/notifications' +import inventoryRoutes from './routes/inventory' +import publicInventoryRoutes from './routes/publicInventory' import { keycloak, memoryStore } from './utils/keycloak' import { protect, requireAdmin } from './middleware/auth' import { startHrSyncScheduler } from './services/hrSyncService' @@ -81,6 +83,8 @@ app.use('/api/export', protect(), exportRoutes) app.use('/api/search', protect(), searchRoutes) app.use('/api/sync', protect(), requireAdmin(), syncRoutes) app.use('/api/notifications', notificationRoutes) +app.use('/api/inventory', inventoryRoutes) +app.use('/api/public/inventory', publicInventoryRoutes) // Health check app.get('/api/health', (req, res) => { diff --git a/backend/src/routes/departments.ts b/backend/src/routes/departments.ts index 484e54b..80214a7 100644 --- a/backend/src/routes/departments.ts +++ b/backend/src/routes/departments.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { getDepartmentOptions, getDepartmentTree, + getOrganizationTree, getDepartmentList, createDepartment, updateDepartment, @@ -13,6 +14,7 @@ const router = Router() router.get('/options', protect(), getDepartmentOptions) router.get('/tree', protect(), getDepartmentTree) +router.get('/organization-tree', protect(), requireAdmin(), getOrganizationTree) router.get('/list', protect(), requireAdmin(), getDepartmentList) router.post('/', protect(), requireAdmin(), createDepartment) router.put('/:id', protect(), requireAdmin(), updateDepartment) diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts new file mode 100644 index 0000000..8e02ebf --- /dev/null +++ b/backend/src/routes/inventory.ts @@ -0,0 +1,25 @@ +import { Router } from 'express' +import { + assignInventoryTaskUnassignedController, + closeInventoryTaskController, + createInventoryTaskController, + getInventoryTaskDetailController, + getInventoryTaskListController, + getInventoryTaskUnassignedController, + previewInventoryTaskController +} from '../controllers/inventoryController' +import { protect, requireAdmin } from '../middleware/auth' + +const router = Router() + +router.use(protect(), requireAdmin()) + +router.post('/tasks/preview', previewInventoryTaskController) +router.post('/tasks', createInventoryTaskController) +router.get('/tasks', getInventoryTaskListController) +router.get('/tasks/:id', getInventoryTaskDetailController) +router.post('/tasks/:id/close', closeInventoryTaskController) +router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController) +router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController) + +export default router diff --git a/backend/src/routes/publicInventory.ts b/backend/src/routes/publicInventory.ts new file mode 100644 index 0000000..20ac36f --- /dev/null +++ b/backend/src/routes/publicInventory.ts @@ -0,0 +1,22 @@ +import { Router } from 'express' +import { + confirmPublicInventoryParticipantController, + getPublicInventoryItemsController, + getPublicInventoryMeController, + getPublicInventoryTaskController, + searchPublicInventoryParticipantsController, + submitPublicInventoryExceptionController, + submitPublicInventoryScanController +} from '../controllers/publicInventoryController' + +const router = Router() + +router.get('/tasks/:token', getPublicInventoryTaskController) +router.get('/tasks/:token/participants', searchPublicInventoryParticipantsController) +router.post('/tasks/:token/participants/confirm', confirmPublicInventoryParticipantController) +router.get('/me', getPublicInventoryMeController) +router.get('/me/items', getPublicInventoryItemsController) +router.post('/me/scan', submitPublicInventoryScanController) +router.post('/me/items/:itemId/exception', submitPublicInventoryExceptionController) + +export default router diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index 5b1a6e0..ff8deb2 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -1,26 +1,55 @@ import prisma from '../utils/prisma' import sql from 'mssql' +import { + HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM, + HR_SOURCE_SYSTEM, + OTHER_DEPARTMENT_NAME, + buildBusinessDepartmentWhere, + buildSyncedBusinessDepartmentWhere +} from '../utils/organization' type SourceDepartment = { - sourceGroupId: number + sourceGroupId: number | null sourceParentGroupId: number | null name: string } +type HrSyncSourceMode = 'register_primary' + +type SourcePresence = 'employee_and_register' | 'register_only' + +type DepartmentSyncBucket = 'formal' | 'fallback' | 'other' + +type SourcePersonSpecialExclusionReason = + | 'business_support_blank_job_number' + | 'longchen_brand_center_register_only' + | 'brand_center_register_only' + type SourcePerson = { sourceUserId: string nickname: string realName: string idNumber: string | null - departmentName: string | null + sourceDepartmentName: string | null + sourceMainDepartmentName: string | null position: string | null phone: string | null email: string | null sourceMainDepartmentId: number | null sourceDeptIds: string | null + sourceJobNumber: string | null managerSourceUserId: string | null sourceEmployeeStatus: string | null - status: 'active' | 'inactive' + status: 'active' + resolvedDepartmentName: string + departmentSyncBucket: DepartmentSyncBucket + sourcePresence: SourcePresence +} + +type BusinessDepartmentCandidate = { + name: string + sourcePersonCount: number + buckets: DepartmentSyncBucket[] } type HrSyncResult = { @@ -36,6 +65,22 @@ type HrSyncResult = { updated: number deactivated: number } + equipment_department_migration?: { + migrated: number + unresolved: number + samples: Array<{ + equipment_id: number + equipment_code: string + equipment_name: string + legacy_department_name: string | null + }> + } + cleanup?: { + deleted_inactive_persons: number + remapped_legacy_person_departments: number + deleted_legacy_departments: number + deleted_inactive_business_departments: number + } sync_job_id?: number changes_count?: number } @@ -44,12 +89,8 @@ type SyncTriggerMode = 'manual' | 'scheduler' type DepartmentSnapshot = { id: number - sourceGroupId: number name: string status: string - sourceParentGroupId: number | null - parentId: number | null - parentName: string | null } type PersonSnapshot = { @@ -64,15 +105,17 @@ type PersonSnapshot = { mainDepartmentName: string | null departmentIds: number[] departmentNames: string[] + departmentSyncBucket: string | null } type HrSyncChangeDraft = { - entityType: 'person' | 'department' + entityType: 'person' | 'department' | 'equipment' entityKey: string entityName: string changeType: string title: string detail: string + countWeight?: number beforeSnapshot?: Record | null afterSnapshot?: Record | null } @@ -88,13 +131,28 @@ type HrSyncSummary = { }> } -const SOURCE_SYSTEM = 'ding_hr' +type LocalDepartmentMap = Map + +type SourcePersonCollection = { + persons: SourcePerson[] + sourceMode: HrSyncSourceMode + sourcePresenceSummary: Record + departmentBucketSummary: Record + specialExclusionSummary: Record +} + const REQUIRED_SQLSERVER_FIELDS = [ 'SQLSERVER_HOST', 'SQLSERVER_DATABASE', 'SQLSERVER_USER', 'SQLSERVER_PASSWORD' ] as const + +const departmentCollator = new Intl.Collator('zh-CN', { + numeric: true, + sensitivity: 'base' +}) + let schedulerStarted = false let schedulerRunning = false const lastSchedulerRunKeys = new Set() @@ -107,20 +165,33 @@ const CHANGE_TYPE_LABELS: Record = { person_departments_changed: '关联部门变动', person_position_changed: '岗位变动', department_created: '新增部门', - department_renamed: '部门名称变动', - department_hierarchy_changed: '部门层级变动', department_deactivated: '部门停用', - department_reactivated: '部门重新启用' + department_reactivated: '部门重新启用', + equipment_department_unresolved: '资产部门待人工处理' } const getConfiguredSet = (value: string, defaultValue: string) => new Set( - (value || defaultValue) + ((value && value.trim() ? value : defaultValue) || '') .split(',') .map((item) => item.trim()) .filter(Boolean) ) +const getFormalStatusValues = () => + getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3') + +const getInactiveStatusValues = () => + getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '5') + +const getExcludedSourceDepartmentNames = () => + getConfiguredSet( + process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '', + '供应商,快递公司,外部交流' + ) + +const getHrSyncSourceMode = (): HrSyncSourceMode => 'register_primary' + export const getHrSyncConfigStatus = () => { const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => { const value = process.env[field] @@ -143,7 +214,6 @@ const getSqlServerConfig = (): any => { const password = process.env.SQLSERVER_PASSWORD const encrypt = process.env.SQLSERVER_ENCRYPT === 'true' const trustServerCertificate = process.env.SQLSERVER_TRUST_CERT !== 'false' - const syncStatus = getHrSyncConfigStatus() if (!syncStatus.complete) { @@ -223,21 +293,9 @@ const parseExtensionFlowerName = (extension: unknown) => { } } -const resolvePersonStatus = (active: unknown, employeeStatus: unknown): 'active' | 'inactive' => { - const inactiveStatusValues = getConfiguredSet( - process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', - '5' - ) - +const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => { + const inactiveStatusValues = getInactiveStatusValues() const sourceStatus = safeTrim(employeeStatus) - const activeFlag = - active === true || - active === 1 || - String(active).toLowerCase() === 'true' - - if (!activeFlag) { - return 'inactive' - } if (sourceStatus && inactiveStatusValues.has(sourceStatus)) { return 'inactive' @@ -246,39 +304,112 @@ const resolvePersonStatus = (active: unknown, employeeStatus: unknown): 'active' return 'active' } -const isFormalEmployee = (person: SourcePerson) => { - const allowedStatusValues = getConfiguredSet( - process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', - '3' - ) - const excludedDepartments = Array.from( - getConfiguredSet( - process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '', - '供应商,快递公司,外部交流' - ) - ) - const excludedNameKeywords = Array.from( - getConfiguredSet( - process.env.HR_SYNC_EXCLUDED_NAME_KEYWORDS || '', - '' - ) - ) +const resolveDepartmentBinding = ( + sourceDepartmentName: string | null, + sourceMainDepartmentName: string | null, + sourceEmployeeStatus: string | null +): { resolvedDepartmentName: string; departmentSyncBucket: DepartmentSyncBucket } => { + const allowedStatusValues = getFormalStatusValues() - if (!allowedStatusValues.has(person.sourceEmployeeStatus || '')) { + if (allowedStatusValues.has(sourceEmployeeStatus || '')) { + if (sourceDepartmentName) { + return { + resolvedDepartmentName: sourceDepartmentName, + departmentSyncBucket: 'formal' + } + } + + if (sourceMainDepartmentName) { + return { + resolvedDepartmentName: sourceMainDepartmentName, + departmentSyncBucket: 'fallback' + } + } + } + + return { + resolvedDepartmentName: OTHER_DEPARTMENT_NAME, + departmentSyncBucket: 'other' + } +} + +const isExcludedSourceDepartment = (value: string | null | undefined) => { + const name = safeTrim(value) + if (!name) { return false } - const departmentName = person.departmentName || '' - if (excludedDepartments.some((keyword) => departmentName.includes(keyword))) { + return getExcludedSourceDepartmentNames().has(name) +} + +const hasSyncableDepartmentClue = ( + sourceDepartmentName: string | null, + sourceMainDepartmentName: string | null +) => { + const primaryDepartmentName = safeTrim(sourceDepartmentName) + if (primaryDepartmentName) { + return !isExcludedSourceDepartment(primaryDepartmentName) + } + + const fallbackDepartmentName = safeTrim(sourceMainDepartmentName) + if (fallbackDepartmentName) { + return !isExcludedSourceDepartment(fallbackDepartmentName) + } + + return false +} + +const shouldSyncPerson = (person: { + nickname: string + realName: string + status: 'active' | 'inactive' + sourceDepartmentName: string | null + sourceMainDepartmentName: string | null + sourceJobNumber: string | null +}) => { + if (person.status !== 'active') { return false } + if (!hasSyncableDepartmentClue(person.sourceDepartmentName, person.sourceMainDepartmentName)) { + return false + } + + const excludedNameKeywords = Array.from( + getConfiguredSet(process.env.HR_SYNC_EXCLUDED_NAME_KEYWORDS || '', '') + ) + if (excludedNameKeywords.length === 0) { + return true + } + const text = `${person.nickname} ${person.realName}`.trim() - if (excludedNameKeywords.some((keyword) => text.includes(keyword))) { - return false + return !excludedNameKeywords.some((keyword) => text.includes(keyword)) +} + +const getSpecialExclusionReason = (person: { + sourceDepartmentName: string | null + sourceJobNumber: string | null + sourcePresence: SourcePresence +}) => { + if (person.sourceDepartmentName === '业务支持' && !person.sourceJobNumber) { + return 'business_support_blank_job_number' as const } - return person.status === 'active' + if ( + person.sourceDepartmentName === '龙晨品牌中心' && + person.sourcePresence === 'register_only' + ) { + return 'longchen_brand_center_register_only' as const + } + + if ( + person.sourceDepartmentName === '品牌中心' && + person.sourcePresence === 'register_only' + ) { + return 'brand_center_register_only' as const + } + + return null } const getSourceDepartments = async (pool: any): Promise => { @@ -286,15 +417,22 @@ const getSourceDepartments = async (pool: any): Promise => { .request() .query(` SELECT - Id AS sourceGroupId, - parent_id AS sourceParentGroupId, + CAST(NULL AS INT) AS sourceGroupId, + CAST(NULL AS INT) AS sourceParentGroupId, name - FROM dbo.hr_groups - ORDER BY Id + FROM ( + SELECT LTRIM(RTRIM([sys00-dept])) AS name + FROM dbo.hr_employee_register + UNION + SELECT LTRIM(RTRIM([sys00-mainDepts])) AS name + FROM dbo.hr_employee_register + ) source_departments + WHERE ISNULL(name, '') <> '' + ORDER BY name `) return result.recordset.map((row: any) => ({ - sourceGroupId: Number(row.sourceGroupId), + sourceGroupId: row.sourceGroupId === null ? null : Number(row.sourceGroupId), sourceParentGroupId: row.sourceParentGroupId === null ? null : Number(row.sourceParentGroupId), name: String(row.name).trim() })) @@ -302,26 +440,53 @@ const getSourceDepartments = async (pool: any): Promise => { const parseSourceDeptIds = (value: string | null) => (value || '') - .split('|') + .replace(/[\[\]]/g, '') + .split(/[|,]/) .map((item) => item.trim()) .filter(Boolean) .map((item) => Number(item)) .filter((item) => !Number.isNaN(item)) -const getSourcePersons = async (pool: any): Promise => { +const buildSourcePersonCollection = ( + persons: SourcePerson[], + specialExclusionSummary: Record +): SourcePersonCollection => { + const sourcePresenceSummary: Record = { + employee_and_register: 0, + register_only: 0 + } + const departmentBucketSummary: Record = { + formal: 0, + fallback: 0, + other: 0 + } + + for (const person of persons) { + sourcePresenceSummary[person.sourcePresence] += 1 + departmentBucketSummary[person.departmentSyncBucket] += 1 + } + + return { + persons, + sourceMode: getHrSyncSourceMode(), + sourcePresenceSummary, + departmentBucketSummary, + specialExclusionSummary + } +} + +const getSourcePersons = async (pool: any): Promise => { const result = await pool .request() .query(` SELECT - e.userid AS sourceUserId, + r.userid AS sourceUserId, e.name AS employeeName, e.mobile, - e.job_number, - e.title, e.email, + e.title, e.dept_id_list, e.manager_userid, - e.active, e.extension, r.[sys00-name] AS registerName, r.[sys00-flowerName] AS flowerName, @@ -329,109 +494,149 @@ const getSourcePersons = async (pool: any): Promise => { r.[sys02-certNo] AS certNo, r.[sys00-mobile] AS registerMobile, r.[sys00-position] AS registerPosition, + r.[sys00-jobNumber] AS registerJobNumber, r.[sys00-dept] AS registerDept, + r.[sys00-mainDepts] AS mainDepts, r.[sys00-mainDeptId] AS mainDeptId, r.[sys00-deptIds] AS registerDeptIds, r.[sys01-employeeStatus] AS employeeStatus - FROM dbo.hr_employee e - LEFT JOIN dbo.hr_employee_register r - ON r.userid = e.userid + FROM dbo.hr_employee_register r + LEFT JOIN dbo.hr_employee e + ON e.userid = r.userid `) - return result.recordset - .map((row: any) => { - const fallbackName = safeTrim(row.employeeName) || safeTrim(row.registerName) || safeTrim(row.sourceUserId) + const specialExclusionSummary: Record = { + business_support_blank_job_number: 0, + longchen_brand_center_register_only: 0, + brand_center_register_only: 0 + } + + const persons: SourcePerson[] = [] + + result.recordset.forEach((row: any) => { + const sourceUserId = safeTrim(row.sourceUserId) + const sourcePresence: SourcePresence = + safeTrim(row.employeeName) || safeTrim(row.email) || safeTrim(row.manager_userid) + ? 'employee_and_register' + : 'register_only' + const fallbackName = + safeTrim(row.flowerName) || + safeTrim(row.registerName) || + safeTrim(row.realName) || + safeTrim(row.employeeName) || + sourceUserId const nickname = safeTrim(row.flowerName) || parseExtensionFlowerName(row.extension) || + safeTrim(row.employeeName) || fallbackName || '未命名' - const realName = safeTrim(row.realName) || fallbackName || nickname + const sourceDepartmentName = safeTrim(row.registerDept) + const sourceMainDepartmentName = safeTrim(row.mainDepts) + const sourceJobNumber = safeTrim(row.registerJobNumber) + const departmentBinding = resolveDepartmentBinding( + sourceDepartmentName, + sourceMainDepartmentName, + safeTrim(row.employeeStatus) + ) - return { - sourceUserId: String(row.sourceUserId).trim(), + const person = { + sourceUserId: sourceUserId || '', nickname, realName, idNumber: safeTrim(row.certNo), - departmentName: safeTrim(row.registerDept), + sourceDepartmentName, + sourceMainDepartmentName, position: safeTrim(row.registerPosition) || safeTrim(row.title), phone: normalizePhone(row.registerMobile) || normalizePhone(row.mobile), email: safeTrim(row.email), sourceMainDepartmentId: parseSourceInt(row.mainDeptId), sourceDeptIds: safeTrim(row.registerDeptIds) || safeTrim(row.dept_id_list), + sourceJobNumber, managerSourceUserId: safeTrim(row.manager_userid), sourceEmployeeStatus: safeTrim(row.employeeStatus), - status: resolvePersonStatus(row.active, row.employeeStatus) - } satisfies SourcePerson + status: resolvePersonStatus(row.employeeStatus), + resolvedDepartmentName: departmentBinding.resolvedDepartmentName, + departmentSyncBucket: departmentBinding.departmentSyncBucket, + sourcePresence + } as SourcePerson | (Omit & { status: 'inactive' }) + + if (!person.sourceUserId) { + return + } + + if (!shouldSyncPerson(person)) { + return + } + + const specialExclusionReason = getSpecialExclusionReason(person) + if (specialExclusionReason) { + specialExclusionSummary[specialExclusionReason] += 1 + return + } + + if (person.status !== 'active') { + return + } + + persons.push(person as SourcePerson) }) - .filter((item: SourcePerson) => item.sourceUserId) - .filter((item: SourcePerson) => isFormalEmployee(item)) + + return buildSourcePersonCollection(persons, specialExclusionSummary) } -const filterRelevantDepartments = ( - allDepartments: SourceDepartment[], - sourcePersons: SourcePerson[] -): SourceDepartment[] => { - const relevantIds = new Set() - const departmentMap = new Map(allDepartments.map((item) => [item.sourceGroupId, item])) +const buildBusinessDepartmentCandidates = (persons: SourcePerson[]): BusinessDepartmentCandidate[] => { + const map = new Map() - for (const person of sourcePersons) { - if (person.sourceMainDepartmentId) { - relevantIds.add(person.sourceMainDepartmentId) + for (const person of persons) { + const name = person.resolvedDepartmentName || OTHER_DEPARTMENT_NAME + const current = map.get(name) + if (current) { + current.sourcePersonCount += 1 + if (!current.buckets.includes(person.departmentSyncBucket)) { + current.buckets.push(person.departmentSyncBucket) + } + continue } - for (const deptId of parseSourceDeptIds(person.sourceDeptIds)) { - relevantIds.add(deptId) - } + map.set(name, { + name, + sourcePersonCount: 1, + buckets: [person.departmentSyncBucket] + }) } - const scopedIds = new Set(relevantIds) - for (const deptId of relevantIds) { - let current = departmentMap.get(deptId) - while (current?.sourceParentGroupId) { - scopedIds.add(current.sourceParentGroupId) - current = departmentMap.get(current.sourceParentGroupId) - } + if (!map.has(OTHER_DEPARTMENT_NAME)) { + map.set(OTHER_DEPARTMENT_NAME, { + name: OTHER_DEPARTMENT_NAME, + sourcePersonCount: 0, + buckets: ['other'] + }) } - return allDepartments.filter((item) => scopedIds.has(item.sourceGroupId)) + return Array.from(map.values()).sort((left, right) => departmentCollator.compare(left.name, right.name)) } -type LocalDepartmentMap = Map - const getDepartmentSnapshotMap = async () => { const departments = await prisma.department.findMany({ where: { - source_system: SOURCE_SYSTEM, - source_group_id: { - not: null - } + source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM }, select: { id: true, name: true, - status: true, - parent_id: true, - source_group_id: true, - source_parent_group_id: true, - parent: { - select: { name: true } - } + status: true } }) - return new Map( + return new Map( departments.map((department) => [ - department.source_group_id as number, + department.name, { id: department.id, - sourceGroupId: department.source_group_id as number, name: department.name, - status: department.status, - sourceParentGroupId: department.source_parent_group_id, - parentId: department.parent_id, - parentName: department.parent?.name || null + status: department.status } ]) ) @@ -440,7 +645,7 @@ const getDepartmentSnapshotMap = async () => { const getPersonSnapshotMap = async () => { const persons = await prisma.person.findMany({ where: { - source_system: SOURCE_SYSTEM, + source_system: HR_SOURCE_SYSTEM, source_userid: { not: null } @@ -453,6 +658,7 @@ const getPersonSnapshotMap = async () => { status: true, position: true, department: true, + department_sync_bucket: true, main_department_id: true, mainDepartment: { select: { @@ -504,9 +710,10 @@ const getPersonSnapshotMap = async () => { position: person.position, department: person.mainDepartment?.name || person.department || departmentNames[0] || null, mainDepartmentId: person.main_department_id, - mainDepartmentName: person.mainDepartment?.name || null, + mainDepartmentName: person.mainDepartment?.name || person.department || null, departmentIds, - departmentNames + departmentNames, + departmentSyncBucket: person.department_sync_bucket } ] }) @@ -516,11 +723,13 @@ const getPersonSnapshotMap = async () => { const buildSyncChange = (change: HrSyncChangeDraft) => change const diffDepartmentSnapshots = ( - beforeMap: Map, - afterMap: Map + beforeMap: Map, + afterMap: Map ) => { const changes: HrSyncChangeDraft[] = [] - const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => left - right) + const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => + departmentCollator.compare(left, right) + ) for (const key of keys) { const before = beforeMap.get(key) @@ -530,11 +739,11 @@ const diffDepartmentSnapshots = ( changes.push( buildSyncChange({ entityType: 'department', - entityKey: String(key), + entityKey: key, entityName: after.name, changeType: 'department_created', title: `新增部门:${after.name}`, - detail: `新部门已同步到系统,上级部门为 ${formatDepartmentName(after.parentName)}。`, + detail: '该业务部门已进入本次组织同步口径,可直接用于人员归属、资产筛选和盘点任务。', beforeSnapshot: null, afterSnapshot: after }) @@ -550,46 +759,14 @@ const diffDepartmentSnapshots = ( changes.push( buildSyncChange({ entityType: 'department', - entityKey: String(key), + entityKey: key, entityName: after.name, changeType: after.status === 'inactive' ? 'department_deactivated' : 'department_reactivated', title: `${after.status === 'inactive' ? '部门停用' : '部门重新启用'}:${after.name}`, detail: after.status === 'inactive' - ? '该部门已不在本次同步范围内,系统已自动停用。' - : '该部门重新进入同步范围,系统已重新启用。', - beforeSnapshot: before, - afterSnapshot: after - }) - ) - } - - if (before.name !== after.name) { - changes.push( - buildSyncChange({ - entityType: 'department', - entityKey: String(key), - entityName: after.name, - changeType: 'department_renamed', - title: `部门名称变动:${before.name} -> ${after.name}`, - detail: `部门名称已从“${before.name}”调整为“${after.name}”。`, - beforeSnapshot: before, - afterSnapshot: after - }) - ) - } - - if (before.sourceParentGroupId !== after.sourceParentGroupId) { - changes.push( - buildSyncChange({ - entityType: 'department', - entityKey: String(key), - entityName: after.name, - changeType: 'department_hierarchy_changed', - title: `部门层级变动:${after.name}`, - detail: `上级部门已从“${formatDepartmentName(before.parentName)}”调整为“${formatDepartmentName( - after.parentName - )}”。`, + ? '该业务部门已不在本次同步范围内,系统已自动停用。' + : '该业务部门重新进入同步范围,系统已重新启用。', beforeSnapshot: before, afterSnapshot: after }) @@ -606,7 +783,7 @@ const diffPersonSnapshots = ( ) => { const changes: HrSyncChangeDraft[] = [] const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => - left.localeCompare(right) + left.localeCompare(right, 'zh-CN') ) for (const key of keys) { @@ -621,7 +798,7 @@ const diffPersonSnapshots = ( entityName: after.realName, changeType: 'person_created', title: `新人入职:${after.realName}`, - detail: `主部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`, + detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`, beforeSnapshot: null, afterSnapshot: after }) @@ -643,8 +820,8 @@ const diffPersonSnapshots = ( title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${after.realName}`, detail: after.status === 'inactive' - ? '该人员已从本次同步名单中移除或状态变为停用,请行政确认是否需要处理资产交接。' - : '该人员重新进入同步名单,可重新参与借还流程。', + ? '该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。' + : '该人员重新进入同步名单,可继续参与借还与盘点流程。', beforeSnapshot: before, afterSnapshot: after }) @@ -663,9 +840,7 @@ const diffPersonSnapshots = ( entityName: after.realName, changeType: 'person_main_department_changed', title: `主部门变动:${after.realName}`, - detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName( - after.mainDepartmentName - )}”。`, + detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`, beforeSnapshot: before, afterSnapshot: after }) @@ -680,9 +855,7 @@ const diffPersonSnapshots = ( entityName: after.realName, changeType: 'person_departments_changed', title: `关联部门变动:${after.realName}`, - detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList( - after.departmentNames - )}”。`, + detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`, beforeSnapshot: before, afterSnapshot: after }) @@ -710,14 +883,14 @@ const diffPersonSnapshots = ( const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary => { const counts = changes.reduce>((acc, change) => { - acc[change.changeType] = (acc[change.changeType] || 0) + 1 + acc[change.changeType] = (acc[change.changeType] || 0) + (change.countWeight || 1) return acc }, {}) if (changes.length === 0) { return { title: '组织同步完成', - content: '本次组织同步已完成,未发现需要行政处理的人员或部门变动。', + content: '本次组织同步已完成,未发现需要行政处理的人员、部门或资产归属异常。', counts, previewChanges: [] } @@ -737,9 +910,7 @@ const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary => change_type: change.changeType })) - const previewText = previewChanges - .map((change, index) => `${index + 1}. ${change.title}`) - .join('\n') + const previewText = previewChanges.map((change, index) => `${index + 1}. ${change.title}`).join('\n') return { title: `组织同步发现 ${changes.length} 项变化`, @@ -828,6 +999,7 @@ const persistHrSyncArtifacts = async ({ }) } }) + notificationId = notification.id const adminUsers = await tx.user.findMany({ @@ -857,104 +1029,58 @@ const persistHrSyncArtifacts = async ({ }) } -const syncDepartments = async (sourceDepartments: SourceDepartment[]) => { +const syncDepartments = async (sourceDepartments: BusinessDepartmentCandidate[]) => { const now = new Date() const existing = await prisma.department.findMany({ + where: buildBusinessDepartmentWhere(), select: { id: true, name: true, - source_group_id: true, source_system: true } }) - const existingBySourceGroupId = new Map( - existing - .filter((item) => item.source_group_id !== null) - .map((item) => [item.source_group_id as number, item]) - ) - const manualByName = new Map( - existing - .filter((item) => item.source_group_id === null && !item.source_system) - .map((item) => [item.name, item]) - ) - + const existingByName = new Map(existing.map((item) => [item.name, item])) let created = 0 let updated = 0 for (const department of sourceDepartments) { - const matchedBySource = existingBySourceGroupId.get(department.sourceGroupId) - const matchedManual = matchedBySource ? null : manualByName.get(department.name) || null - + const matched = existingByName.get(department.name) || null const payload = { name: department.name, status: 'active', - source_group_id: department.sourceGroupId, - source_parent_group_id: department.sourceParentGroupId, - source_system: SOURCE_SYSTEM, + parent_id: null, + source_group_id: null, + source_parent_group_id: null, + source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM, last_synced_at: now } - if (matchedBySource || matchedManual) { + if (matched) { await prisma.department.update({ - where: { id: (matchedBySource || matchedManual)!.id }, + where: { id: matched.id }, data: payload }) - updated++ - - if (matchedManual) { - existingBySourceGroupId.set(department.sourceGroupId, { - ...matchedManual, - source_group_id: department.sourceGroupId, - source_system: SOURCE_SYSTEM - }) - manualByName.delete(department.name) - } + updated += 1 } else { - const createdDepartment = await prisma.department.create({ + await prisma.department.create({ data: payload }) - created++ - existingBySourceGroupId.set(department.sourceGroupId, createdDepartment) + created += 1 } } - const syncedDepartments = await prisma.department.findMany({ - where: { source_system: SOURCE_SYSTEM }, - select: { - id: true, - source_group_id: true, - source_parent_group_id: true - } - }) - - const groupIdToDepartmentId = new Map( - syncedDepartments - .filter((item) => item.source_group_id !== null) - .map((item) => [item.source_group_id as number, item.id]) - ) - - for (const department of syncedDepartments) { - const targetParentId = department.source_parent_group_id - ? groupIdToDepartmentId.get(department.source_parent_group_id) || null - : null - - await prisma.department.update({ - where: { id: department.id }, - data: { parent_id: targetParentId } - }) - } - - const sourceGroupIds = sourceDepartments.map((item) => item.sourceGroupId) + const candidateNames = sourceDepartments.map((item) => item.name) const deactivatedResult = await prisma.department.updateMany({ where: { - source_system: SOURCE_SYSTEM, - source_group_id: { - notIn: sourceGroupIds + source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM, + name: { + notIn: candidateNames } }, data: { status: 'inactive', + parent_id: null, last_synced_at: now } }) @@ -970,21 +1096,17 @@ const syncDepartments = async (sourceDepartments: SourceDepartment[]) => { const buildLocalDepartmentMap = async (): Promise => { const departments = await prisma.department.findMany({ where: { - source_system: SOURCE_SYSTEM, - source_group_id: { - not: null - } + AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }] }, select: { id: true, - name: true, - source_group_id: true + name: true } }) return new Map( departments.map((item) => [ - item.source_group_id as number, + item.name, { id: item.id, name: item.name @@ -993,38 +1115,226 @@ const buildLocalDepartmentMap = async (): Promise => { ) } -const resolvePersonDepartments = ( - person: SourcePerson, - localDepartmentMap: LocalDepartmentMap -) => { - const relatedDepartments = parseSourceDeptIds(person.sourceDeptIds) - .map((deptId) => localDepartmentMap.get(deptId) || null) - .filter((item): item is { id: number; name: string } => Boolean(item)) +const buildDepartmentNameCandidates = (...values: Array) => + Array.from(new Set(values.map((value) => safeTrim(value)).filter(Boolean) as string[])) - const mainDepartment = - person.sourceMainDepartmentId !== null - ? localDepartmentMap.get(person.sourceMainDepartmentId) || null - : null +const cleanupInactiveSyncedPersons = async () => { + const result = await prisma.person.deleteMany({ + where: { + source_system: HR_SOURCE_SYSTEM, + status: 'inactive', + borrow_records: { + none: {} + }, + inventoryTaskParticipants: { + none: {} + }, + inventoryTaskItems: { + none: {} + } + } + }) - if (mainDepartment && !relatedDepartments.some((item) => item.id === mainDepartment.id)) { - relatedDepartments.unshift(mainDepartment) + return result.count +} + +const cleanupLegacyPersonDepartments = async (localDepartmentMap: LocalDepartmentMap) => { + const fallbackDepartment = localDepartmentMap.get(OTHER_DEPARTMENT_NAME) || null + const legacyPersons = await prisma.person.findMany({ + where: { + source_system: HR_SOURCE_SYSTEM, + OR: [ + { + mainDepartment: { + is: { + source_system: HR_SOURCE_SYSTEM + } + } + }, + { + personDepartments: { + some: { + department: { + source_system: HR_SOURCE_SYSTEM + } + } + } + } + ] + }, + select: { + id: true, + department: true, + source_department_name: true, + source_main_department_name: true, + main_department_id: true, + mainDepartment: { + select: { + id: true, + name: true + } + }, + personDepartments: { + select: { + department: { + select: { + id: true, + name: true, + source_system: true + } + } + } + } + } + }) + + let remapped = 0 + + for (const person of legacyPersons) { + const targetDepartment = + buildDepartmentNameCandidates( + person.source_department_name, + person.source_main_department_name, + person.department, + person.mainDepartment?.name, + ...person.personDepartments.map((item) => item.department?.name || null) + ) + .map((name) => localDepartmentMap.get(name) || null) + .find(Boolean) || fallbackDepartment + + const nextDepartmentIds = targetDepartment ? [targetDepartment.id] : [] + const currentDepartmentIds = Array.from( + new Set( + [ + person.main_department_id, + ...person.personDepartments.map((item) => item.department?.id || null) + ].filter((value): value is number => Number.isInteger(value)) + ) + ).sort((left, right) => left - right) + const normalizedNextDepartmentIds = [...nextDepartmentIds].sort((left, right) => left - right) + const needsUpdate = + person.main_department_id !== (targetDepartment?.id || null) || + !areNumberArraysEqual(currentDepartmentIds, normalizedNextDepartmentIds) || + person.personDepartments.some((item) => item.department?.source_system === HR_SOURCE_SYSTEM) + + if (!needsUpdate) { + continue + } + + await prisma.$transaction(async (tx) => { + await tx.person.update({ + where: { + id: person.id + }, + data: { + department: targetDepartment?.name || person.department || OTHER_DEPARTMENT_NAME, + main_department_id: targetDepartment?.id || null + } + }) + + await tx.personDepartment.deleteMany({ + where: { + person_id: person.id + } + }) + + if (targetDepartment) { + await tx.personDepartment.create({ + data: { + person_id: person.id, + department_id: targetDepartment.id + } + }) + } + }) + + remapped += 1 } - const departmentNames = Array.from(new Set(relatedDepartments.map((item) => item.name))) + return remapped +} + +const cleanupLegacyDepartments = async () => { + await prisma.department.updateMany({ + where: { + OR: [ + { source_system: HR_SOURCE_SYSTEM }, + { + source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM, + status: 'inactive' + } + ] + }, + data: { + parent_id: null + } + }) + + const sharedReferenceFilter = { + equipments: { + none: {} + }, + mainPersons: { + none: {} + }, + personDepartments: { + none: {} + }, + inventoryTasks: { + none: {} + }, + inventoryTaskParticipants: { + none: {} + }, + inventoryTaskItems: { + none: {} + }, + children: { + none: {} + } + } + + const [legacyResult, inactiveBusinessResult] = await Promise.all([ + prisma.department.deleteMany({ + where: { + source_system: HR_SOURCE_SYSTEM, + ...sharedReferenceFilter + } + }), + prisma.department.deleteMany({ + where: { + source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM, + status: 'inactive', + ...sharedReferenceFilter + } + }) + ]) return { - mainDepartmentId: mainDepartment?.id || null, - relatedDepartmentIds: Array.from(new Set(relatedDepartments.map((item) => item.id))), - displayDepartment: mainDepartment?.name || person.departmentName || departmentNames[0] || null + deletedLegacyDepartments: legacyResult.count, + deletedInactiveBusinessDepartments: inactiveBusinessResult.count + } +} + +const resolvePersonDepartments = (person: SourcePerson, localDepartmentMap: LocalDepartmentMap) => { + const resolvedDepartment = localDepartmentMap.get(person.resolvedDepartmentName) || null + + return { + mainDepartmentId: resolvedDepartment?.id || null, + relatedDepartmentIds: resolvedDepartment ? [resolvedDepartment.id] : [], + displayDepartment: resolvedDepartment?.name || person.resolvedDepartmentName || OTHER_DEPARTMENT_NAME } } const resolveUniqueIdNumber = async ( sourceUserId: string, idNumber: string | null, - targetPersonId?: number + targetPersonId?: number | null ) => { - if (!idNumber) return null + if (!idNumber) { + return null + } + const conflict = await prisma.person.findFirst({ where: { id_number: idNumber, @@ -1041,10 +1351,7 @@ const resolveUniqueIdNumber = async ( return conflict ? null : idNumber } -const syncPersons = async ( - sourcePersons: SourcePerson[], - localDepartmentMap: LocalDepartmentMap -) => { +const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: LocalDepartmentMap) => { const now = new Date() const existing = await prisma.person.findMany({ select: { @@ -1056,9 +1363,7 @@ const syncPersons = async ( }) const existingBySourceUserId = new Map( - existing - .filter((item) => item.source_userid) - .map((item) => [item.source_userid as string, item]) + existing.filter((item) => item.source_userid).map((item) => [item.source_userid as string, item]) ) const manualByIdNumber = new Map( existing @@ -1073,13 +1378,14 @@ const syncPersons = async ( const matchedBySource = existingBySourceUserId.get(person.sourceUserId) || null const matchedManualByIdNumber = !matchedBySource && person.idNumber ? manualByIdNumber.get(person.idNumber) || null : null - const targetPersonId = matchedBySource?.id || matchedManualByIdNumber?.id + const targetPersonId = matchedBySource?.id || matchedManualByIdNumber?.id || null const normalizedIdNumber = await resolveUniqueIdNumber( person.sourceUserId, person.idNumber, targetPersonId ) const departmentBinding = resolvePersonDepartments(person, localDepartmentMap) + let syncedPersonId = targetPersonId || 0 const payload = { nickname: person.nickname, @@ -1092,15 +1398,17 @@ const syncPersons = async ( email: person.email, status: person.status, source_userid: person.sourceUserId, - source_system: SOURCE_SYSTEM, + source_system: HR_SOURCE_SYSTEM, source_main_department_id: person.sourceMainDepartmentId, source_dept_ids: person.sourceDeptIds, + source_department_name: person.sourceDepartmentName, + source_main_department_name: person.sourceMainDepartmentName, + department_sync_bucket: person.departmentSyncBucket, manager_source_userid: person.managerSourceUserId, source_employee_status: person.sourceEmployeeStatus, last_synced_at: now } - let syncedPersonId = targetPersonId || 0 await prisma.$transaction(async (tx) => { if (targetPersonId) { await tx.person.update({ @@ -1130,16 +1438,16 @@ const syncPersons = async ( }) if (targetPersonId) { - updated++ + updated += 1 } else { - created++ + created += 1 } existingBySourceUserId.set(person.sourceUserId, { id: syncedPersonId, source_userid: person.sourceUserId, id_number: normalizedIdNumber, - source_system: SOURCE_SYSTEM + source_system: HR_SOURCE_SYSTEM }) if (person.idNumber) { @@ -1150,7 +1458,7 @@ const syncPersons = async ( const sourceUserIds = sourcePersons.map((item) => item.sourceUserId) const deactivatedResult = await prisma.person.updateMany({ where: { - source_system: SOURCE_SYSTEM, + source_system: HR_SOURCE_SYSTEM, source_userid: { notIn: sourceUserIds } @@ -1169,12 +1477,106 @@ const syncPersons = async ( } } -const writeSyncLog = async (result: unknown, operator = 'system-sync', success = true) => { +const migrateEquipmentDepartmentsToBusinessDepartments = async () => { + const businessDepartments = await prisma.department.findMany({ + where: { + AND: [buildSyncedBusinessDepartmentWhere(), { status: 'active' }] + }, + select: { + id: true, + name: true + } + }) + + const businessDepartmentIdByName = new Map(businessDepartments.map((item) => [item.name, item.id])) + const fallbackOtherDepartmentId = businessDepartmentIdByName.get(OTHER_DEPARTMENT_NAME) || null + const equipments = await prisma.equipment.findMany({ + where: { + department_id: { + not: null + }, + department: { + is: { + NOT: [ + { source_system: null }, + { source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM } + ] + } + } + }, + select: { + id: true, + equipment_code: true, + equipment_name: true, + department_id: true, + department: { + select: { + id: true, + name: true, + source_system: true + } + }, + } + }) + + const updateBucket = new Map() + const unresolved: Array<{ + equipment_id: number + equipment_code: string + equipment_name: string + legacy_department_name: string | null + }> = [] + + for (const equipment of equipments) { + const legacyDepartmentName = equipment.department?.name || null + const targetDepartmentId = + (legacyDepartmentName ? businessDepartmentIdByName.get(legacyDepartmentName) || null : null) || + fallbackOtherDepartmentId + + if (!targetDepartmentId) { + unresolved.push({ + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + equipment_name: equipment.equipment_name, + legacy_department_name: legacyDepartmentName + }) + continue + } + + const ids = updateBucket.get(targetDepartmentId) || [] + ids.push(equipment.id) + updateBucket.set(targetDepartmentId, ids) + } + + let migrated = 0 + for (const [targetDepartmentId, equipmentIds] of updateBucket.entries()) { + const result = await prisma.equipment.updateMany({ + where: { + id: { + in: equipmentIds + } + }, + data: { + department_id: targetDepartmentId + } + }) + + migrated += result.count + } + + return { + migrated, + unresolved: unresolved.length, + samples: unresolved.slice(0, 20) + } +} + +const writeSyncLog = async (result: any, operator = 'system-sync', success = true) => { await prisma.operationLog.create({ data: { user_id: null, username: operator, - operation_type: success ? '同步钉钉组织与人员' : '同步钉钉组织与人员失败', + operation_type: success ? '同步组织与人员' : '同步组织与人员失败', operation_details: JSON.stringify(result), ip_address: null } @@ -1183,22 +1585,28 @@ const writeSyncLog = async (result: unknown, operator = 'system-sync', success = export const previewHrSync = async () => { const pool = await sql.connect(getSqlServerConfig()) + try { - const [allDepartments, persons] = await Promise.all([ + const [allDepartments, personCollection] = await Promise.all([ getSourceDepartments(pool), getSourcePersons(pool) ]) - const departments = filterRelevantDepartments(allDepartments, persons) + const businessDepartments = buildBusinessDepartmentCandidates(personCollection.persons) return { + source_mode: personCollection.sourceMode, departments: { - total: departments.length, + total: businessDepartments.length, raw_total: allDepartments.length, - sample: departments.slice(0, 10) + sample: businessDepartments.slice(0, 10), + raw_sample: allDepartments.slice(0, 10) }, persons: { - total: persons.length, - sample: persons.slice(0, 10) + total: personCollection.persons.length, + source_presence_summary: personCollection.sourcePresenceSummary, + department_bucket_summary: personCollection.departmentBucketSummary, + special_exclusion_summary: personCollection.specialExclusionSummary, + sample: personCollection.persons.slice(0, 10) } } } finally { @@ -1211,6 +1619,7 @@ export const runHrSync = async ( triggerMode: SyncTriggerMode = inferTriggerMode(operator) ) => { let pool: any = null + try { const [beforeDepartmentMap, beforePersonMap] = await Promise.all([ getDepartmentSnapshotMap(), @@ -1218,28 +1627,66 @@ export const runHrSync = async ( ]) pool = await sql.connect(getSqlServerConfig()) - const [allDepartments, persons] = await Promise.all([ - getSourceDepartments(pool), - getSourcePersons(pool) - ]) - const departments = filterRelevantDepartments(allDepartments, persons) + const personCollection = await getSourcePersons(pool) + const persons = personCollection.persons + const businessDepartments = buildBusinessDepartmentCandidates(persons) - const departmentResult = await syncDepartments(departments) + const departmentResult = await syncDepartments(businessDepartments) const localDepartmentMap = await buildLocalDepartmentMap() const personResult = await syncPersons(persons, localDepartmentMap) - const result = { + const deletedInactivePersons = await cleanupInactiveSyncedPersons() + const remappedLegacyPersonDepartments = await cleanupLegacyPersonDepartments(localDepartmentMap) + const equipmentDepartmentMigration = await migrateEquipmentDepartmentsToBusinessDepartments() + const departmentCleanupResult = await cleanupLegacyDepartments() + + const result: HrSyncResult = { departments: departmentResult, - persons: personResult + persons: personResult, + equipment_department_migration: equipmentDepartmentMigration, + cleanup: { + deleted_inactive_persons: deletedInactivePersons, + remapped_legacy_person_departments: remappedLegacyPersonDepartments, + deleted_legacy_departments: departmentCleanupResult.deletedLegacyDepartments, + deleted_inactive_business_departments: departmentCleanupResult.deletedInactiveBusinessDepartments + } } const [afterDepartmentMap, afterPersonMap] = await Promise.all([ getDepartmentSnapshotMap(), getPersonSnapshotMap() ]) - const changes = [ + + const changes: HrSyncChangeDraft[] = [ ...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap), ...diffPersonSnapshots(beforePersonMap, afterPersonMap) ] + + if (equipmentDepartmentMigration.unresolved > 0) { + const sampleText = equipmentDepartmentMigration.samples + .slice(0, 5) + .map( + (item) => + `${item.equipment_code} / ${item.equipment_name} / ${item.legacy_department_name || '未设置部门'}` + ) + .join(';') + + changes.push( + buildSyncChange({ + entityType: 'equipment', + entityKey: 'legacy_department_migration', + entityName: '资产部门迁移待处理', + changeType: 'equipment_department_unresolved', + countWeight: equipmentDepartmentMigration.unresolved, + title: `${equipmentDepartmentMigration.unresolved} 个资产部门待人工处理`, + detail: + `旧叶子部门无法按名称匹配到新的业务部门,请行政核对后手动调整。` + + (sampleText ? ` 示例:${sampleText}` : ''), + beforeSnapshot: null, + afterSnapshot: equipmentDepartmentMigration + }) + ) + } + const artifact = await persistHrSyncArtifacts({ operator, triggerMode, @@ -1247,6 +1694,7 @@ export const runHrSync = async ( result, changes }) + const finalResult: HrSyncResult = { ...result, sync_job_id: artifact.syncJobId, @@ -1265,6 +1713,7 @@ export const runHrSync = async ( changes: [], errorMessage: error?.message || String(error) }) + await writeSyncLog( { sync_job_id: artifact.syncJobId, @@ -1289,8 +1738,8 @@ export const startHrSyncScheduler = () => { if (schedulerStarted) { return } - schedulerStarted = true + schedulerStarted = true const enabled = process.env.HR_SYNC_ENABLED === 'true' if (!enabled) { console.log('[hr-sync] scheduler disabled') @@ -1304,7 +1753,6 @@ export const startHrSyncScheduler = () => { } const scheduleTimes = getConfiguredScheduleTimes() - setInterval(async () => { const now = new Date() const hh = String(now.getHours()).padStart(2, '0') diff --git a/backend/src/services/inventoryService.ts b/backend/src/services/inventoryService.ts new file mode 100644 index 0000000..88342fe --- /dev/null +++ b/backend/src/services/inventoryService.ts @@ -0,0 +1,1127 @@ +import { randomBytes } from 'crypto' +import type { PrismaClient } from '@prisma/client' +import prisma from '../utils/prisma' +import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' + +type InventoryScopeOptions = { + departmentId?: number | null + assetTypeId?: number | null +} + +type FrozenParticipantDraft = { + person_id: number + person_name: string + person_nickname: string + person_source_userid: string | null + department_id: number | null + department_name: string | null +} + +type FrozenItemDraft = { + participant_person_id: number | null + equipment_id: number + equipment_code: string + equipment_name: string + equipment_model: string | null + equipment_type: string + asset_type_id: number | null + asset_type_name: string | null + snapshot_department_id: number | null + snapshot_department_name: string | null + snapshot_person_id: number | null + snapshot_person_name: string | null + snapshot_person_nickname: string | null + snapshot_status: string | null + source_borrow_record_id: number | null + is_unassigned: boolean +} + +type InventoryScopeBuildResult = { + scopeDepartment: { id: number; name: string } | null + scopeAssetType: { id: number; name: string } | null + participants: FrozenParticipantDraft[] + items: FrozenItemDraft[] +} + +const INVENTORY_TASK_STATUSES = { + active: 'active', + closed: 'closed' +} as const + +const INVENTORY_ITEM_CHECK_STATUS = { + pending: 'pending', + checked: 'checked', + exception: 'exception' +} as const + +const INVENTORY_PARTICIPANT_STATUS = { + pending: 'pending', + inProgress: 'in_progress', + completed: 'completed' +} as const + +const EXCLUDED_EQUIPMENT_STATUSES = ['已报废'] + +const buildScopeWhere = ({ departmentId, assetTypeId }: InventoryScopeOptions) => { + const where: Record = { + status: { + notIn: EXCLUDED_EQUIPMENT_STATUSES + } + } + + if (departmentId) { + where.department_id = departmentId + } + + if (assetTypeId) { + where.asset_type_id = assetTypeId + } + + return where +} + +const getScopeMeta = async ({ departmentId, assetTypeId }: InventoryScopeOptions) => { + const [department, assetType] = await Promise.all([ + departmentId + ? prisma.department.findFirst({ + where: { + id: departmentId, + ...buildSyncedBusinessDepartmentWhere() + }, + select: { id: true, name: true, status: true } + }) + : Promise.resolve(null), + assetTypeId + ? prisma.assetType.findUnique({ + where: { id: assetTypeId }, + select: { id: true, name: true, status: true } + }) + : Promise.resolve(null) + ]) + + if (departmentId && (!department || department.status !== 'active')) { + throw new Error('所选业务部门不存在或已停用') + } + + if (assetTypeId && (!assetType || assetType.status !== 'active')) { + throw new Error('所选资产类型不存在或已停用') + } + + return { + scopeDepartment: department ? { id: department.id, name: department.name } : null, + scopeAssetType: assetType ? { id: assetType.id, name: assetType.name } : null + } +} + +const loadAncestorMap = async (equipments: Array<{ id: number; parent_id: number | null }>) => { + const equipmentMap = new Map(equipments.map((equipment) => [equipment.id, equipment])) + let pendingIds = Array.from( + new Set( + equipments + .map((equipment) => equipment.parent_id) + .filter((id): id is number => Boolean(id)) + ) + ) + + while (pendingIds.length > 0) { + const rows = await prisma.equipment.findMany({ + where: { + id: { + in: pendingIds + } + }, + select: { + id: true, + parent_id: true + } + }) + + pendingIds = [] + for (const row of rows) { + if (!equipmentMap.has(row.id)) { + equipmentMap.set(row.id, row) + } + if (row.parent_id && !equipmentMap.has(row.parent_id)) { + pendingIds.push(row.parent_id) + } + } + pendingIds = Array.from(new Set(pendingIds)) + } + + return equipmentMap +} + +const buildActiveBorrowRecordMap = async (equipmentIds: number[]) => { + if (equipmentIds.length === 0) { + return new Map() + } + + const records = await prisma.borrowRecord.findMany({ + where: { + equipment_id: { + in: equipmentIds + }, + status: '已借出' + }, + orderBy: { borrow_time: 'desc' }, + include: { + person: { + select: { + id: true, + real_name: true, + nickname: true, + source_userid: true, + department: true, + main_department_id: true, + mainDepartment: { + select: { + id: true, + name: true + } + }, + status: true + } + } + } + }) + + const map = new Map() + for (const record of records) { + const bucket = map.get(record.equipment_id) || [] + bucket.push(record) + map.set(record.equipment_id, bucket) + } + return map +} + +const resolveBorrowAnchorId = ( + equipmentId: number, + equipmentMap: Map, + activeBorrowRecordMap: Map +) => { + let current = equipmentMap.get(equipmentId) || null + + while (current) { + if ((activeBorrowRecordMap.get(current.id) || []).length > 0) { + return current.id + } + current = current.parent_id ? equipmentMap.get(current.parent_id) || null : null + } + + return equipmentId +} + +const buildDepartmentBreakdown = (items: FrozenItemDraft[]) => + Array.from( + items.reduce((map, item) => { + const key = item.snapshot_department_name || '未归属部门' + const current = map.get(key) || { department_name: key, total: 0, assigned: 0, unassigned: 0 } + current.total += 1 + if (item.is_unassigned) { + current.unassigned += 1 + } else { + current.assigned += 1 + } + map.set(key, current) + return map + }, new Map()) + .values() + ).sort((left, right) => right.total - left.total) + +const buildAssetTypeBreakdown = (items: FrozenItemDraft[]) => + Array.from( + items.reduce((map, item) => { + const key = item.asset_type_name || item.equipment_type || '未分类' + const current = map.get(key) || { asset_type_name: key, total: 0 } + current.total += 1 + map.set(key, current) + return map + }, new Map()) + .values() + ).sort((left, right) => right.total - left.total) + +export const buildInventoryScope = async ( + options: InventoryScopeOptions +): Promise => { + const meta = await getScopeMeta(options) + const equipments = await prisma.equipment.findMany({ + where: buildScopeWhere(options), + orderBy: [{ department_id: 'asc' }, { asset_type_id: 'asc' }, { created_at: 'desc' }], + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + asset_type_id: true, + status: true, + parent_id: true, + assetType: { + select: { + id: true, + name: true + } + }, + department: { + select: { + id: true, + name: true + } + } + } + }) + + const equipmentMap = await loadAncestorMap(equipments) + const borrowTargetIds = Array.from(new Set(Array.from(equipmentMap.keys()))) + const activeBorrowRecordMap = await buildActiveBorrowRecordMap(borrowTargetIds) + + const participantMap = new Map() + const items: FrozenItemDraft[] = [] + + for (const equipment of equipments) { + const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap) + const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || [] + const activeBorrowRecord = + activeBorrowRecords.length === 1 && activeBorrowRecords[0].person?.status === 'active' + ? activeBorrowRecords[0] + : null + + const participantPersonId = activeBorrowRecord?.person?.id || null + const snapshotDepartmentId = + activeBorrowRecord?.person?.mainDepartment?.id || + equipment.department?.id || + null + const snapshotDepartmentName = + activeBorrowRecord?.person?.mainDepartment?.name || + activeBorrowRecord?.person?.department || + equipment.department?.name || + null + + if (participantPersonId && activeBorrowRecord?.person) { + participantMap.set(participantPersonId, { + person_id: activeBorrowRecord.person.id, + person_name: activeBorrowRecord.person.real_name, + person_nickname: activeBorrowRecord.person.nickname, + person_source_userid: activeBorrowRecord.person.source_userid || null, + department_id: activeBorrowRecord.person.mainDepartment?.id || null, + department_name: + activeBorrowRecord.person.mainDepartment?.name || + activeBorrowRecord.person.department || + null + }) + } + + items.push({ + participant_person_id: participantPersonId, + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + equipment_name: equipment.equipment_name, + equipment_model: equipment.equipment_model, + equipment_type: equipment.equipment_type, + asset_type_id: equipment.asset_type_id, + asset_type_name: equipment.assetType?.name || null, + snapshot_department_id: snapshotDepartmentId, + snapshot_department_name: snapshotDepartmentName, + snapshot_person_id: activeBorrowRecord?.person?.id || null, + snapshot_person_name: activeBorrowRecord?.person?.real_name || null, + snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null, + snapshot_status: equipment.status, + source_borrow_record_id: activeBorrowRecord?.id || null, + is_unassigned: !activeBorrowRecord + }) + } + + return { + scopeDepartment: meta.scopeDepartment, + scopeAssetType: meta.scopeAssetType, + participants: Array.from(participantMap.values()).sort((left, right) => + left.person_nickname.localeCompare(right.person_nickname, 'zh-CN') + ), + items + } +} + +export const previewInventoryTask = async (options: InventoryScopeOptions) => { + const scope = await buildInventoryScope(options) + + return { + scope_department: scope.scopeDepartment, + scope_asset_type: scope.scopeAssetType, + participants_count: scope.participants.length, + items_count: scope.items.length, + unassigned_count: scope.items.filter((item) => item.is_unassigned).length, + department_breakdown: buildDepartmentBreakdown(scope.items), + asset_type_breakdown: buildAssetTypeBreakdown(scope.items), + participants_sample: scope.participants.slice(0, 10), + items_sample: scope.items.slice(0, 10) + } +} + +const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({ + scope_department: scope.scopeDepartment, + scope_asset_type: scope.scopeAssetType, + participants_count: scope.participants.length, + items_count: scope.items.length, + unassigned_count: scope.items.filter((item) => item.is_unassigned).length, + generated_at: new Date().toISOString() +}) + +const createPublicToken = () => randomBytes(24).toString('hex') + +const refreshTaskCounters = async (tx: PrismaClient, taskId: number) => { + const [participantsCount, itemsCount, completedCount, exceptionCount, unassignedCount] = await Promise.all([ + tx.inventoryTaskParticipant.count({ + where: { task_id: taskId } + }), + tx.inventoryTaskItem.count({ + where: { task_id: taskId } + }), + tx.inventoryTaskItem.count({ + where: { + task_id: taskId, + check_status: INVENTORY_ITEM_CHECK_STATUS.checked + } + }), + tx.inventoryTaskItem.count({ + where: { + task_id: taskId, + check_status: INVENTORY_ITEM_CHECK_STATUS.exception + } + }), + tx.inventoryTaskItem.count({ + where: { + task_id: taskId, + is_unassigned: true + } + }) + ]) + + await tx.inventoryTask.update({ + where: { id: taskId }, + data: { + participants_count: participantsCount, + items_count: itemsCount, + completed_count: completedCount, + exception_count: exceptionCount, + unassigned_count: unassignedCount + } + }) +} + +const refreshParticipantStatus = async (tx: PrismaClient, participantId: number) => { + const participant = await tx.inventoryTaskParticipant.findUnique({ + where: { id: participantId }, + select: { id: true, task_id: true } + }) + + if (!participant) { + return + } + + const items = await tx.inventoryTaskItem.findMany({ + where: { + task_id: participant.task_id, + participant_id: participantId + }, + select: { + check_status: true + } + }) + + let status: string = INVENTORY_PARTICIPANT_STATUS.pending + if (items.length > 0) { + const checkedCount = items.filter((item) => item.check_status !== INVENTORY_ITEM_CHECK_STATUS.pending).length + status = + checkedCount === 0 + ? INVENTORY_PARTICIPANT_STATUS.pending + : checkedCount === items.length + ? INVENTORY_PARTICIPANT_STATUS.completed + : INVENTORY_PARTICIPANT_STATUS.inProgress + } + + await tx.inventoryTaskParticipant.update({ + where: { id: participantId }, + data: { + status + } + }) +} + +const serializeTaskListItem = (task: any) => ({ + id: task.id, + title: task.title, + description: task.description, + status: task.status, + public_token: task.public_token, + scope_department_id: task.scope_department_id, + scope_department_name: task.scope_department_name, + scope_asset_type_id: task.scope_asset_type_id, + scope_asset_type_name: task.scope_asset_type_name, + participants_count: task.participants_count, + items_count: task.items_count, + completed_count: task.completed_count, + exception_count: task.exception_count, + unassigned_count: task.unassigned_count, + created_by: task.created_by, + created_by_name: task.creator?.name || task.creator?.username || null, + closed_at: task.closed_at, + created_at: task.created_at, + updated_at: task.updated_at +}) + +export const createInventoryTask = async ( + input: { + title: string + description?: string | null + department_id?: number | null + asset_type_id?: number | null + }, + operatorUserId: number +) => { + const title = String(input.title || '').trim() + if (!title) { + throw new Error('盘点任务标题不能为空') + } + + const scope = await buildInventoryScope({ + departmentId: input.department_id || null, + assetTypeId: input.asset_type_id || null + }) + + const task = await prisma.$transaction(async (tx) => { + const createdTask = await tx.inventoryTask.create({ + data: { + title, + description: input.description?.trim() || null, + status: INVENTORY_TASK_STATUSES.active, + public_token: createPublicToken(), + scope_department_id: scope.scopeDepartment?.id || null, + scope_department_name: scope.scopeDepartment?.name || null, + scope_asset_type_id: scope.scopeAssetType?.id || null, + scope_asset_type_name: scope.scopeAssetType?.name || null, + scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)), + created_by: operatorUserId + } + }) + + if (scope.participants.length > 0) { + await tx.inventoryTaskParticipant.createMany({ + data: scope.participants.map((participant) => ({ + task_id: createdTask.id, + ...participant + })), + skipDuplicates: true + }) + } + + const savedParticipants = await tx.inventoryTaskParticipant.findMany({ + where: { task_id: createdTask.id }, + select: { + id: true, + person_id: true + } + }) + const participantIdByPersonId = new Map( + savedParticipants + .filter((participant) => participant.person_id !== null) + .map((participant) => [participant.person_id as number, participant.id]) + ) + + if (scope.items.length > 0) { + await tx.inventoryTaskItem.createMany({ + data: scope.items.map((item) => ({ + task_id: createdTask.id, + participant_id: + item.participant_person_id !== null + ? participantIdByPersonId.get(item.participant_person_id) || null + : null, + equipment_id: item.equipment_id, + equipment_code: item.equipment_code, + equipment_name: item.equipment_name, + equipment_model: item.equipment_model, + equipment_type: item.equipment_type, + asset_type_id: item.asset_type_id, + asset_type_name: item.asset_type_name, + snapshot_department_id: item.snapshot_department_id, + snapshot_department_name: item.snapshot_department_name, + snapshot_person_id: item.snapshot_person_id, + snapshot_person_name: item.snapshot_person_name, + snapshot_person_nickname: item.snapshot_person_nickname, + snapshot_status: item.snapshot_status, + source_borrow_record_id: item.source_borrow_record_id, + is_unassigned: item.is_unassigned + })) + }) + } + + await refreshTaskCounters(tx as unknown as PrismaClient, createdTask.id) + + await tx.inventoryTaskAction.create({ + data: { + task_id: createdTask.id, + action_type: 'task_created', + action_payload: JSON.stringify({ + participants_count: scope.participants.length, + items_count: scope.items.length, + unassigned_count: scope.items.filter((item) => item.is_unassigned).length + }), + operator_user_id: operatorUserId + } + }) + + return tx.inventoryTask.findUnique({ + where: { id: createdTask.id }, + include: { + creator: { + select: { + id: true, + username: true, + name: true + } + } + } + }) + }) + + return serializeTaskListItem(task) +} + +export const listInventoryTasks = async (page = 1, pageSize = 10) => { + const skip = (page - 1) * pageSize + const [list, total] = await Promise.all([ + prisma.inventoryTask.findMany({ + skip, + take: pageSize, + orderBy: { created_at: 'desc' }, + include: { + creator: { + select: { + id: true, + username: true, + name: true + } + } + } + }), + prisma.inventoryTask.count() + ]) + + return { + list: list.map(serializeTaskListItem), + total, + page, + pageSize + } +} + +export const getInventoryTaskDetail = async (taskId: number) => { + const task = await prisma.inventoryTask.findUnique({ + where: { id: taskId }, + include: { + creator: { + select: { + id: true, + username: true, + name: true + } + }, + participants: { + orderBy: [{ status: 'asc' }, { person_nickname: 'asc' }] + }, + items: { + orderBy: [{ check_status: 'asc' }, { equipment_code: 'asc' }] + }, + actions: { + orderBy: { created_at: 'desc' }, + take: 30 + } + } + }) + + if (!task) { + throw new Error('盘点任务不存在') + } + + const departmentBreakdown = Array.from( + task.items.reduce((map, item) => { + const key = item.snapshot_department_name || '未归属部门' + const current = + map.get(key) || + { department_name: key, total: 0, checked: 0, exception: 0, pending: 0 } + current.total += 1 + if (item.check_status === INVENTORY_ITEM_CHECK_STATUS.checked) { + current.checked += 1 + } else if (item.check_status === INVENTORY_ITEM_CHECK_STATUS.exception) { + current.exception += 1 + } else { + current.pending += 1 + } + map.set(key, current) + return map + }, new Map()) + .values() + ).sort((left, right) => right.total - left.total) + + const assetTypeBreakdown = Array.from( + task.items.reduce((map, item) => { + const key = item.asset_type_name || item.equipment_type || '未分类' + const current = + map.get(key) || + { asset_type_name: key, total: 0, checked: 0, exception: 0, pending: 0 } + current.total += 1 + if (item.check_status === INVENTORY_ITEM_CHECK_STATUS.checked) { + current.checked += 1 + } else if (item.check_status === INVENTORY_ITEM_CHECK_STATUS.exception) { + current.exception += 1 + } else { + current.pending += 1 + } + map.set(key, current) + return map + }, new Map()) + .values() + ).sort((left, right) => right.total - left.total) + + return { + ...serializeTaskListItem(task), + scope_snapshot: task.scope_snapshot_json ? JSON.parse(task.scope_snapshot_json) : null, + participants: task.participants, + items: task.items, + recent_actions: task.actions, + department_breakdown: departmentBreakdown, + asset_type_breakdown: assetTypeBreakdown + } +} + +export const closeInventoryTask = async (taskId: number, operatorUserId: number) => { + const task = await prisma.inventoryTask.findUnique({ + where: { id: taskId }, + select: { + id: true, + status: true + } + }) + + if (!task) { + throw new Error('盘点任务不存在') + } + + if (task.status === INVENTORY_TASK_STATUSES.closed) { + return + } + + await prisma.$transaction(async (tx) => { + await tx.inventoryTask.update({ + where: { id: taskId }, + data: { + status: INVENTORY_TASK_STATUSES.closed, + closed_at: new Date() + } + }) + + await tx.inventoryTaskAction.create({ + data: { + task_id: taskId, + action_type: 'task_closed', + operator_user_id: operatorUserId + } + }) + }) +} + +export const getInventoryTaskPublicInfo = async (publicToken: string) => { + const task = await prisma.inventoryTask.findUnique({ + where: { + public_token: publicToken + }, + select: { + id: true, + title: true, + description: true, + status: true, + scope_department_name: true, + scope_asset_type_name: true, + participants_count: true, + items_count: true, + completed_count: true, + exception_count: true, + unassigned_count: true, + created_at: true, + closed_at: true + } + }) + + if (!task) { + throw new Error('盘点任务不存在') + } + + return task +} + +export const searchTaskParticipants = async (publicToken: string, keyword: string) => { + const task = await prisma.inventoryTask.findUnique({ + where: { + public_token: publicToken + }, + select: { + id: true, + status: true + } + }) + + if (!task) { + throw new Error('盘点任务不存在') + } + + const normalizedKeyword = keyword.trim() + return prisma.inventoryTaskParticipant.findMany({ + where: { + task_id: task.id, + OR: normalizedKeyword + ? [ + { person_name: { contains: normalizedKeyword } }, + { person_nickname: { contains: normalizedKeyword } } + ] + : undefined + }, + orderBy: [{ status: 'asc' }, { person_nickname: 'asc' }], + take: normalizedKeyword ? 20 : 50, + select: { + id: true, + person_id: true, + person_name: true, + person_nickname: true, + department_name: true, + status: true, + confirmed_at: true + } + }) +} + +export const confirmInventoryParticipant = async (publicToken: string, participantId: number) => { + const task = await prisma.inventoryTask.findUnique({ + where: { + public_token: publicToken + }, + select: { + id: true, + status: true, + title: true + } + }) + + if (!task) { + throw new Error('盘点任务不存在') + } + + const participant = await prisma.inventoryTaskParticipant.findFirst({ + where: { + id: participantId, + task_id: task.id + } + }) + + if (!participant) { + throw new Error('盘点参与人不存在') + } + + if (task.status !== INVENTORY_TASK_STATUSES.closed) { + await prisma.$transaction(async (tx) => { + await tx.inventoryTaskParticipant.update({ + where: { id: participantId }, + data: { + confirmed_at: participant.confirmed_at || new Date(), + last_action_at: new Date(), + status: + participant.status === INVENTORY_PARTICIPANT_STATUS.completed + ? INVENTORY_PARTICIPANT_STATUS.completed + : INVENTORY_PARTICIPANT_STATUS.inProgress + } + }) + + await tx.inventoryTaskAction.create({ + data: { + task_id: task.id, + participant_id: participantId, + action_type: 'participant_confirmed' + } + }) + }) + } + + return { + task_id: task.id, + task_title: task.title, + participant_id: participant.id, + participant_name: participant.person_name, + participant_nickname: participant.person_nickname + } +} + +export const getParticipantTaskContext = async (taskId: number, participantId: number) => { + const [task, participant] = await Promise.all([ + prisma.inventoryTask.findUnique({ + where: { id: taskId } + }), + prisma.inventoryTaskParticipant.findFirst({ + where: { + id: participantId, + task_id: taskId + } + }) + ]) + + if (!task || !participant) { + throw new Error('盘点任务会话已失效') + } + + return { task, participant } +} + +export const getParticipantItems = async (taskId: number, participantId: number) => { + const items = await prisma.inventoryTaskItem.findMany({ + where: { + task_id: taskId, + participant_id: participantId + }, + orderBy: [{ check_status: 'asc' }, { equipment_code: 'asc' }] + }) + + return { + items, + summary: { + total: items.length, + pending: items.filter((item) => item.check_status === INVENTORY_ITEM_CHECK_STATUS.pending).length, + checked: items.filter((item) => item.check_status === INVENTORY_ITEM_CHECK_STATUS.checked).length, + exception: items.filter((item) => item.check_status === INVENTORY_ITEM_CHECK_STATUS.exception).length + } + } +} + +const updateTaskItemStatus = async ( + taskId: number, + participantId: number, + itemId: number, + payload: { + check_status: string + exception_type?: string | null + exception_note?: string | null + } +) => { + const item = await prisma.inventoryTaskItem.findFirst({ + where: { + id: itemId, + task_id: taskId, + participant_id: participantId + } + }) + + if (!item) { + throw new Error('盘点资产不在当前人员范围内') + } + + await prisma.$transaction(async (tx) => { + await tx.inventoryTaskItem.update({ + where: { id: itemId }, + data: { + check_status: payload.check_status, + checked_by_participant_id: participantId, + checked_at: new Date(), + exception_type: payload.exception_type ?? null, + exception_note: payload.exception_note ?? null + } + }) + + await tx.inventoryTaskParticipant.update({ + where: { id: participantId }, + data: { + last_action_at: new Date() + } + }) + + await refreshParticipantStatus(tx as unknown as PrismaClient, participantId) + await refreshTaskCounters(tx as unknown as PrismaClient, taskId) + }) +} + +export const submitInventoryScan = async (taskId: number, participantId: number, equipmentCode: string) => { + const normalizedCode = equipmentCode.trim() + if (!normalizedCode) { + throw new Error('资产编码不能为空') + } + + const item = await prisma.inventoryTaskItem.findFirst({ + where: { + task_id: taskId, + equipment_code: normalizedCode + } + }) + + if (!item) { + throw new Error('该资产不在本次盘点任务范围内') + } + + if (item.participant_id !== participantId) { + throw new Error('该资产不在你的盘点范围内') + } + + await updateTaskItemStatus(taskId, participantId, item.id, { + check_status: INVENTORY_ITEM_CHECK_STATUS.checked, + exception_type: null, + exception_note: null + }) + + await prisma.inventoryTaskAction.create({ + data: { + task_id: taskId, + participant_id: participantId, + item_id: item.id, + action_type: 'item_checked', + action_payload: JSON.stringify({ + equipment_code: normalizedCode + }) + } + }) + + return item +} + +export const submitInventoryException = async ( + taskId: number, + participantId: number, + itemId: number, + exceptionType: string, + exceptionNote?: string | null +) => { + if (!['not_found', 'not_with_me'].includes(exceptionType)) { + throw new Error('异常类型无效') + } + + await updateTaskItemStatus(taskId, participantId, itemId, { + check_status: INVENTORY_ITEM_CHECK_STATUS.exception, + exception_type: exceptionType, + exception_note: exceptionNote?.trim() || null + }) + + await prisma.inventoryTaskAction.create({ + data: { + task_id: taskId, + participant_id: participantId, + item_id: itemId, + action_type: 'item_exception_reported', + action_payload: JSON.stringify({ + exception_type: exceptionType, + exception_note: exceptionNote?.trim() || null + }) + } + }) +} + +export const getTaskUnassignedItems = async (taskId: number) => + prisma.inventoryTaskItem.findMany({ + where: { + task_id: taskId, + is_unassigned: true + }, + orderBy: { equipment_code: 'asc' } + }) + +export const assignUnassignedTaskItem = async ( + taskId: number, + itemId: number, + personId: number +) => { + const [task, item, person] = await Promise.all([ + prisma.inventoryTask.findUnique({ + where: { id: taskId }, + select: { id: true } + }), + prisma.inventoryTaskItem.findFirst({ + where: { + id: itemId, + task_id: taskId + } + }), + prisma.person.findUnique({ + where: { id: personId }, + select: { + id: true, + real_name: true, + nickname: true, + source_userid: true, + department: true, + main_department_id: true, + mainDepartment: { + select: { + id: true, + name: true + } + } + } + }) + ]) + + if (!task) { + throw new Error('盘点任务不存在') + } + + if (!item || !item.is_unassigned) { + throw new Error('待处理资产不存在') + } + + if (!person) { + throw new Error('目标人员不存在') + } + + await prisma.$transaction(async (tx) => { + let participant = await tx.inventoryTaskParticipant.findFirst({ + where: { + task_id: taskId, + person_id: person.id + } + }) + + if (!participant) { + participant = await tx.inventoryTaskParticipant.create({ + data: { + task_id: taskId, + person_id: person.id, + person_name: person.real_name, + person_nickname: person.nickname, + person_source_userid: person.source_userid, + department_id: person.main_department_id, + department_name: person.mainDepartment?.name || person.department || null + } + }) + } + + await tx.inventoryTaskItem.update({ + where: { id: itemId }, + data: { + participant_id: participant.id, + snapshot_person_id: person.id, + snapshot_person_name: person.real_name, + snapshot_person_nickname: person.nickname, + snapshot_department_id: person.main_department_id, + snapshot_department_name: person.mainDepartment?.name || person.department || null, + is_unassigned: false + } + }) + + await tx.inventoryTaskAction.create({ + data: { + task_id: taskId, + participant_id: participant.id, + item_id: itemId, + action_type: 'item_reassigned', + action_payload: JSON.stringify({ + target_person_id: person.id, + target_person_name: person.real_name + }) + } + }) + + await refreshParticipantStatus(tx as unknown as PrismaClient, participant.id) + await refreshTaskCounters(tx as unknown as PrismaClient, taskId) + }) +} diff --git a/backend/src/utils/organization.ts b/backend/src/utils/organization.ts new file mode 100644 index 0000000..6b817c2 --- /dev/null +++ b/backend/src/utils/organization.ts @@ -0,0 +1,22 @@ +export const HR_SOURCE_SYSTEM = 'ding_hr' +export const HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM = 'ding_hr_business' +export const OTHER_DEPARTMENT_NAME = '其他' + +export const BUSINESS_DEPARTMENT_SOURCE_SYSTEMS = [ + null, + HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM +] as const + +export const isBusinessDepartmentSourceSystem = (value: string | null | undefined) => + !value || value === HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM + +export const buildBusinessDepartmentWhere = () => ({ + OR: [ + { source_system: null }, + { source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM } + ] +}) + +export const buildSyncedBusinessDepartmentWhere = () => ({ + source_system: HR_BUSINESS_DEPARTMENT_SOURCE_SYSTEM +}) diff --git a/docs/2026-03-19_组织架构改造交接.md b/docs/2026-03-19_组织架构改造交接.md new file mode 100644 index 0000000..60144d9 --- /dev/null +++ b/docs/2026-03-19_组织架构改造交接.md @@ -0,0 +1,149 @@ +# 2026-03-19 组织架构改造交接 + +## 1. 本次目标 + +- 用户要求把“使用者信息 + 使用部门”合并成统一组织架构视图。 +- 目标效果: + - 系统设置里保留 `人员维护` 页,作为批量维护入口。 + - 原 `使用部门` 页改造成 `组织架构` 页。 + - 部门支持层级结构。 + - 员工直接显示在部门节点下面。 + +## 2. 已完成内容 + +### 2.1 后端 + +- 新增组织架构接口: + - `GET /api/departments/organization-tree` +- 相关文件: + - `backend/src/controllers/departmentController.ts` + - `backend/src/routes/departments.ts` + - `backend/src/utils/organization.ts` +- 当前接口能力: + - 返回部门树 + - 返回每个部门节点下的员工 + - 返回未归属员工 + - 返回汇总统计 +- 已修复的问题: + - 员工可挂多个业务部门节点,不再只落一个部门 + - 非业务部门/历史污染部门的人,不再误算为“未归属” + - `/organization-tree` 已收紧为管理员接口: + - `protect() + requireAdmin()` + +### 2.2 前端 + +- 相关文件: + - `frontend/src/views/settings/Settings.vue` + - `frontend/src/views/settings/components/DepartmentManagement.vue` + - `frontend/src/api/department.ts` + - `frontend/src/types/index.ts` +- 已完成效果: + - 系统设置页签文案改为: + - `人员维护` + - `组织架构` + - `组织架构` 页显示: + - 部门树 + - 部门节点下的员工节点 + - 右侧新增部门卡片 + - 右侧未归属员工卡片 + - 搜索/筛选已按部门上下文处理 + - 筛选后部门统计口径已与显示结果保持一致 + +## 3. 本地验证结果 + +- 已执行: + - `cd frontend && npm run build` + - `cd backend && npm run build` +- 结果: + - 前端构建通过 + - 后端构建通过 + +## 4. 线上部署情况 + +### 4.1 安全红线 + +- 服务器有两套系统: + - 固定资产:`/opt/equipment-management` + - 摄影借还:`/opt/shebeiguanli` +- 本次只操作了固定资产系统。 +- 摄影系统容器 `shebeiguanli-*` 未被修改、停止、删除或重建。 + +### 4.2 已做过的部署动作 + +- 执行过 `/opt/equipment-management/deploy.sh` +- 执行过固定资产系统备份 +- 后续发现“服务器源码仍是旧版”后,又额外做了: + - 关键文件精确上传到服务器 + - `frontend` / `backend` 做 `--no-cache` 强制重建 + - 强制 `up -d --force-recreate` + +### 4.3 线上验活结果 + +- 健康检查通过: + - `http://localhost:3000/api/health` + - `https://www.jsgdzc.cn/api/health` +- 固定资产容器状态正常: + - `equipment-frontend` + - `equipment-backend` + - `equipment-mysql` +- 摄影系统容器仍正常: + - `shebeiguanli-frontend` + - `shebeiguanli-backend` + - `shebeiguanli-mysql` + +## 5. 当前关键判断 + +- 服务器源码已经是新版。 +- 前端容器已经做过无缓存重建。 +- 新的前端静态资源里,已经确认包含: + - `组织架构` + - `人员维护` +- 并且已经确认不再包含旧的: + - `使用部门` + - `Department Directory` +- 但用户当时截图仍看到旧界面,极大概率是浏览器缓存 / 旧 HTML 入口缓存导致仍指向旧的 `Settings-*.js`。 + +## 6. 当前建议接手动作 + +下一个 agent 先不要再大改代码,先做“线上结果核实”: + +1. 让用户用无痕窗口打开: + - `https://www.jsgdzc.cn/settings?tab=departments&v=202603191820` +2. 或指导用户强刷缓存: + - `Ctrl + F5` + - 手机端则清理缓存后重开 +3. 如果依然是旧界面: + - 直接检查线上 `index.html` 是否仍引用旧版 `Settings-*.js` + - 再检查 nginx 对 `index.html` 的缓存策略 + +## 7. 可能的下一步修复点 + +如果确认是首页 HTML 缓存问题,优先处理: + +- `frontend/nginx.conf` +- 重点不是静态资源 `.js/.css`,而是 `index.html` 不能被长缓存 +- 当前配置里静态资源有 `immutable`,但 `index.html` 没有单独禁缓存规则,可能需要补: + - `Cache-Control: no-store, no-cache, must-revalidate` + +## 8. 本次涉及的关键文件 + +- 后端: + - `backend/src/controllers/departmentController.ts` + - `backend/src/routes/departments.ts` + - `backend/src/utils/organization.ts` +- 前端: + - `frontend/src/views/settings/Settings.vue` + - `frontend/src/views/settings/components/DepartmentManagement.vue` + - `frontend/src/api/department.ts` + - `frontend/src/types/index.ts` +- 部署相关: + - `deploy.sh` + - `frontend/nginx.conf` + +## 9. 注意事项 + +- 仓库当前是 dirty worktree,很多文件不是本次单独改动,接手时不要误回滚。 +- 不要进入 `/opt/shebeiguanli`。 +- 不要操作 `shebeiguanli-*` 容器。 +- 不要碰摄影系统数据库。 +- 如需再次部署,只允许针对固定资产系统路径和容器。 diff --git a/docs/2026-03-19_项目总交接.md b/docs/2026-03-19_项目总交接.md new file mode 100644 index 0000000..b67e0d5 --- /dev/null +++ b/docs/2026-03-19_项目总交接.md @@ -0,0 +1,332 @@ +# 2026-03-19 项目总交接 + +## 1. 项目定位 + +- 项目:固定资产管理系统 +- 本地目录:`D:\AI Code\GuDingZiChan` +- 线上目录:`/opt/equipment-management` +- 线上域名: + - `https://www.jsgdzc.cn` + - `https://jsgdzc.cn` + +## 2. 服务器红线 + +服务器 `1.13.22.133` 上有两套系统: + +- 固定资产系统:`/opt/equipment-management` +- 摄影设备借还系统:`/opt/shebeiguanli` + +必须遵守: + +- 任何远程操作只能针对固定资产系统 +- 不得进入 `/opt/shebeiguanli` +- 不得停止、删除、重建、修改 `shebeiguanli-*` 容器 +- 不得碰摄影系统数据库、数据卷、配置 + +## 3. 当前已推进过的主线 + +### 3.1 资产基础流程 + +项目已经具备并持续在用的主流程: + +- 资产录入 +- 资产入库 +- 资产借出 +- 资产归还 +- 资产维修 +- 资产报废 +- 二维码打印 +- 移动端扫码借还 + +### 3.2 父资产 / 子资产 + +已经按“父子资产”模式推进过一轮: + +- 父资产录入时支持一起录入子资产 +- 子资产逻辑上独立,不是一次性打包死绑定 +- 子资产可以后续拆卸、转移、重新挂到其他父资产 +- 新增父资产时,已支持自动带出一组常见电脑配件子资产的录入模板方向 + +用户明确过的业务理解: + +- 子资产虽然创建时跟着父资产一起录,但后续必须可独立流转 +- 不能把“创建时一起录入”理解成“永远打包不可分” + +### 3.3 资产价值体系 + +价值体系相关已经做过一轮梳理和修正方向确认: + +- 父资产与子资产价值不能简单永远固化 +- 如果子资产被拆走,父资产价值应该随之变化 +- 数量型资产的总价与单价逻辑用户已明确: + - 例如内存条单价 `500` + - 数量 `2` + - 当前录入理解应是“这一批总价值 1000”,不是“两条都显示 500 的整套价” + +后续如果继续改价值口径,需要重点核对: + +- 资产录入单价/总价 +- 借出价值/可用价值/总价值统计 +- 父子资产拆装后的价值变动 + +### 3.4 标签打印 / 打印机适配 + +这条线做过很多轮,已经沉淀出一些关键经验: + +- 打印机:`H500E` +- 打印清晰度:`300dpi` +- 客服确认过介质宽度和高度范围 +- 实际测试过标签尺寸问题: + - 曾经混淆过 `6*10`、`10*6` + - 最终确认用户当时量到的测试纸接近 `101.6 x 63.5mm` +- 用户后续明确方向是: + - 当前测试纸先将就 + - 最终仍要回到 `60*40` 标签布局 + +已确认过的经验: + +- 浏览器打印弹窗并非一定是系统代码问题,有一部分和驱动 / 浏览器 / 打印方式有关 +- 当时存在“预览图正常,但实际打印被切成多张标签”的问题,根因与尺寸/驱动设置/页面布局共同有关 +- 后面已经做过若干轮标签样式调整,用户确认过一版“暂时先这样” + +### 3.5 移动端扫码与 HTTPS + +这条线已经做过并上线过一轮: + +- 移动端页面已做过适配 +- 手机扫码调用摄像头功能已接入 +- 域名 HTTPS 已配好 +- 安卓设备调用摄像头的问题,用户后来确认和浏览器安全策略有关 + +结论: + +- HTTPS 是手机扫码的前提 +- 不同浏览器对摄像头权限的表现不同 +- 若再次出现“当前设备没有检测到可用摄像头 / 打开摄像头失败”,先检查: + - 是否 HTTPS + - 是否浏览器权限 + - 是否设备浏览器兼容问题 + +### 3.6 前端视觉重构 + +前端视觉并不是由我单独完成的,过程中有另一个 agent 参与重构,当前要点是: + +- 视觉方向固定为: + - 极简 + - 统一 + - 深色主题 +- 规范文件: + - `D:\AI Code\GuDingZiChan\前端视觉规范.md` +- 另有前端台账和视觉总结类文档,已经被多次引用 + +当前应遵守的原则: + +- 不要再回到旧的 Apple 风格类名 +- 不要渐变卡片背景 +- 输入框和按钮布局要统一 +- 左侧导航要固定,不能跟着滚动丢用户信息 +- 移动端页面要能完整滚动,底部操作不能遮挡 + +### 3.7 Logo 与品牌资源 + +Logo 资源这条线也改过: + +- 用户后来明确指定过 logo 素材路径: + - `D:\AI Code\GuDingZiChan\frontend\public\branding\未标题-1.jpg` +- 用户要求: + - 页面内 logo + - 浏览器标签 favicon / logo + - 都要统一 + +### 3.8 组织同步 / 人员部门数据 + +这条线是最近最复杂、最关键的主线之一。 + +目前已确认: + +- SQL Server 源库是人事主数据来源 +- 后续项目内人员/部门信息都要从这套库同步 +- 目标是每天同步 1~2 次 +- 后续要支持站内消息提醒行政处理差异 + +当前最重要的结论: + +- 不能再按旧 `hr_employee + hr_groups` 作为唯一主口径 +- 当前正确方向是: + - `hr_employee_register` 作为人员同步主口径 + - `sys00-dept` 作为业务部门主字段 + - `sys00-mainDepts` 作为正式员工空部门兜底 + - `hr_groups` 只保留为历史追溯参考 + +两份关键文档已经写好: + +- [资产盘点与组织同步联合方案.md](/D:/AI%20Code/GuDingZiChan/docs/%E8%B5%84%E4%BA%A7%E7%9B%98%E7%82%B9%E4%B8%8E%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E8%81%94%E5%90%88%E6%96%B9%E6%A1%88.md) +- [组织同步源表结构说明.md](/D:/AI%20Code/GuDingZiChan/docs/%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E6%BA%90%E8%A1%A8%E7%BB%93%E6%9E%84%E8%AF%B4%E6%98%8E.md) + +### 3.9 资产盘点功能 + +这条线已经进入“方案明确 + 代码已开始落地”的状态。 + +当前仓库里已经存在: + +- 后端路由: + - `backend/src/routes/inventory.ts` + - `backend/src/routes/publicInventory.ts` +- 后端控制器: + - `backend/src/controllers/inventoryController.ts` + - `backend/src/controllers/publicInventoryController.ts` +- 服务: + - `backend/src/services/inventoryService.ts` +- 前端页面: + - `frontend/src/views/inventory/InventoryList.vue` + - `frontend/src/views/inventory/InventoryCreate.vue` + - `frontend/src/views/inventory/InventoryDetail.vue` + - `frontend/src/views/inventory/PublicInventoryTask.vue` + +但这条线不能只看“文件在不在”,必须继续按联合方案文档核: + +- 组织同步准确性是否足够支撑盘点 +- 免登录员工盘点流程是否完全符合文档 +- 行政端任务、异常、无人归属处理是否全部打通 + +### 3.10 通知中心 / 站内消息 + +用户要求过: + +- 钉钉通知先不做 +- 先做站内消息提醒行政 + +仓库当前已存在: + +- 后端通知相关路由:`backend/src/routes/notifications.ts` +- 前端页面:`frontend/src/views/notifications/NotificationCenter.vue` + +后续如果继续推进这条线,要重点核: + +- 组织同步差异是否真正落成站内通知 +- 通知是否支持已读/未读、跳转关联任务 + +## 4. 这次最近在做的线 + +最近这轮的直接任务是: + +- 把“使用者信息 + 使用部门”合并成统一的“组织架构”视图 + +当前这条线的本地代码状态: + +- `Settings.vue` + - 页签文案已经改成: + - `人员维护` + - `组织架构` +- `DepartmentManagement.vue` + - 已改造成“组织架构”页 + - 部门树下直接显示员工节点 +- 后端已新增: + - `GET /api/departments/organization-tree` +- 并且修过这几个关键问题: + - 员工可挂多个业务部门节点 + - 未归属员工不再把旧污染关系误算进去 + - 前端筛选后统计口径一致 + - 组织架构接口已收紧为管理员权限 + +对应交接文档: + +- [2026-03-19_组织架构改造交接.md](/D:/AI%20Code/GuDingZiChan/docs/2026-03-19_%E7%BB%84%E7%BB%87%E6%9E%B6%E6%9E%84%E6%94%B9%E9%80%A0%E4%BA%A4%E6%8E%A5.md) + +## 5. 线上状态 + +### 5.1 已完成的线上动作 + +- 固定资产系统已部署到服务器 +- 已确认: + - `equipment-frontend` + - `equipment-backend` + - `equipment-mysql` + 都正常运行 +- 摄影系统容器保持正常运行,未受影响 +- 域名健康检查正常: + - `https://www.jsgdzc.cn` + - `https://www.jsgdzc.cn/api/health` + +### 5.2 当前线上疑点 + +最新发现: + +- 服务器源码已经是新版 +- 前端容器也做过 `--no-cache` 强制重建 +- 新静态资源里已确认包含: + - `组织架构` + - `人员维护` +- 用户当时看到的旧界面,极大概率是浏览器缓存 / 首页 HTML 缓存导致 + +当前最应该继续核的点: + +- `index.html` 是否还被缓存 +- 浏览器是否仍引用旧的 `Settings-*.js` +- 是否需要在 `frontend/nginx.conf` 里对 `index.html` 单独加禁缓存 + +## 6. 当前未完成 / 待下一位 agent 接手的重点 + +### 6.1 优先级最高 + +1. 继续核实线上“组织架构”页为何用户仍能看到旧页面 +2. 如果确认是缓存问题,优先处理: + - `frontend/nginx.conf` + - `index.html` 缓存策略 +3. 让用户无痕打开: + - `https://www.jsgdzc.cn/settings?tab=departments&v=202603191820` +4. 再次确认实际页面是不是新版 + +### 6.2 第二优先级 + +1. 按组织同步文档继续把人员/部门口径彻底收敛 +2. 清理历史污染数据 +3. 继续让用户抽样核对“系统设置 -> 员工 / 部门”是否符合真实钉钉组织 + +### 6.3 第三优先级 + +1. 继续盘点功能联调 +2. 核对移动端员工免登录扫码盘点完整流程 +3. 核对站内消息是否真正接到组织同步差异 + +## 7. 当前重要文件 + +### 7.1 规则与文档 + +- [AGENTS.md](/D:/AI%20Code/GuDingZiChan/AGENTS.md) +- [前端视觉规范.md](/D:/AI%20Code/GuDingZiChan/%E5%89%8D%E7%AB%AF%E8%A7%86%E8%A7%89%E8%A7%84%E8%8C%83.md) +- [资产盘点与组织同步联合方案.md](/D:/AI%20Code/GuDingZiChan/docs/%E8%B5%84%E4%BA%A7%E7%9B%98%E7%82%B9%E4%B8%8E%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E8%81%94%E5%90%88%E6%96%B9%E6%A1%88.md) +- [组织同步源表结构说明.md](/D:/AI%20Code/GuDingZiChan/docs/%E7%BB%84%E7%BB%87%E5%90%8C%E6%AD%A5%E6%BA%90%E8%A1%A8%E7%BB%93%E6%9E%84%E8%AF%B4%E6%98%8E.md) +- [2026-03-19_组织架构改造交接.md](/D:/AI%20Code/GuDingZiChan/docs/2026-03-19_%E7%BB%84%E7%BB%87%E6%9E%B6%E6%9E%84%E6%94%B9%E9%80%A0%E4%BA%A4%E6%8E%A5.md) + +### 7.2 最近关键代码 + +- 后端: + - `backend/src/controllers/departmentController.ts` + - `backend/src/routes/departments.ts` + - `backend/src/services/hrSyncService.ts` + - `backend/src/utils/organization.ts` + - `backend/src/routes/inventory.ts` + - `backend/src/routes/publicInventory.ts` +- 前端: + - `frontend/src/views/settings/Settings.vue` + - `frontend/src/views/settings/components/DepartmentManagement.vue` + - `frontend/src/views/settings/components/HrSyncManagement.vue` + - `frontend/src/views/settings/components/PersonManagement.vue` + - `frontend/src/views/inventory/InventoryList.vue` + - `frontend/src/views/inventory/InventoryCreate.vue` + - `frontend/src/views/inventory/InventoryDetail.vue` + - `frontend/src/views/inventory/PublicInventoryTask.vue` + - `frontend/nginx.conf` + +## 8. 当前工作树说明 + +当前仓库不是干净工作树,已有很多历史改动和未提交文件: + +- 不要默认把所有未提交内容都当成“本次脏改动” +- 很多内容是之前主线任务的一部分 +- 接手时不能随手回滚 + +## 9. 给下一位 agent 的一句话提醒 + +先别急着继续写新功能,先把“线上组织架构页为什么用户还看到旧页面”这件事彻底核清,优先排缓存和 `index.html` 引用问题;同时始终记住,任何部署和远程操作都只能碰 `/opt/equipment-management`,绝不能影响摄影借还系统。 diff --git a/docs/2026-03-23_新对话完整交接.md b/docs/2026-03-23_新对话完整交接.md new file mode 100644 index 0000000..6dda916 --- /dev/null +++ b/docs/2026-03-23_新对话完整交接.md @@ -0,0 +1,718 @@ +# 2026-03-23 新对话完整交接 + +## 1. 这份文档的用途 + +这份文档是给“新开对话后直接接手项目的 agent”准备的。 + +目标不是简单列文件,而是把下面三件事一次性交代清楚: + +- 这个项目现在真正要达成的目标是什么 +- 项目现在已经到了什么状态,哪些是已验证的,哪些只是本地刚改还没验证 +- 当前正在做什么,下一位 agent 接手后应该先干什么,不能干什么 + +--- + +## 2. 项目的真正目标 + +### 2.1 总目标 + +当前项目的真正目标不是继续堆很多新功能,而是尽快把这套固定资产系统做成一套能够替代行政当前 Excel 台账、并且稳定可交付的内部系统。 + +简单说,优先级是: + +1. 先能替代表格 +2. 先把真实资产数据迁进系统 +3. 先让行政能持续维护 +4. 再去继续做更大的功能扩展 + +### 2.2 当前主线目标 + +当前主线已经明确收敛到下面这件事: + +- 把 `8.xlsx` 里的固定资产数据迁移进固定资产系统 +- 迁移时不乱猜、不硬造人、不为了全量导入而污染系统 +- 能自动确认的先导入 +- 不确定的留成明确清单,交给行政补录 +- 最终让系统取代 Excel,后续在系统里继续维护 + +### 2.3 当前不是主线的内容 + +下面这些内容虽然项目里都有,但当前不是第一优先级: + +- 前端视觉继续大改 +- 资产盘点功能继续深挖 +- 组织同步继续追到和钉钉人数完全一致 +- 钉钉通知 +- 更复杂的统计分析 + +这些都没有消失,只是当前优先级低于“Excel 迁移落地”。 + +--- + +## 3. 当前项目的大背景 + +### 3.1 系统定位 + +- 项目:固定资产管理系统 +- 本地目录:`D:\AI Code\GuDingZiChan` +- 线上固定资产系统目录:`/opt/equipment-management` + +### 3.2 服务器红线 + +固定资产系统和另一套摄影设备借还系统在同一台服务器上。 + +必须始终遵守: + +- 任何远程操作只能针对固定资产系统 +- 绝不能进入摄影系统目录 +- 绝不能停止、删除、重建、修改摄影系统容器 +- 绝不能碰摄影系统数据库、数据卷、配置 + +这条是最高优先级红线。 + +### 3.3 用户沟通偏好 + +用户是非程序员,所以后续接手时要注意: + +- 默认中文 +- 先讲结论,再讲细节 +- 不要频繁停下来问 +- 能自动推进就自动推进 +- 不要赌不确定的数据 +- 涉及线上真实数据、不可逆动作、密钥账号、证书时才停下来确认 + +--- + +## 4. 当前项目整体状态 + +### 4.1 业务流程状态 + +项目已经具备并实际围绕这些流程在推进: + +- 资产录入 +- 资产入库 +- 资产借出 +- 资产归还 +- 资产维修 +- 资产报废 +- 二维码打印 +- 移动端扫码借还 + +### 4.2 父子资产状态 + +父资产 / 子资产这条线已经做过一轮,业务理解已经明确: + +- 创建父资产时可以带一批子资产一起录 +- 子资产逻辑上必须独立 +- 子资产后续可以拆卸、转移、重新挂到别的父资产 +- 不能把“创建时一起录”理解成“永远打包死绑定” + +### 4.3 前端视觉状态 + +前端视觉这条线已经交给另一个 agent 重构。 + +当前结论: + +- 视觉方向固定为:极简、统一、深色主题 +- 前端不是当前主线 +- 现在不要为了迁移任务去打断前端重构 +- 除非迁移任务必须改页面,否则尽量少碰前端视觉 + +### 4.4 资产盘点状态 + +资产盘点和组织同步联合方案已经写成文档,并且仓库里已有一部分代码文件,但这条线当前不在第一优先级。 + +关键文档: + +- `docs/资产盘点与组织同步联合方案.md` + +当前判断: + +- 盘点功能后面还要继续 +- 但在真实资产迁移没收口前,不是当前最优先要继续啃的线 + +--- + +## 5. 当前“已验证”的真实状态 + +这一节只写已经验证过的状态,不把还没验证的本地修改混进来。 + +### 5.1 最新已验证 live 资产状态 + +截至最新一轮已验证结果,线上固定资产系统是: + +- live 总资产数:`1640` +- 其中有借用记录的资产:`1247` +- 当前无标准地址的资产:`59` + +无标准地址的状态分布: + +- 可用:`16` +- 已借出:`43` + +### 5.2 已完成的 live 导入情况 + +当前已经确认发生过的真实导入动作: + +- 先前已导入 `1247` 条“有明确使用人”的资产 +- 后续又增量导入 `393` 条“可先导入但不带使用人”的资产 +- 当前 live 总数 `1640` 就是这两部分叠加后的结果 + +### 5.3 已完成的地址回填情况 + +地址回填已经做过两轮,已验证结果是: + +- 之前无标准地址:`96` +- 回填后降到:`59` + +已经确认并使用过的高置信度地址映射包括: + +- `常州 -> 常州-天安` +- `佛山3D / 佛山平面 / 佛山建模 / 广东佛山 -> 佛山` +- `二楼分销 -> 常州-天安` +- `南京机房远程数据机 -> 南京-苏宁园区` +- `10号楼1楼机房监控 / 10号楼1楼监控室 / 10号楼1楼配电间监控 / 10号楼3楼机房 -> 南京-苏宁园区` + +### 5.4 最新已验证 dry-run 结果 + +最新已验证 dry-run 输出目录是: + +- `temp_migration_output/migration_dry_run_2026-03-23_03-01-06` + +这一版 dry-run 的权威统计是: + +- `importReady = 1255` +- `importWithoutOwner = 401` +- `pendingReview = 23` +- `discardNoImport = 34` + +注意: + +- 这里的 `1255 / 401 / 23 / 34` 是 dry-run 的分桶结果 +- 不等于都已经 live 导入 +- live 实际已导入的是: + - `1247` 条 with-owner + - `393` 条 without-owner + +### 5.5 最新已验证行政补录清单 + +最新已验证行政补录工作簿是: + +- `行政手动补录清单-2026-03-23_03-07-12.xlsx` + +这份工作簿里的统计是: + +- `待确认部门`:`23` +- `缺少编码`:`6` +- `重复编码`: + - 重复编码数:`5` + - 重复记录数:`10` +- `待补使用人`:`196` +- `花名核对`:`105` +- `待补地址`:`59` + +--- + +## 6. 组织同步和人员部门数据的真实口径 + +### 6.1 当前已经确认的同步主口径 + +后续任何人如果再碰“人员/部门同步”,必须基于这个结论: + +- `dbo.hr_employee_register`:人员同步主表 +- `dbo.hr_employee`:补充字段表 +- `dbo.hr_groups`:历史组织树参考表,不再作为当前主部门主依据 + +### 6.2 当前代码里的核心实现位置 + +关键文件: + +- `backend/src/services/hrSyncService.ts` +- `backend/src/utils/organization.ts` + +### 6.3 当前同步规则的关键点 + +当前系统里关于同步的核心逻辑已经收敛为: + +- 业务部门优先取 `sys00-dept` +- 如果 `sys00-dept` 为空,再兜底 `sys00-mainDepts` +- 正式员工默认按状态值 `3` +- 默认只把状态值 `5` 视为 inactive +- `hr_groups` 不再决定当前业务部门 + +### 6.4 当前已存在的特殊排除 + +当前同步逻辑里已经明确做了特殊排除: + +- `业务支持 + 无工号` +- `龙晨品牌中心 + register_only` +- `品牌中心 + register_only` + +这条非常关键,因为它直接影响为什么有些 Excel 里的历史花名,不能为了迁移资产而强行补成当前 active 员工。 + +### 6.5 当前系统里的“当前使用人”模型 + +这个点必须记住,因为它影响整个迁移策略: + +- 系统里的“当前使用人”不是挂在 `equipment.owner` 上 +- 而是挂在 `BorrowRecord -> active Person` 上 + +所以: + +- 如果为了迁移方便手工补一批假 active person +- 会直接污染当前使用人模型 +- 这是明确不能做的 + +--- + +## 7. Excel 迁移这条线已经做成什么样了 + +### 7.1 Excel 来源 + +当前迁移基于: + +- 根目录下的 `8.xlsx` +- 工作表:`📜固定资产明细` + +### 7.2 迁移基本策略 + +用户已经接受并确认的迁移原则是: + +- 能确定的先导入 +- owner 不确定但资产本身确定的,可以先导入但不建当前使用人 +- 真正不确定的继续留清单 +- 不为了全量导入而乱猜 +- 不为了 owner 去手工造 active person + +### 7.3 dry-run 主脚本 + +当前 dry-run 主脚本是: + +- `backend/scripts/prepareEquipmentMigrationDryRun.js` + +这个脚本的职责是: + +- 读取 `8.xlsx` +- 通过远程只读获取 live 引用快照 +- 读取 live 的: + - active 资产类型 + - active 业务部门 + - active 标准地址 + - active 同步人员 +- 把 Excel 资产行分桶为: + - `import-ready` + - `import-without-owner` + - `pending-review` + - `discard-no-import` + +### 7.4 live 导入脚本 + +当前 live 导入脚本是: + +- `backend/scripts/importConfirmedEquipmentToLive.js` + +这支脚本已经支持: + +- `--keep-existing` +- `--include-ownerless` +- 排除空编码 +- 排除重复编码 +- 增量导入,不一定每次清库 + +### 7.5 地址回填脚本 + +当前地址回填脚本是: + +- `backend/scripts/backfillImportedEquipmentLocations.js` + +这支脚本已经支持: + +- 不只处理 `import-ready` +- 也处理 `import-without-owner` + +### 7.6 行政清单导出脚本 + +当前行政补录清单脚本是: + +- `backend/scripts/exportAdminManualReviewWorkbook.js` + +它当前已能导出: + +- 概览 +- 待确认部门 +- 缺少编码 +- 重复编码 +- 待补使用人 +- 花名核对 +- 待补地址 + +如果输出文件被 Excel 占用,会自动换一个时间戳文件名。 + +--- + +## 8. 当前剩余问题到底是什么 + +### 8.1 还没导入的真正 pending 资产 + +当前真正还没有解决的 pending-review 只有 `23` 条。 + +按原始部门分布是: + +- `品牌项目中心`:`11` +- `品牌运营中心`:`4` +- `卓禾事业部`:`3` +- `品牌项目中心(常州)`:`2` +- `品牌营销策划中心`:`1` +- `朴美项目部`:`1` +- `运营中心C`:`1` + +这 `1` 条 `运营中心C` 的问题不是部门,而是原 Excel 状态为空,也就是: + +- `unknown_source_status` + +### 8.2 已导入但还没挂使用人的资产 + +当前这部分有 `196` 条。 + +原因构成是: + +- `193` 条:`owner_not_in_active_synced_roster` +- `3` 条:`multi_owner_cell` + +### 8.3 这 196 条为什么不能继续自动吃 + +这一步之前已经专门查过: + +- 对这些历史花名,已经同时查过: + - `hr_employee_register.sys00-flowerName` + - `hr_employee_register.sys00-name` + - `hr_employee_register.sys02-realName` + - `hr_employee.name` +- 四个字段一起查,全部没有命中 + +结论已经明确: + +- 这不是简单的“同步漏人” +- 更像是 Excel 用的是旧花名、旧称呼、历史口径 +- 所以不能继续自动硬挂 +- 必须行政确认后再补 + +### 8.4 待补地址为什么也没有继续自动猜 + +当前剩余 `59` 条待补地址里,主要是: + +- 空白:`32` +- `电子设备`:`5` +- `河北仓`:`3` +- `天安/杭州`:`2` +- `山东仓`:`2` +- `抓数用`:`2` +- 其余各 `1` + +不继续自动猜的原因已经明确: + +- 空白无法推断 +- `电子设备`、`抓数用` 不是地点 +- `河北仓`、`山东仓` 等更像真实外部仓点,可能应该新增标准地址 +- `天安/杭州` 是双地点写法,当前没有足够证据判断落哪边 + +### 8.5 缺少编码和重复编码 + +这部分当前没有自动硬导: + +- 缺少编码:`6` +- 重复编码: + - 重复编码组:`5` + - 重复记录:`10` + +这部分继续保持人工处理,不自动乱造编码。 + +--- + +## 9. 用户已经明确接受的业务原则 + +这部分非常关键,后续不能偏离。 + +### 9.1 关于 owner + +用户已经接受: + +- 有些资产可以先导入,不带 owner +- 不要为了导入方便去补假人员 +- 当前使用人必须遵守系统现有的 active person + borrow record 模型 + +### 9.2 关于部门 + +用户要的是“现在项目里真正能用、并且和系统业务口径一致的部门”,不是把各种历史叶子组都塞进去。 + +所以: + +- 业务部门要尽量干净 +- 历史污染部门不要再灌回前台 +- 如果历史 Excel 部门无法高置信度映射,就宁可先留清单 + +### 9.3 关于迁移节奏 + +用户接受的节奏是: + +- 先把确定数据导入 +- 再把不确定数据整理清单 +- 行政后补 +- 再回挂 + +用户不接受的是: + +- 为了追求“一次性全量导完”而硬猜 + +--- + +## 10. 当前“正在进行”的任务 + +这一节是最重要的交接重点。 + +### 10.1 当前主任务 + +当前正在做的事情不是再导一轮 live,而是补齐“行政补录结果 -> 再次 dry-run / 再导入”的闭环。 + +原因是: + +- 纯自动可吃的内容已经基本吃到头了 +- 剩下的主要靠行政确认 +- 如果没有一个“行政填完就能继续喂回系统”的闭环,后面又会变回人工重复劳动 + +### 10.2 我刚刚已经动手但还没验证的本地改动 + +这一轮我已经开始改了两处脚本,但**用户在中途打断要求先写交接文档,所以这两处改动还没有重新跑 dry-run 验证,也没有部署,也没有导出新工作簿验证**。 + +本地已改但未验证的文件: + +- `backend/scripts/prepareEquipmentMigrationDryRun.js` +- `backend/scripts/exportAdminManualReviewWorkbook.js` + +本地已改的方向是: + +#### A. `prepareEquipmentMigrationDryRun.js` + +已经开始补这些能力: + +- 支持读取手工 overrides 文件 +- 支持对单条资产做字段覆盖 +- 支持对历史 owner / 历史部门 / 历史地址做人工映射覆盖 +- 已额外补了一条高置信度部门映射: + - `品牌营销策划中心 -> 品牌策划中心` + +但当前状态只是“文件已改”,**没有重新执行脚本验证结果**。 + +#### B. `exportAdminManualReviewWorkbook.js` + +已经开始把行政工作簿改成可填写格式,方向是: + +- 给 `待确认部门` 增加: + - `行政填写_目标部门` + - `行政填写_目标员工` + - `行政填写_标准地址` + - `行政填写_资产状态` + - `行政填写_备注` +- 给 `待补使用人` 增加: + - `行政填写_目标员工` + - `行政填写_备注` +- 给 `花名核对` 增加: + - `行政填写_目标员工` + - `行政填写_备注` +- 给 `待补地址` 增加: + - `行政填写_标准地址` + - `行政填写_备注` +- 增加 `填写说明` 工作表 + +但当前同样只是“本地已改”,**还没有重新导出工作簿验证结构**。 + +### 10.3 当前原计划但还没开始写的脚本 + +我本来下一步要继续补一支新脚本,方向已经明确,但还没开始落文件: + +- 计划新增一个“行政工作簿 -> overrides JSON”的生成脚本 + +预期用途是: + +- 行政把工作簿填完 +- 脚本把填写结果转成 `migration_manual_overrides.json` +- dry-run 脚本读取这个 overrides +- 重新分桶 +- 再用现有导入脚本继续导入 / 补挂 / 回填 + +这支脚本**目前还没开始创建文件**。 + +--- + +## 11. 下一位 agent 接手后应该怎么做 + +### 11.1 第一优先级 + +先不要继续改业务逻辑,先把“刚刚本地已改但未验证”的两处脚本跑通。 + +也就是先做: + +1. 检查 `backend/scripts/prepareEquipmentMigrationDryRun.js` 是否有语法错误 +2. 用当前 Excel 和当前 live 快照重新跑一轮 dry-run +3. 看新 dry-run 是否正常输出 +4. 看 `品牌营销策划中心 -> 品牌策划中心` 这条高置信度映射是否让 pending 再减少 +5. 重新跑 `exportAdminManualReviewWorkbook.js` +6. 看新工作簿列是否正确、是否真的可供行政填写 + +### 11.2 第二优先级 + +如果上面两步都正常,再继续补“工作簿 -> overrides JSON”的脚本。 + +这件事比继续硬猜部门更重要,因为: + +- 剩下的大头已经不是代码自己能完全猜出来的 +- 没有闭环,行政补完还得人工二次处理 + +### 11.3 第三优先级 + +如果 overrides 流程做完,再继续: + +- 用行政填写后的结果生成 overrides +- 重新跑 dry-run +- 看还能继续自动吃掉多少 pending / ownerless / null-location +- 再决定是否继续增量 live 导入 + +### 11.4 当前不建议优先做的事 + +接手后暂时不要优先做这些: + +- 再去大改前端视觉 +- 再去追 773 人和当前 roster 的完全一致 +- 再去做资产盘点 UI +- 再去追打印功能 +- 再去清理别的历史测试线 + +原因很简单: + +- 当前最接近交付的主线就是 Excel 迁移 +- 其它线会分散精力 + +--- + +## 12. 接手时必须知道的关键文件 + +### 12.1 文档 + +- `docs/2026-03-19_项目总交接.md` +- `docs/2026-03-19_组织架构改造交接.md` +- `docs/2026-03-23_资产迁移推进记录.md` +- `docs/资产盘点与组织同步联合方案.md` +- `docs/组织同步源表结构说明.md` + +### 12.2 Excel 迁移相关脚本 + +- `backend/scripts/prepareEquipmentMigrationDryRun.js` +- `backend/scripts/importConfirmedEquipmentToLive.js` +- `backend/scripts/backfillImportedEquipmentLocations.js` +- `backend/scripts/exportAdminManualReviewWorkbook.js` +- `backend/scripts/locationNormalization.js` + +### 12.3 关键输出目录和文件 + +- 最新已验证 dry-run: + - `temp_migration_output/migration_dry_run_2026-03-23_03-01-06` +- 最新已验证导入备份: + - `temp_migration_output/live_import_backup_2026-03-23_02-47-21` +- 最新已验证地址回填: + - `temp_migration_output/location_backfill_2026-03-23_03-01-32` +- 最新已验证行政清单: + - `行政手动补录清单-2026-03-23_03-07-12.xlsx` + +### 12.4 组织同步相关关键文件 + +- `backend/src/services/hrSyncService.ts` +- `backend/src/utils/organization.ts` + +--- + +## 13. 当前已确认的一些具体判断 + +### 13.1 `品牌营销策划中心` + +这一条已经拿到较强证据: + +- `hr_groups` 里存在 `品牌营销策划中心` +- 当前系统业务部门里存在 `品牌策划中心` +- 这两者高度相关 + +所以这条被视为高置信度映射候选: + +- `品牌营销策划中心 -> 品牌策划中心` + +但要注意: + +- 这条映射只是本地已经加到脚本,**还没重跑验证** + +### 13.2 `品牌项目中心 / 品牌运营中心 / 卓禾事业部 / 朴美项目部` + +这几条当前仍然没有足够证据做自动硬映射。 + +现阶段判断: + +- 不适合继续拍脑袋映射 +- 更适合走“行政填写 -> overrides -> 重跑”的路线 + +### 13.3 `运营中心C` 那 1 条 + +这 1 条不是部门不对,而是 Excel 原始状态为空。 + +所以后续应该走: + +- 行政补状态 +- 再决定导入 + +而不是去改部门。 + +--- + +## 14. 当前仓库状态提醒 + +### 14.1 仓库是 dirty worktree + +当前仓库里有很多已修改文件,不全是这次迁移单独改的。 + +所以接手时要注意: + +- 不要误回滚用户和其他 agent 的改动 +- 尤其不要因为只看当前任务,就把别的脏文件一把清掉 + +### 14.2 当前这次交接里最重要的本地未验证改动 + +当前最需要盯住的是这两个文件: + +- `backend/scripts/prepareEquipmentMigrationDryRun.js` +- `backend/scripts/exportAdminManualReviewWorkbook.js` + +这两处已经改了,但还没重新执行和验证。 + +--- + +## 15. 密钥与敏感信息处理 + +接手后要注意: + +- 不要把服务器密码、源库密码、生产连接串再写进仓库或文档 +- 如果需要远程操作,沿用当前会话已有的方式和凭据 +- 但不要把这些敏感信息再次落盘 + +--- + +## 16. 一句话交接结论 + +当前项目的主线不是继续做新功能,而是把 `8.xlsx` 的资产迁移彻底收口。 + +已验证状态下: + +- live 已有 `1640` 条资产 +- 还剩 `23` 条真正 pending 未导 +- 还剩 `196` 条已导入但待补使用人 +- 还剩 `59` 条待补标准地址 + +当前正在做的不是继续硬猜,而是补“行政填写结果 -> overrides -> 重新 dry-run / 再导入”的闭环。 + +本地已经改了两处脚本,但还没验证;下一位 agent 接手后,第一件事就是把这两处脚本跑通、验清楚,再继续后面的闭环脚本。 diff --git a/docs/2026-03-23_资产迁移推进记录.md b/docs/2026-03-23_资产迁移推进记录.md new file mode 100644 index 0000000..f1b67e0 --- /dev/null +++ b/docs/2026-03-23_资产迁移推进记录.md @@ -0,0 +1,104 @@ +# 2026-03-23 资产迁移推进记录 + +## 当前 live 结果 + +- 固定资产 live 总数:1640 +- 其中有借用记录的资产:1247 +- 当前无标准地址的资产:59 +- 当前无标准地址状态分布: + - 可用:16 + - 已借出:43 + +## 今天已经完成的动作 + +- 扩展了 `backend/scripts/importConfirmedEquipmentToLive.js` + - 支持 `--keep-existing` + - 支持 `--include-ownerless` + - 支持把 `import-without-owner` 资产增量补导到 live + - 自动排除空编码和重复编码 +- 已增量导入 `393` 条“不带使用人但可先导入”的资产 +- 已新增并使用 `backend/scripts/exportAdminManualReviewWorkbook.js` + - 自动导出行政补录工作簿 + - 如果目标文件被 Excel 占用,自动生成时间戳版本 +- 已补强地址标准化规则: + - `常州 -> 常州-天安` + - `佛山3D / 佛山平面 / 佛山建模 / 广东佛山 -> 佛山` + - `二楼分销 -> 常州-天安` + - `南京机房远程数据机 -> 南京-苏宁园区` + - `10号楼1楼机房监控 / 10号楼1楼监控室 / 10号楼1楼配电间监控 / 10号楼3楼机房 -> 南京-苏宁园区` +- 已扩展 `backend/scripts/backfillImportedEquipmentLocations.js` + - 不再只处理 `import-ready` + - 也能处理 `import-without-owner` +- 今天地址回填结果: + - 第一轮回填:32 条 + - 第二轮回填:5 条 + - 无标准地址从 96 压到 59 + +## 关键输出文件 + +- 最新 dry-run: + - `D:\AI Code\GuDingZiChan\temp_migration_output\migration_dry_run_2026-03-23_03-01-06` +- 最新地址回填结果: + - `D:\AI Code\GuDingZiChan\temp_migration_output\location_backfill_2026-03-23_03-01-32` +- 最新导入备份: + - `D:\AI Code\GuDingZiChan\temp_migration_output\live_import_backup_2026-03-23_02-47-21` +- 最新行政补录清单: + - `D:\AI Code\GuDingZiChan\行政手动补录清单-2026-03-23_03-07-12.xlsx` + +## 当前行政补录清单统计 + +- `待确认部门`:23 +- `缺少编码`:6 +- `重复编码`: + - 重复编码数:5 + - 重复记录数:10 +- `待补使用人`:196 +- `花名核对`:105 +- `待补地址`:59 + +## 关于 196 条“待补使用人”的结论 + +- 其中 `193` 条原因是:`owner_not_in_active_synced_roster` +- 其中 `3` 条原因是:`multi_owner_cell` +- 已做过进一步核对: + - 这批花名按 `hr_employee_register.sys00-flowerName` + - `hr_employee_register.sys00-name` + - `hr_employee_register.sys02-realName` + - `hr_employee.name` + - 四个字段去查,都没有命中 +- 当前结论: + - 这不是简单的“同步规则漏同步了当前员工” + - 更像是 Excel 里使用的是另一套历史花名/历史称呼 + - 需要行政按“花名核对”工作表,把历史花名对应到当前在职员工 + +## 当前剩余 59 条待补地址的主要口径 + +- 空白:32 +- `电子设备`:5 +- `河北仓`:3 +- `天安/杭州`:2 +- `山东仓`:2 +- `抓数用`:2 +- 其余都是 1 条 + +## 剩余地址为什么先不继续自动处理 + +- `空白` 无法推断 +- `电子设备`、`抓数用` 不是地点字段 +- `河北仓`、`山东仓`、`睢宁仓`、`沭阳` 等更像真实外部仓点 + - 可能应该新增标准地址 + - 不适合硬映射到现有 `常州-仓库` +- `天安/杭州`、`杭州/天安` 是双地址写法 + - 当前没有足够证据判断应该落到哪一个标准地址 + +## 如果后面继续推进,优先顺序建议 + +1. 行政先处理 `花名核对` 工作表,确认 105 个历史花名对应的当前人员 +2. 行政确认 59 条待补地址里,哪些应新增为正式标准地址 +3. 再决定是否补第二轮“带使用人回挂”或继续回填新增地址 + +## 安全边界 + +- 本次所有 live 操作都只针对固定资产系统 +- 未操作 `/opt/shebeiguanli` +- 未操作任何 `shebeiguanli-*` 容器或其数据库 diff --git a/docs/组织同步源表结构说明.md b/docs/组织同步源表结构说明.md new file mode 100644 index 0000000..6704b75 --- /dev/null +++ b/docs/组织同步源表结构说明.md @@ -0,0 +1,572 @@ +# 组织同步源表结构说明 + +更新时间:2026-03-19 +适用项目:固定资产管理系统(`D:\AI Code\GuDingZiChan`) +状态:最新源表核查结论与同步实现基线 + +--- + +## 1. 文档目的 + +这份文档用于固定当前 SQL Server 人事源的真实情况,以及固定资产系统后续应采用的同步口径。 + +这次更新的重点不是“字段长什么样”,而是把下面几件事彻底说清楚: + +- 哪张表现在更新及时 +- 哪张表已经明显滞后 +- 为什么系统里按旧口径只能算出 `653` +- 为什么用户在钉钉里看到大约 `773` +- 固定资产系统后续到底应该按哪套表和字段同步 + +--- + +## 2. 最新结论 + +先说最终结论: + +- `dbo.hr_employee_register` 现在要作为**人员同步主口径** +- `dbo.hr_employee` 只作为**补充字段来源** +- `dbo.hr_groups` 只作为**历史参考 / 追溯参考**,不再作为当前业务部门主依据 + +这不是主观猜测,而是当前数据直接说明了这件事: + +- `hr_employee` 最新工号只到 `JS24081502` +- `hr_employee_register` 最新工号已经到 `JS26031604` +- `hr_employee_register` 里存在大量 `2025`、`2026` 工号的新员工 +- 这些新员工很多根本不在 `hr_employee` +- 这些新员工的业务部门名也有很多不在 `hr_groups` 一级树里 + +一句话概括: + +- `hr_employee + hr_groups` 更像一套旧的、部分停留在 `2024-08` 左右的组织镜像 +- `hr_employee_register` 更像当前仍在持续更新的人事登记口径 + +--- + +## 3. 三张表的新定位 + +### 3.1 `dbo.hr_employee_register` + +新定位:**当前人员同步主表** + +主要用途: + +- 人员主键:`userid` +- 花名:`sys00-flowerName` +- 姓名:`sys00-name` / `sys02-realName` +- 工号:`sys00-jobNumber` +- 岗位:`sys00-position` +- 手机:`sys00-mobile` +- 员工状态:`sys01-employeeStatus` +- 业务部门:`sys00-dept` +- 主部门兜底:`sys00-mainDepts` +- 源追溯字段:`sys00-mainDeptId`、`sys00-deptIds` + +后续同步时: + +- 人员名单从这里出 +- 部门名优先从这里取 +- 是否正式员工也从这里判断 + +### 3.2 `dbo.hr_employee` + +新定位:**补充字段表,不再做主名单表** + +保留价值: + +- `email` +- `manager_userid` +- `title` +- `extension` +- 某些历史账号还能补到 `name`、`mobile` + +但它不再适合作为“谁该同步”的主判断依据。 + +### 3.3 `dbo.hr_groups` + +新定位:**旧组织树参考表,不再做当前主部门树** + +保留价值: + +- 历史追溯 +- 排查旧同步结果为什么会错 +- 理解旧资产 / 旧部门引用来自哪里 + +不再适合作为: + +- 当前前台业务部门树 +- 当前人员主部门解析依据 +- 当前人数口径判断依据 + +--- + +## 4. 当前已核实的关键数字 + +### 4.1 表规模 + +- `hr_employee`:`1050` +- `hr_groups`:`781` +- `hr_employee_register`:`1426` + +### 4.2 `hr_employee` 与 `hr_employee_register` 对应情况 + +按 `userid` 核查: + +- `hr_employee` 中能匹配到 `register` 的:`796` +- `hr_employee` 中匹配不到 `register` 的:`254` +- `register` 中存在但不在 `hr_employee` 的:`630` + +这说明: + +- 两张表不是同一套完整在职名单 +- `hr_employee` 不能代表“当前全部在职内部员工” + +### 4.3 `hr_employee` 口径为什么明显旧 + +已核实: + +- `hr_employee` 最大工号:`JS24081502` +- `hr_employee_register` 最大工号:`JS26031604` + +这说明: + +- `hr_employee` 至少在“新人入职覆盖”这件事上,已经明显慢于 `register` + +### 4.4 `register` 中内部人员分布 + +按 `hr_employee_register` 统计: + +- `employeeType = 1, employeeStatus = 3`:`1322` +- `employeeType = 1, employeeStatus = 2`:`103` +- `employeeType = 2, employeeStatus = 3`:`1` + +按“`sys00-dept` 不是空,且不属于 `供应商 / 快递公司 / 外部交流`”统计: + +- 内部业务部门样本总数:`817` +- 其中 `status = 3` 的:`714` + +### 4.5 为什么 `653` 和 `773` 差很多 + +按旧口径: + +- 从 `hr_employee + hr_groups` +- 排除 `供应商 / 快递公司 / 外部交流` + +只能得到:`653` + +但继续往下拆后发现: + +- `register` 里有 `290` 个“内部部门 + `status=3` + 不在 `hr_employee`”的人 +- 这批人里,大量部门名根本不在 `hr_groups` 一级树里 + +最典型的新增部门包括: + +- `龙晨品牌中心` +- `视觉中心` +- `品牌策划中心` +- `摄影中心` +- `商品企划部` +- `品牌中心` + +仅这些“`register` 里有、`hr_groups` 里没有”的新部门人员,就接近 `116` 人,已经非常接近 `653` 与 `773` 之间的差值。 + +所以真正的问题不是“少排了一个外部树”,而是: + +- 旧同步口径依赖了过旧的 `hr_employee + hr_groups` +- 当前真实内部员工和业务部门已经大量沉淀在 `hr_employee_register` + +--- + +## 5. 当前组织同步实现基线 + +### 5.1 人员主名单来源 + +后续代码实现一律按: + +- 主来源:`hr_employee_register` +- 补充来源:`LEFT JOIN hr_employee ON userid` + +### 5.2 内部 / 外部过滤规则 + +当前默认排除的源部门名: + +- `供应商` +- `快递公司` +- `外部交流` + +判断方式: + +- 先看 `sys00-dept` +- 如果 `sys00-dept` 明确是外部部门名,则直接排除 +- 只有 `sys00-dept` 为空时,才再看 `sys00-mainDepts` +- `sys00-mainDepts` 只作为空主部门时的兜底参考,不用于覆盖外部主部门判断 + +### 5.3 正式员工与非正式员工规则 + +正式员工判断: + +- `sys01-employeeStatus = '3'` + +正式员工部门落地规则: + +1. 优先 `sys00-dept` +2. 若为空,兜底 `sys00-mainDepts` +3. 若两者都为空,落到 `其他` + +非正式员工规则: + +- 不再直接丢弃 +- 仍同步进系统 +- 统一归入 `其他` + +### 5.4 在职 / 停用判断 + +后续代码不再以 `hr_employee.active` 作为主依据。 + +默认规则: + +- 若 `sys01-employeeStatus` 命中停用状态集合,则视为 `inactive` +- 默认停用状态集合仍保留环境变量控制 +- 默认值:`5` + +当前已核实的数据里主要是: + +- `3` +- `2` + +因此当前大部分 `register` 记录会被视为有效同步候选。 + +### 5.5 部门模型 + +本地 `department` 表继续只保留“业务部门”: + +- `运营中心A` +- `客服中心` +- `仓储中心` +- `视觉中心` +- `龙晨品牌中心` +- `品牌策划中心` +- `摄影中心` +- `其他` + +不再把 `hr_groups` 叶子节点灌进前台。 + +--- + +## 6. 字段使用建议 + +### 6.1 继续保留到本地 `person` 的字段 + +- `source_userid` +- `source_main_department_id` +- `source_dept_ids` +- `source_department_name` +- `source_main_department_name` +- `department_sync_bucket` +- `manager_source_userid` +- `source_employee_status` +- `last_synced_at` + +### 6.2 字段优先级 + +姓名相关: + +1. 花名:`sys00-flowerName` +2. 姓名:`sys00-name` +3. 真实姓名:`sys02-realName` +4. 再兜底 `hr_employee.name` + +联系方式相关: + +1. 手机:`sys00-mobile` +2. 再兜底 `hr_employee.mobile` + +岗位相关: + +1. `sys00-position` +2. 再兜底 `hr_employee.title` + +邮箱 / 上级: + +- 目前仍主要从 `hr_employee` 补 + +--- + +## 7. 需要特别记住的事实 + +1. `hr_employee_register` 已经不是“补充信息表”这么简单,它现在才是最新的人事主口径 +2. `hr_employee` 仍有价值,但更像历史补充表 +3. `hr_groups` 里明确混有外部组织树,不能直接拿整棵树做前台部门 +4. `sys00-mainDeptId` 不能再当主部门依据 +5. `sys00-dept` 是当前最接近业务真实视图的主部门字段 +6. `sys00-mainDepts` 只能做正式员工空部门兜底 +7. 当前代码若继续坚持 `hr_employee` 为主表,会持续漏掉大量 `2025/2026` 新员工 + +--- + +## 8. 后续建议 + +如果继续推进组织同步,建议顺序就是: + +1. 代码改为 `register` 主表同步 +2. 前端同步页文案改成新口径 +3. 跑一次预览,核对: + - 人数 + - 部门集合 + - `其他` 人数 +4. 再决定是否立刻正式同步到线上 + +如果后续还要继续逼近“钉钉约 773 人”的口径,下一步应继续核: + +- `status = 2` 在业务上到底算不算应同步人员 +- `龙晨品牌中心 / 品牌中心 / 品牌策划中心` 是否确属当前业务大部门 +- 钉钉架构页展示是否有额外筛选 + +--- + +## 9. 当前最终判断 + +一句话最终判断: + +- 这次组织同步的根因已经找到 +- 不是筛选条件写错一点点 +- 而是旧代码依赖了过旧的主表和组织树 + +后续固定资产系统应采用: + +- `hr_employee_register` 作为主同步源 +- `sys00-dept` 作为主业务部门 +- `sys00-mainDepts` 作为正式员工兜底 +- `hr_employee` 只做补充字段 +- `hr_groups` 只做追溯参考 + +--- + +## 10. 2026-03-19 补充核查结论 + +本轮继续往下核以后,又确认了下面这些关键事实: + +1. 当前差异的核心不是“少排了几个外部树”,而是源表更新时间和口径不一致 +2. `hr_employee` 内部口径人数约 `653` +3. `hr_employee_register` 内部口径人数约 `817` +4. `hr_employee` 最大工号只到 `JS24081502` +5. `hr_employee_register` 最大工号已经到 `JS26031604` + +这说明: + +- `hr_employee` 明显缺少 `2025/2026` 的新员工数据 +- `register` 已经包含大量更新的人事数据 +- 继续拿 `hr_employee + hr_groups` 作为主同步口径,只会持续漏同步新人 + +--- + +## 11. 对“653 vs 773”差值的当前判断 + +当前最接近根因的判断是: + +1. `653` 是旧口径下算出来的内部人数 +2. 你在钉钉里看到的大约 `773`,更接近 `register` 这套新口径 +3. 两者之间的缺口,主要来自: + - `register` 里新增的业务部门 + - `register` 里新增的 `2025/2026` 新员工 + +目前已经明确出现的新业务部门包括: + +- `龙晨品牌中心` +- `视觉中心` +- `品牌策划中心` +- `摄影中心` +- `商品企划部` +- `品牌中心` + +这些部门在旧 `hr_groups` 一级树里并不完整存在,所以旧逻辑天然会漏。 + +--- + +## 12. 当前代码落地状态 + +截至 `2026-03-19`,项目代码已继续朝这个方向收口: + +1. 同步页预览已明确标注当前口径为“登记表主口径” +2. 同步页会展示: + - `登记表 + 员工表匹配人数` + - `仅登记表命中人数` + - `formal / fallback / other` 三类人数 +3. 后端排除外部部门时,已兼容读取: + - `HR_SYNC_EXCLUDED_DEPARTMENTS` + - `HR_SYNC_EXCLUDED_DEPARTMENT_NAMES` + +这部分的目的不是改文案,而是让后续继续逼近真实人数时,能直接在系统里看到差异来自哪一批数据。 + +--- + +## 13. 2026-03-19 再次深挖“653 vs 773”后的新判断 + +这轮继续往下查以后,已经可以把问题再说得更具体一些。 + +### 13.1 只看 `register` 内部业务部门时的真实规模 + +按下面这个口径统计: + +- `sys00-dept` 非空 +- `sys00-dept` 不属于 `供应商 / 快递公司 / 外部交流` + +得到: + +- 总人数:`817` +- `employeeStatus = 3`:`714` +- `employeeStatus = 2`:`103` + +也就是说,单纯把 `register` 当“全部应同步内部人员”,人数会明显高于你在钉钉里看到的大约 `773`。 + +### 13.2 旧树缺口已经可以量化 + +继续把 `register.sys00-dept` 和 `hr_groups.name` 直接对比后,发现: + +- 仍能在 `hr_groups` 里找到部门名的:`612` +- 在 `hr_groups` 里根本找不到部门名的:`205` + +这 `205` 人主要集中在下面这些新业务部门: + +- `龙晨品牌中心`:`69` +- `视觉中心`:`62` +- `摄影中心`:`32` +- `品牌策划中心`:`17` +- `商品企划部`:`14` +- `效率部(懂业务的技术部)`:`8` +- `品牌中心`:`3` + +这说明旧口径漏人的原因已经非常明确: + +- 不只是旧表少更新 +- 还因为当前真实业务部门里,已经有一批部门根本不在旧 `hr_groups` 主树口径里 + +### 13.3 当前最可疑的一批人:`业务支持` 里没有工号的 44 人 + +这轮最关键的新发现是: + +- `业务支持` 总人数:`45` +- 其中没有工号的:`44` +- 唯一有工号的 1 人:`JS24070406` + +更关键的是: + +- `817 - 44 = 773` + +也就是说,如果仅把这 `44` 个“`业务支持` 且无工号”的人暂时视为不进入钉钉统计口径的人群,人数会非常接近你看到的大约 `773`。 + +再继续拆以后,这 `44` 人本身又分成两类: + +- `20` 人:`hr_employee` 里也存在,且仍是 `active = true` +- `24` 人:只在 `hr_employee_register` 里出现,旧员工表和旧组织树里都没有对应记录 + +对应到旧组织树: + +- 有旧根部门 `业务支持` 的:`20` +- 完全没有旧组织根的:`24` + +这 `44` 人的 `sys00-mainDepts` 又高度集中在: + +- `门禁系统`:`16` +- `仓储支持`:`14` +- `山东项目组`:`8` +- `云杉对接`:`3` +- `钉钉技术`:`1` +- `软件支持`:`1` +- `视觉支持`:`1` + +从命名上看,这一批更像: + +- 支撑组 +- 项目组 +- 系统对接 / 技术支持账号 + +而不像标准业务部门下的普通员工口径。 + +### 13.4 不能简单按“没工号就全排除” + +虽然“无工号”是一个很强的信号,但不能直接全局套用。 + +原因是: + +- 内部业务部门里无工号的总人数有 `114` +- 其中 `龙晨品牌中心` 就有 `65` +- `业务支持` 有 `44` +- `品牌中心` 有 `3` +- `广州项目组` 有 `2` + +如果按“无工号一律排除”,那会把人数直接压到 `703`,明显又偏低了。 + +所以当前更合理的判断不是: + +- “没有工号就都不是内部员工” + +而是: + +- `业务支持` 这批无工号人员,极可能是一个特殊口径 +- 需要单独处理,不能拿来代表全部无工号人员 + +### 13.5 对 `龙晨品牌中心` 的处理更新(用户实页复核后) + +`龙晨品牌中心` 目前也很特殊: + +- 总人数:`69` +- `register_only`:`69` +- 无工号:`65` +- `status = 3`:`66` + +这部分在早期阶段之所以没有直接排掉,是因为当时只看总人数,担心一刀切后和你提到的 `773` 偏差更大。 + +但在 2026-03-19 进一步拿系统实际页面逐个反查后,已经有了更强证据: + +- 你在“使用者列表”里连续翻页时,发现一整批明显不对的人名 +- 这些样本反查后,全部落在 `sys00-dept = 龙晨品牌中心` +- 这批人只存在于 `hr_employee_register` +- `hr_employee` 里完全没有对应记录 +- 页面样本里还出现了类似 `001`、`朱凤—财务管理部—会计` 这种明显不适合作为正式员工名册直接展示的账号 + +所以这一步可以把结论往前推进: + +- `龙晨品牌中心` 不能再按“先保留观察”处理 +- 在固定资产系统当前人员同步口径里,应按**特殊排除**处理 +- 更稳妥的排除条件是:`sourceDepartmentName = 龙晨品牌中心` 且 `sourcePresence = register_only` + +同性质的小批量异常还有: + +- `品牌中心`:`3` 人 +- 3 人全部 `status = 3` +- 3 人全部无工号 +- 同样只在 `register` 里出现 + +这 3 人也应按同类特殊排除处理 + +### 13.6 当前最稳的中间结论 + +截至这轮核查,可以先固定下面这个判断: + +1. `hr_employee + hr_groups` 的旧口径已经不可信,继续用只会持续漏人 +2. `hr_employee_register` 仍然是最接近真实源的人事主口径 +3. 但 `register` 不能直接“整表内部部门全量同步”,因为这样会到 `817` +4. 目前已经明确应从主同步口径里特殊排除的,不只有 `业务支持 + 无工号` +5. 还包括: + - `龙晨品牌中心(仅登记表命中)` + - `品牌中心(仅登记表命中)` +6. 如果只排掉 `业务支持 + 无工号`,虽然人数能接近 `773`,但页面实页仍会出现成批错误人员 +7. 之前那种“只追人数接近 `773`”的口径已经不够,必须以页面实际人员正确性为优先 +8. 原 `773` 的组成是: + - `status = 3`:`670` + - `status = 2`:`103` +9. 这说明“钉钉约 `773` 人”的口径,不能直接当成唯一目标值;正确的目标应该是“页面里的人员名单和真实组织一致” + +### 13.7 下一步建议 + +最建议继续做的是: + +1. 先把同步规则正式收敛为: + - `register` 主口径 + - 业务部门主字段仍是 `sys00-dept` + - 对 `业务支持 + 无工号` 做特殊排除 + - 对 `龙晨品牌中心(仅登记表命中)` 做特殊排除 + - 对 `品牌中心(仅登记表命中)` 做特殊排除 +2. 同步后重新让你从页面直接抽样验证 +3. 如果还有成批错误人员,再继续反查它们是否又集中在新的异常部门线上 diff --git a/docs/资产盘点与组织同步联合方案.md b/docs/资产盘点与组织同步联合方案.md new file mode 100644 index 0000000..289eac7 --- /dev/null +++ b/docs/资产盘点与组织同步联合方案.md @@ -0,0 +1,1188 @@ +# 资产盘点与组织同步联合方案 + +更新时间:2026-03-19 +适用项目:固定资产管理系统(`D:\AI Code\GuDingZiChan`) +状态:执行基线文档 + +--- + +## 1. 文档目的 + +这份文档用于把“组织同步修正”和“资产盘点功能”合并成一套完整方案,并固定所有已经明确的产品决策、字段口径、实现边界、接口方向、页面范围与测试标准。 + +后续无论是 Codex 继续实现,还是交给其他 agent / 工程师接手,都必须优先参考本文件,避免因为上下文压缩导致细节丢失、口径跑偏、功能返工。 + +一句话概括: + +- 先把“人、部门、在用关系”校准准确。 +- 再在这套准确数据上做“行政发起、员工免登录手机扫码”的资产盘点。 + +--- + +## 2. 业务背景 + +### 2.1 当前项目现状 + +项目已经具备以下能力: + +- 资产录入 +- 资产入库 +- 资产借出 +- 资产归还 +- 资产维修 +- 资产打印二维码 +- 移动端扫码(部分场景) +- 人员与部门同步(已接入外部 SQL Server 人事源) + +但是当前“组织同步”口径与实际钉钉组织架构不一致,导致: + +- 系统里的人数明显少于钉钉可见人数 +- 系统中的部门名称很多是叶子组 / 小组,不是业务实际看到的大部门 +- 按部门筛选人员、资产、后续发起盘点时,命中结果会不准确 + +### 2.2 盘点功能的业务目标 + +希望新增一套“资产盘点”能力,流程如下: + +1. 行政人员在后台发起盘点任务 +2. 行政人员可以按“某个部门 / 某类资产”发起任务 +3. 员工收到通知后,用手机访问固定资产系统的任务专属链接 +4. 员工无需登录,进入后先搜索并确认自己的花名 +5. 员工扫码盘点自己名下资产 +6. 系统记录盘点结果、异常结果、未盘点结果 +7. 行政在后台查看进度、异常、未归属资产,并手动关闭任务 + +盘点功能是否能准确运行,前提是: + +- 人员数据准确 +- 部门口径准确 +- 当前在用关系准确 + +因此“组织同步修正”必须先于、或者至少与“盘点功能”一起落地。 + +--- + +## 3. 当前问题核查结论(已确认) + +### 3.1 当前系统的组织同步口径不对 + +已核实当前同步逻辑主要来源于: + +- 文件:`backend/src/services/hrSyncService.ts` + +当前逻辑核心问题: + +1. 当前同步把 `employeeStatus = '3'` 作为正式员工过滤条件 +2. 当前同步会排除: + - `供应商` + - `快递公司` + - `外部交流` +3. 当前系统主要以 `sys00-mainDeptId -> hr_groups` 解析本地部门 +4. `hr_groups` 里很多节点是小组/叶子部门,不是用户在钉钉里看到的业务大部门 + +### 3.2 已核实的数据差异 + +通过源 SQL Server 与本地同步结果对比,已确认: + +- 源库活跃用户总量明显高于本地同步后人数 +- 用户在钉钉看到的人数约为 773 +- 当前系统线上本地库只有约 424 个活跃同步人员 +- 差异不只是过滤条件,还包括“部门字段口径不一致” + +### 3.3 已核实的关键字段结论 + +#### A. `sys00-dept` + +这是**最接近钉钉里业务大部门视图**的字段。 + +例如截图中的这些部门,主要对应这个字段: + +- 运营中心A +- 客服中心 +- 仓储中心 +- 视觉中心 +- 效率部(懂业务的技术部) +- 财务中心 +- 人力行政中心 +- 供应链中心 +- 业务支持 + +#### B. `sys00-mainDepts` + +这是**更细一级的主部门名称**,很多时候已经拆成组/部/小队,例如: + +- 平面视觉部 +- 3D视觉组 +- 税务组 +- 结算组 +- 品类一部 +- 发货组 +- 书架组 +- 视频部 + +它不适合作为系统统一的业务部门展示口径,但适合作为兜底字段。 + +#### C. `sys00-mainDeptId -> hr_groups` + +这是**更底层的结构化部门树来源**,很多情况下会落到叶子节点,甚至有些人这里还是空的。 + +它适合保留作“源数据追溯”,不适合作为前台主部门口径。 + +### 3.4 最终字段口径结论 + +后续组织同步改造采用如下规则: + +- 业务部门主口径:`sys00-dept` +- 正式员工空部门兜底:`sys00-mainDepts` +- 原始追溯信息保留:`sys00-mainDeptId`、`sys00-deptIds` +- `hr_groups` 不再作为系统前台主部门树 + +### 3.5 2026-03-19 补充核查结论 + +这轮继续往下查以后,已经确认一个更关键的事实: + +- `hr_employee_register` 才是当前更新更及时的人事主口径 +- `hr_employee` 与 `hr_groups` 都存在明显滞后 + +已核实到: + +- `hr_employee` 最大工号只到 `JS24081502` +- `hr_employee_register` 最大工号已到 `JS26031604` +- `register` 中存在大量 `2025 / 2026` 新员工,且很多根本不在 `hr_employee` +- `register` 中还有一批当前业务部门名,根本不在 `hr_groups` 一级树中 + +因此,从 2026-03-19 开始,本项目组织同步的**代码实现基线**调整为: + +- 人员主来源:`hr_employee_register` +- `hr_employee` 仅作邮箱、上级、旧补充字段兜底 +- `hr_groups` 仅保留为历史追溯参考,不再作为当前主部门树 + +--- + +## 4. 已确定的产品决策 + +### 4.1 组织同步相关 + +1. 项目尚未正式投入使用,本次以“准确优先”为原则 +2. 不兼容当前错误的同步部门映射结果 +3. 正式员工定义: + - `source_employee_status = '3'` +4. 正式员工主部门规则: + - 优先使用 `sys00-dept` + - 如果 `sys00-dept` 为空,则兜底使用 `sys00-mainDepts` +5. 非正式员工处理规则: + - 仍同步入系统 + - 统一归入顶级部门 `其他` + - 不再沿用原始来源部门继续细分 +6. 前台、筛选、统计、盘点使用的部门口径,全部使用“业务部门口径” +7. 原始部门树仅保留为同步追溯信息,不再作为主展示树 + +### 4.2 盘点功能相关 + +1. 行政发起盘点任务 +2. 员工通过“任务专属链接”进入盘点,不使用统一公开盘点入口 +3. 员工盘点时免登录 +4. 员工进入任务后先搜索并确认花名 +5. 不填工号 +6. 任务支持按“业务部门 / 资产类型”发起 +7. 同时选择部门和资产类型时,采用 AND 逻辑 +8. 任务创建时冻结参与人名单与资产范围 +9. 后续借还、调部门、转人,不影响本次任务口径 +10. 员工只能盘本人名下资产 +11. 员工可以上报异常: + - 未找到 + - 不在身边 +12. 当前没有明确在用人的资产,不排除,留给行政后台处理 +13. 任务关闭方式为“行政手动关闭” + +--- + +## 5. 本次改造范围 + +### 5.1 在范围内 + +- 修正 HR 同步逻辑 +- 修正部门数据模型口径 +- 修正人员接口返回的部门口径 +- 修正前台部门选择器、人员筛选、盘点部门筛选口径 +- 新增盘点任务相关数据库表 +- 新增盘点后台接口 +- 新增盘点员工公开接口 +- 新增盘点后台页面 +- 新增盘点员工手机端页面 +- 新增方案文档 +- 补充验证与回归测试 + +### 5.2 不在范围内 + +- 钉钉公告发送 +- 钉钉免登 +- 钉钉消息通知 +- 供应商 / 快递公司的单独业务流程 +- 摄影设备借还系统的任何功能调整 + +--- + +## 6. 远程与部署安全红线 + +服务器 `1.13.22.133` 上同时运行两套系统: + +- 固定资产系统:`/opt/equipment-management` +- 摄影设备借还系统:`/opt/shebeiguanli` + +必须遵守: + +- 任何远程操作只能针对固定资产系统 +- 不得进入 `/opt/shebeiguanli` +- 不得停止、删除、修改摄影系统容器 +- 不得连接、修改、覆盖摄影系统数据库或数据卷 + +一句话:宁可固定资产系统暂时不动,也绝不能影响摄影设备借还系统。 + +--- + +## 7. 组织同步改造设计 + +### 7.1 设计目标 + +同步后的本地系统,应该满足: + +1. 部门名称与业务日常视图一致 +2. 行政在系统里看到的部门尽量与钉钉截图一致 +3. 资产录入/借还/盘点按部门筛选时,结果符合业务理解 +4. 仍保留外部系统原始字段用于排错和同步差异分析 + +### 7.2 本地部门模型目标口径 + +本地 `department` 表后续承载的是“业务部门”而不是“原始 hr_groups 叶子树”。 + +业务部门示例: + +- 运营中心A +- 客服中心 +- 仓储中心 +- 视觉中心 +- 财务中心 +- 人力行政中心 +- 供应链中心 +- 业务支持 +- 其他 + +不再作为前台部门项暴露的示例: + +- 淘系组 +- 发货组 +- 京东 +- 税务组 +- 结算组 +- 评价组 +- 3D视觉组 + +### 7.3 人员同步规则 + +#### 正式员工 + +判断规则: + +- `source_employee_status = '3'` + +部门归属规则: + +1. 读取 `sys00-dept` +2. 如果有值,则使用该值作为本地业务部门名 +3. 如果为空,则读取 `sys00-mainDepts` +4. 如果 `sys00-mainDepts` 有值,则用作兜底业务部门名 +5. 如果两者都为空,则归入 `其他` + +#### 非正式员工 + +判断规则: + +- `source_employee_status != '3'`,但仍保留同步 + +部门归属规则: + +- 统一归入 `其他` + +补充实现说明: + +- 这两条规则的判断源都来自 `hr_employee_register` +- 不再以 `hr_employee.active + hr_groups` 作为主同步入口 + +### 7.4 本地人员字段要求 + +现有 `person` 表保留并补充以下口径: + +#### 继续保留 + +- `department` +- `main_department_id` +- `source_userid` +- `source_system` +- `source_main_department_id` +- `source_dept_ids` +- `manager_source_userid` +- `source_employee_status` +- `last_synced_at` + +#### 新增建议字段 + +- `source_department_name` + - 保存 `sys00-dept` 原始值 +- `source_main_department_name` + - 保存 `sys00-mainDepts` 原始值 +- `department_sync_bucket` + - 可选,建议值: + - `formal` + - `fallback` + - `other` + - 用于前台和同步预览快速区分该人员是按哪条规则落地的 + +### 7.5 同步后的本地部门关系 + +本次改造后,本地人员与部门的关系以“业务部门”重新建立: + +- `Person.main_department_id` 指向本地业务部门 +- `Person.department` 存储当前业务部门名(用于快速展示/兼容旧逻辑) +- `PersonDepartment` 也只维护业务部门关联,不再灌入叶子组列表 + +### 7.6 原始结构字段保留原则 + +以下字段只保留为同步追溯,不作为系统业务部门树依据: + +- `source_main_department_id` +- `source_dept_ids` +- 外部 `hr_groups` + +用途: + +- 同步预览页对照展示 +- 差异排查 +- 出问题时回溯原始来源 + +### 7.7 本地部门同步策略 + +每次 HR 同步时: + +1. 先根据源人员数据计算出“本次应该存在的业务部门名称集合” +2. 本地没有的部门自动创建 +3. 本地已有同名业务部门则复用 +4. 本地原先由错误同步生成、但本轮不再命中的旧同步部门,标记为 `inactive` +5. 特殊部门 `其他` 永远保留为活跃顶级部门 + +### 7.8 资产部门迁移策略 + +已有资产如果 `department_id` 指向的是旧错误同步部门: + +1. 优先按名称匹配新业务部门 +2. 匹配成功则迁移到新业务部门 +3. 匹配失败则记录到人工处理清单 +4. 人工处理清单需包含: + - 资产 ID + - 资产编码 + - 资产名称 + - 原部门 ID / 名称 + - 迁移失败原因 + +--- + +## 8. 组织同步接口与页面改造 + +### 8.1 后端改造重点文件 + +优先改动范围: + +- `backend/prisma/schema.prisma` +- `backend/src/services/hrSyncService.ts` +- `backend/src/controllers/departmentController.ts` +- `backend/src/controllers/personController.ts` +- 可能涉及: + - `backend/src/controllers/batchImportController.ts` + - `backend/src/controllers/borrowController.ts` + - `backend/src/controllers/exportController.ts` + +### 8.2 `HR 同步预览` 页面需要新增的对照信息 + +原本页面只展示简化结果还不够,改造后应增加: + +- 源业务部门:`sys00-dept` +- 源兜底部门:`sys00-mainDepts` +- 源主部门 ID:`sys00-mainDeptId` +- 源部门 ID 列表:`sys00-deptIds` +- 本次解析后的本地业务部门 +- 部门解析来源: + - `sys00-dept` + - `sys00-mainDepts` + - `other` + +补充要求: + +- 预览页里的“原始部门候选数”不再来自 `hr_groups` 整棵树 +- 改为来自 `hr_employee_register` 中的源业务部门字段候选集合 + +### 8.3 部门相关接口改造方向 + +#### `GET /api/departments/options` + +返回: + +- 仅业务部门 +- 默认只返回 `status = active` +- 不再返回叶子组 + +#### `GET /api/departments/tree` + +返回: + +- 业务部门树 +- 初期可以是扁平顶级结构 +- `其他` 为顶级部门 + +#### `GET /api/persons/list` / `GET /api/persons/:id` + +返回: + +- `department` 为业务部门名 +- `main_department` 为业务部门对象 +- 同时保留原始字段方便后台同步页排查 + +--- + +## 9. 资产盘点功能设计 + +### 9.1 总体目标 + +新增一套“任务制”盘点能力,特点: + +- 后台创建任务 +- 任务通过专属链接对外分发 +- 员工免登录 +- 手机扫码为主 +- 一次任务冻结一次范围 +- 行政后台持续跟踪结果 + +### 9.2 核心数据表 + +建议在 Prisma 中新增以下模型: + +#### A. `InventoryTask` + +用途:盘点任务主表 + +建议字段: + +- `id` +- `title` +- `description` +- `status` + - `draft` + - `active` + - `closed` +- `public_token` +- `scope_department_id` +- `scope_department_name` +- `scope_asset_type_id` +- `scope_asset_type_name` +- `scope_snapshot_json` + - 保存创建任务时的筛选条件快照 +- `participants_count` +- `items_count` +- `completed_count` +- `exception_count` +- `unassigned_count` +- `created_by` +- `created_at` +- `updated_at` +- `closed_at` + +#### B. `InventoryTaskParticipant` + +用途:保存任务冻结参与人名单 + +建议字段: + +- `id` +- `task_id` +- `person_id` +- `person_name` +- `person_nickname` +- `person_source_userid` +- `department_id` +- `department_name` +- `status` + - `pending` + - `in_progress` + - `completed` +- `confirmed_at` +- `last_action_at` +- `created_at` +- `updated_at` + +#### C. `InventoryTaskItem` + +用途:保存任务冻结资产快照 + +建议字段: + +- `id` +- `task_id` +- `equipment_id` +- `equipment_code` +- `equipment_name` +- `equipment_model` +- `equipment_type` +- `asset_type_id` +- `asset_type_name` +- `snapshot_department_id` +- `snapshot_department_name` +- `snapshot_person_id` +- `snapshot_person_name` +- `snapshot_person_nickname` +- `snapshot_status` +- `is_unassigned` +- `check_status` + - `pending` + - `checked` + - `exception` +- `checked_by_participant_id` +- `checked_at` +- `exception_type` + - `not_found` + - `not_with_me` +- `exception_note` +- `created_at` +- `updated_at` + +#### D. `InventoryTaskAction` + +用途:动作日志表 + +建议字段: + +- `id` +- `task_id` +- `participant_id` +- `item_id` +- `action_type` + - `participant_confirmed` + - `item_checked` + - `item_exception_reported` + - `item_reassigned` + - `task_closed` +- `action_payload` +- `ip_address` +- `user_agent` +- `operator_user_id` + - 后台管理员操作时使用 +- `created_at` + +### 9.3 任务创建规则 + +行政创建任务时可以选择: + +- 业务部门 +- 资产类型 + +命中逻辑: + +- 只选部门:筛该部门下任务范围内资产 +- 只选资产类型:筛该类型资产 +- 两者都选:取交集 + +任务创建时必须冻结: + +- 参与人名单 +- 资产清单 +- 每个资产对应的使用人、使用部门、资产类型、状态快照 + +### 9.4 资产归属规则 + +创建任务时资产归属必须按“当时有效状态”计算: + +#### 已有明确在用人的资产 + +进入对应员工任务范围。 + +“明确在用人”判断依赖: + +- 有效借用关系 +- 当前占用 / 当前使用记录 +- 系统中现有借出逻辑的实际在用判定 + +#### 没有明确在用人的资产 + +不排除,不丢掉。 + +处理方式: + +- 写入 `InventoryTaskItem` +- 标记 `is_unassigned = true` +- 在后台“无人归属”清单中处理 +- 不出现在普通员工的待盘列表中 + +### 9.5 员工侧盘点流程 + +员工端目标: + +- 纯手机使用 +- 不要求登录 +- 操作路径尽量短 + +流程: + +1. 员工访问任务专属链接 +2. 系统校验任务状态 +3. 若任务已关闭,显示只读状态 +4. 若任务有效,员工进入确认身份页 +5. 员工在本任务参与人中搜索花名并确认 +6. 确认后生成本任务临时会话 +7. 员工进入扫码盘点页 +8. 扫码后: + - 如果资产属于本人任务范围,标记为已盘 + - 如果资产不属于本人任务范围,只提示“该资产不在你的盘点范围内” + - 不允许代盘 +9. 员工可主动上报异常: + - 未找到 + - 不在身边 +10. 员工可查看: + - 已盘数量 + - 未盘数量 + - 已上报异常数量 + +### 9.6 员工临时会话设计 + +员工端不登录,但仍需要一个“任务内身份态”。 + +建议做法: + +- 员工确认花名后,后端签发一个仅针对该任务的临时 token / session +- 该 token 只允许访问: + - 任务详情公开信息 + - 本人待盘资产 + - 本人已盘资产 + - 扫码提交 + - 异常提交 + +不能访问后台接口,不能跨任务复用。 + +### 9.7 后台管理功能 + +后台需要新增以下能力: + +- 创建盘点任务 +- 查看任务列表 +- 查看任务详情 +- 查看任务进度 +- 查看异常记录 +- 查看无人归属资产 +- 手动关闭任务 + +任务详情页建议展示: + +- 任务标题 +- 任务状态 +- 创建时间 +- 关闭时间 +- 筛选条件 +- 资产总数 +- 已盘数量 +- 未盘数量 +- 异常数量 +- 无人归属数量 +- 按部门完成率 +- 按资产类型完成率 +- 参与人员明细 +- 资产明细 + +### 9.8 关闭规则 + +任务关闭方式: + +- 仅行政手动关闭 + +关闭后的行为: + +- 员工端不可再提交盘点结果 +- 员工端仍可查看任务已关闭提示 +- 后台仍可查看全部结果 + +--- + +## 10. 盘点接口设计建议 + +### 10.1 后台接口 + +建议新增路由文件: + +- `backend/src/routes/inventory.ts` +- `backend/src/controllers/inventoryController.ts` + +建议接口: + +#### `POST /api/inventory/tasks` + +用途: + +- 创建盘点任务 + +入参: + +- `title` +- `description` +- `department_id?` +- `asset_type_id?` + +返回: + +- 任务主数据 +- 创建结果摘要 + +#### `POST /api/inventory/tasks/preview` + +用途: + +- 创建前预览任务命中范围 + +返回: + +- 参与人数 +- 资产数 +- 无人归属数 +- 部门命中摘要 +- 资产类型命中摘要 + +#### `GET /api/inventory/tasks` + +用途: + +- 任务列表 + +#### `GET /api/inventory/tasks/:id` + +用途: + +- 任务详情 + +#### `POST /api/inventory/tasks/:id/close` + +用途: + +- 手动关闭任务 + +#### `GET /api/inventory/tasks/:id/exceptions` + +用途: + +- 查看异常 + +#### `GET /api/inventory/tasks/:id/unassigned` + +用途: + +- 查看无人归属资产 + +#### `POST /api/inventory/tasks/:id/unassigned/:itemId/assign` + +用途: + +- 后台将无人归属资产人工归到某员工/某部门 + +### 10.2 员工公开接口 + +建议新增路由文件: + +- `backend/src/routes/publicInventory.ts` +- `backend/src/controllers/publicInventoryController.ts` + +建议接口: + +#### `GET /api/public-inventory/tasks/:token` + +用途: + +- 公开读取任务基本信息 + +#### `GET /api/public-inventory/tasks/:token/participants` + +用途: + +- 在当前任务范围内搜索参与人 + +入参: + +- `keyword` + +#### `POST /api/public-inventory/tasks/:token/participants/confirm` + +用途: + +- 确认花名,换取临时任务会话 + +#### `GET /api/public-inventory/me` + +用途: + +- 获取当前临时会话对应的任务与本人信息 + +#### `GET /api/public-inventory/me/items` + +用途: + +- 获取本人待盘/已盘资产 + +#### `POST /api/public-inventory/me/scan` + +用途: + +- 提交扫码盘点 + +入参: + +- `equipment_code` + +#### `POST /api/public-inventory/me/items/:itemId/exception` + +用途: + +- 提交异常 + +入参: + +- `exception_type` +- `exception_note?` + +--- + +## 11. 前端页面设计建议 + +### 11.1 后台页面 + +建议新增: + +- `frontend/src/views/inventory/InventoryTaskList.vue` +- `frontend/src/views/inventory/InventoryTaskDetail.vue` +- `frontend/src/views/inventory/components/InventoryTaskCreateDrawer.vue` + +后台入口建议加入侧边栏或现有业务菜单。 + +页面要求: + +- 遵守 `前端视觉规范.md` +- 使用深色极简风格 +- 不堆砌说明文字 +- 表单间距统一 +- 桌面端与移动端都能正常显示 + +### 11.2 员工公开页面 + +建议新增: + +- `frontend/src/views/public/PublicInventoryTask.vue` + +建议包含三个状态视图: + +1. 任务身份确认页 +2. 盘点扫码页 +3. 任务已关闭 / 任务不可用页 + +员工端要求: + +- 必须适配手机屏幕 +- 必须完整滚动,不允许页面被截断 +- 必须优先适配扫码 +- 操作按钮尽量固定在底部安全区域内 + +### 11.3 前端 API 客户端要求 + +员工端不能复用当前后台 API client。 + +原因: + +- 当前后台 API client 有统一鉴权逻辑 +- 员工端免登录,不能走会触发 401 的后台链路 + +建议: + +- 新增 `frontend/src/api/publicInventory.ts` +- 使用独立 axios 实例 +- 不挂后台登录拦截器 + +### 11.4 路由改造 + +建议新增路由: + +- 后台: + - `/inventory` + - `/inventory/:id` +- 员工公开页: + - `/inventory/task/:token` + +员工公开路由不能挂后台登录守卫。 + +--- + +## 12. 现有功能的联动影响 + +### 12.1 会受本次改造影响的现有模块 + +- 人员列表 +- 部门列表 / 部门管理 +- 资产录入页的部门下拉 +- 资产列表的部门筛选 +- 批量转移资产部门 +- 借出记录中的人员部门展示 +- 导出功能中的人员/部门字段 +- HR 同步预览页 + +### 12.2 需要保持不变的现有功能 + +- 资产打印二维码 +- 资产借出 / 归还主流程 +- 维修流程 +- 移动端已有扫码能力 +- 摄影设备借还系统 + +--- + +## 13. 数据迁移与兼容策略 + +### 13.1 原则 + +项目尚未正式投入使用,因此: + +- 以“修正正确口径”为主 +- 不做复杂兼容 +- 允许直接重算同步人员与部门关系 + +### 13.2 迁移建议顺序 + +1. Prisma schema 新增字段与盘点表 +2. 生成 Prisma Client +3. 迁移本地数据库结构 +4. 改造 HR 同步逻辑 +5. 运行一次手动同步 +6. 检查业务部门生成是否正确 +7. 迁移现有资产部门引用 +8. 完成盘点表和接口接入 +9. 接盘点前端 + +### 13.3 资产部门迁移处理结果 + +建议同步或迁移时输出摘要: + +- 成功迁移数量 +- 匹配不到的数量 +- 匹配不到的资产清单 + +如果没有专门表,也至少要: + +- 写日志 +- 写同步摘要 +- 后台可查看 + +--- + +## 14. 测试与验收标准 + +### 14.1 组织同步验收 + +必须满足: + +- `视觉中心` 能在系统中作为业务部门出现 +- `客服中心` 能在系统中作为业务部门出现 +- `仓储中心` 能在系统中作为业务部门出现 +- `效率部(懂业务的技术部)` 能在系统中作为业务部门出现 +- `财务中心` 不再被拆成 `税务组 / 结算组 / 总账组` 作为主部门展示 + +正式员工: + +- `sys00-dept` 有值时,正确落地 +- `sys00-dept` 为空时,正确兜底到 `sys00-mainDepts` + +非正式员工: + +- 成功同步 +- 全部进入 `其他` + +### 14.2 前台部门口径验收 + +必须满足: + +- 资产录入页部门下拉不再出现 `淘系组` +- 资产录入页部门下拉不再出现 `发货组` +- 资产录入页部门下拉不再出现 `税务组` +- 资产录入页部门下拉不再出现 `京东` +- 人员列表看到的是业务部门 +- 部门管理页看到的是业务部门 + +### 14.3 盘点后台验收 + +必须满足: + +- 可创建盘点任务 +- 可按部门创建盘点任务 +- 可按资产类型创建盘点任务 +- 部门 + 资产类型同时选择时按交集命中 +- 创建时冻结参与人和资产范围 +- 后台可查看: + - 总数 + - 已盘 + - 未盘 + - 异常 + - 无人归属 + - 按部门完成率 + - 按资产类型完成率 +- 可手动关闭任务 + +### 14.4 员工端验收 + +必须满足: + +- 免登录进入任务 +- 能搜索本任务参与人 +- 确认花名后进入本人盘点界面 +- 只能盘本人名下资产 +- 扫到非本人资产只提示,不允许代盘 +- 能上报: + - 未找到 + - 不在身边 +- 任务关闭后不能继续提交 + +### 14.5 回归验收 + +必须满足: + +- 不影响资产录入 +- 不影响借出 +- 不影响归还 +- 不影响维修 +- 不影响二维码打印 +- 不影响已有移动端扫码借还 +- 不影响摄影设备借还系统及其数据库 + +--- + +## 15. 建议实现顺序(执行路线) + +### 阶段 1:文档与数据模型 + +1. 写本方案文档 +2. 改 Prisma schema +3. 新增盘点表与同步补充字段 +4. 生成 Prisma Client + +### 阶段 2:组织同步修正 + +1. 重写 HR 同步里的部门解析规则 +2. 改造部门同步逻辑为“业务部门集合” +3. 改造人员主部门写入规则 +4. 改造部门 / 人员接口返回口径 +5. 改造同步预览展示字段 + +### 阶段 3:现有业务联动修正 + +1. 资产录入部门下拉切业务部门 +2. 资产列表部门筛选切业务部门 +3. 人员列表与借用记录展示切业务部门 +4. 导出逻辑切业务部门 + +### 阶段 4:盘点后端 + +1. 建立盘点表 +2. 实现后台任务预览接口 +3. 实现后台任务创建接口 +4. 实现任务详情、列表、关闭接口 +5. 实现公开端确认身份、扫码、异常接口 + +### 阶段 5:盘点前端 + +1. 后台任务列表页 +2. 后台任务创建抽屉/弹窗 +3. 后台任务详情页 +4. 员工公开盘点页 +5. 手机适配和扫码交互优化 + +### 阶段 6:验证与部署 + +1. 前端构建 +2. 后端构建 +3. Prisma 生成 +4. 本地流程验证 +5. 远程部署时只操作固定资产系统 + +--- + +## 16. 需要明确记住的几条底线 + +1. 后续所有“按部门”的功能,都应该基于业务部门口径,而不是 `hr_groups` 叶子组 +2. `sys00-dept` 是主口径,`sys00-mainDepts` 是兜底,不要反过来 +3. 非正式员工统一进 `其他` +4. 无明确在用人的资产不要排除,要留给行政后台处理 +5. 员工盘点免登录,但仍要先确认花名并使用任务内临时会话 +6. 员工只能盘本人资产,不允许代盘 +7. 任务创建后范围冻结,不能随着借还和转岗实时变化 +8. 任务关闭只能由行政手动执行 +9. 员工端必须走独立公开 API,不复用后台鉴权链路 +10. 任何远程部署都不能影响摄影设备借还系统 + +--- + +## 17. 后续如上下文压缩,优先恢复的事实 + +如果后续上下文被压缩,先恢复下面这些事实: + +1. 这是“组织同步修正 + 资产盘点”的联合方案 +2. 部门主口径是 `sys00-dept` +3. `sys00-mainDepts` 只是正式员工空部门兜底 +4. 非正式员工统一进 `其他` +5. `hr_groups` 只保留为追溯,不再做前台主部门树 +6. 盘点任务是“任务专属链接 + 员工免登录 + 搜索花名确认 + 只能盘本人资产” +7. 无明确在用人的资产进入行政后台待处理,不排除 +8. 任务创建时冻结名单与资产范围 +9. 任务手动关闭 +10. 员工端要有独立公开接口与独立前端 API client + +--- + +## 18. 参考文件 + +当前实现与后续改造时重点参考: + +- `D:\AI Code\GuDingZiChan\backend\src\services\hrSyncService.ts` +- `D:\AI Code\GuDingZiChan\backend\src\controllers\departmentController.ts` +- `D:\AI Code\GuDingZiChan\backend\src\controllers\personController.ts` +- `D:\AI Code\GuDingZiChan\backend\prisma\schema.prisma` +- `D:\AI Code\GuDingZiChan\frontend\src\views\settings\components\HrSyncManagement.vue` +- `D:\AI Code\GuDingZiChan\frontend\src\views\settings\components\DepartmentManagement.vue` +- `D:\AI Code\GuDingZiChan\frontend\src\views\equipment\EquipmentForm.vue` +- `D:\AI Code\GuDingZiChan\frontend\src\views\equipment\EquipmentList.vue` +- `D:\AI Code\GuDingZiChan\前端视觉规范.md` + +--- + +## 19. 文档用途说明 + +这不是纯讨论文档,而是执行基线文档。 + +后续实现时: + +- 如果代码与本文档冲突,以本文档为准 +- 如果实现过程中发现必须调整产品规则,必须先更新本文档,再改代码 +- 如果交接给其他 agent,优先先读本文档 + +--- + +## 20. 2026-03-19 执行补记 + +本轮继续核查后,需要额外记住下面这几件事: + +1. 组织同步当前继续沿“`hr_employee_register` 主口径”推进 +2. `hr_employee` 不再适合作为主人员名单来源,只保留作补充字段 +3. 同步页预览现在需要重点看两组数字: + - `登记表 + 员工表匹配` + - `仅登记表命中` +4. 如果后续人数还和钉钉不一致,先看是不是新业务部门或新员工只存在于 `register` +5. 后续正式同步前,优先核对: + - `其他` 人数是否异常 + - `register_only` 人数是否持续升高 + - 新业务部门是否符合行政实际认知 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 59f9cba..4f4e982 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -22,6 +22,9 @@ server { gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; location / { + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + add_header Pragma "no-cache" always; + expires -1; try_files $uri $uri/ /index.html; } @@ -71,6 +74,9 @@ server { } location / { + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + add_header Pragma "no-cache" always; + expires -1; try_files $uri $uri/ /index.html; } diff --git a/frontend/src/api/department.ts b/frontend/src/api/department.ts index d2ef814..bf3202a 100644 --- a/frontend/src/api/department.ts +++ b/frontend/src/api/department.ts @@ -1,5 +1,11 @@ import api from './index' -import type { ApiResponse, PaginatedResponse, Department, DepartmentTreeNode } from '@/types' +import type { + ApiResponse, + PaginatedResponse, + Department, + DepartmentTreeNode, + OrganizationTreeResponse +} from '@/types' export const getDepartmentOptions = async () => { const response = await api.get>('/departments/options') @@ -11,6 +17,11 @@ export const getDepartmentTree = async () => { return response.data } +export const getOrganizationTree = async () => { + const response = await api.get>('/departments/organization-tree') + return response.data +} + export const getDepartmentList = async (params: { page?: number pageSize?: number diff --git a/frontend/src/api/inventory.ts b/frontend/src/api/inventory.ts new file mode 100644 index 0000000..7cdc988 --- /dev/null +++ b/frontend/src/api/inventory.ts @@ -0,0 +1,68 @@ +import api from './index' +import type { + ApiResponse, + InventoryTaskDetail, + InventoryTaskItem, + InventoryTaskListItem, + InventoryTaskPreview, + PaginatedResponse +} from '@/types' + +export interface InventoryTaskPreviewPayload { + department_id?: number | null + asset_type_id?: number | null +} + +export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload { + title: string + description?: string +} + +export const previewInventoryTask = async (data: InventoryTaskPreviewPayload) => { + const response = await api.post>('/inventory/tasks/preview', data) + return response.data +} + +export const createInventoryTask = async (data: CreateInventoryTaskPayload) => { + const response = await api.post>('/inventory/tasks', data) + return response.data +} + +export const getInventoryTaskList = async (params: { + page?: number + pageSize?: number +}) => { + const response = await api.get>>('/inventory/tasks', { + params + }) + return response.data +} + +export const getInventoryTaskDetail = async (id: number) => { + const response = await api.get>(`/inventory/tasks/${id}`) + return response.data +} + +export const closeInventoryTask = async (id: number) => { + const response = await api.post>(`/inventory/tasks/${id}/close`) + return response.data +} + +export const getInventoryTaskUnassignedItems = async (id: number) => { + const response = await api.get>(`/inventory/tasks/${id}/unassigned`) + return response.data +} + +export const assignInventoryTaskUnassignedItem = async ( + taskId: number, + itemId: number, + personId: number +) => { + const response = await api.post>( + `/inventory/tasks/${taskId}/unassigned/${itemId}/assign`, + { + person_id: personId + } + ) + return response.data +} diff --git a/frontend/src/api/publicInventory.ts b/frontend/src/api/publicInventory.ts new file mode 100644 index 0000000..e1dbad4 --- /dev/null +++ b/frontend/src/api/publicInventory.ts @@ -0,0 +1,105 @@ +import axios from 'axios' +import type { AxiosResponse } from 'axios' +import type { + ApiResponse, + InventoryTaskItem, + PublicInventoryItemsResponse, + PublicInventoryParticipantOption, + PublicInventorySessionData, + PublicInventoryTaskInfo +} from '@/types' + +const resolvePublicInventoryBaseUrl = () => { + const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api' + return `${baseUrl.replace(/\/$/, '')}/public/inventory` +} + +const publicInventoryApi = axios.create({ + baseURL: resolvePublicInventoryBaseUrl(), + timeout: 10000, + withCredentials: true, + headers: { + 'Content-Type': 'application/json' + } +}) + +publicInventoryApi.interceptors.response.use( + (response: AxiosResponse) => { + const data = response.data as ApiResponse + if (data?.code === 200) { + return response + } + return Promise.reject(new Error(data?.message || '请求失败')) + }, + (error) => { + const message = + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + '请求失败' + return Promise.reject(new Error(message)) + } +) + +export const getPublicInventoryTask = async (token: string) => { + const response = await publicInventoryApi.get>(`/tasks/${token}`) + return response.data +} + +export const searchPublicInventoryParticipants = async (token: string, keyword?: string) => { + const response = await publicInventoryApi.get>( + `/tasks/${token}/participants`, + { + params: { + keyword: keyword || undefined + } + } + ) + return response.data +} + +export const confirmPublicInventoryParticipant = async (token: string, participantId: number) => { + const response = await publicInventoryApi.post< + ApiResponse<{ + task_id: number + task_title: string + participant_id: number + participant_name: string + participant_nickname: string + }> + >(`/tasks/${token}/participants/confirm`, { + participant_id: participantId + }) + return response.data +} + +export const getPublicInventoryMe = async () => { + const response = await publicInventoryApi.get>('/me') + return response.data +} + +export const getPublicInventoryItems = async () => { + const response = await publicInventoryApi.get>('/me/items') + return response.data +} + +export const submitPublicInventoryScan = async (equipmentCode: string) => { + const response = await publicInventoryApi.post>('/me/scan', { + equipment_code: equipmentCode + }) + return response.data +} + +export const submitPublicInventoryException = async ( + itemId: number, + data: { + exception_type: 'not_found' | 'not_with_me' + exception_note?: string + } +) => { + const response = await publicInventoryApi.post>( + `/me/items/${itemId}/exception`, + data + ) + return response.data +} diff --git a/frontend/src/components/CameraScannerModal.vue b/frontend/src/components/CameraScannerModal.vue index 95e8e5a..3c0be6a 100644 --- a/frontend/src/components/CameraScannerModal.vue +++ b/frontend/src/components/CameraScannerModal.vue @@ -2,18 +2,13 @@
-
-
-
-
-

{{ title }}

-

- {{ description }} -

-
+
+
+
+

{{ title }}

-
+
-
+ +
diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index c268101..6097199 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -10,19 +10,19 @@ -
+
-
-
+
+
-

{{ currentSection }}

-

{{ currentSubtitle }}

+

{{ currentSection }}

+

{{ currentSubtitle }}

-
-
+
@@ -137,6 +137,7 @@ import { useSiteStore } from '@/stores/site' import { HomeIcon, CubeIcon, + ClipboardDocumentCheckIcon, ArrowRightIcon, ChartBarIcon, BellIcon, @@ -155,10 +156,11 @@ const mobileNavOpen = ref(false) const navItems = [ { path: '/dashboard', title: '首页', icon: HomeIcon }, { path: '/equipment', title: '资产管理', icon: CubeIcon, requiresAdmin: true }, + { path: '/inventory', title: '资产盘点', icon: ClipboardDocumentCheckIcon, requiresAdmin: true }, { path: '/borrow', title: '使用管理', icon: ArrowRightIcon }, - { path: '/statistics', title: '统计分析', icon: ChartBarIcon }, - { path: '/notifications', title: '站内消息', icon: BellIcon, requiresAdmin: true }, - { path: '/settings', title: '系统设置', icon: Cog6ToothIcon, requiresAdmin: true } + { path: '/statistics', title: '统计', icon: ChartBarIcon }, + { path: '/notifications', title: '消息', icon: BellIcon, requiresAdmin: true }, + { path: '/settings', title: '设置', icon: Cog6ToothIcon, requiresAdmin: true } ] const visibleNavItems = computed(() => { @@ -175,6 +177,9 @@ const currentSubtitle = computed(() => { if (route.path === '/dashboard') return '资产运行总览' if (route.path === '/equipment') return '资产维护与盘点' if (route.path.startsWith('/equipment/')) return '移动录入与现场维护' + if (route.path === '/inventory') return '任务范围与现场进度' + if (route.path === '/inventory/create') return '生成新的盘点任务' + if (route.path.startsWith('/inventory/')) return '查看盘点人、资产与异常结果' if (route.path.startsWith('/borrow')) return '借还管理' if (route.path.startsWith('/return/')) return '扫码归还' if (route.path === '/statistics') return '数据分析' diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b2ea4db..875fc1e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,6 +8,8 @@ import { useAuthStore } from './stores/auth' import api from './api' import type { User } from './types' +const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/' + const bootstrap = async () => { const app = createApp(App) const pinia = createPinia() @@ -17,6 +19,7 @@ const bootstrap = async () => { const authStore = useAuthStore() const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' + const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX) if (authMode === 'local') { try { @@ -55,6 +58,12 @@ const bootstrap = async () => { return } + if (isPublicInventoryRoute) { + app.use(router) + app.mount('#app') + return + } + try { const authenticated = await keycloak.init({ onLoad: 'login-required', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 339f803..7382313 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,6 +10,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/Login.vue'), meta: { requiresAuth: false } }, + { + path: '/inventory/task/:token', + name: 'PublicInventoryTask', + component: () => import('../views/inventory/PublicInventoryTask.vue'), + meta: { requiresAuth: false, title: '手机盘点' } + }, { path: '/', component: () => import('../layouts/MainLayout.vue'), @@ -64,6 +70,24 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/statistics/Statistics.vue'), meta: { title: '统计分析' } }, + { + path: 'inventory', + name: 'InventoryList', + component: () => import('../views/inventory/InventoryList.vue'), + meta: { title: '资产盘点', requiresAdmin: true } + }, + { + path: 'inventory/create', + name: 'InventoryCreate', + component: () => import('../views/inventory/InventoryCreate.vue'), + meta: { title: '新建盘点任务', requiresAdmin: true } + }, + { + path: 'inventory/:id', + name: 'InventoryDetail', + component: () => import('../views/inventory/InventoryDetail.vue'), + meta: { title: '盘点任务详情', requiresAdmin: true } + }, { path: 'notifications', name: 'Notifications', @@ -88,13 +112,17 @@ const router = createRouter({ router.beforeEach((to, _from, next) => { const authStore = useAuthStore() const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' + const isAuthenticated = + authMode === 'local' + ? authStore.isAuthenticated + : Boolean(keycloak.authenticated && authStore.isAuthenticated) - if (to.path === '/login' && authStore.isAuthenticated) { + if (to.path === '/login' && isAuthenticated) { next('/dashboard') return } - if (to.meta.requiresAuth && !authStore.isAuthenticated) { + if (to.meta.requiresAuth && !isAuthenticated) { if (authMode === 'local') { next('/login') } else { diff --git a/frontend/src/style.css b/frontend/src/style.css index 54f0c68..ce5a065 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -127,6 +127,18 @@ transform: none; } + .btn-secondary--highlight { + border-color: rgba(245, 158, 11, 0.28); + background: rgba(245, 158, 11, 0.1); + color: #fcd34d; + } + + .btn-secondary--highlight:hover { + border-color: rgba(245, 158, 11, 0.46); + background: rgba(245, 158, 11, 0.16); + color: #ffffff; + } + .btn-danger { @apply inline-flex min-h-[44px] items-center justify-center gap-2 px-5 py-2.5 text-sm font-medium text-white transition-all duration-150; border-radius: var(--radius-md); @@ -166,15 +178,15 @@ border: 1px solid var(--border-color); border-radius: var(--radius-lg); background: var(--bg-secondary); - padding: var(--space-5); - box-shadow: 0 16px 44px rgba(0, 0, 0, 0.18); + padding: var(--space-4); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); } .panel { border: 1px solid var(--border-color); border-radius: var(--radius-lg); background: var(--bg-elevated); - padding: var(--space-4); + padding: var(--space-3); } .input { @@ -246,12 +258,12 @@ .page-header { display: flex; flex-direction: column; - gap: var(--space-5); - padding: var(--space-5); + gap: var(--space-4); + padding: var(--space-4); border: 1px solid var(--border-color); border-radius: var(--radius-xl); background: var(--bg-secondary); - box-shadow: 0 16px 44px rgba(0, 0, 0, 0.18); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); } .page-kicker { @@ -280,26 +292,26 @@ border: 1px solid var(--border-color); border-radius: var(--radius-lg); background: var(--bg-elevated); - padding: var(--space-4); + padding: var(--space-3); } .page-meta__label { - font-size: 11px; - letter-spacing: 0.05em; + font-size: 10px; + letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-tertiary); } .page-meta__value { margin-top: var(--space-1); - font-size: 20px; + font-size: 18px; font-weight: 600; color: var(--text-primary); } .page-meta__subvalue { margin-top: 2px; - font-size: 12px; + font-size: 11px; color: var(--text-secondary); } @@ -312,26 +324,34 @@ .filter-bar { display: flex; flex-direction: column; - gap: var(--space-4); + gap: var(--space-3); } .filter-bar__inputs { display: grid; gap: var(--space-3); flex: 1; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } .filter-bar__actions { display: flex; - flex-wrap: wrap; - gap: var(--space-3); + flex-wrap: nowrap; + gap: var(--space-2); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .filter-bar__actions::-webkit-scrollbar { + display: none; } .list-header { display: flex; flex-direction: column; - gap: var(--space-3); - margin-bottom: var(--space-4); + gap: var(--space-2); + margin-bottom: var(--space-3); } .table-container { @@ -347,19 +367,20 @@ } .data-table thead th { - padding: var(--space-4); + padding: var(--space-3); border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-tertiary); - font-size: 11px; + font-size: 10px; font-weight: 600; - letter-spacing: 0.05em; + letter-spacing: 0.04em; text-transform: uppercase; text-align: left; + white-space: nowrap; } .data-table tbody td { - padding: var(--space-4); + padding: var(--space-3); border-bottom: 1px solid var(--border-color); color: var(--text-secondary); font-size: 14px; @@ -375,7 +396,7 @@ } .badge { - @apply inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium; + @apply inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium; border-radius: var(--radius-full); } @@ -430,7 +451,7 @@ border: 1px solid var(--border-color); border-radius: var(--radius-lg); background: var(--bg-elevated); - padding: var(--space-4); + padding: var(--space-3); transition: border-color 150ms ease, background 150ms ease, transform 150ms ease; } @@ -487,8 +508,8 @@ border: 1px solid var(--border-color); border-radius: var(--radius-xl); background: var(--bg-secondary); - padding: var(--space-5); - box-shadow: 0 22px 60px rgba(0, 0, 0, 0.42); + padding: var(--space-4); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32); } .mobile-sticky-actions { @@ -505,12 +526,12 @@ .scanner-hero { display: grid; - gap: var(--space-4); - padding: var(--space-5); + gap: var(--space-3); + padding: var(--space-4); border: 1px solid rgba(16, 163, 127, 0.2); border-radius: var(--radius-xl); background: var(--bg-secondary); - box-shadow: 0 16px 44px rgba(0, 0, 0, 0.18); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12); } .scanner-hero__stats { @@ -523,7 +544,7 @@ border: 1px solid var(--border-color); border-radius: var(--radius-lg); background: var(--bg-elevated); - padding: var(--space-4); + padding: var(--space-3); } .scanner-hero__stat-label { @@ -633,7 +654,7 @@ border: 1px dashed var(--border-color); border-radius: var(--radius-xl); background: var(--bg-secondary); - padding: var(--space-8) var(--space-5); + padding: var(--space-6) var(--space-4); text-align: center; color: var(--text-secondary); } @@ -694,6 +715,18 @@ color: #ffffff; } + .table-action--highlight { + border-color: rgba(245, 158, 11, 0.22); + background: rgba(245, 158, 11, 0.08); + color: #fcd34d; + } + + .table-action--highlight:hover { + border-color: rgba(245, 158, 11, 0.42); + background: rgba(245, 158, 11, 0.14); + color: #ffffff; + } + .table-action--danger { border-color: rgba(239, 68, 68, 0.22); background: rgba(239, 68, 68, 0.08); @@ -748,7 +781,7 @@ } .filter-bar__inputs { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4b6be9a..ac2dbb4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -80,6 +80,30 @@ export interface DepartmentTreeNode { last_synced_at?: string | null } +export interface OrganizationDepartmentTreeNode extends DepartmentTreeNode { + direct_person_count: number + total_person_count: number + people: Person[] + children: OrganizationDepartmentTreeNode[] +} + +export interface OrganizationTreeSummary { + department_total: number + active_department_total: number + person_total: number + active_person_total: number + inactive_person_total: number + synced_person_total: number + local_person_total: number + unassigned_person_total: number +} + +export interface OrganizationTreeResponse { + departments: OrganizationDepartmentTreeNode[] + unassigned_people: Person[] + summary: OrganizationTreeSummary +} + // 设备接口 export interface Equipment { id: number @@ -115,6 +139,7 @@ export interface Equipment { equipment_model?: string unit_price?: number } | null + current_borrow?: EquipmentCurrentBorrow | null children?: Array<{ id: number equipment_code: string @@ -124,11 +149,28 @@ export interface Equipment { unit_price?: number status?: string mount_status?: string + current_borrow?: EquipmentCurrentBorrow | null }> created_at: string updated_at: string } +export interface EquipmentCurrentBorrow { + id: number + person_id: number + person_name: string + person_nickname: string + person_identifier?: string | null + borrow_time: string + status: string + person?: { + id: number + nickname: string + real_name: string + id_number?: string + } | null +} + // 借用记录接口 export interface Person { id: number @@ -149,6 +191,9 @@ export interface Person { source_system?: string | null source_main_department_id?: number | null source_dept_ids?: string | null + source_department_name?: string | null + source_main_department_name?: string | null + department_sync_bucket?: string | null manager_source_userid?: string | null source_employee_status?: string | null last_synced_at?: string | null @@ -181,6 +226,252 @@ export interface BorrowRecord { updated_at: string } +export type InventoryTaskStatus = 'active' | 'closed' +export type InventoryParticipantStatus = 'pending' | 'in_progress' | 'completed' +export type InventoryItemCheckStatus = 'pending' | 'checked' | 'exception' +export type InventoryExceptionType = 'not_found' | 'not_with_me' + +export interface InventoryScopeEntity { + id: number + name: string +} + +export interface InventoryTaskScopeSnapshot { + scope_department: InventoryScopeEntity | null + scope_asset_type: InventoryScopeEntity | null + participants_count: number + items_count: number + unassigned_count: number + generated_at: string +} + +export interface InventoryPreviewDepartmentBreakdown { + department_name: string + total: number + assigned: number + unassigned: number +} + +export interface InventoryPreviewAssetTypeBreakdown { + asset_type_name: string + total: number +} + +export interface InventoryPreviewParticipantSample { + person_id: number + person_name: string + person_nickname: string + person_source_userid: string | null + department_id: number | null + department_name: string | null +} + +export interface InventoryPreviewItemSample { + participant_person_id: number | null + equipment_id: number + equipment_code: string + equipment_name: string + equipment_model: string | null + equipment_type: string + asset_type_id: number | null + asset_type_name: string | null + snapshot_department_id: number | null + snapshot_department_name: string | null + snapshot_person_id: number | null + snapshot_person_name: string | null + snapshot_person_nickname: string | null + snapshot_status: string | null + source_borrow_record_id: number | null + is_unassigned: boolean +} + +export interface InventoryTaskPreview { + scope_department: InventoryScopeEntity | null + scope_asset_type: InventoryScopeEntity | null + participants_count: number + items_count: number + unassigned_count: number + department_breakdown: InventoryPreviewDepartmentBreakdown[] + asset_type_breakdown: InventoryPreviewAssetTypeBreakdown[] + participants_sample: InventoryPreviewParticipantSample[] + items_sample: InventoryPreviewItemSample[] +} + +export interface InventoryTaskListItem { + id: number + title: string + description?: string | null + status: InventoryTaskStatus | string + public_token: string + scope_department_id?: number | null + scope_department_name?: string | null + scope_asset_type_id?: number | null + scope_asset_type_name?: string | null + participants_count: number + items_count: number + completed_count: number + exception_count: number + unassigned_count: number + created_by: number + created_by_name?: string | null + closed_at?: string | null + created_at: string + updated_at: string +} + +export interface InventoryTaskParticipant { + id: number + task_id: number + person_id: number | null + person_name: string + person_nickname: string + person_source_userid?: string | null + department_id: number | null + department_name: string | null + status: InventoryParticipantStatus | string + confirmed_at?: string | null + last_action_at?: string | null + created_at: string + updated_at: string +} + +export interface InventoryTaskItem { + id: number + task_id: number + participant_id: number | null + equipment_id: number + equipment_code: string + equipment_name: string + equipment_model: string | null + equipment_type: string + asset_type_id: number | null + asset_type_name: string | null + snapshot_department_id: number | null + snapshot_department_name: string | null + snapshot_person_id: number | null + snapshot_person_name: string | null + snapshot_person_nickname: string | null + snapshot_status: string | null + source_borrow_record_id: number | null + is_unassigned: boolean + check_status: InventoryItemCheckStatus | string + checked_by_participant_id: number | null + checked_at?: string | null + exception_type?: InventoryExceptionType | string | null + exception_note?: string | null + created_at: string + updated_at: string +} + +export interface InventoryTaskAction { + id: number + task_id: number + participant_id?: number | null + item_id?: number | null + action_type: string + action_payload?: string | null + ip_address?: string | null + user_agent?: string | null + operator_user_id?: number | null + created_at: string +} + +export interface InventoryTaskDepartmentBreakdown { + department_name: string + total: number + checked: number + exception: number + pending: number +} + +export interface InventoryTaskAssetTypeBreakdown { + asset_type_name: string + total: number + checked: number + exception: number + pending: number +} + +export interface InventoryTaskDetail extends InventoryTaskListItem { + scope_snapshot: InventoryTaskScopeSnapshot | null + participants: InventoryTaskParticipant[] + items: InventoryTaskItem[] + recent_actions: InventoryTaskAction[] + department_breakdown: InventoryTaskDepartmentBreakdown[] + asset_type_breakdown: InventoryTaskAssetTypeBreakdown[] +} + +export interface PublicInventoryTaskInfo { + id: number + title: string + description?: string | null + status: InventoryTaskStatus | string + scope_department_name?: string | null + scope_asset_type_name?: string | null + participants_count: number + items_count: number + completed_count: number + exception_count: number + unassigned_count: number + created_at: string + closed_at?: string | null +} + +export interface PublicInventoryParticipantOption { + id: number + person_id: number | null + person_name: string + person_nickname: string + department_name?: string | null + status: InventoryParticipantStatus | string + confirmed_at?: string | null +} + +export interface PublicInventorySessionTask { + id: number + title: string + status: InventoryTaskStatus | string + scope_department_name?: string | null + scope_asset_type_name?: string | null + completed_count: number + exception_count: number + items_count: number + closed_at?: string | null +} + +export interface PublicInventorySessionData { + task: PublicInventorySessionTask + participant: InventoryTaskParticipant +} + +export interface PublicInventoryItemsResponse { + items: InventoryTaskItem[] + summary: { + total: number + pending: number + checked: number + exception: number + } +} + +export type InventoryPreviewResponse = InventoryTaskPreview + +export interface InventoryTaskListResponse extends PaginatedResponse {} + +export type InventoryPublicTaskInfo = PublicInventoryTaskInfo +export type InventoryPublicParticipantOption = PublicInventoryParticipantOption + +export interface InventoryPublicConfirmResponse { + task_id: number + task_title: string + participant_id: number + participant_name: string + participant_nickname: string +} + +export type InventoryPublicMeResponse = PublicInventorySessionData +export type InventoryPublicItemsResponse = PublicInventoryItemsResponse + // 用户接口 export interface User { id: number | string @@ -255,22 +546,40 @@ export interface ValueStatistics { } export interface HrSyncPreview { + source_mode: 'register_primary' | string departments: { total: number raw_total: number sample: Array<{ - sourceGroupId: number + name: string + sourcePersonCount: number + buckets: Array<'formal' | 'fallback' | 'other'> + }> + raw_sample?: Array<{ + sourceGroupId: number | null sourceParentGroupId: number | null name: string }> } persons: { total: number + source_presence_summary: Record<'employee_and_register' | 'register_only', number> + department_bucket_summary: Record<'formal' | 'fallback' | 'other', number> + special_exclusion_summary: Record< + 'business_support_blank_job_number' | 'longchen_brand_center_register_only' | 'brand_center_register_only', + number + > sample: Array<{ sourceUserId: string nickname: string realName: string departmentName: string | null + sourceDepartmentName: string | null + sourceMainDepartmentName: string | null + sourceJobNumber?: string | null + resolvedDepartmentName: string + departmentSyncBucket: 'formal' | 'fallback' | 'other' + sourcePresence: 'employee_and_register' | 'register_only' position: string | null sourceMainDepartmentId: number | null sourceDeptIds: string | null @@ -299,6 +608,16 @@ export interface HrSyncResult { updated: number deactivated: number } + equipment_department_migration?: { + migrated: number + unresolved: number + samples: Array<{ + equipment_id: number + equipment_code: string + equipment_name: string + legacy_department_name: string | null + }> + } sync_job_id?: number changes_count?: number } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 11fab6a..eb131b4 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,106 +1,79 @@ @@ -121,17 +94,6 @@ const password = ref('') const errorText = ref('') const siteStore = useSiteStore() -const keycloakBaseUrl = import.meta.env.VITE_KEYCLOAK_URL ?? '' - -const keycloakHostname = computed(() => { - if (!keycloakBaseUrl) return '认证中心' - try { - return new URL(keycloakBaseUrl).host - } catch { - return keycloakBaseUrl - } -}) - const authMode = (import.meta.env as any).VITE_AUTH_MODE || 'keycloak' const isLocalAuth = computed(() => authMode === 'local') const siteName = computed(() => siteStore.siteName || '固定资产管理系统') diff --git a/frontend/src/views/borrow/BorrowList.vue b/frontend/src/views/borrow/BorrowList.vue index 90fc55e..3d80283 100644 --- a/frontend/src/views/borrow/BorrowList.vue +++ b/frontend/src/views/borrow/BorrowList.vue @@ -5,11 +5,11 @@

Usage Flow

借用与归还管理

-
+
扫码出库 扫码入库
@@ -35,27 +35,29 @@
- - - - +
+ + + + +
@@ -179,28 +181,28 @@ -
+

状态快照

-
+

已借出

-

{{ formatNumber(pageSummary.borrowed) }}

+

{{ formatNumber(pageSummary.borrowed) }}

待归还

已归还

-

{{ formatNumber(pageSummary.returned) }}

+

{{ formatNumber(pageSummary.returned) }}

已完成

长期借用

-

{{ formatNumber(pageSummary.longTerm) }}

+

{{ formatNumber(pageSummary.longTerm) }}

在职长期
diff --git a/frontend/src/views/borrow/BorrowScan.vue b/frontend/src/views/borrow/BorrowScan.vue index 7cf3ca3..0b5d9b1 100644 --- a/frontend/src/views/borrow/BorrowScan.vue +++ b/frontend/src/views/borrow/BorrowScan.vue @@ -10,17 +10,17 @@
-
+

Mobile First

-

先扫码,再确认出库

+

扫码出库

@@ -42,22 +42,22 @@
-

手动输入 / 扫码枪补录

+

手动输入

-
+
-
@@ -106,38 +106,34 @@
-
-

已挂载配件(将随主资产一起借出)

-
+
+

已挂载配件({{ mountedChildren.length }} 件)

+
-
- {{ child.equipment_name }} - - {{ child.equipment_code }} · ¥{{ child.unit_price }} - -
+ {{ child.equipment_name }} + {{ child.equipment_code }}
-

- 配件总价值:¥{{ mountedChildrenValue.toFixed(2) }} +

+ 配件价值:¥{{ mountedChildrenValue.toFixed(0) }}

-
-

- 请核对资产信息无误后再进行出库操作。 +

+

+ 核对无误后再出库

-
+
@@ -208,13 +204,13 @@ 当前没有匹配到使用者,请换个关键词再试。

-

- 未找到需要的人员?前往 +

+ 没有对应人员?前往 - 系统设置 > 使用者信息 + 人员维护 新增。

@@ -260,8 +256,8 @@ class="input" :disabled="borrowForm.is_long_term" /> -

- 选择"在职长期"后将不设置具体归还日期。 +

+ 勾选后不设具体归还日期

diff --git a/frontend/src/views/borrow/ReturnScan.vue b/frontend/src/views/borrow/ReturnScan.vue index 117bbcc..dc77470 100644 --- a/frontend/src/views/borrow/ReturnScan.vue +++ b/frontend/src/views/borrow/ReturnScan.vue @@ -10,17 +10,17 @@
-
+

Return Flow

-

先扫码,再确认归还人

+

扫码归还

@@ -58,22 +58,22 @@
-

手动输入 / 扫码枪补录

+

手动输入

-
+
-
@@ -157,9 +157,9 @@

- 提示:该资产有多位使用者同时借出,请选择正确的归还人。 + 该资产有多位使用者,请选择正确归还人。

@@ -200,17 +200,17 @@
-
-

配件对比

+
+

配件对比

-
+

借出时配件:

-
+
@@ -229,11 +229,11 @@

当前配件:

-
+
@@ -250,12 +250,12 @@
-
-

+

+

- 配件与借出时不一致,请确认配件变更情况 + 配件有差异,请核对

@@ -267,8 +267,8 @@ diff --git a/frontend/src/views/dashboard/Dashboard.vue b/frontend/src/views/dashboard/Dashboard.vue index 1bc0fc5..1cf90ee 100644 --- a/frontend/src/views/dashboard/Dashboard.vue +++ b/frontend/src/views/dashboard/Dashboard.vue @@ -20,7 +20,7 @@

Overview

资产运行总览

-
+
扫码出库 扫码入库 资产管理 @@ -47,7 +47,7 @@
-
+
@@ -63,7 +63,7 @@
-
+
@@ -71,7 +71,7 @@ 实时
-
+

可用

{{ formatNumber(stats?.available_equipment || 0) }}

@@ -108,7 +108,7 @@
{{ formatNumber(item.total_quantity) }}
-
+
暂无数据
@@ -203,38 +203,38 @@
-

运营概览

-
+

运营概览

+

总资产价值

-

+

{{ formatCurrencyNoFraction(stats?.total_value || 0) }}

可用资产

-

+

{{ formatNumber(stats?.available_equipment || 0) }}

-

+

价值 {{ formatCurrencyNoFraction(stats?.available_value || 0) }}

借出资产

-

+

{{ formatNumber(stats?.borrowed_equipment || 0) }} 件

-

+

价值 {{ formatCurrencyNoFraction(stats?.borrowed_value || 0) }}

维护中

-

+

{{ formatNumber(stats?.maintenance_equipment || 0) }} 件

-

+

价值 {{ formatCurrencyNoFraction(stats?.maintenance_value || 0) }}

diff --git a/frontend/src/views/equipment/EquipmentForm.vue b/frontend/src/views/equipment/EquipmentForm.vue index e417e9a..95038ae 100644 --- a/frontend/src/views/equipment/EquipmentForm.vue +++ b/frontend/src/views/equipment/EquipmentForm.vue @@ -9,7 +9,7 @@
- +
diff --git a/frontend/src/views/equipment/EquipmentList.vue b/frontend/src/views/equipment/EquipmentList.vue index 006aa66..b9e3b7c 100644 --- a/frontend/src/views/equipment/EquipmentList.vue +++ b/frontend/src/views/equipment/EquipmentList.vue @@ -5,7 +5,7 @@

Asset Control

资产管理中心

-
+
@@ -40,7 +40,7 @@ v-model="filters.keyword" type="text" class="input w-full pl-9" - placeholder="搜索资产编码 / 名称 / 型号" + placeholder="搜索资产编码 / 名称 / 型号 / 使用人 / 工号" @input="handleSearch" />
@@ -56,14 +56,11 @@ -
- -
-
- +
-
+
-
-