Files
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

1451 lines
60 KiB
TypeScript

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);
});