feat(P21PWA1-1,P21PWA1-2,P21PWA1-3): 资产搜索扩展/扫码适配器/IndexedDB离线队列与去重重放
这个提交包含在:
+46
@@ -13,6 +13,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -3473,6 +3474,41 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -7186,6 +7222,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const ASSET_LIST_QUERY_SCHEMA = z.object({
|
|||||||
assigneeId: UUID_SCHEMA.optional(),
|
assigneeId: UUID_SCHEMA.optional(),
|
||||||
legalEntityId: UUID_SCHEMA.optional(),
|
legalEntityId: UUID_SCHEMA.optional(),
|
||||||
costCenterId: UUID_SCHEMA.optional(),
|
costCenterId: UUID_SCHEMA.optional(),
|
||||||
|
q: z.string().trim().max(120).optional(),
|
||||||
mine: BOOLEAN_QUERY_SCHEMA,
|
mine: BOOLEAN_QUERY_SCHEMA,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,6 +201,7 @@ function parseAssetListQuery(query) {
|
|||||||
assigneeId: parsed.data.assigneeId,
|
assigneeId: parsed.data.assigneeId,
|
||||||
legalEntityId: parsed.data.legalEntityId,
|
legalEntityId: parsed.data.legalEntityId,
|
||||||
costCenterId: parsed.data.costCenterId,
|
costCenterId: parsed.data.costCenterId,
|
||||||
|
q: parsed.data.q?.trim() ?? "",
|
||||||
mine: parseBooleanQueryValue(parsed.data.mine),
|
mine: parseBooleanQueryValue(parsed.data.mine),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -848,6 +850,18 @@ async function assetsRoutes(fastify) {
|
|||||||
if (query.costCenterId) {
|
if (query.costCenterId) {
|
||||||
where.cost_center_id = 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) {
|
if (mine) {
|
||||||
where.assignee_employee_id = request.employee.id;
|
where.assignee_employee_id = request.employee.id;
|
||||||
} else if (query.assigneeId) {
|
} else if (query.assigneeId) {
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ const DEFAULT_DOCUMENT_CATEGORIES = [
|
|||||||
default_workflow_key: null,
|
default_workflow_key: null,
|
||||||
enabled: true,
|
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",
|
code: "ASSET_CARD_DEFAULT",
|
||||||
name: "固定资产卡片",
|
name: "固定资产卡片",
|
||||||
@@ -616,6 +626,8 @@ async function main() {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("- workflow definition: purchase_request v1 (1-step department_manager)");
|
console.log("- workflow definition: purchase_request v1 (1-step department_manager)");
|
||||||
// eslint-disable-next-line no-console
|
// 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`);
|
console.log(`- leave types: ${DEFAULT_LEAVE_TYPES.length} seeded with default policies`);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
|
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ function resolveAssetExportEndpoint(config) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTravelExportEndpoint(config) {
|
||||||
|
return String(config.erpSyncEndpoint ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
async function parseResponseBody(response) {
|
async function parseResponseBody(response) {
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
if (contentType.includes('application/json')) {
|
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) {
|
function buildErpPayload(input) {
|
||||||
return buildReimbursementJournalPayload(input);
|
return buildReimbursementJournalPayload(input);
|
||||||
}
|
}
|
||||||
@@ -358,6 +413,7 @@ module.exports = {
|
|||||||
exportAssetToErp,
|
exportAssetToErp,
|
||||||
exportJournalToErp,
|
exportJournalToErp,
|
||||||
exportProcurementToErp,
|
exportProcurementToErp,
|
||||||
|
exportTravelToErp,
|
||||||
normalizeProvider,
|
normalizeProvider,
|
||||||
pullMasterDataFromErp,
|
pullMasterDataFromErp,
|
||||||
pushPaidReimbursementToErp,
|
pushPaidReimbursementToErp,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户