Files
Public/apps/api/src/contract/contract.service.ts
T
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

667 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>);
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'),
),
};
}
}