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:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+666
View File
@@ -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'),
),
};
}
}