Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
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'),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user