import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import type { AuthUser } from '../common/decorators/current-user.decorator'; import { BIZ_TYPE } from '../bidding/bid-workflow'; import { SealService } from '../seal/seal.service'; import { CONTRACT_BIZ, CONTRACT_STATUS, canAccessContract, canEditContract, milestoneSumOk } from './contract-workflow'; import { nextBizNo } from '../system/numbering'; import { assertCanSeeOwner, ownerScope, withAnd } from '../common/data-scope'; import { BUDGET_CATEGORY, PROJECT_STATUS, splitEven } from '../project/project-workflow'; const USER_SELECT = { id: true, displayName: true, username: true }; const DETAIL_INCLUDE = { bidCase: { select: { id: true, bidNo: true, name: true, status: true, quoteAmount: true, party: { select: { name: true } } } }, legalEntity: true, createdBy: { select: USER_SELECT }, milestones: { include: { invoice: true }, orderBy: { dueAt: 'asc' as const } }, reviewers: { include: { user: { select: USER_SELECT } }, orderBy: { decidedAt: 'asc' as const }, }, actionLogs: { include: { actor: { select: USER_SELECT } }, orderBy: { createdAt: 'asc' as const }, }, sealRequests: { orderBy: { createdAt: 'desc' as const } }, invoices: true, projects: true, }; type Tx = Prisma.TransactionClient; export type CreateContractInput = { bidCaseId?: string; legalEntityId?: string; amount: number; name?: string; partyA?: string; partyB?: string; remark?: string; contractNo?: string; kind?: string; }; export type UpdateContractInput = { name?: string; amount?: number; partyA?: string; partyB?: string; remark?: string; nasPath?: string; contractNo?: string; }; function officialContractNo(raw?: string | null) { const n = (raw || '').trim(); if (!n || n === '待填' || n === '-' || n === '—') return ''; return n; } export type MilestoneInput = { name: string; ratio: number; dueAt?: string; trigger?: string; }; export type ReviewInput = { result: 'APPROVED' | 'REJECTED'; comment?: string; }; export type SealInput = { sealType: string; reason: string; takeOut?: boolean; returnAt?: string; }; @Injectable() export class ContractService { constructor( private readonly prisma: PrismaService, private readonly seals: SealService, ) {} assertAccess(user: AuthUser) { if (!canAccessContract(user.roles, user.permissions)) { throw new ForbiddenException('无权访问合同管理'); } } async list(user: AuthUser, page: number, pageSize: number, keyword?: string, bucket?: string) { this.assertAccess(user); const where: Prisma.ContractWhereInput = { tenantId: '1' }; if (bucket === 'tender' || bucket === 'bid' || bucket === 'approval') { where.OR = [{ kind: 'TENDER' }, { bidCaseId: { not: null } }]; } if (bucket === 'direct') { where.kind = 'DIRECT'; } if (bucket === 'incremental' || bucket === 'other') { where.kind = 'OTHER'; where.bidCaseId = null; } if (keyword) { where.AND = [ { OR: [ { name: { contains: keyword } }, { contractNo: { contains: keyword } }, { partyA: { contains: keyword } }, { partyB: { contains: keyword } }, ], }, ]; } const scoped = withAnd(where, (await ownerScope(this.prisma, user, 'createdById')) as Record); const [items, total] = await this.prisma.$transaction([ this.prisma.contract.findMany({ where: scoped, include: { bidCase: { select: { id: true, bidNo: true, externalNo: true, name: true, party: { select: { name: true } }, createdBy: { select: USER_SELECT }, }, }, legalEntity: true, createdBy: { select: USER_SELECT }, projects: { select: { id: true, projectNo: true, status: true } }, milestones: true, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.contract.count({ where: scoped }), ]); return { items, page, pageSize, total }; } async get(id: string, user: AuthUser) { this.assertAccess(user); const row = await this.prisma.contract.findFirst({ where: { id, tenantId: '1' }, include: DETAIL_INCLUDE, }); if (!row) throw new NotFoundException('合同不存在'); await assertCanSeeOwner(this.prisma, user, row.createdById); const files = await this.prisma.fileAsset.findMany({ where: { tenantId: '1', bizId: id, bizType: { startsWith: 'CONTRACT' } }, orderBy: { createdAt: 'asc' }, }); return { ...row, files, myActions: this.myActions(row, user) }; } async createIncremental(dto: CreateContractInput, user: AuthUser) { this.assertAccess(user); if (!canEditContract(user.roles)) { throw new ForbiddenException('无权起草合同'); } const name = dto.name?.trim(); if (!name) throw new BadRequestException('请填写合同名称'); if (!dto.amount || dto.amount <= 0) throw new BadRequestException('请填写合同金额'); const kind = dto.kind === 'TENDER' || dto.kind === 'DIRECT' ? dto.kind : 'OTHER'; const contract = await this.prisma.$transaction(async (tx) => { const entity = dto.legalEntityId ? await tx.legalEntity.findFirst({ where: { id: dto.legalEntityId, tenantId: '1' } }) : (await tx.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) || (await tx.legalEntity.findFirst({ where: { tenantId: '1' } })); if (dto.legalEntityId && !entity) throw new NotFoundException('我方主体不存在'); if (kind === 'TENDER' && dto.bidCaseId) { const bid = await tx.bidCase.findFirst({ where: { id: dto.bidCaseId, tenantId: '1' } }); if (!bid) throw new NotFoundException('投标事项不存在'); const existing = await tx.contract.findFirst({ where: { bidCaseId: bid.id, status: { in: [CONTRACT_STATUS.DRAFT, CONTRACT_STATUS.PENDING, CONTRACT_STATUS.APPROVED] }, }, }); if (existing) { throw new BadRequestException('该中标事项已自动生成合同草稿,请到合同管理完善后提交审批'); } } const contractNo = officialContractNo(dto.contractNo) || null; if (contractNo) { const clash = await tx.contract.findFirst({ where: { tenantId: '1', contractNo } }); if (clash) throw new BadRequestException('该合同编号已存在'); } const created = await tx.contract.create({ data: { tenantId: '1', contractNo, name, kind, bidCaseId: kind === 'TENDER' ? dto.bidCaseId || undefined : undefined, legalEntityId: entity?.id, amount: dto.amount, partyA: dto.partyA?.trim() || undefined, partyB: dto.partyB?.trim() || entity?.name || undefined, remark: dto.remark, status: CONTRACT_STATUS.DRAFT, createdById: user.id, }, }); const label = kind === 'TENDER' ? '投标合同' : kind === 'DIRECT' ? '直签合同' : '其他合同'; await this.log(tx, created.id, user.id, 'CREATE', undefined, CONTRACT_STATUS.DRAFT, `新建${label}`); return created; }); return this.get(contract.id, user); } async createFromBid(dto: CreateContractInput, user: AuthUser) { this.assertAccess(user); if (!canEditContract(user.roles)) { throw new ForbiddenException('无权起草合同'); } if (!dto.bidCaseId) throw new BadRequestException('请选择投标事项'); if (!dto.amount || dto.amount <= 0) { throw new BadRequestException('请填写合同金额'); } const contract = await this.prisma.$transaction(async (tx) => { const bid = await tx.bidCase.findFirst({ where: { id: dto.bidCaseId, tenantId: '1' }, include: { party: true, legalEntity: true }, }); if (!bid) throw new NotFoundException('投标事项不存在'); if (bid.status !== 'WON') { throw new BadRequestException('只有中标事项可以转合同'); } const existing = await tx.contract.findFirst({ where: { bidCaseId: bid.id, status: { in: [CONTRACT_STATUS.DRAFT, CONTRACT_STATUS.PENDING, CONTRACT_STATUS.APPROVED] }, }, }); if (existing) { throw new BadRequestException('该中标事项已自动生成合同草稿,请到合同管理完善后提交审批'); } const contractNo = officialContractNo(dto.contractNo) || null; if (contractNo) { const clash = await tx.contract.findFirst({ where: { tenantId: '1', contractNo } }); if (clash) throw new BadRequestException('该合同编号已存在'); } const created = await tx.contract.create({ data: { tenantId: '1', contractNo, name: dto.name?.trim() || bid.name, bidCaseId: bid.id, legalEntityId: bid.legalEntityId, amount: dto.amount, partyA: dto.partyA?.trim() || bid.party?.name || undefined, partyB: dto.partyB?.trim() || bid.legalEntity?.name || undefined, remark: dto.remark, kind: 'TENDER', status: CONTRACT_STATUS.DRAFT, createdById: user.id, }, }); await tx.todoTask.updateMany({ where: { status: 'OPEN', bizType: BIZ_TYPE.CONVERT_CONTRACT, bizId: { in: [bid.id, created.id] }, }, data: { status: 'DONE' }, }); await this.log(tx, created.id, user.id, 'CREATE', undefined, CONTRACT_STATUS.DRAFT, `由 ${bid.bidNo} 转合同`); return created; }); return this.get(contract.id, user); } async update(id: string, user: AuthUser, dto: UpdateContractInput) { this.assertAccess(user); if (!canEditContract(user.roles)) throw new ForbiddenException('无权修改合同'); const row = await this.require(id); if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { throw new BadRequestException('当前状态不能修改'); } const contractNo = dto.contractNo === undefined ? undefined : officialContractNo(dto.contractNo) || null; if (contractNo) { const clash = await this.prisma.contract.findFirst({ where: { tenantId: '1', contractNo, id: { not: id } }, }); if (clash) throw new BadRequestException('该合同编号已存在'); } await this.prisma.contract.update({ where: { id }, data: { name: dto.name?.trim() || undefined, amount: dto.amount, partyA: dto.partyA?.trim() || undefined, partyB: dto.partyB?.trim() || undefined, remark: dto.remark, nasPath: dto.nasPath, contractNo, status: CONTRACT_STATUS.DRAFT, }, }); await this.prisma.contractActionLog.create({ data: { contractId: id, actorId: user.id, action: 'UPDATE', fromStatus: row.status, toStatus: CONTRACT_STATUS.DRAFT, }, }); return this.get(id, user); } async replaceMilestones(id: string, user: AuthUser, items: MilestoneInput[]) { this.assertAccess(user); if (!canEditContract(user.roles)) throw new ForbiddenException('无权修改收款条款'); const row = await this.require(id); if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { throw new BadRequestException('当前状态不能改收款条款'); } if (!items?.length) throw new BadRequestException('请至少填写一条收款条款'); const ratios = items.map((i) => Number(i.ratio)); if (ratios.some((n) => !Number.isFinite(n) || n <= 0)) { throw new BadRequestException('比例必须大于 0'); } if (!milestoneSumOk(ratios)) { throw new BadRequestException('收款条款比例合计必须为 100%'); } await this.prisma.$transaction(async (tx) => { await tx.paymentMilestone.deleteMany({ where: { contractId: id } }); await tx.paymentMilestone.createMany({ data: items.map((i) => ({ contractId: id, name: i.name, ratio: i.ratio, dueAt: i.dueAt ? new Date(i.dueAt) : null, trigger: i.trigger || null, })), }); await tx.contract.update({ where: { id }, data: { status: CONTRACT_STATUS.DRAFT }, }); await this.log(tx, id, user.id, 'MILESTONES', row.status, CONTRACT_STATUS.DRAFT, `更新 ${items.length} 条收款条款`); }); return this.get(id, user); } async createSeal(id: string, user: AuthUser, dto: SealInput) { this.assertAccess(user); if (!canEditContract(user.roles)) throw new ForbiddenException('无权申请用章'); if (!dto.sealType?.trim() || !dto.reason?.trim()) { throw new BadRequestException('请填写印章类型和事由'); } const row = await this.require(id); if (row.status === CONTRACT_STATUS.APPROVED) { throw new BadRequestException('合同已通过,用章请走证照用章模块补登记'); } await this.prisma.sealRequest.create({ data: { tenantId: '1', sealType: dto.sealType.trim(), reason: dto.reason.trim(), contractId: row.id, bidCaseId: row.bidCaseId, applicantId: user.id, takeOut: Boolean(dto.takeOut), returnAt: dto.returnAt ? new Date(dto.returnAt) : null, status: 'PENDING', }, }); await this.prisma.contractActionLog.create({ data: { contractId: id, actorId: user.id, action: 'SEAL_REQUEST', comment: `${dto.sealType}:${dto.reason}`, }, }); return this.get(id, user); } async submit(id: string, user: AuthUser) { this.assertAccess(user); if (!canEditContract(user.roles)) throw new ForbiddenException('无权提交合同审批'); await this.prisma.$transaction(async (tx) => { const row = await tx.contract.findFirst({ where: { id, tenantId: '1' }, include: { milestones: true, sealRequests: true, reviewers: true }, }); if (!row) throw new NotFoundException('合同不存在'); if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { throw new BadRequestException('当前状态不能提交审批'); } if (Number(row.amount) <= 0) throw new BadRequestException('请填写合同金额'); if (!officialContractNo(row.contractNo)) { throw new BadRequestException('请先填写正式合同编号,不要用系统自动号'); } const ratios = row.milestones.map((m) => Number(m.ratio)); if (!ratios.length || !milestoneSumOk(ratios)) { throw new BadRequestException('请先保存合计为 100% 的收款条款'); } if (!row.sealRequests.length) { throw new BadRequestException('提交审批前请先申请用章,必须关联本合同'); } const approvers = await tx.user.findMany({ where: { tenantId: '1', status: 'ACTIVE', roles: { some: { role: { code: { in: ['owner', 'biz_director'] } } } }, }, }); const ids = [...new Set(approvers.map((u) => u.id))]; if (!ids.length) throw new BadRequestException('没有可审批的企业负责人或商务总监'); await tx.contract.update({ where: { id }, data: { status: CONTRACT_STATUS.PENDING, submittedAt: new Date() }, }); await tx.contractReviewer.deleteMany({ where: { contractId: id } }); await tx.contractReviewer.createMany({ data: ids.map((userId) => ({ contractId: id, userId })), }); await tx.approvalTask.createMany({ data: ids.map((assigneeId) => ({ tenantId: '1', title: `合同审批:${row.name}`, bizType: CONTRACT_BIZ.APPROVAL, bizId: id, assigneeId, })), }); await tx.todoTask.updateMany({ where: { status: 'OPEN', bizType: BIZ_TYPE.CONVERT_CONTRACT, bizId: { in: [id, row.bidCaseId].filter(Boolean) as string[] }, }, data: { status: 'DONE' }, }); await this.log(tx, id, user.id, 'SUBMIT', row.status, CONTRACT_STATUS.PENDING, '提交合同审批(含用章)'); }); return this.get(id, user); } async review(id: string, user: AuthUser, dto: ReviewInput) { this.assertAccess(user); if (dto.result === 'REJECTED' && !dto.comment?.trim()) { throw new BadRequestException('驳回请填写意见'); } await this.prisma.$transaction(async (tx) => { const row = await tx.contract.findFirst({ where: { id, tenantId: '1' }, include: { reviewers: true, bidCase: { include: { expenses: true, party: { select: { name: true } } } }, sealRequests: true }, }); if (!row) throw new NotFoundException('合同不存在'); if (row.status !== CONTRACT_STATUS.PENDING) { throw new BadRequestException('当前不是待审批状态'); } const mine = row.reviewers.find((r) => r.userId === user.id && r.result === 'PENDING'); if (!mine) throw new ForbiddenException('没有待办的合同审批'); await tx.contractReviewer.update({ where: { id: mine.id }, data: { result: dto.result, comment: dto.comment?.trim() || null, decidedAt: new Date(), }, }); await tx.approvalTask.updateMany({ where: { bizId: id, assigneeId: user.id, status: 'PENDING', bizType: CONTRACT_BIZ.APPROVAL, }, data: { status: dto.result === 'APPROVED' ? 'APPROVED' : 'REJECTED' }, }); if (dto.result === 'REJECTED') { await tx.contract.update({ where: { id }, data: { status: CONTRACT_STATUS.DRAFT }, }); await tx.approvalTask.updateMany({ where: { bizId: id, status: 'PENDING' }, data: { status: 'CANCELLED' }, }); await tx.contractReviewer.updateMany({ where: { contractId: id, result: 'PENDING' }, data: { result: 'CANCELLED' }, }); await this.log(tx, id, user.id, 'REJECT', CONTRACT_STATUS.PENDING, CONTRACT_STATUS.DRAFT, dto.comment!.trim()); return; } await this.log(tx, id, user.id, 'APPROVE', CONTRACT_STATUS.PENDING, undefined, dto.comment?.trim() || '同意'); const pending = await tx.contractReviewer.count({ where: { contractId: id, result: 'PENDING' }, }); if (pending > 0) return; await this.seals.applyContractApproved(tx, id); const presales = (row.bidCase?.expenses || []).reduce( (s, e) => s + Number(e.amount), 0, ); const existingProject = await tx.project.findFirst({ where: { contractId: row.id } }); const contractAmount = Number(row.amount || 0); let project = existingProject; if (existingProject) { const patch: Prisma.ProjectUpdateInput = { presalesCost: presales, partyName: row.partyA || row.bidCase?.party?.name || existingProject.partyName, }; if (contractAmount > 0) patch.amount = contractAmount; if (existingProject.status === PROJECT_STATUS.NOT_STARTED) { patch.status = PROJECT_STATUS.ACTIVE; } project = await tx.project.update({ where: { id: existingProject.id }, data: patch }); const budgetCount = await tx.projectBudget.count({ where: { projectId: existingProject.id } }); if (contractAmount > 0 && budgetCount === 0) { const split = splitEven(contractAmount); await tx.projectBudget.createMany({ data: [ { projectId: existingProject.id, category: BUDGET_CATEGORY.BUSINESS, amount: split.business }, { projectId: existingProject.id, category: BUDGET_CATEGORY.TECH, amount: split.tech }, ], }); } } else { const projectNo = await nextBizNo(tx, 'PRJ', (start) => tx.project.count({ where: { projectNo: { startsWith: start } } }), ); project = await tx.project.create({ data: { tenantId: '1', projectNo, name: row.name, contractId: row.id, partyName: row.partyA || row.bidCase?.party?.name || null, status: PROJECT_STATUS.ACTIVE, presalesCost: presales, }, }); if (contractAmount > 0) { const split = splitEven(contractAmount); await tx.projectBudget.createMany({ data: [ { projectId: project.id, category: BUDGET_CATEGORY.BUSINESS, amount: split.business }, { projectId: project.id, category: BUDGET_CATEGORY.TECH, amount: split.tech }, ], }); } } if (row.bidCaseId) { await tx.expenseClaim.updateMany({ where: { bidCaseId: row.bidCaseId }, data: { projectId: project.id }, }); } await tx.contract.update({ where: { id }, data: { status: CONTRACT_STATUS.APPROVED, approvedAt: new Date(), signedAt: new Date(), }, }); await this.log( tx, id, user.id, 'APPROVED', CONTRACT_STATUS.PENDING, CONTRACT_STATUS.APPROVED, existingProject ? `合同通过,项目 ${project.projectNo} 已在中标时立项,继承售前成本 ${presales}` : `立项 ${project.projectNo},继承售前成本 ${presales}`, ); }); return this.get(id, user); } async decideApproval(approvalId: string, user: AuthUser, dto: ReviewInput) { const task = await this.prisma.approvalTask.findFirst({ where: { id: approvalId, tenantId: '1' }, }); if (!task) throw new NotFoundException('审批任务不存在'); if (task.assigneeId !== user.id) throw new ForbiddenException('这不是你的审批'); if (task.status !== 'PENDING') throw new BadRequestException('该审批已办理'); if (task.bizType !== CONTRACT_BIZ.APPROVAL || !task.bizId) { throw new BadRequestException('不是合同审批'); } return this.review(task.bizId, user, dto); } async moveKind(id: string, user: AuthUser, kind: string) { this.assertAccess(user); if (!canEditContract(user.roles)) throw new ForbiddenException('无权调整合同分类'); const normalized = String(kind || '').trim().toUpperCase(); if (!['TENDER', 'DIRECT', 'OTHER'].includes(normalized)) { throw new BadRequestException('合同分类只能是投标合同、直签合同或其他合同'); } const row = await this.require(id); if (row.status !== CONTRACT_STATUS.DRAFT && row.status !== CONTRACT_STATUS.REJECTED) { throw new BadRequestException('仅草稿或已驳回合同可以调整分类'); } const updated = await this.prisma.contract.update({ where: { id }, data: { kind: normalized } }); return this.get(updated.id, user); } private async require(id: string) { const row = await this.prisma.contract.findFirst({ where: { id, tenantId: '1' } }); if (!row) throw new NotFoundException('合同不存在'); return row; } private async log( tx: Tx, contractId: string, actorId: string, action: string, fromStatus?: string, toStatus?: string, comment?: string, ) { await tx.contractActionLog.create({ data: { contractId, actorId, action, fromStatus, toStatus, comment }, }); } private myActions( row: { status: string; reviewers: { userId: string; result: string }[]; milestones: unknown[]; sealRequests: unknown[]; projects: unknown[]; }, user: AuthUser, ) { const editable = (row.status === CONTRACT_STATUS.DRAFT || row.status === CONTRACT_STATUS.REJECTED) && canEditContract(user.roles); return { canEdit: editable, canSubmit: editable, canRequestSeal: editable, canReview: Boolean( row.status === CONTRACT_STATUS.PENDING && row.reviewers.some((r) => r.userId === user.id && r.result === 'PENDING'), ), }; } }