import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { AppModule } from '../src/app.module'; describe('API e2e', () => { let app: INestApplication; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix('api/v1'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); await app.init(); }); afterAll(async () => { await app.close(); }); it('liveness', async () => { const res = await request(app.getHttpServer()).get('/api/v1/health/live'); expect(res.status).toBe(200); expect(res.body.code).toBe(0); expect(res.body.data.status).toBe('ok'); }); it('readiness', async () => { const res = await request(app.getHttpServer()).get('/api/v1/health/ready'); expect(res.status).toBe(200); expect(res.body.data.checks.postgres).toBe('up'); }); it('login and menu', async () => { const login = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ username: 'admin', password: 'Admin@123456', agreeTerms: true }); expect(login.status).toBe(201); expect(login.body.data.accessToken).toBeDefined(); const token = login.body.data.accessToken; const menus = await request(app.getHttpServer()) .get('/api/v1/system/menus') .set('Authorization', `Bearer ${token}`); expect(menus.status).toBe(200); expect(menus.body.data.length).toBeGreaterThan(5); }); it('rejects bad password', async () => { const res = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ username: 'admin', password: 'wrong-password', agreeTerms: true }); expect(res.status).toBe(401); expect(res.body.code).toBe(401); }); it('rejects login without agreeing to terms', async () => { const res = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ username: 'admin', password: 'Admin@123456' }); expect(res.status).toBe(400); expect(String(res.body.message || '')).toContain('用户协议'); }); it('serves user agreement and privacy policy', async () => { const agreement = await request(app.getHttpServer()).get('/api/v1/legal/user-agreement'); expect(agreement.status).toBe(200); expect(agreement.body.data.title).toBe('用户协议'); expect(agreement.body.data.paragraphs.length).toBeGreaterThan(3); const privacy = await request(app.getHttpServer()).get('/api/v1/legal/privacy'); expect(privacy.status).toBe(200); expect(privacy.body.data.title).toBe('隐私政策'); }); async function login(username: string, password: string) { const res = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ username, password, agreeTerms: true }); expect(res.status).toBe(201); return res.body.data.accessToken as string; } it('runs screening to final review, and hides terminated from employees', async () => { const owner = await login('owner', 'Demo@123456'); const directors = await request(app.getHttpServer()) .get('/api/v1/directors') .set('Authorization', `Bearer ${owner}`); expect(directors.status).toBe(200); const dirsoft = (directors.body.data as { id: string; name: string }[]).find( (d) => d.name === '吴软件', ); const dir3d = (directors.body.data as { id: string; name: string }[]).find( (d) => d.name === '赵三维', ); expect(dirsoft && dir3d).toBeTruthy(); const created = await request(app.getHttpServer()) .post('/api/v1/bid-cases') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段1主链演示', externalNo: 'E2E-P1-001', projectType: '集成', source: '招标公告', qualificationNeed: '涉乙', tenderMethod: '公开招标', applyMethod: '网上申领', openMethod: '线下开标', openPlace: '交易中心', reviewerIds: [dirsoft!.id, dir3d!.id], }); expect(created.status).toBe(201); const bidId = created.body.data.id as string; expect(created.body.data.status).toBe('TECH_INITIAL'); const softToken = await login('dirsoft', 'Demo@123456'); const first = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED', comment: '软件可做' }); expect(first.status).toBe(201); expect(first.body.data.status).toBe('TECH_INITIAL'); const d3Token = await login('dir3d', 'Demo@123456'); const second = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${d3Token}`) .send({ result: 'APPROVED', comment: '三维可做' }); expect(second.status).toBe(201); expect(second.body.data.status).toBe('FETCH'); const biz = await login('bizstaff', 'Demo@123456'); const fetched = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/fetch`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\bids\\E2E-P1-001', openPlace: '交易中心三楼' }); expect(fetched.status).toBe(201); expect(fetched.body.data.status).toBe('TECH_FINAL'); const final1 = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED' }); expect(final1.body.data.status).toBe('TECH_FINAL'); const final2 = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${d3Token}`) .send({ result: 'APPROVED' }); expect(final2.status).toBe(201); expect(final2.body.data.status).toBe('DRAFTING'); const drafting = await request(app.getHttpServer()) .get('/api/v1/bid-cases?bucket=drafting') .set('Authorization', `Bearer ${owner}`); expect(drafting.body.data.items.some((i: { id: string }) => i.id === bidId)).toBe(true); const rejectCreate = await request(app.getHttpServer()) .post('/api/v1/bid-cases') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段1驳回演示', externalNo: 'E2E-P1-002', projectType: '软件', source: '招标公告', qualificationNeed: '无需资质', tenderMethod: '公开招标', reviewerIds: [dirsoft!.id], }); const rejectId = rejectCreate.body.data.id as string; const rejected = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${rejectId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'REJECTED', comment: '工期不够' }); expect(rejected.body.data.status).toBe('TERMINATED'); const employee = await login('employee', 'Demo@123456'); const hidden = await request(app.getHttpServer()) .get('/api/v1/bid-cases?bucket=terminated') .set('Authorization', `Bearer ${employee}`); expect(hidden.status).toBe(403); const visible = await request(app.getHttpServer()) .get('/api/v1/bid-cases?bucket=terminated') .set('Authorization', `Bearer ${owner}`); expect(visible.status).toBe(200); expect(visible.body.data.items.some((i: { id: string }) => i.id === rejectId)).toBe(true); }, 60000); it('runs drafting to open result, and bounces rejected drafts back', async () => { const owner = await login('owner', 'Demo@123456'); const bizdir = await login('bizdir', 'Demo@123456'); const biz = await login('bizstaff', 'Demo@123456'); const directors = await request(app.getHttpServer()) .get('/api/v1/directors') .set('Authorization', `Bearer ${owner}`); const dirsoft = (directors.body.data as { id: string; name: string }[]).find( (d) => d.name === '吴软件', ); expect(dirsoft).toBeTruthy(); const staff = await request(app.getHttpServer()) .get('/api/v1/staff') .set('Authorization', `Bearer ${owner}`); const bizUser = (staff.body.data as { id: string; username: string }[]).find( (s) => s.username === 'bizstaff', ); const employee = (staff.body.data as { id: string; username: string }[]).find( (s) => s.username === 'employee', ); expect(bizUser && employee).toBeTruthy(); const created = await request(app.getHttpServer()) .post('/api/v1/bid-cases') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段2主链演示', externalNo: 'E2E-P2-001', projectType: '软件', source: '招标公告', qualificationNeed: '无需资质', tenderMethod: '公开招标', reviewerIds: [dirsoft!.id], }); const bidId = created.body.data.id as string; const softToken = await login('dirsoft', 'Demo@123456'); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/fetch`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\p2' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED' }); const assigned = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/assign-drafter`) .set('Authorization', `Bearer ${bizdir}`) .send({ drafterId: bizUser!.id, review1Id: bizUser!.id }); expect(assigned.body.data.status).toBe('DRAFTING'); const submitted = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/submit-draft`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\p2\\v1', note: '初稿' }); expect(submitted.body.data.status).toBe('REVIEW1'); const bounced = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${biz}`) .send({ result: 'REJECTED', comment: '封面格式不对' }); expect(bounced.body.data.status).toBe('DRAFTING'); const resubmit = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/submit-draft`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\p2\\v2', note: '已改封面' }); expect(resubmit.body.data.status).toBe('REVIEW1'); expect(resubmit.body.data.versions).toHaveLength(2); const r1 = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${biz}`) .send({ result: 'APPROVED' }); expect(r1.body.data.status).toBe('REVIEW2'); const r2 = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${bizdir}`) .send({ result: 'APPROVED' }); expect(r2.body.data.status).toBe('PRINT'); const printed = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/print`) .set('Authorization', `Bearer ${biz}`) .send({ amount: 800, note: '印标店' }); expect(printed.body.data.status).toBe('LIST'); expect(printed.body.data.expenses.length).toBeGreaterThan(0); const completed = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/complete`) .set('Authorization', `Bearer ${biz}`) .send({ bidderIds: [employee!.id], bondMethod: '电汇', bondAmount: 50000, quoteAmount: '1280000', }); expect(completed.body.data.status).toBe('PENDING'); const empToken = await login('employee', 'Demo@123456'); const opened = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/open-result`) .set('Authorization', `Bearer ${empToken}`) .send({ result: 'LOST', costDisposition: 'MARKETING', comment: '第二名' }); expect(opened.status).toBe(201); expect(opened.body.data.status).toBe('LOST'); expect(opened.body.data.costDisposition).toBe('MARKETING'); const resultList = await request(app.getHttpServer()) .get('/api/v1/bid-cases?bucket=result') .set('Authorization', `Bearer ${owner}`); expect(resultList.body.data.items.some((i: { id: string }) => i.id === bidId)).toBe(true); }, 60000); it('converts a won bid to contract, rejects then approves into a project', async () => { const owner = await login('owner', 'Demo@123456'); const bizdir = await login('bizdir', 'Demo@123456'); const biz = await login('bizstaff', 'Demo@123456'); const empToken = await login('employee', 'Demo@123456'); const directors = await request(app.getHttpServer()) .get('/api/v1/directors') .set('Authorization', `Bearer ${owner}`); const dirsoft = (directors.body.data as { id: string; name: string }[]).find( (d) => d.name === '吴软件', ); const staff = await request(app.getHttpServer()) .get('/api/v1/staff') .set('Authorization', `Bearer ${owner}`); const bizUser = (staff.body.data as { id: string; username: string }[]).find( (s) => s.username === 'bizstaff', ); const employee = (staff.body.data as { id: string; username: string }[]).find( (s) => s.username === 'employee', ); expect(dirsoft && bizUser && employee).toBeTruthy(); const created = await request(app.getHttpServer()) .post('/api/v1/bid-cases') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段3主链演示', externalNo: 'E2E-P3-001', projectType: '软件', source: '招标公告', qualificationNeed: '无需资质', tenderMethod: '公开招标', reviewerIds: [dirsoft!.id], }); const bidId = created.body.data.id as string; const softToken = await login('dirsoft', 'Demo@123456'); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/fetch`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\p3' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/assign-drafter`) .set('Authorization', `Bearer ${bizdir}`) .send({ drafterId: bizUser!.id, review1Id: bizUser!.id }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/submit-draft`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\p3\\v1' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${biz}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${bizdir}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/print`) .set('Authorization', `Bearer ${biz}`) .send({ amount: 800, note: '印标店' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/complete`) .set('Authorization', `Bearer ${biz}`) .send({ bidderIds: [employee!.id], bondMethod: '电汇', quoteAmount: '1280000' }); const opened = await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/open-result`) .set('Authorization', `Bearer ${empToken}`) .send({ result: 'WON', comment: '第一名' }); expect(opened.status).toBe(201); expect(opened.body.data.status).toBe('WON'); expect(opened.body.data.contracts).toHaveLength(1); expect(opened.body.data.contracts[0].status).toBe('DRAFT'); expect(opened.body.data.contracts[0].projects).toHaveLength(1); const contractId = opened.body.data.contracts[0].id as string; const asBiz = await request(app.getHttpServer()) .get(`/api/v1/bid-cases/${bidId}`) .set('Authorization', `Bearer ${biz}`); expect(asBiz.body.data.myActions.canConvertToContract).toBe(false); const forbidden = await request(app.getHttpServer()) .post('/api/v1/contracts') .set('Authorization', `Bearer ${empToken}`) .send({ bidCaseId: bidId, amount: 1280000 }); expect(forbidden.status).toBe(403); const drafted = await request(app.getHttpServer()) .post('/api/v1/contracts') .set('Authorization', `Bearer ${biz}`) .send({ bidCaseId: bidId, amount: 1280000 }); expect(drafted.status).toBe(400); await request(app.getHttpServer()) .patch(`/api/v1/contracts/${contractId}`) .set('Authorization', `Bearer ${biz}`) .send({ contractNo: `HT-E2E-${bidId.slice(0, 8)}` }); const noTerms = await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/submit`) .set('Authorization', `Bearer ${biz}`); expect(noTerms.status).toBe(400); const miles = await request(app.getHttpServer()) .put(`/api/v1/contracts/${contractId}/milestones`) .set('Authorization', `Bearer ${biz}`) .send({ items: [ { name: '预付款', ratio: 30 }, { name: '验收款', ratio: 70 }, ], }); expect(miles.status).toBe(200); const sealed = await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/seal`) .set('Authorization', `Bearer ${biz}`) .send({ sealType: '合同章', reason: '签订中标合同' }); expect(sealed.status).toBe(201); const submitted = await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/submit`) .set('Authorization', `Bearer ${biz}`); expect(submitted.status).toBe(201); expect(submitted.body.data.status).toBe('PENDING'); const rejected = await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/reviews`) .set('Authorization', `Bearer ${owner}`) .send({ result: 'REJECTED', comment: '收款节点要改' }); expect(rejected.status).toBe(201); expect(rejected.body.data.status).toBe('DRAFT'); expect(rejected.body.data.projects).toHaveLength(1); await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/submit`) .set('Authorization', `Bearer ${biz}`); const ownerOk = await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/reviews`) .set('Authorization', `Bearer ${owner}`) .send({ result: 'APPROVED' }); expect(ownerOk.body.data.status).toBe('PENDING'); expect(ownerOk.body.data.projects).toHaveLength(1); const dirOk = await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/reviews`) .set('Authorization', `Bearer ${bizdir}`) .send({ result: 'APPROVED' }); expect(dirOk.status).toBe(201); expect(dirOk.body.data.status).toBe('APPROVED'); expect(dirOk.body.data.projects).toHaveLength(1); expect(dirOk.body.data.projects[0].projectNo).toMatch(/^PRJ-\d{4}-\d{5}$/); expect(Number(dirOk.body.data.projects[0].presalesCost)).toBe(800); expect(dirOk.body.data.sealRequests[0].status).toBe('APPROVED'); const bid = await request(app.getHttpServer()) .get(`/api/v1/bid-cases/${bidId}`) .set('Authorization', `Bearer ${owner}`); expect(bid.body.data.expenses.every((e: { projectId: string | null }) => e.projectId)).toBe(true); const projects = await request(app.getHttpServer()) .get('/api/v1/projects') .set('Authorization', `Bearer ${owner}`); expect(projects.body.data.items.some((p: { contractId: string }) => p.contractId === contractId)).toBe( true, ); }, 60000); it('requires cost object, offsets loans, and flags overdue bonds', async () => { const owner = await login('owner', 'Demo@123456'); const biz = await login('bizstaff', 'Demo@123456'); const finance = await login('finance', 'Demo@123456'); const emp = await login('employee', 'Demo@123456'); const directors = await request(app.getHttpServer()) .get('/api/v1/directors') .set('Authorization', `Bearer ${owner}`); const dirsoft = (directors.body.data as { id: string; name: string }[]).find((d) => d.name === '吴软件'); const created = await request(app.getHttpServer()) .post('/api/v1/bid-cases') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段4差旅借款', externalNo: 'E2E-P4-001', projectType: '软件', source: '招标公告', qualificationNeed: '无需资质', tenderMethod: '公开招标', reviewerIds: [dirsoft!.id], }); const bidId = created.body.data.id as string; const badCost = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ amount: 800, costType: 'OTHER' }); expect(badCost.status).toBe(400); const noBid = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ amount: 800, costType: 'BID' }); expect(noBid.status).toBe(400); const loan = await request(app.getHttpServer()) .post('/api/v1/loans') .set('Authorization', `Bearer ${biz}`) .send({ costType: 'BID', bidCaseId: bidId, amount: 2000, purpose: '取标差旅' }); expect(loan.status).toBe(201); const loanId = loan.body.data.id as string; await request(app.getHttpServer()) .post(`/api/v1/loans/${loanId}/submit`) .set('Authorization', `Bearer ${biz}`); const loanOk = await request(app.getHttpServer()) .post(`/api/v1/loans/${loanId}/reviews`) .set('Authorization', `Bearer ${finance}`) .send({ result: 'APPROVED' }); expect(loanOk.body.data.status).toBe('OPEN'); const dup = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ costType: 'BID', bidCaseId: bidId, amount: 800 }); expect(dup.status).toBe(400); const exp = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ costType: 'BID', bidCaseId: bidId, amount: 800, loanId }); expect(exp.status).toBe(201); const expId = exp.body.data.id as string; await request(app.getHttpServer()) .post(`/api/v1/expenses/${expId}/submit`) .set('Authorization', `Bearer ${biz}`); const bounced = await request(app.getHttpServer()) .post(`/api/v1/expenses/${expId}/reviews`) .set('Authorization', `Bearer ${finance}`) .send({ result: 'REJECTED', comment: '发票不齐' }); expect(bounced.body.data.status).toBe('REJECTED'); const stillOpen = await request(app.getHttpServer()) .get(`/api/v1/loans/${loanId}`) .set('Authorization', `Bearer ${biz}`); expect(stillOpen.body.data.status).toBe('OPEN'); const categorized = await request(app.getHttpServer()) .patch(`/api/v1/expenses/${expId}`) .set('Authorization', `Bearer ${biz}`) .send({ costType: 'BID', bidCaseId: bidId, amount: 800, loanId, expenseCategory: '交通' }); expect(categorized.status).toBe(200); expect(categorized.body.data.expenseCategory).toBe('交通'); const uploaded = await request(app.getHttpServer()) .post('/api/v1/files') .set('Authorization', `Bearer ${biz}`) .attach('file', Buffer.from('%PDF-1.4 invoice'), { filename: 'fapiao.pdf', contentType: 'application/pdf', }) .field('bizType', 'EXPENSE') .field('bizId', expId); expect(uploaded.status).toBe(201); expect(uploaded.body.data.fileName).toBe('fapiao.pdf'); const withFile = await request(app.getHttpServer()) .get(`/api/v1/expenses/${expId}`) .set('Authorization', `Bearer ${biz}`); expect(withFile.body.data.files).toHaveLength(1); await request(app.getHttpServer()) .post(`/api/v1/expenses/${expId}/submit`) .set('Authorization', `Bearer ${biz}`); const expOk = await request(app.getHttpServer()) .post(`/api/v1/expenses/${expId}/reviews`) .set('Authorization', `Bearer ${finance}`) .send({ result: 'APPROVED' }); expect(expOk.body.data.status).toBe('APPROVED'); const offset = await request(app.getHttpServer()) .get(`/api/v1/loans/${loanId}`) .set('Authorization', `Bearer ${biz}`); expect(offset.body.data.status).toBe('OPEN'); expect(Number(offset.body.data.remaining)).toBe(1200); const rest = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ costType: 'BID', bidCaseId: bidId, amount: 1200, loanId }); const restId = rest.body.data.id as string; await request(app.getHttpServer()) .post(`/api/v1/expenses/${restId}/submit`) .set('Authorization', `Bearer ${biz}`); await request(app.getHttpServer()) .post(`/api/v1/expenses/${restId}/reviews`) .set('Authorization', `Bearer ${finance}`) .send({ result: 'APPROVED' }); const done = await request(app.getHttpServer()) .get(`/api/v1/loans/${loanId}`) .set('Authorization', `Bearer ${biz}`); expect(done.body.data.status).toBe('OFFSET'); expect(Number(done.body.data.remaining)).toBe(0); const noBond = await request(app.getHttpServer()) .post('/api/v1/bonds') .set('Authorization', `Bearer ${finance}`) .send({ bondType: 'TENDER', amount: 50000 }); expect(noBond.status).toBe(400); const empBond = await request(app.getHttpServer()) .get('/api/v1/bonds') .set('Authorization', `Bearer ${emp}`); expect(empBond.status).toBe(403); const bond = await request(app.getHttpServer()) .post('/api/v1/bonds') .set('Authorization', `Bearer ${finance}`) .send({ bondType: 'TENDER', amount: 50000, bidCaseId: bidId }); expect(bond.status).toBe(201); const yesterday = new Date(Date.now() - 86400000).toISOString(); await request(app.getHttpServer()) .post(`/api/v1/bonds/${bond.body.data.id}/pay`) .set('Authorization', `Bearer ${finance}`) .send({ dueBackAt: yesterday }); const overdue = await request(app.getHttpServer()) .get('/api/v1/bonds?bucket=overdue') .set('Authorization', `Bearer ${finance}`); expect(overdue.body.data.items.some((b: { id: string }) => b.id === bond.body.data.id)).toBe(true); }, 60000); it('invoices a contract milestone then records receipt', async () => { const owner = await login('owner', 'Demo@123456'); const finance = await login('finance', 'Demo@123456'); const list = await request(app.getHttpServer()) .get('/api/v1/contracts') .set('Authorization', `Bearer ${owner}`); const contract = (list.body.data.items as { id: string; status: string; milestones: { id: string }[]; }[]).find((c) => c.status === 'APPROVED' && c.milestones?.length); expect(contract).toBeTruthy(); const mileId = contract!.milestones[0].id; const created = await request(app.getHttpServer()) .post('/api/v1/invoices') .set('Authorization', `Bearer ${finance}`) .send({ contractId: contract!.id, milestoneId: mileId }); expect(created.status).toBe(201); expect(created.body.data.status).toBe('TO_ISSUE'); expect(Number(created.body.data.amount)).toBeGreaterThan(0); const dup = await request(app.getHttpServer()) .post('/api/v1/invoices') .set('Authorization', `Bearer ${finance}`) .send({ contractId: contract!.id, milestoneId: mileId }); expect(dup.status).toBe(400); const issued = await request(app.getHttpServer()) .post(`/api/v1/invoices/${created.body.data.id}/issue`) .set('Authorization', `Bearer ${finance}`) .send({ invoiceNo: 'INV-P4-001' }); expect(issued.body.data.status).toBe('ISSUED'); const received = await request(app.getHttpServer()) .post(`/api/v1/invoices/${created.body.data.id}/receive`) .set('Authorization', `Bearer ${finance}`); expect(received.body.data.status).toBe('RECEIVED'); }, 30000); it('breaks down WBS, fills timesheets, blocks over-budget expenses, and invoices on acceptance', async () => { const owner = await login('owner', 'Demo@123456'); const bizdir = await login('bizdir', 'Demo@123456'); const biz = await login('bizstaff', 'Demo@123456'); const empToken = await login('employee', 'Demo@123456'); const hr = await login('hr', 'Demo@123456'); const directors = await request(app.getHttpServer()) .get('/api/v1/directors') .set('Authorization', `Bearer ${owner}`); const dirsoft = (directors.body.data as { id: string; name: string }[]).find((d) => d.name === '吴软件'); const staff = await request(app.getHttpServer()) .get('/api/v1/staff') .set('Authorization', `Bearer ${owner}`); const bizUser = (staff.body.data as { id: string; username: string }[]).find((s) => s.username === 'bizstaff'); const employee = (staff.body.data as { id: string; username: string }[]).find((s) => s.username === 'employee'); expect(dirsoft && bizUser && employee).toBeTruthy(); const created = await request(app.getHttpServer()) .post('/api/v1/bid-cases') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段5主链演示', externalNo: 'E2E-P5-001', projectType: '软件', source: '招标公告', qualificationNeed: '无需资质', tenderMethod: '公开招标', reviewerIds: [dirsoft!.id], }); const bidId = created.body.data.id as string; const softToken = await login('dirsoft', 'Demo@123456'); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/fetch`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\p5' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${softToken}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/assign-drafter`) .set('Authorization', `Bearer ${bizdir}`) .send({ drafterId: bizUser!.id, review1Id: bizUser!.id }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/submit-draft`) .set('Authorization', `Bearer ${biz}`) .send({ nasPath: '\\\\nas\\p5\\v1' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${biz}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/reviews`) .set('Authorization', `Bearer ${bizdir}`) .send({ result: 'APPROVED' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/print`) .set('Authorization', `Bearer ${biz}`) .send({ amount: 600, note: '印标' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/complete`) .set('Authorization', `Bearer ${biz}`) .send({ bidderIds: [employee!.id], bondMethod: '电汇', quoteAmount: '880000' }); await request(app.getHttpServer()) .post(`/api/v1/bid-cases/${bidId}/open-result`) .set('Authorization', `Bearer ${empToken}`) .send({ result: 'WON' }); const opened = await request(app.getHttpServer()) .get(`/api/v1/bid-cases/${bidId}`) .set('Authorization', `Bearer ${biz}`); const contractId = opened.body.data.contracts[0].id as string; await request(app.getHttpServer()) .patch(`/api/v1/contracts/${contractId}`) .set('Authorization', `Bearer ${biz}`) .send({ contractNo: `HT-E2E5-${bidId.slice(0, 8)}` }); await request(app.getHttpServer()) .put(`/api/v1/contracts/${contractId}/milestones`) .set('Authorization', `Bearer ${biz}`) .send({ items: [ { name: '预付款', ratio: 30 }, { name: '验收款', ratio: 70 }, ], }); await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/seal`) .set('Authorization', `Bearer ${biz}`) .send({ sealType: '合同章', reason: '阶段5合同' }); await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/submit`) .set('Authorization', `Bearer ${biz}`); await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/reviews`) .set('Authorization', `Bearer ${owner}`) .send({ result: 'APPROVED' }); const approved = await request(app.getHttpServer()) .post(`/api/v1/contracts/${contractId}/reviews`) .set('Authorization', `Bearer ${bizdir}`) .send({ result: 'APPROVED' }); expect(approved.body.data.projects).toHaveLength(1); const projectId = approved.body.data.projects[0].id as string; const hidden = await request(app.getHttpServer()) .get('/api/v1/projects') .set('Authorization', `Bearer ${hr}`); expect(hidden.status).toBe(403); const depts = await request(app.getHttpServer()) .get('/api/v1/departments') .set('Authorization', `Bearer ${softToken}`); const software = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_SOFTWARE'); const d3 = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_3D'); expect(software && d3).toBeTruthy(); const t1 = await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/tasks`) .set('Authorization', `Bearer ${softToken}`) .send({ name: '软件开发', departmentId: software!.id, assigneeId: employee!.id }); expect(t1.status).toBe(201); const t2 = await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/tasks`) .set('Authorization', `Bearer ${softToken}`) .send({ name: '三维制作', departmentId: d3!.id }); expect(t2.status).toBe(201); const tasks = await request(app.getHttpServer()) .get('/api/v1/project-tasks') .set('Authorization', `Bearer ${owner}`); expect(tasks.body.data.filter((t: { projectId: string }) => t.projectId === projectId)).toHaveLength(2); const budgets = await request(app.getHttpServer()) .put(`/api/v1/projects/${projectId}/budgets`) .set('Authorization', `Bearer ${owner}`) .send({ items: [ { category: 'LABOR', amount: 1000 }, { category: 'TRAVEL', amount: 500 }, { category: 'PURCHASE', amount: 8000 }, ], }); expect(budgets.status).toBe(200); expect(Number(budgets.body.data.hourRate)).toBe(200); const sheet = await request(app.getHttpServer()) .post('/api/v1/timesheets') .set('Authorization', `Bearer ${empToken}`) .send({ projectId, taskId: t1.body.data.id, workDate: '2026-08-29', hours: 2, note: '联调', }); expect(sheet.status).toBe(201); expect(Number(sheet.body.data.costAmount)).toBe(400); const overLabor = await request(app.getHttpServer()) .post('/api/v1/timesheets') .set('Authorization', `Bearer ${empToken}`) .send({ projectId, workDate: '2026-08-30', hours: 4 }); expect(overLabor.status).toBe(400); const noCat = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ costType: 'PROJECT', projectId, amount: 100 }); expect(noCat.status).toBe(400); const travel = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ costType: 'PROJECT', projectId, amount: 400, budgetCategory: 'TRAVEL', remark: '现场' }); expect(travel.status).toBe(201); const overTravel = await request(app.getHttpServer()) .post('/api/v1/expenses') .set('Authorization', `Bearer ${biz}`) .send({ costType: 'PROJECT', projectId, amount: 200, budgetCategory: 'TRAVEL' }); expect(overTravel.status).toBe(400); const tooSoon = await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/accept`) .set('Authorization', `Bearer ${owner}`) .send({ kind: 'CUSTOMER', result: 'APPROVED' }); expect(tooSoon.status).toBe(400); const internal = await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/accept`) .set('Authorization', `Bearer ${softToken}`) .send({ kind: 'INTERNAL', result: 'APPROVED' }); expect(internal.status).toBe(201); expect(internal.body.data.status).toBe('INTERNAL_ACCEPTED'); const customer = await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/accept`) .set('Authorization', `Bearer ${owner}`) .send({ kind: 'CUSTOMER', result: 'APPROVED' }); expect(customer.status).toBe(201); expect(customer.body.data.status).toBe('ACCEPTED'); expect(customer.body.data.invoicesCreated).toBe(2); expect( customer.body.data.contract.milestones.every((m: { invoice: { status: string } | null }) => m.invoice?.status), ).toBe(true); const empProjects = await request(app.getHttpServer()) .get('/api/v1/projects') .set('Authorization', `Bearer ${empToken}`); expect(empProjects.status).toBe(200); expect(empProjects.body.data.items.some((p: { id: string }) => p.id === projectId)).toBe(true); }, 60000); it('warns expiring qualifications, overdue borrows and take-out seals, and lists calendar dates', async () => { const owner = await login('owner', 'Demo@123456'); const biz = await login('bizstaff', 'Demo@123456'); const emp = await login('employee', 'Demo@123456'); const hr = await login('hr', 'Demo@123456'); const forbidden = await request(app.getHttpServer()) .get('/api/v1/qualifications') .set('Authorization', `Bearer ${hr}`); expect(forbidden.status).toBe(403); const yesterday = new Date(Date.now() - 86400000).toISOString(); const expired = await request(app.getHttpServer()) .post('/api/v1/qualifications') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段6过期资质', code: 'QUAL_P6_OLD', expiresAt: yesterday, keeper: '行政' }); expect(expired.status).toBe(201); expect(expired.body.data.bucket).toBe('expired'); const expiringList = await request(app.getHttpServer()) .get('/api/v1/qualifications?bucket=expired') .set('Authorization', `Bearer ${owner}`); expect(expiringList.body.data.items.some((q: { id: string }) => q.id === expired.body.data.id)).toBe(true); const staff = await request(app.getHttpServer()) .get('/api/v1/staff') .set('Authorization', `Bearer ${owner}`); const employee = (staff.body.data as { id: string; username: string }[]).find((s) => s.username === 'employee'); expect(employee).toBeTruthy(); const borrow = await request(app.getHttpServer()) .post('/api/v1/credential-borrows') .set('Authorization', `Bearer ${biz}`) .send({ itemName: '涉乙证书原件', borrowerId: employee!.id, dueAt: yesterday, }); expect(borrow.status).toBe(201); expect(borrow.body.data.bucket).toBe('overdue'); const overdueBorrows = await request(app.getHttpServer()) .get('/api/v1/credential-borrows?bucket=overdue') .set('Authorization', `Bearer ${owner}`); expect(overdueBorrows.body.data.items.some((b: { id: string }) => b.id === borrow.body.data.id)).toBe(true); const directors = await request(app.getHttpServer()) .get('/api/v1/directors') .set('Authorization', `Bearer ${owner}`); const dirsoft = (directors.body.data as { id: string; name: string }[]).find((d) => d.name === '吴软件'); const bid = await request(app.getHttpServer()) .post('/api/v1/bid-cases') .set('Authorization', `Bearer ${owner}`) .send({ name: '阶段6开标日程', externalNo: 'E2E-P6-001', projectType: '软件', source: '招标公告', qualificationNeed: '无需资质', tenderMethod: '公开招标', openAt: new Date(Date.now() + 3 * 86400000).toISOString(), openPlace: '交易中心', reviewerIds: [dirsoft!.id], }); expect(bid.status).toBe(201); const bidId = bid.body.data.id as string; const noDue = await request(app.getHttpServer()) .post('/api/v1/seal-requests') .set('Authorization', `Bearer ${biz}`) .send({ sealType: '公章', reason: '开标带章', bidCaseId: bidId, takeOut: true }); expect(noDue.status).toBe(400); const seal = await request(app.getHttpServer()) .post('/api/v1/seal-requests') .set('Authorization', `Bearer ${biz}`) .send({ sealType: '公章', reason: '开标带章', bidCaseId: bidId, takeOut: true, returnAt: yesterday, }); expect(seal.status).toBe(201); expect(seal.body.data.status).toBe('PENDING'); const sealId = seal.body.data.id as string; const approved = await request(app.getHttpServer()) .post(`/api/v1/seal-requests/${sealId}/reviews`) .set('Authorization', `Bearer ${owner}`) .send({ result: 'APPROVED' }); expect(approved.status).toBe(201); expect(approved.body.data.status).toBe('OUT'); expect(approved.body.data.bucket).toBe('overdue'); const overview = await request(app.getHttpServer()) .get('/api/v1/office/overview') .set('Authorization', `Bearer ${owner}`); expect(overview.body.data.expiringQualifications).toBeGreaterThan(0); expect(overview.body.data.overdueBorrows).toBeGreaterThan(0); expect(overview.body.data.overdueSeals).toBeGreaterThan(0); expect(overview.body.data.upcomingOpens).toBeGreaterThan(0); const calendar = await request(app.getHttpServer()) .get('/api/v1/office/calendar') .set('Authorization', `Bearer ${owner}`); expect(calendar.body.data.some((i: { kind: string }) => i.kind === 'BORROW' && i.overdue)).toBe(true); expect(calendar.body.data.some((i: { kind: string }) => i.kind === 'SEAL' && i.overdue)).toBe(true); expect(calendar.body.data.some((i: { kind: string }) => i.kind === 'BID_OPEN')).toBe(false); const returnedBorrow = await request(app.getHttpServer()) .post(`/api/v1/credential-borrows/${borrow.body.data.id}/return`) .set('Authorization', `Bearer ${biz}`); expect(returnedBorrow.body.data.bucket).toBe('returned'); const returnedSeal = await request(app.getHttpServer()) .post(`/api/v1/seal-requests/${sealId}/return`) .set('Authorization', `Bearer ${biz}`); expect(returnedSeal.body.data.status).toBe('RETURNED'); const empCal = await request(app.getHttpServer()) .get('/api/v1/office/calendar') .set('Authorization', `Bearer ${emp}`); expect(empCal.status).toBe(200); expect(empCal.body.data.some((i: { kind: string }) => i.kind === 'BID_OPEN')).toBe(false); }, 30000); it('maintains hire-transfer-leave, imports attendance, payroll without housing fund, and lets directors change performance ratio', async () => { const hr = await login('hr', 'Demo@123456'); const empToken = await login('employee', 'Demo@123456'); const soft = await login('dirsoft', 'Demo@123456'); const film = await login('dirfilm', 'Demo@123456'); const suffix = String(Date.now()).slice(-8); const payDate = new Date(2026, 8, 1); payDate.setMonth(payDate.getMonth() + (Number(suffix) % 30)); const payMonth = `${payDate.getFullYear()}-${String(payDate.getMonth() + 1).padStart(2, '0')}`; const forbiddenDept = await request(app.getHttpServer()) .post('/api/v1/departments') .set('Authorization', `Bearer ${empToken}`) .send({ name: '不可建', code: `NOPE_${suffix}`, deptType: 'TECH' }); expect(forbiddenDept.status).toBe(403); const forbiddenPay = await request(app.getHttpServer()) .get('/api/v1/payroll?yearMonth=2026-07') .set('Authorization', `Bearer ${empToken}`); expect(forbiddenPay.status).toBe(403); const depts = await request(app.getHttpServer()) .get('/api/v1/departments') .set('Authorization', `Bearer ${hr}`); expect(Array.isArray(depts.body.data)).toBe(true); const software = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_SOFTWARE'); const filmDept = (depts.body.data as { id: string; code: string }[]).find((d) => d.code === 'TECH_FILM'); expect(software && filmDept).toBeTruthy(); const createdDept = await request(app.getHttpServer()) .post('/api/v1/departments') .set('Authorization', `Bearer ${hr}`) .send({ name: '阶段7演示组', code: `TECH_P7_${suffix}`, deptType: 'TECH', parentId: software!.id, categoryCode: 'CAT_SOFTWARE', }); expect(createdDept.status).toBe(201); expect(createdDept.body.data.code).toBe(`TECH_P7_${suffix}`); const roster = await request(app.getHttpServer()) .get('/api/v1/employees') .set('Authorization', `Bearer ${hr}`); const dirEmp = (roster.body.data.items as { id: string; employeeNo: string }[]).find((e) => e.employeeNo === 'E023'); expect(dirEmp).toBeTruthy(); const hired = await request(app.getHttpServer()) .post('/api/v1/employees') .set('Authorization', `Bearer ${hr}`) .send({ name: '阶段7实习生', employeeNo: `E7${suffix}`, departmentId: software!.id, title: '实习工程师', restSchedule: 'SINGLE', employmentStatus: 'INTERN', hiredAt: '2026-07-16', managerId: dirEmp!.id, baseSalary: 10000, perfBase: 3000, socialBase: 10000, }); expect(hired.status).toBe(201); expect(hired.body.data.employmentStatus).toBe('INTERN'); const employeeId = hired.body.data.id as string; const intern = await request(app.getHttpServer()) .post('/api/v1/employment-events') .set('Authorization', `Bearer ${hr}`) .send({ employeeId, kind: 'PROBATION', effectiveAt: '2026-07-20', comment: '转试用' }); expect(intern.status).toBe(201); expect(intern.body.data.employmentStatus).toBe('PROBATION'); const regular = await request(app.getHttpServer()) .post('/api/v1/employment-events') .set('Authorization', `Bearer ${hr}`) .send({ employeeId, kind: 'REGULARIZE', effectiveAt: '2026-07-25', comment: '转正' }); expect(regular.status).toBe(201); expect(regular.body.data.employmentStatus).toBe('REGULAR'); const att = await request(app.getHttpServer()) .post('/api/v1/attendance') .set('Authorization', `Bearer ${hr}`) .send({ employeeId, yearMonth: payMonth, workDays: 12, leaveDays: 1, overtimeHours: 8, remark: '加班转调休', }); expect(att.status).toBe(201); expect(att.body.data.overtimeHours).toBe(8); expect(att.body.data.compLeaveHours).toBe(8); const run = await request(app.getHttpServer()) .post('/api/v1/payroll/run') .set('Authorization', `Bearer ${hr}`) .send({ yearMonth: payMonth }); expect(run.status).toBe(201); const item = ( run.body.data.items as { id: string; employeeId: string; housingFund: number; tax: number; socialPersonal: number; leaveDeduct: number; grossPay: number; performancePay: number; overtimeHours: number; netPay: number; }[] ).find((i) => i.employeeId === employeeId); expect(item).toBeTruthy(); expect(item!.housingFund).toBe(0); expect(item!.socialPersonal).toBeGreaterThan(0); expect(item!.tax).toBeGreaterThan(0); expect(item!.leaveDeduct).toBeGreaterThan(0); expect(item!.overtimeHours).toBe(8); expect(item!.grossPay).toBeGreaterThan(item!.netPay); const beforePerf = item!.performancePay; const beforeGross = item!.grossPay; const otherDept = await request(app.getHttpServer()) .patch(`/api/v1/payroll-items/${item!.id}`) .set('Authorization', `Bearer ${film}`) .send({ performanceRatio: 50 }); expect(otherDept.status).toBe(403); const adjusted = await request(app.getHttpServer()) .patch(`/api/v1/payroll-items/${item!.id}`) .set('Authorization', `Bearer ${soft}`) .send({ performanceRatio: 80 }); expect(adjusted.status).toBe(200); expect(adjusted.body.data.performanceRatio).toBe(80); expect(adjusted.body.data.performancePay).toBeLessThan(beforePerf); expect(adjusted.body.data.grossPay).toBeLessThan(beforeGross); expect(adjusted.body.data.housingFund).toBe(0); const transferred = await request(app.getHttpServer()) .post('/api/v1/employment-events') .set('Authorization', `Bearer ${hr}`) .send({ employeeId, kind: 'TRANSFER', toDepartmentId: filmDept!.id, effectiveAt: '2026-07-28', comment: '支援摄制', }); expect(transferred.status).toBe(201); expect(transferred.body.data.departmentId).toBe(filmDept!.id); const left = await request(app.getHttpServer()) .post('/api/v1/employment-events') .set('Authorization', `Bearer ${hr}`) .send({ employeeId, kind: 'LEAVE', effectiveAt: '2026-07-31', comment: '交接完成' }); expect(left.status).toBe(201); expect(left.body.data.employmentStatus).toBe('LEFT'); const confirmed = await request(app.getHttpServer()) .post(`/api/v1/payroll/${run.body.data.run.id}/confirm`) .set('Authorization', `Bearer ${hr}`); expect(confirmed.status).toBe(201); expect(confirmed.body.data.status).toBe('CONFIRMED'); const reopened = await request(app.getHttpServer()) .patch(`/api/v1/payroll-items/${item!.id}`) .set('Authorization', `Bearer ${soft}`) .send({ performanceRatio: 60 }); expect(reopened.status).toBe(200); expect(reopened.body.data.performanceRatio).toBe(60); const after = await request(app.getHttpServer()) .get('/api/v1/payroll?yearMonth=2026-07') .set('Authorization', `Bearer ${hr}`); expect(after.status).toBe(200); expect(after.body.data.run.status).toBe('DRAFT'); }, 30000); it('shows funnel and spend, writes logs, and fills remaining ledgers', async () => { const fail = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ username: 'admin', password: 'wrong-password', agreeTerms: true }); expect(fail.status).toBe(401); const employee = await login('employee', 'Demo@123456'); const denied = await request(app.getHttpServer()) .get('/api/v1/reports/bid-conversion') .set('Authorization', `Bearer ${employee}`); expect(denied.status).toBe(403); const report = await request(app.getHttpServer()) .post('/api/v1/office/reports') .set('Authorization', `Bearer ${employee}`) .send({ periodType: 'DAY', date: '2026-08-29', content: '完成接口联调,明天对方案。', submit: true }); expect(report.status).toBe(201); expect(report.body.data.periodType).toBe('DAY'); expect(report.body.data.content).toContain('接口联调'); expect(report.body.data.status).toBe('SENT'); expect(report.body.data.toUser.displayName).toBe('软件项目经理'); expect(report.body.data.ccUser.displayName).toBe('吴软件'); const viewed = await request(app.getHttpServer()) .get(`/api/v1/office/reports/${report.body.data.id}`) .set('Authorization', `Bearer ${employee}`); expect(viewed.status).toBe(200); expect(viewed.body.data.content).toContain('明天对方案'); const owner = await login('owner', 'Demo@123456'); const funnel = await request(app.getHttpServer()) .get('/api/v1/reports/bid-conversion') .set('Authorization', `Bearer ${owner}`); expect(funnel.status).toBe(200); expect(funnel.body.data[0].status).toBe('筛选'); expect(funnel.body.data.some((r: { id: string; presalesSpend: number }) => r.id === 'won')).toBe(true); expect(typeof funnel.body.data[0].presalesSpend).toBe('number'); const profit = await request(app.getHttpServer()) .get('/api/v1/reports/project-profit') .set('Authorization', `Bearer ${owner}`); expect(profit.status).toBe(200); const depts = await request(app.getHttpServer()) .get('/api/v1/departments') .set('Authorization', `Bearer ${owner}`); expect(Array.isArray(depts.body.data)).toBe(true); const admin = await login('admin', 'Admin@123456'); const partyName = `E2E招标人-${Date.now()}`; const party = await request(app.getHttpServer()) .post('/api/v1/parties') .set('Authorization', `Bearer ${admin}`) .send({ name: partyName, creditNo: '91320000MA00E2E01X', level: 'A', contacts: [{ name: '李工', phone: '13900001111', title: '招标办' }], }); expect(party.status).toBe(201); expect(party.body.data.creditNo).toBe('91320000MA00E2E01X'); expect(party.body.data.contacts[0].name).toBe('李工'); const partyId = party.body.data.id as string; const partyPatched = await request(app.getHttpServer()) .patch(`/api/v1/parties/${partyId}`) .set('Authorization', `Bearer ${admin}`) .send({ name: partyName, creditNo: '91320000MA00E2E02X', level: 'B', contacts: party.body.data.contacts }); expect(partyPatched.body.data.creditNo).toBe('91320000MA00E2E02X'); const partyView = await request(app.getHttpServer()) .get(`/api/v1/parties/${partyId}`) .set('Authorization', `Bearer ${admin}`); expect(partyView.status).toBe(200); expect(partyView.body.data.contacts).toHaveLength(1); const logs = await request(app.getHttpServer()) .get('/api/v1/system/logs?pageSize=100') .set('Authorization', `Bearer ${admin}`); expect(logs.status).toBe(200); const actions = (logs.body.data.items as { action: string }[]).map((i) => i.action); expect(actions).toEqual(expect.arrayContaining(['LOGIN', 'LOGIN_FAIL', 'FORBIDDEN'])); const roles = await request(app.getHttpServer()) .get('/api/v1/system/roles') .set('Authorization', `Bearer ${admin}`); const employeeRole = (roles.body.data.items as { id: string; code: string }[]).find((r) => r.code === 'employee'); expect(employeeRole).toBeTruthy(); const scoped = await request(app.getHttpServer()) .patch(`/api/v1/system/roles/${employeeRole!.id}`) .set('Authorization', `Bearer ${admin}`) .send({ dataScope: 'DEPT' }); expect(scoped.status).toBe(200); expect(scoped.body.data.dataScope).toBe('DEPT'); const rules = await request(app.getHttpServer()) .get('/api/v1/system/number-rules') .set('Authorization', `Bearer ${admin}`); expect(rules.status).toBe(200); expect((rules.body.data as { code: string }[]).map((r) => r.code).sort()).toEqual(['BID', 'HT', 'PRJ']); const board = await request(app.getHttpServer()) .get('/api/v1/resource-board') .set('Authorization', `Bearer ${owner}`); expect(board.status).toBe(200); expect(board.body.data.items.length).toBeGreaterThan(0); expect(board.body.data.items[0].load).toMatch(/idle|full|over/); const seal = await request(app.getHttpServer()) .post('/api/v1/seals') .set('Authorization', `Bearer ${admin}`) .send({ name: '项目章', keeper: '行政' }); expect(seal.status).toBe(201); expect(seal.body.data.name).toBe('项目章'); const projects = await request(app.getHttpServer()) .get('/api/v1/projects?pageSize=50') .set('Authorization', `Bearer ${owner}`); const projectId = (projects.body.data.items as { id: string }[])[0]?.id; expect(projectId).toBeTruthy(); const purchase = await request(app.getHttpServer()) .post('/api/v1/purchase-requests') .set('Authorization', `Bearer ${owner}`) .send({ title: '渲染节点硬盘', amount: 80, projectId }); expect(purchase.status).toBe(201); expect(purchase.body.data.status).toBe('PENDING'); const change = await request(app.getHttpServer()) .post('/api/v1/project-changes') .set('Authorization', `Bearer ${owner}`) .send({ projectId, summary: '增加交底次数', amountDelta: 0 }); expect(change.status).toBe(201); expect(change.body.data.status).toBe('PENDING'); const camera = await request(app.getHttpServer()) .post('/api/v1/registered-assets') .set('Authorization', `Bearer ${owner}`) .send({ kind: 'FIXED', name: `摄像机 ${Date.now()}`, code: `CAM-${Date.now()}` }); expect(camera.status).toBe(201); const asset = await request(app.getHttpServer()) .post('/api/v1/assets') .set('Authorization', `Bearer ${owner}`) .send({ registeredAssetId: camera.body.data.id, projectId }); expect(asset.status).toBe(201); expect(asset.body.data.status).toBe('IN_USE'); expect(asset.body.data.registeredAssetId || asset.body.data.registeredAsset?.id).toBe(camera.body.data.id); const finance = await login('finance', 'Demo@123456'); const payment = await request(app.getHttpServer()) .post('/api/v1/payments') .set('Authorization', `Bearer ${finance}`) .send({ kind: 'EXPENSE', amount: 88, remark: '阶段8演示打款' }); expect(payment.status).toBe(201); expect(payment.body.data.status).toBe('TODO'); const paid = await request(app.getHttpServer()) .post(`/api/v1/payments/${payment.body.data.id}/pay`) .set('Authorization', `Bearer ${finance}`); expect(paid.status).toBe(201); expect(paid.body.data.status).toBe('PAID'); }, 60000); it('occupies registry assets and office applies land on purchase occupancy calendar', async () => { const owner = await login('owner', 'Demo@123456'); const employee = await login('employee', 'Demo@123456'); const stamp = Date.now(); const nameless = await request(app.getHttpServer()) .post('/api/v1/assets') .set('Authorization', `Bearer ${owner}`) .send({ assetName: '手填器材' }); expect(nameless.status).toBe(400); const laptop = await request(app.getHttpServer()) .post('/api/v1/registered-assets') .set('Authorization', `Bearer ${owner}`) .send({ kind: 'DIGITAL', name: `渲染账号 ${stamp}`, code: `DIG-${stamp}` }); expect(laptop.status).toBe(201); const useApply = await request(app.getHttpServer()) .post('/api/v1/office/applies') .set('Authorization', `Bearer ${employee}`) .send({ kind: 'ASSET_USE', assetKind: 'DIGITAL', registeredAssetId: laptop.body.data.id, reason: '项目渲染', endAt: '2026-09-15', }); expect(useApply.status).toBe(201); expect(useApply.body.data.status).toBe('PENDING'); const useOk = await request(app.getHttpServer()) .post(`/api/v1/office/applies/${useApply.body.data.id}/decide`) .set('Authorization', `Bearer ${owner}`) .send({ result: 'APPROVED' }); expect(useOk.status).toBe(201); expect(useOk.body.data.status).toBe('APPROVED'); expect(useOk.body.data.occupancy?.status).toBe('IN_USE'); const buy = await request(app.getHttpServer()) .post('/api/v1/office/applies') .set('Authorization', `Bearer ${employee}`) .send({ kind: 'ASSET_BUY', assetKind: 'FIXED', title: '摄影灯套装', amount: 1280, reason: '补充灯光', }); expect(buy.status).toBe(201); const buyOk = await request(app.getHttpServer()) .post(`/api/v1/office/applies/${buy.body.data.id}/decide`) .set('Authorization', `Bearer ${owner}`) .send({ result: 'APPROVED' }); expect(buyOk.status).toBe(201); expect(buyOk.body.data.purchase?.status).toBe('APPROVED'); const car = await request(app.getHttpServer()) .post('/api/v1/registered-assets') .set('Authorization', `Bearer ${owner}`) .send({ kind: 'FIXED', name: `商务车 ${stamp}`, code: `CAR-${stamp}` }); expect(car.status).toBe(201); const vehicle = await request(app.getHttpServer()) .post('/api/v1/office/applies') .set('Authorization', `Bearer ${employee}`) .send({ kind: 'VEHICLE', registeredAssetId: car.body.data.id, destination: '南京政务中心', startAt: '2026-09-01', endAt: '2026-09-02', reason: '送标', }); expect(vehicle.status).toBe(201); const vehicleOk = await request(app.getHttpServer()) .post(`/api/v1/office/applies/${vehicle.body.data.id}/decide`) .set('Authorization', `Bearer ${owner}`) .send({ result: 'APPROVED' }); expect(vehicleOk.status).toBe(201); expect(vehicleOk.body.data.occupancy?.status).toBe('IN_USE'); const cal = await request(app.getHttpServer()) .get('/api/v1/office/calendar') .set('Authorization', `Bearer ${employee}`); expect(cal.status).toBe(200); const kinds = (cal.body.data as { kind: string; title: string }[]).map((i) => i.kind); expect(kinds).toEqual(expect.arrayContaining(['VEHICLE'])); }, 30000); });