diff --git a/client/package-lock.json b/client/package-lock.json index f97d5c6c..25c53d02 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.62.0", + "@zxing/browser": "^0.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "react": "^18.2.0", @@ -3473,6 +3474,41 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@zxing/browser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz", + "integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.21.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -7186,6 +7222,16 @@ "punycode": "^2.1.0" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/client/package.json b/client/package.json index 8b8f542b..887df397 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.62.0", + "@zxing/browser": "^0.1.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "react": "^18.2.0", diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 9154dda8..5de3bfc7 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -28,6 +28,7 @@ const ASSET_LIST_QUERY_SCHEMA = z.object({ assigneeId: UUID_SCHEMA.optional(), legalEntityId: UUID_SCHEMA.optional(), costCenterId: UUID_SCHEMA.optional(), + q: z.string().trim().max(120).optional(), mine: BOOLEAN_QUERY_SCHEMA, }); @@ -200,6 +201,7 @@ function parseAssetListQuery(query) { assigneeId: parsed.data.assigneeId, legalEntityId: parsed.data.legalEntityId, costCenterId: parsed.data.costCenterId, + q: parsed.data.q?.trim() ?? "", mine: parseBooleanQueryValue(parsed.data.mine), }; } @@ -848,6 +850,18 @@ async function assetsRoutes(fastify) { if (query.costCenterId) { where.cost_center_id = query.costCenterId; } + if (query.q) { + where.OR = [ + { asset_no: query.q }, + { serial_no: query.q }, + { + name: { + contains: query.q, + mode: "insensitive", + }, + }, + ]; + } if (mine) { where.assignee_employee_id = request.employee.id; } else if (query.assigneeId) { diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 57e040fa..ba100a1b 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -172,6 +172,16 @@ const DEFAULT_DOCUMENT_CATEGORIES = [ default_workflow_key: null, enabled: true, }, + { + code: "TR_RECON", + name: "差旅对账批次", + entity_type: "travel_reconciliation_batch", + sequence_prefix: "TRB", + number_length: 4, + reset_period: "yearly", + default_workflow_key: null, + enabled: true, + }, { code: "ASSET_CARD_DEFAULT", name: "固定资产卡片", @@ -616,6 +626,8 @@ async function main() { // eslint-disable-next-line no-console console.log("- workflow definition: purchase_request v1 (1-step department_manager)"); // eslint-disable-next-line no-console + console.log("- workflow definition: travel_request v1 (1-step department_manager)"); + // eslint-disable-next-line no-console console.log(`- leave types: ${DEFAULT_LEAVE_TYPES.length} seeded with default policies`); // eslint-disable-next-line no-console console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`); diff --git a/server/src/services/integrations/erp.js b/server/src/services/integrations/erp.js index 5b2f69c6..fdaece51 100644 --- a/server/src/services/integrations/erp.js +++ b/server/src/services/integrations/erp.js @@ -48,6 +48,10 @@ function resolveAssetExportEndpoint(config) { ); } +function resolveTravelExportEndpoint(config) { + return String(config.erpSyncEndpoint ?? '').trim(); +} + async function parseResponseBody(response) { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { @@ -345,6 +349,57 @@ async function exportProcurementToErp(config, payload) { }; } +async function exportTravelToErp(config, payload) { + if (!config.erpSyncEnabled) { + return { + ok: false, + code: 'erp_sync_disabled', + message: 'ERP sync disabled', + retryable: false, + }; + } + + const provider = normalizeProvider(config); + if (provider === 'mock') { + const identity = payload?.batchNo ?? payload?.entityId ?? 'UNKNOWN'; + return { + ok: true, + code: 'mock_synced', + message: 'mock ERP travel synced', + response: { + externalSummaryId: `ERP-TRAVEL-SUM-${identity}`, + externalSummaryNo: `ERP-TRAVEL-SUM-${identity}`, + externalBatchId: `ERP-TRAVEL-BATCH-${identity}`, + externalBatchNo: `ERP-TRAVEL-BATCH-${identity}`, + }, + }; + } + + if (provider === 'http') { + return postJsonToErp(config, { + endpoint: resolveTravelExportEndpoint(config), + timeoutMs: coerceTimeout(config.erpSyncTimeoutMs, config.erpSyncTimeoutMs), + payload, + successCode: 'http_synced', + successMessage: 'ERP travel synced', + missingEndpointCode: 'erp_travel_endpoint_missing', + missingEndpointMessage: 'ERP travel endpoint missing', + http4xxCode: 'erp_travel_http_4xx', + http5xxCode: 'erp_travel_http_5xx', + timeoutCode: 'erp_travel_timeout', + networkCode: 'erp_travel_network_error', + messagePrefix: 'ERP travel sync', + }); + } + + return { + ok: false, + code: 'erp_sync_provider_invalid', + message: `Unsupported ERP sync provider: ${provider}`, + retryable: false, + }; +} + function buildErpPayload(input) { return buildReimbursementJournalPayload(input); } @@ -358,6 +413,7 @@ module.exports = { exportAssetToErp, exportJournalToErp, exportProcurementToErp, + exportTravelToErp, normalizeProvider, pullMasterDataFromErp, pushPaidReimbursementToErp, diff --git a/server/tests/unit/routes/assets.search.test.js b/server/tests/unit/routes/assets.search.test.js new file mode 100644 index 00000000..61f3b5e4 --- /dev/null +++ b/server/tests/unit/routes/assets.search.test.js @@ -0,0 +1,181 @@ +const Fastify = require('fastify'); + +const { AppError } = require('../../../src/utils/AppError'); +const assetsRoutes = require('../../../src/routes/assets'); + +function buildAssetRecord({ + id = 'asset-1', + assetNo = 'ZC-001', + serialNo = 'SN-001', + assigneeEmployeeId = 'employee-1', + name = '测试笔记本', +} = {}) { + const now = new Date('2026-03-11T00:00:00.000Z'); + return { + id, + asset_no: assetNo, + name, + category_id: 'cat-1', + legal_entity_id: 'legal-1', + cost_center_id: 'cc-1', + purchase_request_id: null, + serial_no: serialNo, + location: '深圳办公室', + status: 'in_use', + assignee_employee_id: assigneeEmployeeId, + activated_at: now, + retired_at: null, + created_at: now, + updated_at: now, + category: null, + legal_entity: { + id: 'legal-1', + code: 'HY', + name: '海韵科技', + }, + cost_center: { + id: 'cc-1', + code: 'CC001', + name: '研发中心', + legal_entity_id: 'legal-1', + }, + purchase_request: null, + assignee: { + id: assigneeEmployeeId, + employee_no: 'E001', + name: '张三', + department: null, + }, + }; +} + +function buildPrisma({ items = [] } = {}) { + return { + assetCard: { + findMany: vi.fn().mockResolvedValue(items), + }, + }; +} + +async function buildApp({ employee, prisma }) { + const app = Fastify(); + + app.decorate('prisma', prisma); + app.decorate('config', {}); + app.decorate('authenticate', async (request) => { + if (!request.headers.authorization) { + throw new AppError({ + code: 'AUTH_001', + message: '未登录', + statusCode: 401, + }); + } + request.employee = employee; + }); + app.decorate('hasPermission', (actor, permissionCode) => { + return (actor?.permissionCodes ?? []).includes('*') || (actor?.permissionCodes ?? []).includes(permissionCode); + }); + app.decorate('requirePermission', (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ + code: 'PERM_001', + message: '无权限', + statusCode: 403, + details: { permission: permissionCode }, + }); + } + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: 'SYS_001', message: '服务器内部错误', details: {} }, + }); + }); + + await app.register(assetsRoutes, { prefix: '/api' }); + return app; +} + +describe('assets search route', () => { + it('adds q matcher for assetNo serialNo and name fallback', async () => { + const prisma = buildPrisma({ + items: [buildAssetRecord()], + }); + const app = await buildApp({ + employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] }, + prisma, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/assets?q=ZC-001', + headers: { authorization: 'Bearer token' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.items[0]).toEqual( + expect.objectContaining({ + assetNo: 'ZC-001', + serialNo: 'SN-001', + }), + ); + expect(prisma.assetCard.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { asset_no: 'ZC-001' }, + { serial_no: 'ZC-001' }, + { name: { contains: 'ZC-001', mode: 'insensitive' } }, + ], + }), + }), + ); + + await app.close(); + }); + + it('forces own-scope lookup even when caller passes mine=0', async () => { + const prisma = buildPrisma({ + items: [buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'ZC-002', serialNo: 'SN-002' })], + }); + const app = await buildApp({ + employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] }, + prisma, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/assets?q=SN-002&mine=0', + headers: { authorization: 'Bearer token' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.mine).toBe(true); + expect(prisma.assetCard.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + assignee_employee_id: 'employee-1', + OR: [ + { asset_no: 'SN-002' }, + { serial_no: 'SN-002' }, + { name: { contains: 'SN-002', mode: 'insensitive' } }, + ], + }), + }), + ); + + await app.close(); + }); +});