ASSET1 统一电子资产与固定资产台账
这个提交包含在:
@@ -207,4 +207,45 @@ describe("documentSequence service", () => {
|
||||
expect(config.documentCategoryCode).toBe("ASSET_CARD_DEFAULT");
|
||||
expect(config.prefix).toBe("ZC");
|
||||
});
|
||||
|
||||
it("can issue asset numbers with independent sequence keys per asset number family", async () => {
|
||||
const prisma = buildPrisma({
|
||||
sequenceMap: {
|
||||
"asset_card:ASSET_EA_DOC": {
|
||||
document_type: "asset_card:ASSET_EA_DOC",
|
||||
prefix: "EA-DOC",
|
||||
date_format: "YYYY",
|
||||
separator: "-",
|
||||
number_length: 6,
|
||||
reset_period: "yearly",
|
||||
current_number: 8,
|
||||
last_reset_date: null,
|
||||
updated_at: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updated_by: "tester",
|
||||
},
|
||||
},
|
||||
categoryMap: {
|
||||
ASSET_EA_DOC: {
|
||||
code: "ASSET_EA_DOC",
|
||||
entity_type: "asset_card",
|
||||
sequence_prefix: "EA-DOC",
|
||||
number_length: 6,
|
||||
reset_period: "yearly",
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const issued = await issueDocumentNumber(prisma, {
|
||||
documentType: "asset_card",
|
||||
documentCategoryCode: "ASSET_EA_DOC",
|
||||
sequenceKey: "asset_card:ASSET_EA_DOC",
|
||||
actorId: "tester",
|
||||
timeZone: "Asia/Shanghai",
|
||||
});
|
||||
|
||||
expect(issued.sequence.documentType).toBe("asset_card:ASSET_EA_DOC");
|
||||
expect(issued.documentNumber).toMatch(/^EA-DOC-\d{4}-000009$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,18 @@ const assetsRoutes = require('../../../src/routes/assets');
|
||||
|
||||
function buildAssetRecord({
|
||||
id = 'asset-1',
|
||||
assetNo = 'ZC-001',
|
||||
assetNo = 'FA-2026-000001',
|
||||
serialNo = 'SN-001',
|
||||
assetType = 'fixed_asset',
|
||||
sensitivityLevel = 'internal',
|
||||
assigneeEmployeeId = 'employee-1',
|
||||
ownerEmployeeId = null,
|
||||
keeperEmployeeId = null,
|
||||
archiveLocation = null,
|
||||
externalRef = null,
|
||||
sourceHash = null,
|
||||
confidentialityNote = null,
|
||||
metadata = null,
|
||||
name = '测试笔记本',
|
||||
} = {}) {
|
||||
const now = new Date('2026-03-11T00:00:00.000Z');
|
||||
@@ -15,6 +24,8 @@ function buildAssetRecord({
|
||||
id,
|
||||
asset_no: assetNo,
|
||||
name,
|
||||
asset_type: assetType,
|
||||
sensitivity_level: sensitivityLevel,
|
||||
category_id: 'cat-1',
|
||||
legal_entity_id: 'legal-1',
|
||||
cost_center_id: 'cc-1',
|
||||
@@ -23,6 +34,16 @@ function buildAssetRecord({
|
||||
location: '深圳办公室',
|
||||
status: 'in_use',
|
||||
assignee_employee_id: assigneeEmployeeId,
|
||||
owner_employee_id: ownerEmployeeId,
|
||||
keeper_employee_id: keeperEmployeeId,
|
||||
archive_location: archiveLocation,
|
||||
external_ref: externalRef,
|
||||
source_hash: sourceHash,
|
||||
verification_no: null,
|
||||
confidentiality_note: confidentialityNote,
|
||||
metadata,
|
||||
last_verified_at: null,
|
||||
expires_at: null,
|
||||
activated_at: now,
|
||||
retired_at: null,
|
||||
created_at: now,
|
||||
@@ -46,6 +67,8 @@ function buildAssetRecord({
|
||||
name: '张三',
|
||||
department: null,
|
||||
},
|
||||
owner: null,
|
||||
keeper: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,25 +132,25 @@ async function buildApp({ employee, prisma }) {
|
||||
}
|
||||
|
||||
describe('assets search route', () => {
|
||||
it('prefers exact assetNo matching before serial or fuzzy lookup', async () => {
|
||||
it('uses one scoped OR query for assetNo, serialNo, VER/hash/ref/location and name lookup', async () => {
|
||||
const prisma = buildPrisma({
|
||||
items: [buildAssetRecord()],
|
||||
});
|
||||
const app = await buildApp({
|
||||
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
|
||||
employee: { id: 'admin-1', permissionCodes: ['asset.manage'] },
|
||||
prisma,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/assets?q=ZC-001',
|
||||
url: '/api/assets?q=FA-2026-000001',
|
||||
headers: { authorization: 'Bearer token' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
assetNo: 'ZC-001',
|
||||
assetNo: 'FA-2026-000001',
|
||||
serialNo: 'SN-001',
|
||||
}),
|
||||
);
|
||||
@@ -135,100 +158,18 @@ describe('assets search route', () => {
|
||||
expect(prisma.assetCard.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
asset_no: 'ZC-001',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('falls back to serialNo exact lookup when assetNo misses', async () => {
|
||||
const prisma = {
|
||||
assetCard: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([buildAssetRecord({ id: 'asset-2', assetNo: 'ZC-003', serialNo: 'SN-003' })]),
|
||||
},
|
||||
};
|
||||
const app = await buildApp({
|
||||
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
|
||||
prisma,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/assets?q=SN-003',
|
||||
headers: { authorization: 'Bearer token' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
asset_no: 'SN-003',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
serial_no: 'SN-003',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('falls back to fuzzy name search only after both exact lookups miss', async () => {
|
||||
const prisma = {
|
||||
assetCard: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([buildAssetRecord({ id: 'asset-3', assetNo: 'ZC-004', serialNo: 'SN-004', name: '研发测试笔记本' })]),
|
||||
},
|
||||
};
|
||||
const app = await buildApp({
|
||||
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
|
||||
prisma,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/assets?q=测试笔记本',
|
||||
headers: { authorization: 'Bearer token' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(3);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
asset_no: '测试笔记本',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
serial_no: '测试笔记本',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
name: { contains: '测试笔记本', mode: 'insensitive' },
|
||||
AND: expect.arrayContaining([
|
||||
expect.objectContaining({}),
|
||||
expect.objectContaining({
|
||||
OR: expect.arrayContaining([
|
||||
{ asset_no: 'FA-2026-000001' },
|
||||
{ serial_no: 'FA-2026-000001' },
|
||||
{ verification_no: 'FA-2026-000001' },
|
||||
{ source_hash: 'FA-2026-000001' },
|
||||
{ name: { contains: 'FA-2026-000001', mode: 'insensitive' } },
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -237,14 +178,9 @@ describe('assets search route', () => {
|
||||
});
|
||||
|
||||
it('forces own-scope lookup even when caller passes mine=0', async () => {
|
||||
const prisma = {
|
||||
assetCard: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'ZC-002', serialNo: 'SN-002' })]),
|
||||
},
|
||||
};
|
||||
const prisma = buildPrisma({
|
||||
items: [buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'FA-2026-000002', serialNo: 'SN-002' })],
|
||||
});
|
||||
const app = await buildApp({
|
||||
employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] },
|
||||
prisma,
|
||||
@@ -258,26 +194,101 @@ describe('assets search route', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.mine).toBe(true);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
assignee_employee_id: 'employee-1',
|
||||
asset_no: 'SN-002',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
assignee_employee_id: 'employee-1',
|
||||
serial_no: 'SN-002',
|
||||
AND: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
OR: [
|
||||
{ assignee_employee_id: 'employee-1' },
|
||||
{ owner_employee_id: 'employee-1' },
|
||||
{ keeper_employee_id: 'employee-1' },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
OR: expect.arrayContaining([{ serial_no: 'SN-002' }]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('redacts sensitive electronic asset fields for asset.view_all callers that are not related employees', async () => {
|
||||
const prisma = buildPrisma({
|
||||
items: [
|
||||
buildAssetRecord({
|
||||
id: 'asset-3',
|
||||
assetNo: 'EA-DOC-2026-000001',
|
||||
assetType: 'electronic_document',
|
||||
sensitivityLevel: 'confidential',
|
||||
archiveLocation: '离线档案柜-A',
|
||||
externalRef: 'DOC-001',
|
||||
sourceHash: 'sha256:abcdef',
|
||||
confidentialityNote: '仅负责人可查看',
|
||||
metadata: { source: 'redacted_index' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const app = await buildApp({
|
||||
employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] },
|
||||
prisma,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/assets?q=EA-DOC-2026-000001',
|
||||
headers: { authorization: 'Bearer token' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
assetNo: 'EA-DOC-2026-000001',
|
||||
archiveLocation: null,
|
||||
externalRef: null,
|
||||
sourceHash: null,
|
||||
confidentialityNote: null,
|
||||
metadata: null,
|
||||
sensitiveFieldsRedacted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects redacted index import items that still contain full local paths', async () => {
|
||||
const prisma = buildPrisma();
|
||||
const app = await buildApp({
|
||||
employee: { id: 'admin-1', permissionCodes: ['asset.manage'] },
|
||||
prisma,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/assets/import/redacted-doc-index',
|
||||
headers: { authorization: 'Bearer token' },
|
||||
payload: {
|
||||
dryRun: true,
|
||||
items: [
|
||||
{
|
||||
name: '合同索引',
|
||||
archiveLocation: '/home/shunli/Projects/faculty-daily-assistant/公司/合同.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual(
|
||||
expect.objectContaining({
|
||||
'items.0.archiveLocation': 'local_absolute_path',
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户