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
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+47
View File
@@ -0,0 +1,47 @@
# 企业综合运营管理平台
内部运营系统。当前先交付 **Web 端**,手机端和桌面端后续用同一套 API 再做。
## 技术栈
- 后端:NestJS 11 + Prisma 6 + PostgreSQL 16
- 前端:React 18 + Vite 5 + Ant Design 5
- 依赖:Redis、MinIO(本地 Docker
## 目录
```
apps/api 后端
apps/web Web
infra/ 预留脚本
docs/ 架构说明
```
## 启动
```bash
cp .env.example .env
docker compose up -d
pnpm --filter api exec prisma migrate dev --name init
pnpm --filter api prisma:seed
pnpm --filter api start:dev
pnpm --filter web dev
```
- Webhttp://localhost:5173
- APIhttp://localhost:3000/api/v1
- Swaggerhttp://localhost:3000/api/v1/docs
演示账号:`admin` / `Admin@123456`
其他人:`owner` `bizdir` `bizstaff` `dirsoft` `dir3d` `dirfilm` `dirmaterial` `finance` `hr` `employee`,密码均为 `Demo@123456`
## 测试
```bash
pnpm --filter api test
pnpm --filter api test:e2e
```
## 范围
本期把十个一级模块和二级菜单全部铺到 Web 上,登录、权限、投标筛选可写。合同、工时、薪酬等以台账和空数据为主,后续按模块补流程。不含外包、线索、仓库,也不做原生 IM。
+12
View File
@@ -0,0 +1,12 @@
{
"platform": "android",
"version": "3.1.10",
"build": 324,
"url": "https://oa.fysxkj.com/fengying-oa.apk",
"windowsUrl": "https://oa.fysxkj.com/fengying-oa-setup.exe",
"linuxUrl": "https://oa.fysxkj.com/fengying-oa-linux.tar.gz",
"linuxDebUrl": "https://oa.fysxkj.com/fengying-oa_amd64.deb",
"changelog": "桌面端功能补全:语音播放/录制/转写、位置/转发/编辑、工作台模块路由、Linux deb 安装包(Ubuntu/Debian/Deepin25)、OTA 修复。",
"force": false,
"publishedAt": "2026-09-02T07:15:00.000Z"
}
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node --transpile-only prisma/seed.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
"@alicloud/openapi-client": "^0.4.15",
"@alicloud/openapi-core": "^1.0.8",
"@alicloud/tea-util": "^1.4.11",
"@darabonba/typescript": "^1.0.5",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.0",
"argon2": "^0.45.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.3.0",
"ioredis": "^6.0.0",
"livekit-server-sdk": "^2.18.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "6.19.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"pnpm": {
"onlyBuiltDependencies": [
"argon2",
"@prisma/client",
"prisma",
"@prisma/engines"
]
},
"prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts"
}
}
+7234
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import { parseOaJson } from './oa-json';
const prisma = new PrismaClient();
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function compactName(name: string) {
return name.replace(/[\s()()【】\[\]《》·,,。.\-_/\\]/g, '').toLowerCase();
}
function namesOverlap(a: string, b: string) {
const na = compactName(a);
const nb = compactName(b);
if (!na || !nb) return false;
if (na === nb) return true;
const shorter = na.length <= nb.length ? na : nb;
const longer = na.length <= nb.length ? nb : na;
return shorter.length >= 8 && longer.includes(shorter);
}
async function linkContracts() {
const bids = await prisma.bidCase.findMany({
select: { id: true, bidNo: true, externalNo: true, name: true },
});
const contracts = await prisma.contract.findMany({
where: { bidCaseId: null },
select: { id: true, name: true, contractNo: true },
});
let linked = 0;
for (const c of contracts) {
const no = c.contractNo || '';
const tb = no.match(/TB-\d+-\d+/);
const hits = bids.filter((b) => {
if (tb && (b.bidNo === tb[0] || b.bidNo.includes(tb[0]) || (b.externalNo && b.externalNo.includes(tb[0])))) {
return true;
}
if (no && (b.bidNo && no.includes(b.bidNo))) return true;
return namesOverlap(c.name, b.name);
});
const unique = [...new Map(hits.map((h) => [h.id, h])).values()];
if (unique.length !== 1) continue;
await prisma.contract.update({ where: { id: c.id }, data: { bidCaseId: unique[0].id } });
linked += 1;
}
return { scanned: contracts.length, linked };
}
async function backfillSealApplicants() {
const dumps = ['/opt/import/prod/oa_fresh.json', '/opt/import/prod/oa_core.json', '/opt/import/prod/oa_clean.json'];
const dump = dumps.find((p) => fs.existsSync(p));
if (!dump) return { updated: 0, skipped: 'no dump' };
const raw = parseOaJson<Record<string, unknown>>(dump);
const hrPath = '/opt/import/prod/oa_hr.json';
const hr = fs.existsSync(hrPath) ? parseOaJson<{ users?: Record<string, unknown>[] }>(hrPath) : { users: [] };
const users = await prisma.user.findMany();
const byUsername = new Map(users.map((u) => [u.username, u]));
const byName = new Map(users.map((u) => [u.displayName, u]));
const admin = users.find((u) => u.displayName === '系统管理员');
const oaUser = new Map((hr.users || []).map((u) => [String(u.id), str(u.userName || u.username)]));
const userFromOaId = (id: unknown) => {
const uname = oaUser.get(str(id));
return uname ? byUsername.get(uname) : undefined;
};
const applies = (raw.oa_seal_apply || raw.oa_seal || []) as Record<string, unknown>[];
const rows = await prisma.sealRequest.findMany({
include: { applicant: { select: { displayName: true } } },
});
let updated = 0;
for (const r of rows) {
if (r.applicant?.displayName && r.applicant.displayName !== '系统管理员') continue;
const reason = r.reason || '';
const created = r.createdAt ? r.createdAt.getTime() : 0;
const hit = applies.find((a) => {
const content = str(a.content || a.apply_subject || a.reason);
if (content && reason && (content === reason || reason.includes(content) || content.includes(reason))) return true;
const t = str(a.create_time);
if (t && created) {
const d = new Date(t.includes('T') ? t : t.replace(' ', 'T'));
return Math.abs(d.getTime() - created) < 2000 && str(a.seal_type || '') === r.sealType;
}
return false;
});
if (!hit) continue;
const who =
byName.get(str(hit.applicant_name) === '张靖妍' ? '张婧妍' : str(hit.applicant_name)) ||
userFromOaId(hit.applicant_id) ||
userFromOaId(hit.create_by) ||
userFromOaId(hit.creator_id) ||
userFromOaId(hit.apply_user);
if (!who || who.id === admin?.id) continue;
await prisma.sealRequest.update({ where: { id: r.id }, data: { applicantId: who.id } });
updated += 1;
}
return { updated, scanned: rows.length };
}
async function main() {
const contracts = await linkContracts();
const seals = await backfillSealApplicants();
const [tender, incremental] = await Promise.all([
prisma.contract.count({ where: { bidCaseId: { not: null } } }),
prisma.contract.count({ where: { bidCaseId: null } }),
]);
console.log(JSON.stringify({ contracts, seals, tender, incremental }, null, 2));
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+127
View File
@@ -0,0 +1,127 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { normalizeProjectType } from '../src/project/project-workflow';
const prisma = new PrismaClient();
function str(v: unknown) {
return v == null ? '' : String(v).trim();
}
function num(v: unknown) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function compact(name: string) {
return String(name || '')
.replace(/[()()\[\]【】\s\-—_.,,、]/g, '')
.replace(/招标公告|采购项目|技术服务合同|合同/g, '');
}
function loadCore(): { project_list?: Record<string, unknown>[]; reimbursement?: Record<string, unknown>[] } {
const candidates = [
join('/opt/import/prod/oa_core.json'),
join(process.cwd(), '../../import/prod/oa_core.json'),
join(process.cwd(), 'import/prod/oa_core.json'),
];
for (const p of candidates) {
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8'));
}
return {};
}
async function main() {
const core = loadCore();
const oldProjects = core.project_list || [];
const reimburses = core.reimbursement || [];
const projects = await prisma.project.findMany({
include: { contract: { include: { bidCase: { select: { projectType: true, name: true } } } }, expenses: { select: { id: true, projectId: true } } },
});
const expenses = await prisma.expenseClaim.findMany({
where: { tenantId: '1' },
select: { id: true, projectId: true, purpose: true, remark: true },
});
let typed = 0;
let amounted = 0;
let linked = 0;
for (const p of projects) {
const old = oldProjects.find((o) => {
const code = str(o.project_code || o.system_number);
const name = str(o.project_name);
return (code && (code === p.projectNo || p.projectNo.endsWith(code))) ||
(name && (name === p.name || compact(name) === compact(p.name)));
});
const nextType =
normalizeProjectType(p.projectType) ||
normalizeProjectType(p.contract?.bidCase?.projectType) ||
normalizeProjectType(str(old?.project_category || old?.project_type));
const nextAmount =
num(p.amount) ||
num(p.contract?.amount) ||
num(old?.project_budget);
const data: Prisma.ProjectUpdateInput = {};
if (nextType && nextType !== p.projectType) {
data.projectType = nextType;
typed += 1;
}
if (nextAmount > 0 && num(p.amount) <= 0) {
data.amount = nextAmount;
amounted += 1;
}
if (Object.keys(data).length) {
await prisma.project.update({ where: { id: p.id }, data });
}
}
const refreshed = await prisma.project.findMany({ select: { id: true, name: true, projectNo: true } });
for (const e of expenses) {
if (e.projectId) continue;
const hit = refreshed.find((p) => {
const blob = `${e.purpose || ''} ${e.remark || ''}`;
return blob.includes(p.projectNo) || blob.includes(p.name) || (p.name.length >= 8 && blob.includes(p.name.slice(0, 12)));
});
const old = reimburses.find((r) => str(r.project_name) && hit && compact(str(r.project_name)) === compact(hit.name));
const byName = refreshed.find((p) => {
const r = reimburses.find((x) => str(x.id) && String(e.remark || '').includes(String(x.id)));
return r ? compact(str(r.project_name)) === compact(p.name) : false;
});
const target = hit || byName;
if (!target) continue;
await prisma.expenseClaim.update({ where: { id: e.id }, data: { projectId: target.id, costType: 'PROJECT' } });
linked += 1;
}
// old reimbursements already imported with project names
for (const r of reimburses) {
const name = str(r.project_name);
if (!name) continue;
const project = refreshed.find((p) => p.name === name || compact(p.name) === compact(name));
if (!project) continue;
const updated = await prisma.expenseClaim.updateMany({
where: {
tenantId: '1',
projectId: null,
OR: [{ purpose: { contains: name.slice(0, 16) } }, { remark: { contains: name.slice(0, 16) } }],
},
data: { projectId: project.id, costType: 'PROJECT' },
});
linked += updated.count;
}
console.log(`project ledger backfill: typed=${typed} amounted=${amounted} expensesLinked=${linked}`);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+79
View File
@@ -0,0 +1,79 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const KEYS = ['ccDaily', 'ccWeekly', 'ccMonthly'] as const;
function rolesOf(emp: { users: { roles: { role: { code: string } }[] }[] }) {
return [...new Set(emp.users.flatMap((u) => u.roles.map((r) => r.role.code)))];
}
function isDirector(emp: { title?: string | null; jobRole?: string | null; users: { roles: { role: { code: string } }[] }[] }) {
if (/总监/.test(`${emp.title || ''}${emp.jobRole || ''}`)) return true;
return rolesOf(emp).some((r) => r.endsWith('_director') || r === 'biz_director' || r === 'owner');
}
function parseIds(raw?: string | null) {
if (!raw) return [];
return [...new Set(raw.split(/[,,、\s]+/).filter((s) => UUID_RE.test(s)))];
}
async function main() {
const depts = await prisma.department.findMany({ where: { tenantId: '1' }, select: { id: true, parentId: true } });
const byId = new Map(depts.map((d) => [d.id, d]));
const people = await prisma.employee.findMany({
where: { tenantId: '1' },
include: { users: { select: { roles: { select: { role: { select: { code: true } } } } } } },
});
const byDept = new Map<string, typeof people>();
for (const e of people) {
const list = byDept.get(e.departmentId) || [];
list.push(e);
byDept.set(e.departmentId, list);
}
function scopeDeptIds(start: string, selfId: string) {
const scope: string[] = [];
const seen = new Set<string>();
let cur: string | null | undefined = start;
while (cur && !seen.has(cur)) {
seen.add(cur);
scope.push(cur);
const hasDir = (byDept.get(cur) || []).some((e) => e.id !== selfId && isDirector(e));
if (hasDir) break;
cur = byId.get(cur)?.parentId;
}
return new Set(scope);
}
let n = 0;
for (const emp of people) {
const scope = scopeDeptIds(emp.departmentId, emp.id);
const dirs = people.filter((d) => scope.has(d.departmentId) && d.id !== emp.id && d.id !== emp.managerId && isDirector(d));
const dirIds = dirs.map((d) => d.id);
const allowed = new Set(dirIds);
const data: Partial<Record<(typeof KEYS)[number], string | null>> = {};
for (const key of KEYS) {
const cur = parseIds(emp[key]);
let next = cur.filter((id) => allowed.has(id));
if (!next.length && dirIds.length && cur.some((id) => id === emp.managerId || !allowed.has(id))) {
next = dirIds;
}
const joined = next.length ? next.join(',') : null;
if (joined !== (emp[key] || null)) data[key] = joined;
}
if (!Object.keys(data).length) continue;
await prisma.employee.update({ where: { id: emp.id }, data });
n += 1;
console.log(`${emp.name}: ${JSON.stringify(data)}`);
}
console.log(`updated ${n} / ${people.length} employee cc fields`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+36
View File
@@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const techPeople = await prisma.employee.findMany({
where: { department: { deptType: 'TECH' } },
select: { name: true, department: { select: { deptType: true, name: true } } },
});
const names = techPeople.map((e) => e.name).filter((n) => n && n.length >= 2);
const rows = await prisma.expenseClaim.findMany({
where: { tenantId: '1', costType: 'PROJECT', budgetCategory: 'TRAVEL' },
include: {
applicant: { select: { employee: { select: { name: true, department: { select: { deptType: true } } } } } },
lines: { select: { remark: true } },
},
});
let n = 0;
for (const row of rows) {
const dept = row.applicant.employee?.department?.deptType || '';
const text = [row.purpose, row.remark, ...row.lines.map((l) => l.remark)].filter(Boolean).join(' ');
const forTech = names.some((name) => text.includes(name));
if (dept !== 'TECH' && !forTech) continue;
await prisma.expenseClaim.update({ where: { id: row.id }, data: { budgetCategory: 'TECH' } });
n += 1;
}
console.log(`reclassified ${n} / ${rows.length} travel expenses into tech budget; tech staff ${names.length}`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+88
View File
@@ -0,0 +1,88 @@
import { PrismaClient } from '@prisma/client';
import { ensureWonLedger } from '../src/contract/won-ledger';
import { BIZ_TYPE } from '../src/bidding/bid-workflow';
const prisma = new PrismaClient();
async function main() {
const owner = await prisma.user.findFirst({
where: { tenantId: '1', status: 'ACTIVE', roles: { some: { role: { code: { in: ['owner', 'admin'] } } } } },
select: { id: true },
});
const won = await prisma.bidCase.findMany({
where: { tenantId: '1', status: 'WON' },
include: { party: true, legalEntity: true, expenses: { select: { amount: true } } },
});
let newContracts = 0;
let newProjects = 0;
let todos = 0;
for (const bid of won) {
const actorId = bid.createdById || owner?.id;
if (!actorId) throw new Error('没有可用的合同创建人');
const beforeC = await prisma.contract.count({
where: { bidCaseId: bid.id, status: { in: ['DRAFT', 'PENDING', 'APPROVED'] } },
});
const beforeP = await prisma.project.count({
where: { contract: { bidCaseId: bid.id } },
});
const result = await prisma.$transaction((tx) => ensureWonLedger(tx, bid, actorId));
if (
(await prisma.contract.count({
where: { bidCaseId: bid.id, status: { in: ['DRAFT', 'PENDING', 'APPROVED'] } },
})) > beforeC
) {
newContracts += 1;
}
if ((await prisma.project.count({ where: { contract: { bidCaseId: bid.id } } })) > beforeP) {
newProjects += 1;
}
await prisma.todoTask.updateMany({
where: {
status: 'OPEN',
bizType: BIZ_TYPE.CONVERT_CONTRACT,
bizId: bid.id,
},
data: { bizId: result.contract.id, title: `完善合同草稿:${bid.name}` },
});
const openTodo = await prisma.todoTask.findFirst({
where: {
status: 'OPEN',
bizType: BIZ_TYPE.CONVERT_CONTRACT,
bizId: result.contract.id,
},
});
if (!openTodo && result.contract.status === 'DRAFT') {
const assignees = await prisma.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: { in: ['biz_director', 'biz_staff', 'owner'] } } } },
},
});
if (assignees.length) {
await prisma.todoTask.createMany({
data: assignees.map((u) => ({
tenantId: '1',
title: `完善合同草稿:${bid.name}`,
bizType: BIZ_TYPE.CONVERT_CONTRACT,
bizId: result.contract.id,
assigneeId: u.id,
})),
});
todos += assignees.length;
}
}
}
console.log(
`won bids ${won.length}; new contracts ${newContracts}; new projects ${newProjects}; todos ${todos}`,
);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+65
View File
@@ -0,0 +1,65 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
const prisma = new PrismaClient();
async function main() {
const username = '18115604840';
const password = '123456';
const role = await prisma.role.findFirst({ where: { code: 'admin' } });
if (!role) throw new Error('缺少 admin 角色,请先执行 prisma seed');
const root =
(await prisma.department.findFirst({ where: { code: 'ROOT' } })) ||
(await prisma.department.findFirst({ orderBy: { sortNo: 'asc' } }));
if (!root) throw new Error('缺少部门');
let emp = await prisma.employee.findFirst({ where: { mobile: username } });
if (!emp) {
emp = await prisma.employee.create({
data: {
name: '系统管理员',
employeeNo: 'E-ADMIN',
departmentId: root.id,
title: '管理员',
restSchedule: 'DOUBLE',
employmentStatus: 'REGULAR',
mobile: username,
hiredAt: new Date(),
},
});
} else if (!emp.mobile) {
emp = await prisma.employee.update({ where: { id: emp.id }, data: { mobile: username } });
}
const hash = await argon2.hash(password);
let user = await prisma.user.findFirst({ where: { username } });
if (!user) {
user = await prisma.user.create({
data: {
username,
passwordHash: hash,
displayName: emp.name,
employeeId: emp.id,
mustChangePassword: false,
status: 'ACTIVE',
},
});
} else {
user = await prisma.user.update({
where: { id: user.id },
data: { employeeId: emp.id, status: 'ACTIVE' },
});
}
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: role.id } },
create: { userId: user.id, roleId: role.id },
update: {},
});
console.log('prod admin ok', username);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+141
View File
@@ -0,0 +1,141 @@
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'fs';
const prisma = new PrismaClient();
type OaExpense = {
name: string;
amount: number;
project: string;
date: string;
purpose: string;
remark: string | null;
expenseCategory: string;
};
type OaLoan = { name: string; amount: number; purpose: string; project: string };
const NAME_ALIAS: Record<string, string> = {
: '张婧妍',
: '张婧妍',
};
function normName(v?: string | null) {
const s = (v || '').trim();
return NAME_ALIAS[s] || s;
}
function amtEq(a: unknown, b: unknown) {
return Math.abs(Number(a) - Number(b)) < 0.021;
}
function sameProject(oa: string, locals: string[]) {
if (!oa) return locals.length === 0;
return locals.some((p) => p === oa || p.includes(oa) || oa.includes(p));
}
async function main() {
const oaExpenses = JSON.parse(readFileSync('/opt/apps/api/prisma/_oa_reimb_map.json', 'utf8')) as OaExpense[];
const oaLoans = JSON.parse(readFileSync('/opt/apps/api/prisma/_oa_loan_map.json', 'utf8')) as OaLoan[];
const expenses = await prisma.expenseClaim.findMany({
include: {
applicant: { select: { displayName: true, employee: { select: { name: true } } } },
project: { select: { name: true } },
bidCase: { select: { name: true } },
},
orderBy: { claimNo: 'asc' },
});
const used = new Set<number>();
let patchedExpenses = 0;
const unmatchedExpenses: string[] = [];
for (const row of expenses) {
const names = [normName(row.applicant?.displayName), normName(row.applicant?.employee?.name)].filter(Boolean);
const locals = [row.project?.name, row.bidCase?.name].filter((v): v is string => Boolean(v));
const date = String(row.submittedAt || '').slice(0, 10);
const idxs = oaExpenses
.map((o, i) => ({ o, i }))
.filter(({ o, i }) => !used.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name)));
const byProj = idxs.filter(({ o }) => sameProject(o.project, locals));
const byDate = idxs.filter(({ o }) => o.date === date);
const hit = (byProj.length === 1 ? byProj[0] : null) || (byDate.length === 1 ? byDate[0] : null) || (idxs.length === 1 ? idxs[0] : byProj[0] || byDate[0] || null);
if (!hit) {
unmatchedExpenses.push(`${row.claimNo} ${names[0] || '?'} ${row.amount}`);
continue;
}
used.add(hit.i);
await prisma.expenseClaim.update({
where: { id: row.id },
data: {
purpose: hit.o.purpose || null,
remark: hit.o.remark,
expenseCategory: hit.o.expenseCategory,
},
});
if (hit.o.purpose) {
await prisma.approvalTask.updateMany({
where: { bizId: row.id, bizType: 'EXPENSE_APPROVAL' },
data: { title: hit.o.purpose },
});
}
patchedExpenses += 1;
}
const loans = await prisma.loanRecord.findMany({
include: {
applicant: { select: { displayName: true, employee: { select: { name: true } } } },
},
orderBy: { loanNo: 'asc' },
});
const usedLoan = new Set<number>();
let patchedLoans = 0;
const unmatchedLoans: string[] = [];
for (const row of loans) {
const names = [normName(row.applicant?.displayName), normName(row.applicant?.employee?.name)].filter(Boolean);
const hit = oaLoans
.map((o, i) => ({ o, i }))
.find(({ o, i }) => !usedLoan.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name)) && (!row.purpose || row.purpose === o.purpose || o.purpose.includes(row.purpose) || row.purpose.includes(o.purpose)));
const fallback = oaLoans
.map((o, i) => ({ o, i }))
.find(({ o, i }) => !usedLoan.has(i) && amtEq(o.amount, row.amount) && names.includes(normName(o.name)));
const pick = hit || fallback;
if (!pick) {
unmatchedLoans.push(`${row.loanNo} ${names[0] || '?'}`);
await prisma.loanRecord.update({ where: { id: row.id }, data: { remark: null } });
if (row.purpose) {
await prisma.approvalTask.updateMany({
where: { bizId: row.id, bizType: 'LOAN_APPROVAL' },
data: { title: row.purpose },
});
}
continue;
}
usedLoan.add(pick.i);
await prisma.loanRecord.update({
where: { id: row.id },
data: { purpose: pick.o.purpose, remark: null },
});
await prisma.approvalTask.updateMany({
where: { bizId: row.id, bizType: 'LOAN_APPROVAL' },
data: { title: pick.o.purpose },
});
patchedLoans += 1;
}
console.log(
JSON.stringify(
{ patchedExpenses, unmatchedExpenses, patchedLoans, unmatchedLoans },
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+125
View File
@@ -0,0 +1,125 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_clean.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function entityKey(raw: unknown) {
const s = str(raw);
if (!s) return '';
if (s.includes('鲁兵')) return '山东鲁兵';
if (s.includes('隆创')) return '隆创';
if (s.includes('风影')) return '江苏风影随行科技有限公司';
return s;
}
const ENTITIES: { key: string; name: string; shortName: string }[] = [
{ key: '江苏风影随行科技有限公司', name: '江苏风影随行科技有限公司', shortName: '江苏风影' },
{ key: '山东鲁兵', name: '山东鲁兵', shortName: '山东鲁兵' },
{ key: '隆创', name: '隆创', shortName: '隆创' },
];
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const tenders = raw.oa_tender || [];
const entityIds = new Map<string, string>();
for (const e of ENTITIES) {
const existing = await prisma.legalEntity.findFirst({
where: { tenantId: '1', OR: [{ name: e.name }, { shortName: e.shortName }] },
});
if (existing) {
await prisma.legalEntity.update({ where: { id: existing.id }, data: { name: e.name, shortName: e.shortName } });
entityIds.set(e.key, existing.id);
} else {
const created = await prisma.legalEntity.create({
data: { name: e.name, shortName: e.shortName, isDefault: e.shortName === '江苏风影' },
});
entityIds.set(e.key, created.id);
}
}
await prisma.legalEntity.updateMany({
where: { name: { not: '江苏风影随行科技有限公司' } },
data: { isDefault: false },
});
await prisma.legalEntity.updateMany({
where: { name: '江苏风影随行科技有限公司' },
data: { isDefault: true },
});
const users = await prisma.user.findMany();
const userByName = new Map(users.map((u) => [u.displayName, u]));
const bids = await prisma.bidCase.findMany();
const usedNos = new Set<string>();
const matched: { rowId: string; t: Row }[] = [];
for (const t of tenders) {
const name = str(t.project_name);
const sys = str(t.system_number);
const official = str(t.project_number);
const row = bids.find((b) => b.name === name) || bids.find((b) => b.bidNo === sys || b.externalNo === official);
if (!row) continue;
matched.push({ rowId: row.id, t });
}
for (const m of matched) {
await prisma.bidCase.update({
where: { id: m.rowId },
data: { bidNo: `TMP-${m.rowId.replace(/-/g, '').slice(0, 20)}` },
});
}
for (const { rowId, t } of matched) {
const row = bids.find((b) => b.id === rowId)!;
const official = str(t.project_number);
const key = entityKey(t.participating_unit);
let bidNo = official || row.externalNo || row.bidNo;
if (usedNos.has(bidNo)) bidNo = `${bidNo}-${row.id.slice(0, 4)}`;
usedNos.add(bidNo);
const preparer = str(t.bid_prepare_person);
const creator = userByName.get(preparer === '张靖妍' ? '张婧妍' : preparer);
await prisma.bidCase.update({
where: { id: row.id },
data: {
bidNo,
externalNo: official || row.externalNo,
legalEntityId: key ? entityIds.get(key) || null : null,
openAt: when(t.bid_open_date) || row.openAt,
applyEndAt: when(t.file_apply_deadline) || row.applyEndAt,
applyStartAt: when(t.create_time) || row.applyStartAt,
createdById: creator?.id || row.createdById,
createdAt: when(t.create_time) || row.createdAt,
},
});
}
const patched = matched.length;
const loan = await prisma.loanRecord.findFirst({ where: { purpose: { contains: '重庆投标' } } });
const chongqing = await prisma.bidCase.findFirst({ where: { name: { contains: '门诊特病' } } });
if (loan && chongqing) {
await prisma.loanRecord.update({
where: { id: loan.id },
data: { bidCaseId: chongqing.id, costType: 'BID' },
});
}
console.log(JSON.stringify({ patchedBids: patched, entities: ENTITIES.map((e) => e.shortName), linkedChongqingLoan: Boolean(loan && chongqing) }));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+125
View File
@@ -0,0 +1,125 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function ensureUser(name: string, username: string, deptName: string, employeeNo: string, passwordHash: string) {
const existing = await prisma.user.findFirst({ where: { OR: [{ username }, { displayName: name }] } });
if (existing) return existing.id;
const dept =
(await prisma.department.findFirst({ where: { name: deptName } })) ||
(await prisma.department.findFirst({ where: { name: { contains: '研发' } } })) ||
(await prisma.department.findFirst({ where: { parentId: { not: null } } }));
if (!dept) throw new Error(`没有部门可挂 ${name}`);
const empRole = await prisma.role.findFirst({ where: { code: 'employee' } });
if (!empRole) throw new Error('没有 employee 角色');
let emp = await prisma.employee.findFirst({ where: { OR: [{ name }, { employeeNo }] } });
if (!emp) {
emp = await prisma.employee.create({
data: {
name,
employeeNo,
departmentId: dept.id,
title: '员工',
employmentStatus: 'LEFT',
remark: `OA 报销有此人(原部门 ${deptName}),人事花名册未导入,补建后挂单据,账号停用。`,
},
});
}
const user = await prisma.user.create({
data: {
username,
displayName: name,
passwordHash,
status: 'DISABLED',
employeeId: emp.id,
},
});
await prisma.userRole.create({ data: { userId: user.id, roleId: empRole.id } });
console.log('created leftover', name, user.id, 'dept', dept.name);
return user.id;
}
async function main() {
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('no admin');
await ensureUser('李乐菡', 'lilehan', '项目一部', 'E0115', admin.passwordHash);
const users = await prisma.user.findMany();
const byName = new Map(users.map((u) => [u.displayName, u.id]));
const ALIAS: Record<string, string> = { : '张婧妍' };
for (const [from, to] of Object.entries(ALIAS)) {
const uid = byName.get(to);
if (uid) byName.set(from, uid);
}
const expenses = await prisma.expenseClaim.findMany({ select: { id: true, remark: true, applicantId: true, status: true } });
let n = 0;
for (const e of expenses) {
const name = (e.remark || '').split(' / ')[0]?.trim();
if (!name || name === '系统管理员') continue;
const uid = byName.get(name);
if (!uid || uid === e.applicantId) continue;
await prisma.expenseClaim.update({ where: { id: e.id }, data: { applicantId: uid } });
n += 1;
}
const loans = await prisma.loanRecord.findMany({ select: { id: true, remark: true, applicantId: true } });
let ln = 0;
for (const e of loans) {
const name = (e.remark || '').split(' / ')[0]?.trim();
if (!name || name === '系统管理员') continue;
const uid = byName.get(name);
if (!uid || uid === e.applicantId) continue;
await prisma.loanRecord.update({ where: { id: e.id }, data: { applicantId: uid } });
ln += 1;
}
const approvers = await prisma.user.findMany({
where: { status: 'ACTIVE', roles: { some: { role: { code: { in: ['admin', 'finance', 'owner'] } } } } },
});
const pendingExp = await prisma.expenseClaim.findMany({ where: { status: 'PENDING' } });
const pendingLoan = await prisma.loanRecord.findMany({ where: { status: 'PENDING' } });
let tasks = 0;
for (const row of pendingExp) {
for (const u of approvers) {
const hit = await prisma.approvalTask.findFirst({
where: { bizType: 'EXPENSE_APPROVAL', bizId: row.id, assigneeId: u.id, status: 'PENDING' },
});
if (hit) continue;
await prisma.approvalTask.create({
data: {
title: `报销确认:${row.claimNo}`,
bizType: 'EXPENSE_APPROVAL',
bizId: row.id,
assigneeId: u.id,
},
});
tasks += 1;
}
}
for (const row of pendingLoan) {
for (const u of approvers) {
const hit = await prisma.approvalTask.findFirst({
where: { bizType: 'LOAN_APPROVAL', bizId: row.id, assigneeId: u.id, status: 'PENDING' },
});
if (hit) continue;
await prisma.approvalTask.create({
data: {
title: `借款确认:${row.loanNo}`,
bizType: 'LOAN_APPROVAL',
bizId: row.id,
assigneeId: u.id,
},
});
tasks += 1;
}
}
const left = await prisma.expenseClaim.count({ where: { applicantId: admin.id } });
console.log(JSON.stringify({ rematchedExpenses: n, rematchedLoans: ln, approvalTasks: tasks, adminStillHolds: left }));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+107
View File
@@ -0,0 +1,107 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { mapExpenseLineKind, parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const FLOW = '/opt/import/prod/oa_flow_finance.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s;
}
async function main() {
const flow = parseOaJson<Record<string, Row[]>>(FLOW);
const itemsByReimb = new Map<string, Row[]>();
for (const item of flow.reimbursement_item || []) {
const key = str(item.reimbursement_id);
if (!key) continue;
const list = itemsByReimb.get(key) || [];
list.push(item);
itemsByReimb.set(key, list);
}
let claims = 0;
let lines = 0;
let skipped = 0;
for (const r of flow.reimbursement || []) {
const claimNo = str(r.reimburse_no);
if (!claimNo) continue;
const row = await prisma.expenseClaim.findFirst({ where: { tenantId: '1', claimNo } });
if (!row) {
skipped += 1;
continue;
}
const incoming = itemsByReimb.get(str(r.id)) || [];
await prisma.$transaction(async (tx) => {
await tx.expenseLine.deleteMany({ where: { claimId: row.id } });
for (const [i, line] of incoming.entries()) {
await tx.expenseLine.create({
data: {
claimId: row.id,
kind: mapExpenseLineKind(str(line.item_type), str(line.remark)),
amount: new Prisma.Decimal(num(line.amount)),
invoiceNo: clip(line.invoice_no, 80) || undefined,
occurAt: when(line.expense_date) || undefined,
remark: clip(line.remark || line.item_type, 200) || undefined,
sortNo: num(line.sort_order) || i,
},
});
lines += 1;
}
});
claims += 1;
}
const sample = await prisma.expenseClaim.findFirst({
where: { claimNo: 'BX-20260828-002' },
include: { lines: { orderBy: { sortNo: 'asc' } } },
});
const sum = sample?.lines.reduce((s, l) => s + Number(l.amount), 0);
console.log(
JSON.stringify(
{
claims,
lines,
skipped,
sample: sample
? {
claimNo: sample.claimNo,
header: Number(sample.amount),
lineCount: sample.lines.length,
lineSum: sum,
kinds: sample.lines.map((l) => `${l.kind}:${l.amount}:${l.remark}`),
}
: null,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+141
View File
@@ -0,0 +1,141 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_hr.json';
const ROLES: { code: string; name: string; dataScope: string }[] = [
{ code: 'rd_director', name: '研发总监', dataScope: 'DEPT' },
{ code: '3d_director', name: '三维总监', dataScope: 'DEPT' },
{ code: 'video_director', name: '摄制总监', dataScope: 'DEPT' },
{ code: 'material_director', name: '物资总监', dataScope: 'DEPT' },
{ code: 'pm', name: '项目经理', dataScope: 'DEPT' },
{ code: 'rd_staff', name: '研发技术岗', dataScope: 'SELF' },
{ code: '3d_staff', name: '三维技术岗', dataScope: 'SELF' },
{ code: 'video_staff', name: '摄制技术岗', dataScope: 'SELF' },
{ code: 'material_staff', name: '物资技术岗', dataScope: 'SELF' },
{ code: 'tech', name: '普通岗位', dataScope: 'SELF' },
];
const POST_ROLE: Record<string, string> = {
sysadmin: 'admin',
boss: 'owner',
business_director: 'biz_director',
business_staff: 'biz_staff',
rd_director: 'rd_director',
'3d_director': '3d_director',
video_director: 'video_director',
material_director: 'material_director',
pm: 'pm',
tech: 'tech',
rd_staff: 'rd_staff',
'3d_staff': '3d_staff',
video_staff: 'video_staff',
material_staff: 'material_staff',
};
function pickRole(userName: string, postCodes: string[]) {
if (userName === 'xibei') return 'employee';
if (userName === 'administrator' || userName === 'admin') return 'admin';
const rank: Record<string, number> = {
employee: 0,
tech: 1,
rd_staff: 1,
'3d_staff': 1,
video_staff: 1,
material_staff: 1,
pm: 2,
biz_staff: 3,
rd_director: 4,
'3d_director': 4,
video_director: 4,
material_director: 4,
tech_director: 4,
biz_director: 5,
owner: 6,
admin: 7,
};
let best: string | null = null;
let bestRank = -1;
for (const c of postCodes) {
const role = POST_ROLE[c];
if (!role) continue;
const r = rank[role] ?? 1;
if (r > bestRank) {
best = role;
bestRank = r;
}
}
return best || 'employee';
}
async function main() {
for (const r of ROLES) {
const existing = await prisma.role.findFirst({ where: { code: r.code } });
if (existing) {
await prisma.role.update({ where: { id: existing.id }, data: { name: r.name, dataScope: r.dataScope } });
} else {
await prisma.role.create({ data: { name: r.name, code: r.code, dataScope: r.dataScope } });
}
}
await prisma.role.updateMany({ where: { code: 'biz_staff' }, data: { name: '商务专员' } });
await prisma.role.updateMany({ where: { code: 'biz_director' }, data: { name: '商务总监' } });
await prisma.role.updateMany({ where: { code: 'owner' }, data: { name: '企业负责人' } });
const dump = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as {
posts: { id: number; code: string; name: string }[];
users: { id: number; userName: string; nickName: string }[];
userPosts: { postId: number; userId: number }[];
};
const postById = new Map(dump.posts.map((p) => [p.id, p]));
const postsByUser = new Map<number, { code: string; name: string }[]>();
for (const up of dump.userPosts) {
const post = postById.get(up.postId);
if (!post) continue;
const list = postsByUser.get(up.userId) || [];
list.push(post);
postsByUser.set(up.userId, list);
}
const roles = await prisma.role.findMany();
const roleByCode = new Map(roles.map((r) => [r.code, r.id]));
let n = 0;
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const user = await prisma.user.findFirst({ where: { username: u.userName } });
if (!user) continue;
const posts = postsByUser.get(u.id) || [];
const roleCode = pickRole(u.userName, posts.map((p) => p.code));
const roleId = roleByCode.get(roleCode);
if (!roleId) continue;
await prisma.userRole.deleteMany({ where: { userId: user.id } });
await prisma.userRole.create({ data: { userId: user.id, roleId } });
const title = posts.find((p) => POST_ROLE[p.code] === roleCode)?.name || posts[0]?.name;
if (title && user.employeeId) {
await prisma.employee.update({ where: { id: user.employeeId }, data: { title } });
}
n += 1;
}
const delQual = await prisma.qualification.deleteMany({ where: { code: 'QUAL_P6_OLD' } });
const cancelled = await prisma.approvalTask.updateMany({
where: { bizType: 'EXPENSE_APPROVAL', status: 'PENDING' },
data: { status: 'CANCELLED' },
});
console.log(
JSON.stringify({
reassignedUsers: n,
deletedTestQuals: delQual.count,
cancelledExpenseApprovals: cancelled.count,
}),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,40 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const admin = await prisma.role.findFirst({ where: { code: 'admin' } });
if (!admin) throw new Error('缺少 admin 角色');
await prisma.role.updateMany({ where: { code: 'rd_director' }, data: { dataScope: 'ALL' } });
const users = await prisma.user.findMany({
where: {
status: 'ACTIVE',
OR: [
{ displayName: { contains: '潘兴辉' } },
{ employee: { name: { contains: '潘兴辉' } } },
{ roles: { some: { role: { code: 'rd_director' } } } },
],
},
select: { id: true, displayName: true, username: true },
});
if (!users.length) throw new Error('没有找到潘兴辉或研发总监账号');
const result = await prisma.userRole.createMany({
data: users.map((u) => ({ userId: u.id, roleId: admin.id })),
skipDuplicates: true,
});
console.log(
`granted admin to: ${users.map((u) => u.displayName).join('、')} (new links ${result.count})`,
);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.findFirst({
where: {
status: 'ACTIVE',
OR: [{ displayName: { contains: '孙春庆' } }, { employee: { name: { contains: '孙春庆' } } }],
},
include: { roles: true },
});
if (!user) throw new Error('没有找到孙春庆');
const role =
(await prisma.role.findFirst({ where: { code: 'bid_screener' } })) ||
(await prisma.role.create({
data: { name: '项目筛选发起', code: 'bid_screener', dataScope: 'SELF' },
}));
const perms = await prisma.permission.findMany({
where: { code: { in: ['office', 'office:overview', 'bid', 'bid:screening'] } },
});
await prisma.rolePermission.createMany({
data: perms.map((p) => ({ roleId: role.id, permissionId: p.id })),
skipDuplicates: true,
});
await prisma.userRole.createMany({
data: [{ userId: user.id, roleId: role.id }],
skipDuplicates: true,
});
console.log(`granted bid screening to ${user.displayName} (${user.username})`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+587
View File
@@ -0,0 +1,587 @@
// @ts-nocheck
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
const DUMP = path.resolve('/opt/import/prod/oa_hr.json');
const DEFAULT_PASSWORD = '123456';
const DEMO_USERS = [
'owner',
'bizdir',
'bizstaff',
'dirsoft',
'dir3d',
'dirfilm',
'dirmaterial',
'finance',
'hr',
'employee',
'softpm',
'batchdemo1',
];
const DEPT_META: Record<number, { deptType: string; categoryCode?: string }> = {
1: { deptType: 'ADMIN' },
2: { deptType: 'ADMIN' },
4: { deptType: 'BUSINESS' },
7: { deptType: 'ADMIN' },
8: { deptType: 'ADMIN' },
9: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
10: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
19: { deptType: 'TECH', categoryCode: 'CAT_FILM' },
23: { deptType: 'TECH', categoryCode: 'CAT_3D' },
210: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
211: { deptType: 'TECH', categoryCode: 'CAT_3D' },
212: { deptType: 'TECH', categoryCode: 'CAT_FILM' },
213: { deptType: 'TECH', categoryCode: 'CAT_MATERIAL' },
214: { deptType: 'TECH', categoryCode: 'CAT_SOFTWARE' },
};
const OLD_DEPT_TO_OA: Record<string, string> = {
ROOT: 'OA_1',
BIZ: 'OA_4',
TECH_SOFTWARE: 'OA_9',
TECH_3D: 'OA_211',
TECH_FILM: 'OA_212',
TECH_MATERIAL: 'OA_213',
FIN: 'OA_2',
HR: 'OA_2',
};
const DEPT_MANAGER_OA: Record<number, number> = {
1: 101,
2: 101,
4: 107,
7: 101,
8: 101,
9: 131,
10: 131,
19: 102,
23: 105,
210: 131,
211: 105,
212: 102,
213: 104,
214: 131,
};
const POST_ROLE: Record<string, string> = {
sysadmin: 'admin',
boss: 'owner',
business_director: 'biz_director',
business_staff: 'biz_staff',
rd_director: 'rd_director',
'3d_director': '3d_director',
video_director: 'video_director',
material_director: 'material_director',
pm: 'pm',
tech: 'tech',
rd_staff: 'rd_staff',
'3d_staff': '3d_staff',
video_staff: 'video_staff',
material_staff: 'material_staff',
};
const ROLE_RANK: Record<string, number> = {
employee: 0,
tech: 1,
rd_staff: 1,
'3d_staff': 1,
video_staff: 1,
material_staff: 1,
pm: 2,
biz_staff: 3,
tech_director: 4,
rd_director: 4,
'3d_director': 4,
video_director: 4,
material_director: 4,
biz_director: 5,
owner: 6,
admin: 7,
};
type Dump = {
depts: { id: number; name: string; parentId: number; orderNum: number; delFlag: string }[];
posts: { id: number; code: string; name: string }[];
users: {
id: number;
sex: string;
email: string;
phone: string;
deptId: number;
remark: string | null;
status: string;
nickName: string;
userName: string;
entryDate: string | null;
leaveDate: string | null;
employmentStatus: string;
}[];
userPosts: { postId: number; userId: number }[];
};
function empNo(oaId: number) {
return `E${String(oaId).padStart(4, '0')}`;
}
function gender(sex: string) {
if (sex === '0') return 'MALE';
if (sex === '1') return 'FEMALE';
return null;
}
function empStatus(code: string) {
if (code === '1') return 'INTERN';
if (code === '2') return 'PROBATION';
if (code === '4') return 'LEFT';
return 'REGULAR';
}
function when(v: string | null) {
if (!v || v.startsWith('0000')) return null;
const d = new Date(v.includes('T') ? v : `${v}T00:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
function addMonths(d: Date, n: number) {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return x;
}
function pickRole(userName: string, postCodes: string[]) {
if (userName === 'xibei') return 'employee';
let best: string | null = null;
let bestRank = -1;
for (const c of postCodes) {
const role = POST_ROLE[c];
if (!role) continue;
const r = ROLE_RANK[role] ?? 1;
if (r > bestRank) {
best = role;
bestRank = r;
}
}
return best || 'employee';
}
async function remumber<T extends { id: string; createdAt: Date }>(
rows: T[],
prefix: string,
update: (id: string, no: string) => Promise<unknown>,
) {
for (const r of rows) await update(r.id, `TMP-${r.id.replace(/-/g, '').slice(0, 20)}`);
const groups = new Map<number, T[]>();
const sorted = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
for (const r of sorted) {
const y = r.createdAt.getFullYear();
const list = groups.get(y) || [];
list.push(r);
groups.set(y, list);
}
for (const [year, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
let i = 1;
for (const r of list) {
await update(r.id, `${prefix}-${year}-${String(i).padStart(5, '0')}`);
i += 1;
}
}
}
async function remapDeptFks(codeToId: Map<string, string>) {
const old = await prisma.department.findMany({ where: { tenantId: '1' } });
const oldByCode = new Map(old.map((d) => [d.code, d.id]));
const mapping = new Map<string, string>();
for (const d of old) {
const targetCode = OLD_DEPT_TO_OA[d.code] || (d.code.startsWith('OA_') ? d.code : 'OA_2');
const nid = codeToId.get(targetCode);
if (nid && nid !== d.id) mapping.set(d.id, nid);
}
for (const [from, to] of mapping) {
await prisma.projectTask.updateMany({ where: { departmentId: from }, data: { departmentId: to } });
await prisma.employmentEvent.updateMany({ where: { fromDeptId: from }, data: { fromDeptId: to } });
await prisma.employmentEvent.updateMany({ where: { toDeptId: from }, data: { toDeptId: to } });
}
return { oldByCode, mapping };
}
async function reassignDemoUsers(demoIds: string[], adminId: string) {
if (!demoIds.length) return;
await prisma.bidReviewer.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.bidAssignee.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.contractReviewer.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.todoTask.deleteMany({ where: { assigneeId: { in: demoIds } } });
await prisma.approvalTask.deleteMany({ where: { assigneeId: { in: demoIds } } });
await prisma.workReport.deleteMany({ where: { OR: [{ authorId: { in: demoIds } }, { toUserId: { in: demoIds } }, { ccUserId: { in: demoIds } }] } });
await prisma.refreshToken.deleteMany({ where: { userId: { in: demoIds } } });
await prisma.userRole.deleteMany({ where: { userId: { in: demoIds } } });
const patch = async (model: string, field: string) => {
const delegate = (prisma as unknown as Record<string, { updateMany: (a: unknown) => Promise<unknown> }>)[model];
if (!delegate?.updateMany) return;
try {
await delegate.updateMany({
where: { [field]: { in: demoIds } },
data: { [field]: adminId },
});
} catch (e) {
console.warn(`skip ${model}.${field}`, (e as Error).message);
}
};
await patch('bidCase', 'createdById');
await patch('bidActionLog', 'actorId');
await patch('bidDocVersion', 'submittedById');
await patch('contract', 'createdById');
await patch('contractActionLog', 'actorId');
await patch('expenseClaim', 'applicantId');
await patch('expenseClaim', 'decidedById');
await patch('loanRecord', 'applicantId');
await patch('loanRecord', 'decidedById');
await patch('sealRequest', 'applicantId');
await patch('sealRequest', 'decidedById');
await patch('timesheet', 'userId');
await patch('projectTask', 'assigneeId');
await patch('projectAcceptance', 'actorId');
await patch('employmentEvent', 'actorId');
await patch('attendanceMonth', 'importedById');
await patch('payrollRun', 'createdById');
await patch('purchaseRequest', 'applicantId');
await patch('purchaseRequest', 'decidedById');
await patch('paymentRecord', 'createdById');
await patch('projectChange', 'actorId');
await patch('assetOccupancy', 'holderId');
await patch('invoiceRecord', 'createdById');
await patch('operationLog', 'userId');
await patch('credentialBorrow', 'borrowerId');
await prisma.user.deleteMany({ where: { id: { in: demoIds } } });
}
async function main() {
const dump = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Dump;
const hash = await argon2.hash(DEFAULT_PASSWORD);
const posts = new Map(dump.posts.map((p) => [p.id, p]));
const postsByUser = new Map<number, string[]>();
const postNamesByUser = new Map<number, string[]>();
for (const up of dump.userPosts) {
const p = posts.get(up.postId);
if (!p) continue;
postsByUser.set(up.userId, [...(postsByUser.get(up.userId) || []), p.code]);
postNamesByUser.set(up.userId, [...(postNamesByUser.get(up.userId) || []), p.name]);
}
await prisma.role.updateMany({ where: { code: { in: ['admin', 'owner', 'finance', 'hr'] } }, data: { dataScope: 'ALL' } });
await prisma.role.updateMany({ where: { code: { in: ['biz_director', 'tech_director'] } }, data: { dataScope: 'DEPT' } });
await prisma.role.updateMany({ where: { code: { in: ['biz_staff', 'employee'] } }, data: { dataScope: 'SELF' } });
const numberRules = [
{ code: 'BID', name: '投标编号', prefix: 'BID', padLength: 5 },
{ code: 'HT', name: '合同编号', prefix: 'HT', padLength: 5 },
{ code: 'PRJ', name: '项目编号', prefix: 'PRJ', padLength: 5 },
{ code: 'BX', name: '报销编号', prefix: 'BX', padLength: 5 },
{ code: 'JK', name: '借款编号', prefix: 'JK', padLength: 5 },
{ code: 'BZ', name: '保证金编号', prefix: 'BZ', padLength: 5 },
];
for (const r of numberRules) {
await prisma.numberRule.upsert({
where: { tenantId_code: { tenantId: '1', code: r.code } },
update: { prefix: r.prefix, padLength: r.padLength, name: r.name },
create: { ...r, tenantId: '1' },
});
}
const roles = await prisma.role.findMany();
const roleByCode = new Map(roles.map((r) => [r.code, r.id]));
const codeToId = new Map<string, string>();
const sortedDepts = [...dump.depts].sort((a, b) => a.parentId - b.parentId || a.orderNum - b.orderNum);
for (const d of sortedDepts) {
const code = `OA_${d.id}`;
const meta = DEPT_META[d.id] || { deptType: 'ADMIN' };
const parentCode = d.parentId ? `OA_${d.parentId}` : null;
const parentId = parentCode ? codeToId.get(parentCode) || null : null;
const existing = await prisma.department.findFirst({ where: { tenantId: '1', code } });
const row = existing
? await prisma.department.update({
where: { id: existing.id },
data: {
name: d.name,
deptType: meta.deptType,
categoryCode: meta.categoryCode || null,
sortNo: d.orderNum,
parentId,
},
})
: await prisma.department.create({
data: {
name: d.name,
code,
deptType: meta.deptType,
categoryCode: meta.categoryCode || null,
sortNo: d.orderNum,
parentId,
},
});
codeToId.set(code, row.id);
}
await remapDeptFks(codeToId);
const officeId = codeToId.get('OA_2')!;
const empByOa = new Map<number, string>();
const empByName = new Map<string, string>();
const adminUser = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!adminUser) throw new Error('本地必须先有 admin 账号');
const adminEmpId =
adminUser.employeeId ||
(
await prisma.employee.create({
data: {
name: '系统管理员',
employeeNo: 'E0000',
departmentId: officeId,
title: '系统管理员',
employmentStatus: 'REGULAR',
},
})
).id;
const adminOa = dump.users.find((u) => u.userName === 'administrator');
await prisma.employee.update({
where: { id: adminEmpId },
data: {
name: adminOa?.nickName || '系统管理员',
employeeNo: 'E0000',
departmentId: officeId,
title: '系统管理员',
employmentStatus: 'REGULAR',
hiredAt: when(adminOa?.entryDate || '2026-06-04'),
gender: gender(adminOa?.sex || '2'),
email: adminOa?.email || null,
mobile: adminOa?.phone || null,
remark: adminOa?.remark || '超级管理员',
managerId: null,
},
});
empByOa.set(1, adminEmpId);
empByName.set('系统管理员', adminEmpId);
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const deptId = codeToId.get(`OA_${u.deptId}`) || officeId;
const status = empStatus(u.employmentStatus);
const hiredAt = when(u.entryDate);
const titles = postNamesByUser.get(u.id) || [];
const title = titles[0] || u.remark || '员工';
const no = empNo(u.id);
const data = {
name: u.nickName,
departmentId: deptId,
title,
restSchedule: DEPT_META[u.deptId]?.categoryCode === 'CAT_SOFTWARE' ? 'DOUBLE' : 'SINGLE',
employmentStatus: status,
hiredAt,
internStartAt: status === 'INTERN' ? hiredAt : null,
probationEndAt: status === 'PROBATION' && hiredAt ? addMonths(hiredAt, 3) : null,
leftAt: when(u.leaveDate),
gender: gender(u.sex),
email: u.email || null,
mobile: u.phone || null,
remark: u.remark,
};
const existing = await prisma.employee.findFirst({ where: { tenantId: '1', employeeNo: no } });
const row = existing
? await prisma.employee.update({ where: { id: existing.id }, data })
: await prisma.employee.create({ data: { ...data, employeeNo: no } });
empByOa.set(u.id, row.id);
empByName.set(u.nickName, row.id);
}
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const mgrOa = DEPT_MANAGER_OA[u.deptId];
if (!mgrOa || mgrOa === u.id) continue;
const managerId = empByOa.get(mgrOa);
const id = empByOa.get(u.id);
if (managerId && id) await prisma.employee.update({ where: { id }, data: { managerId } });
}
await prisma.user.update({
where: { id: adminUser.id },
data: {
passwordHash: hash,
displayName: '系统管理员',
employeeId: adminEmpId,
status: 'ACTIVE',
},
});
for (const u of dump.users) {
if (u.userName === 'administrator') continue;
const employeeId = empByOa.get(u.id)!;
const roleCode = pickRole(u.userName, postsByUser.get(u.id) || []);
const roleId = roleByCode.get(roleCode);
if (!roleId) throw new Error(`缺少角色 ${roleCode}`);
const disabled = u.status === '1' || u.employmentStatus === '4';
const existing = await prisma.user.findFirst({ where: { tenantId: '1', username: u.userName } });
const row = existing
? await prisma.user.update({
where: { id: existing.id },
data: {
passwordHash: hash,
displayName: u.nickName,
employeeId,
status: disabled ? 'DISABLED' : 'ACTIVE',
},
})
: await prisma.user.create({
data: {
username: u.userName,
passwordHash: hash,
displayName: u.nickName,
employeeId,
status: disabled ? 'DISABLED' : 'ACTIVE',
},
});
await prisma.userRole.deleteMany({ where: { userId: row.id } });
await prisma.userRole.create({ data: { userId: row.id, roleId } });
}
const users = await prisma.user.findMany({ include: { employee: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
userByName.set('系统管理员', adminUser.id);
const rematchByText = async (
model: 'expenseClaim' | 'loanRecord' | 'sealRequest',
field: 'remark' | 'reason',
) => {
const rows = await prisma[model].findMany({ select: { id: true, [field]: true } as never });
let n = 0;
for (const r of rows as { id: string; remark?: string; reason?: string }[]) {
const name = ((r.remark || r.reason || '') as string).split(' / ')[0]?.trim();
if (!name) continue;
const uid = userByName.get(name);
if (!uid) continue;
await prisma[model].update({ where: { id: r.id }, data: { applicantId: uid } });
n += 1;
}
console.log(`matched ${model}`, n);
};
await rematchByText('expenseClaim', 'remark');
await rematchByText('loanRecord', 'remark');
await rematchByText('sealRequest', 'reason');
const keepUsernames = new Set(['admin', ...dump.users.filter((u) => u.userName !== 'administrator').map((u) => u.userName)]);
const demo = await prisma.user.findMany({
where: { OR: [{ username: { in: DEMO_USERS } }, { username: { notIn: [...keepUsernames] } }] },
});
const demoIds = demo.filter((u) => u.username !== 'admin').map((u) => u.id);
console.log(
'delete demo users',
demo.filter((u) => u.username !== 'admin').map((u) => u.username),
);
await reassignDemoUsers(demoIds, adminUser.id);
const keepEmp = new Set([adminEmpId, ...empByOa.values()]);
const leftoverEmp = await prisma.employee.findMany({ where: { id: { notIn: [...keepEmp] } } });
const leftoverIds = leftoverEmp.map((e) => e.id);
if (leftoverIds.length) {
await prisma.payrollItem.deleteMany({ where: { employeeId: { in: leftoverIds } } });
await prisma.attendanceMonth.deleteMany({ where: { employeeId: { in: leftoverIds } } });
await prisma.employmentEvent.deleteMany({ where: { employeeId: { in: leftoverIds } } });
await prisma.user.updateMany({ where: { employeeId: { in: leftoverIds } }, data: { employeeId: null } });
await prisma.employee.updateMany({ where: { managerId: { in: leftoverIds } }, data: { managerId: null } });
await prisma.employee.deleteMany({ where: { id: { in: leftoverIds } } });
}
console.log(
'deleted demo employees',
leftoverEmp.map((e) => e.name),
);
const keepDept = new Set(codeToId.values());
const leftoverDept = await prisma.department.findMany({ where: { id: { notIn: [...keepDept] } } });
if (leftoverDept.length) {
const leftoverDeptIds = leftoverDept.map((d) => d.id);
await prisma.employee.updateMany({
where: { departmentId: { in: leftoverDeptIds } },
data: { departmentId: officeId },
});
await prisma.projectTask.updateMany({
where: { departmentId: { in: leftoverDeptIds } },
data: { departmentId: officeId },
});
await prisma.department.updateMany({ where: { id: { in: leftoverDeptIds } }, data: { parentId: null } });
await prisma.department.deleteMany({ where: { id: { in: leftoverDeptIds } } });
}
console.log(
'deleted demo depts',
leftoverDept.map((d) => `${d.code}:${d.name}`),
);
const demoBid = await prisma.bidCase.findMany({ where: { name: '某市展厅数字化升级' } });
for (const b of demoBid) {
const used =
(await prisma.expenseClaim.count({ where: { bidCaseId: b.id } })) +
(await prisma.contract.count({ where: { bidCaseId: b.id } }));
if (used) continue;
await prisma.bidReviewer.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidAssignee.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidActionLog.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidDocVersion.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bondRecord.deleteMany({ where: { bidCaseId: b.id } });
await prisma.sealRequest.deleteMany({ where: { bidCaseId: b.id } });
await prisma.bidCase.delete({ where: { id: b.id } });
console.log('deleted demo bid', b.bidNo);
}
await remumber(
await prisma.bidCase.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'BID',
(id, bidNo) => prisma.bidCase.update({ where: { id }, data: { bidNo } }),
);
await remumber(
await prisma.contract.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'HT',
(id, contractNo) => prisma.contract.update({ where: { id }, data: { contractNo } }),
);
await remumber(
await prisma.project.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'PRJ',
(id, projectNo) => prisma.project.update({ where: { id }, data: { projectNo } }),
);
await remumber(
await prisma.expenseClaim.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'BX',
(id, claimNo) => prisma.expenseClaim.update({ where: { id }, data: { claimNo } }),
);
await remumber(
await prisma.loanRecord.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'JK',
(id, loanNo) => prisma.loanRecord.update({ where: { id }, data: { loanNo } }),
);
await remumber(
await prisma.bondRecord.findMany({ select: { id: true, createdAt: true }, orderBy: { createdAt: 'asc' } }),
'BZ',
(id, bondNo) => prisma.bondRecord.update({ where: { id }, data: { bondNo } }),
);
const empCount = await prisma.employee.count();
const userCount = await prisma.user.count();
const deptCount = await prisma.department.count();
const sample = await prisma.expenseClaim.findMany({ take: 3, include: { applicant: true }, orderBy: { createdAt: 'desc' } });
console.log({ empCount, userCount, deptCount, sampleNos: sample.map((s) => `${s.claimNo}:${s.applicant.displayName}`) });
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+306
View File
@@ -0,0 +1,306 @@
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { createGunzip } from 'zlib';
import { basename, join } from 'path';
import { createHash, randomUUID } from 'crypto';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const DUMP = process.env.OA_DUMP || '/opt/backup/cloud-20260830/oa.sql.gz';
const UPLOAD_ROOT = process.env.OA_UPLOAD || '/opt/backup/cloud-20260830/oa-uploadPath';
const SKIP_TITLE = /已更新至|发现新版本|推荐更新|桌面端|移动端|^App /;
function stableId(key: string) {
const hex = createHash('sha1').update(key).digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function parseMysqlTuples(valuesSql: string): unknown[][] {
const rows: unknown[][] = [];
let i = 0;
const s = valuesSql;
while (i < s.length) {
while (i < s.length && ' \n\r\t,'.includes(s[i])) i += 1;
if (i >= s.length) break;
if (s[i] !== '(') throw new Error(`expected ( at ${i}`);
i += 1;
const row: unknown[] = [];
while (true) {
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s.startsWith('_binary ', i)) i += 8;
if (s.startsWith('NULL', i) && (i + 4 === s.length || ',) \n\r\t'.includes(s[i + 4]))) {
row.push(null);
i += 4;
} else if (s[i] === "'" || s[i] === '"') {
const q = s[i];
i += 1;
let out = '';
while (i < s.length) {
const ch = s[i];
if (ch === '\\') {
const n = s[i + 1];
out += n === 'n' ? '\n' : n === 'r' ? '\r' : n === 't' ? '\t' : n === '0' ? '\0' : n;
i += 2;
continue;
}
if (ch === q) {
if (s[i + 1] === q) {
out += q;
i += 2;
continue;
}
i += 1;
break;
}
out += ch;
i += 1;
}
row.push(out);
} else {
const start = i;
while (i < s.length && s[i] !== ',' && s[i] !== ')') i += 1;
const raw = s.slice(start, i).trim();
row.push(raw === '' ? null : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw);
}
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s[i] === ',') {
i += 1;
continue;
}
if (s[i] === ')') {
i += 1;
rows.push(row);
break;
}
throw new Error(`bad token at ${i}: ${s.slice(i, i + 20)}`);
}
}
return rows;
}
async function readDump(): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
createReadStream(DUMP)
.pipe(createGunzip())
.on('data', (c: Buffer) => chunks.push(c))
.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
.on('error', reject);
});
}
function insertsFor(dump: string, table: string) {
const rows: unknown[][] = [];
const needle = `INSERT INTO \`${table}\` VALUES `;
let from = 0;
while (true) {
const start = dump.indexOf(needle, from);
if (start < 0) break;
let i = start + needle.length;
const s = dump;
while (i < s.length) {
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s[i] === ';') {
i += 1;
break;
}
if (s[i] === ',') {
i += 1;
continue;
}
if (s[i] !== '(') break;
const sliceStart = i;
let depth = 0;
let inStr = false;
let q = '';
while (i < s.length) {
const ch = s[i];
if (inStr) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === q) {
inStr = false;
}
i += 1;
continue;
}
if (ch === "'" || ch === '"') {
inStr = true;
q = ch;
i += 1;
continue;
}
if (ch === '(') depth += 1;
if (ch === ')') {
depth -= 1;
if (depth === 0) {
i += 1;
break;
}
}
i += 1;
}
rows.push(...parseMysqlTuples(s.slice(sliceStart, i)));
}
from = i;
}
return rows;
}
function mimeOf(name: string) {
const lower = name.toLowerCase();
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (lower.endsWith('.doc')) return 'application/msword';
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
return 'application/octet-stream';
}
function resolveUpload(rel: string) {
const cleaned = rel.replace(/^\/profile\//, '').replace(/^\//, '');
const candidates = [join(UPLOAD_ROOT, cleaned), join(UPLOAD_ROOT, 'upload', cleaned.replace(/^upload\//, ''))];
return candidates.find((p) => existsSync(p));
}
async function attachFile(noticeId: string, rel: string, authorId: string) {
if (!rel?.trim()) return;
const src = resolveUpload(rel);
if (!src) {
console.warn('missing attachment', rel);
return;
}
const fileName = basename(src);
const buf = readFileSync(src);
const id = stableId(`oa-file:${rel}`);
const ext = (fileName.split('.').pop() || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
const storageKey = `NOTICE/${noticeId}/${id}.${ext || 'bin'}`;
const full = join(process.cwd(), 'uploads', storageKey);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, buf);
await prisma.fileAsset.upsert({
where: { id },
update: { fileName, size: buf.length, mimeType: mimeOf(fileName), storageKey, bizId: noticeId },
create: {
id,
tenantId: '1',
bizType: 'NOTICE',
bizId: noticeId,
fileName,
mimeType: mimeOf(fileName),
size: buf.length,
storageKey,
uploadedById: authorId,
},
});
}
async function upsertNotice(opts: {
key: string;
title: string;
content: string;
kind: 'ANNOUNCEMENT' | 'POLICY';
createdAt?: Date | null;
authorId: string;
attachment?: string | null;
}) {
const title = opts.title.trim();
if (!title) return;
const id = stableId(opts.key);
const content = opts.content || '';
await prisma.notice.upsert({
where: { id },
update: { title, content, kind: opts.kind, status: 'PUBLISHED' },
create: {
id,
tenantId: '1',
title,
content,
kind: opts.kind,
status: 'PUBLISHED',
createdById: opts.authorId,
createdAt: opts.createdAt || undefined,
},
});
if (opts.attachment) await attachFile(id, opts.attachment, opts.authorId);
}
function asDate(v: unknown) {
if (!v) return null;
const d = new Date(String(v));
return Number.isNaN(d.getTime()) ? null : d;
}
async function main() {
const admin = await prisma.user.findFirst({ where: { username: '18115604840' } });
if (!admin) throw new Error('缺少管理员账号');
await prisma.notice.deleteMany({ where: { title: '未命名', status: 'DRAFT' } });
const dump = await readDump();
let n = 0;
for (const row of insertsFor(dump, 'oa_announcement')) {
const title = String(row[1] || '');
const status = String(row[4] || '');
if (status && status !== '正常') continue;
await upsertNotice({
key: `oa-announcement:${row[0]}`,
title,
content: String(row[15] || ''),
kind: 'ANNOUNCEMENT',
createdAt: asDate(row[17]) || asDate(row[11]),
authorId: admin.id,
attachment: row[14] ? String(row[14]) : null,
});
n += 1;
}
for (const row of insertsFor(dump, 'oa_regulation')) {
const title = String(row[1] || '');
const status = String(row[16] || '');
if (status && status !== '正常') continue;
await upsertNotice({
key: `oa-regulation:${row[0]}`,
title,
content: String(row[19] || ''),
kind: 'POLICY',
createdAt: asDate(row[15]) || asDate(row[21]),
authorId: admin.id,
attachment: row[18] ? String(row[18]) : null,
});
n += 1;
}
for (const row of insertsFor(dump, 'sys_notice')) {
const type = String(row[2] || '');
const title = String(row[1] || '');
const status = String(row[4] || '0');
if (type !== '2') continue;
if (status === '1') continue;
if (SKIP_TITLE.test(title)) continue;
await upsertNotice({
key: `sys-notice:${row[0]}`,
title,
content: String(row[3] || ''),
kind: 'ANNOUNCEMENT',
createdAt: asDate(row[7]),
authorId: admin.id,
});
n += 1;
}
const total = await prisma.notice.count();
const files = await prisma.fileAsset.count({ where: { bizType: 'NOTICE' } });
console.log(`imported ${n} source rows; notices=${total} files=${files}`);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+371
View File
@@ -0,0 +1,371 @@
// @ts-nocheck
import { PrismaClient, Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_core.json';
const HR = '/opt/import/prod/oa_hr.json';
const OA_FILES = '/opt/import/prod/oa-files';
const UPLOAD = path.join(process.cwd(), 'uploads');
type Row = Record<string, unknown>;
const MIME: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
zip: 'application/zip',
rar: 'application/x-rar-compressed',
};
const NAME_ALIAS: Record<string, string> = {
: '张婧妍',
};
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 2000) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s || undefined;
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
return '软件';
}
function mapQual(raw: unknown) {
const s = str(raw);
if (!s) return '无需资质';
try {
const arr = JSON.parse(s) as { item?: string }[];
const item = str(arr?.[0]?.item);
if (!item || item === '无') return '无需资质';
if (item.includes('军')) return '军二';
if (item.includes('涉')) return '涉乙';
return item.slice(0, 20);
} catch {
if (s.includes('军')) return '军二';
if (s.includes('涉')) return '涉乙';
return '其他';
}
}
function profilePaths(v: unknown) {
const out: string[] = [];
if (!v) return out;
for (const chunk of str(v).split(/[,;]/)) {
const piece = chunk.trim();
const first = piece.split('|')[0].trim();
const idx = first.indexOf('/profile/upload/');
const p = idx >= 0 ? first.slice(idx) : first;
if (p.startsWith('/profile/upload/')) out.push(p);
}
return out;
}
function relOf(p: string) {
return p.replace(/^\/profile\/upload\//, '');
}
function displayName(p: string) {
const pipe = str(p).split('|')[1];
if (pipe && !pipe.includes('/')) return path.basename(pipe);
return path.basename(relOf(p.split('|')[0]));
}
function mimeOf(name: string) {
const ext = name.split('.').pop()?.toLowerCase() || 'bin';
return MIME[ext] || 'application/octet-stream';
}
function prettyName(name: string) {
try {
return decodeURIComponent(name);
} catch {
return name;
}
}
async function attach(
bizType: string,
bizId: string,
urls: unknown,
uploadedById: string,
seen: Set<string>,
) {
let n = 0;
let firstNas: string | undefined;
for (const raw of profilePaths(urls)) {
const rel = relOf(raw);
const src = path.join(OA_FILES, rel);
if (!fs.existsSync(src)) continue;
const buf = fs.readFileSync(src);
const hash = createHash('sha1').update(buf).digest('hex').slice(0, 16);
const key = `${bizId}:${hash}`;
if (seen.has(key)) continue;
seen.add(key);
const id = randomUUID();
const ext = (path.extname(rel).replace('.', '') || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
const storageKey = `OA/${bizType}/${bizId}/${id}.${ext || 'bin'}`;
const dest = path.join(UPLOAD, storageKey);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
const name = prettyName(displayName(raw) || path.basename(rel));
await prisma.fileAsset.create({
data: {
id,
tenantId: '1',
bizType,
bizId,
fileName: name.slice(0, 180),
mimeType: mimeOf(name),
size: buf.length,
storageKey,
uploadedById,
},
});
n += 1;
firstNas ||= raw;
}
return { n, firstNas };
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const hr = JSON.parse(fs.readFileSync(HR, 'utf8')) as { users: { id: number; userName: string; nickName: string }[] };
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('没有 admin');
fs.mkdirSync(UPLOAD, { recursive: true });
const users = await prisma.user.findMany();
const byUsername = new Map(users.map((u) => [u.username, u]));
const byName = new Map(users.map((u) => [u.displayName, u]));
for (const [a, b] of Object.entries(NAME_ALIAS)) {
const u = byName.get(b);
if (u) byName.set(a, u);
}
const oaUser = new Map(hr.users.map((u) => [String(u.id), u.userName]));
const userFromOaId = (id: unknown) => {
const uname = oaUser.get(str(id));
return uname ? byUsername.get(uname) : undefined;
};
const userFromName = (name: unknown) => {
const n = NAME_ALIAS[str(name)] || str(name);
return byName.get(n);
};
await prisma.fileAsset.deleteMany({ where: { storageKey: { startsWith: 'OA/' } } });
const seen = new Set<string>();
const stats = { bids: 0, bidFiles: 0, contracts: 0, contractFiles: 0, expenses: 0, expenseFiles: 0, projects: 0, purchases: 0 };
const bids = await prisma.bidCase.findMany();
const bidByName = new Map(bids.map((b) => [b.name, b]));
const tenders = (raw.oa_tender || []).filter((t) => /^TB-/.test(str(t.system_number)) && str(t.project_name));
for (const t of tenders) {
const local = bidByName.get(str(t.project_name));
if (!local) {
console.warn('no local bid', t.project_name);
continue;
}
const creator = userFromOaId(t.create_by) || userFromName(t.bid_prepare_person) || admin;
const drafter = userFromName(t.bid_prepare_person);
const bidder = userFromOaId(t.bid_delivery_person_id) || userFromName(t.bid_delivery_person);
const announce = await attach('BID_ANNOUNCE', local.id, t.announcement_file, creator.id, seen);
const extra = await attach('BID_FILE', local.id, t.attachment, creator.id, seen);
const formal = await attach('BID_FORMAL', local.id, t.formal_bid_file, creator.id, seen);
const proof = await attach('BID_PROOF', local.id, t.submit_proof_file, creator.id, seen);
const nas = announce.firstNas || extra.firstNas || formal.firstNas || proof.firstNas;
await prisma.bidCase.update({
where: { id: local.id },
data: {
externalNo: clip(t.project_number, 120) || local.externalNo,
projectType: mapProjectType(t.project_type, t.project_name),
source: clip(t.project_source, 50) || local.source,
qualificationNeed: mapQual(t.bid_doc_requirement),
tenderMethod: clip(t.tender_method, 50) || local.tenderMethod,
applyStartAt: when(t.create_time) || local.applyStartAt,
applyEndAt: when(t.file_apply_deadline) || local.applyEndAt,
applyMethod: announce.firstNas || extra.firstNas ? '网上申领' : '其他',
openAt: when(t.bid_open_date) || local.openAt,
openMethod: str(t.tender_address) ? '线下开标' : '其他',
openPlace: clip(t.tender_address, 200) || local.openPlace,
projectNature: clip(t.project_nature, 50) || local.projectNature,
securityLevel: str(t.security_level) || local.securityLevel,
contactName: clip(t.bid_contact_person, 64) || local.contactName,
contactPhone: clip(t.bid_contact_phone, 32) || local.contactPhone,
bondMethod: clip(t.deposit_pay_method, 20) || local.bondMethod,
bondStatus: clip(t.deposit_status, 20) || local.bondStatus,
bondPaidAt: when(t.deposit_pay_date) || local.bondPaidAt,
delivererName: clip(t.bid_delivery_person, 64) || local.delivererName,
priceCap: t.price_limit == null || t.price_limit === '' ? local.priceCap : String(t.price_limit),
quoteAmount: t.bid_quotation == null || t.bid_quotation === '' ? local.quoteAmount : String(t.bid_quotation),
summary: clip(t.project_content || t.project_intro, 4000) || local.summary,
nasPath: nas || local.nasPath,
remark: clip(t.remark, 1000) || local.remark,
createdById: creator.id,
},
});
if (drafter) {
await prisma.bidAssignee.upsert({
where: { bidCaseId_userId_role: { bidCaseId: local.id, userId: drafter.id, role: 'DRAFTER' } },
update: {},
create: { bidCaseId: local.id, userId: drafter.id, role: 'DRAFTER' },
});
}
if (bidder) {
await prisma.bidAssignee.upsert({
where: { bidCaseId_userId_role: { bidCaseId: local.id, userId: bidder.id, role: 'BIDDER' } },
update: {},
create: { bidCaseId: local.id, userId: bidder.id, role: 'BIDDER' },
});
}
if (formal.firstNas) {
const exists = await prisma.bidDocVersion.findFirst({ where: { bidCaseId: local.id, versionNo: 1 } });
if (!exists) {
await prisma.bidDocVersion.create({
data: {
bidCaseId: local.id,
versionNo: 1,
nasPath: formal.firstNas,
note: 'OA 正式标书',
submittedById: (drafter || creator).id,
},
});
} else {
await prisma.bidDocVersion.update({
where: { id: exists.id },
data: { nasPath: formal.firstNas },
});
}
}
stats.bids += 1;
stats.bidFiles += announce.n + extra.n + formal.n + proof.n;
}
const contracts = await prisma.contract.findMany();
const contractByName = new Map(contracts.map((c) => [c.name, c]));
for (const c of raw.oa_contract || []) {
const local = contractByName.get(str(c.contract_name));
if (!local) {
console.warn('no local contract', c.contract_name);
continue;
}
const creator = userFromOaId(c.create_by) || userFromOaId(c.creator_id) || admin;
const original = await attach('CONTRACT', local.id, c.contract_original_url, creator.id, seen);
const accept = await attach('CONTRACT_ACCEPT', local.id, c.acceptance_url, creator.id, seen);
const award = await attach('CONTRACT_AWARD', local.id, c.award_notice_url, creator.id, seen);
await prisma.contract.update({
where: { id: local.id },
data: {
nasPath: original.firstNas || accept.firstNas || award.firstNas || local.nasPath,
signedAt: when(c.sign_date) || local.signedAt,
createdById: creator.id,
partyA: local.partyA || str(c.party_a_name) || undefined,
partyB: local.partyB || str(c.party_b_name) || undefined,
remark:
local.remark ||
[str(c.payment_method) && `付款:${c.payment_method}`, str(c.remark)]
.filter(Boolean)
.join(' / ') ||
undefined,
},
});
stats.contracts += 1;
stats.contractFiles += original.n + accept.n + award.n;
}
const expenses = await prisma.expenseClaim.findMany();
for (const r of raw.reimbursement || []) {
const amount = num(r.total_amount);
const name = str(r.employee_name);
const subject = str(r.subject);
let hits = expenses.filter((e) => Math.abs(Number(e.amount) - amount) < 0.009 && (e.remark || '').includes(name));
if (subject) {
const tighter = hits.filter((e) => (e.remark || '').includes(subject));
if (tighter.length) hits = tighter;
}
const stamp = when(r.created_time);
if (stamp && hits.length > 1) {
const day = stamp.toISOString().slice(0, 10);
const byDay = hits.filter((e) => (e.submittedAt || e.createdAt).toISOString().slice(0, 10) === day);
if (byDay.length) hits = byDay;
}
const local = hits[0];
if (!local) continue;
const who = userFromName(name) || users.find((u) => u.id === local.applicantId) || admin;
const items = (raw.reimbursement_item || []).filter((i) => String(i.reimbursement_id) === String(r.id));
let n = 0;
for (const item of items) {
n += (await attach('EXPENSE', local.id, item.url, who.id, seen)).n;
}
n += (await attach('EXPENSE', local.id, r.attachment_url, who.id, seen)).n;
if (n) {
stats.expenses += 1;
stats.expenseFiles += n;
}
}
const projects = await prisma.project.findMany();
const projectByName = new Map(projects.map((p) => [p.name, p]));
for (const p of raw.project_list || []) {
const local = projectByName.get(str(p.project_name));
if (!local) continue;
const n = await attach('PROJECT', local.id, p.bzj_attachment, admin.id, seen);
stats.projects += n.n ? 1 : 0;
}
const purchases = await prisma.purchaseRequest.findMany();
for (const r of raw.oa_purchasing || []) {
const titleBits = [str(r.contract_no), str(r.contract_name)].filter(Boolean);
const local = purchases.find((p) => titleBits.some((b) => p.title.includes(b)));
if (!local) continue;
const who = userFromOaId(r.create_by) || userFromOaId(r.creator_id) || admin;
const a = await attach('PURCHASE', local.id, r.contract_original_url, who.id, seen);
const b = await attach('PURCHASE_ACCEPT', local.id, r.acceptance_url, who.id, seen);
if (a.n + b.n) stats.purchases += 1;
}
const fileCount = await prisma.fileAsset.count();
console.log(JSON.stringify({ ...stats, fileAssetTotal: fileCount }, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+475
View File
@@ -0,0 +1,475 @@
import { PrismaClient, Prisma } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
const DUMP = path.resolve('/opt/import/prod/oa_clean.json');
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s || undefined;
}
function realPartyName(raw: unknown) {
let name = str(raw).replace(/\s+/g, ' ');
if (!name) return '';
const alias: Record<string, string> = {
'73047': '中国人民解放军73047部队保障部',
'中国人民解放军 32272 部队': '中国人民解放军32272部队',
};
name = alias[name] || name;
if (/^未指定/.test(name)) return '';
if (/^\d+$/.test(name)) return '';
if (name.startsWith('/') || name.endsWith('.pdf')) return '';
if (name.length < 2) return '';
return name;
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
return '软件';
}
function mapBidStatus(row: Row) {
const win = str(row.winning_status);
const st = str(row.project_status);
if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON';
if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST';
if (st === '流标') return 'FAILED';
if (st === '弃标' || win.includes('弃标')) return 'TERMINATED';
return 'PENDING';
}
function mapContractStatus(v: unknown) {
const s = str(v);
if (s === '已通过') return 'APPROVED';
if (s === '审批中') return 'PENDING';
return 'DRAFT';
}
function mapProjectStatus(v: unknown) {
const s = str(v);
if (s === '验收通过') return 'ACCEPTED';
if (s === '已完成') return 'DONE';
if (s === '待验收') return 'INTERNAL_ACCEPTED';
if (s === '未开始') return 'NOT_STARTED';
return 'ACTIVE';
}
function mapExpenseStatus(v: unknown) {
const s = str(v);
if (s === '2') return 'APPROVED';
if (s === '3') return 'REJECTED';
if (s === '1') return 'PENDING';
return 'DRAFT';
}
function mapLoanStatus(row: Row) {
const st = str(row.approval_status);
const offset = num(row.offset_amount);
if (st === '2') {
if (str(row.offset_status) === '2' || offset > 0) return offset >= num(row.amount) ? 'OFFSET' : 'OPEN';
return 'OPEN';
}
if (st === '1') return 'PENDING';
if (st === '3') return 'REJECTED';
return 'DRAFT';
}
function mapSealStatus(v: unknown) {
const s = str(v);
if (s === '2') return 'APPROVED';
if (s === '3') return 'REJECTED';
return 'PENDING';
}
function mapItemCategory(itemType: string) {
if (itemType.includes('交通') || itemType.includes('差旅')) return 'travel';
if (itemType.includes('餐')) return 'meal';
if (itemType.includes('住宿')) return 'stay';
if (itemType.includes('办公')) return 'office';
if (itemType.includes('招待')) return 'entertain';
return 'other';
}
function isBidExpense(itemType: string) {
return itemType.includes('投标') || itemType.includes('制标');
}
async function purgeBusiness() {
await prisma.smsSendLog.deleteMany();
await prisma.operationLog.deleteMany();
await prisma.paymentRecord.deleteMany();
await prisma.projectChange.deleteMany();
await prisma.todoTask.deleteMany();
await prisma.approvalTask.deleteMany();
await prisma.workReport.deleteMany();
await prisma.fileAsset.deleteMany();
await prisma.expenseClaim.deleteMany();
await prisma.loanRecord.deleteMany();
await prisma.bondRecord.deleteMany();
await prisma.invoiceRecord.deleteMany();
await prisma.timesheet.deleteMany();
await prisma.projectAcceptance.deleteMany();
await prisma.projectBudget.deleteMany();
await prisma.projectTask.deleteMany();
await prisma.purchaseRequest.deleteMany();
await prisma.assetOccupancy.deleteMany();
await prisma.contractReviewer.deleteMany();
await prisma.contractActionLog.deleteMany();
await prisma.paymentMilestone.deleteMany();
await prisma.sealRequest.deleteMany();
await prisma.credentialBorrow.deleteMany();
await prisma.project.deleteMany();
await prisma.contract.deleteMany();
await prisma.bidDocVersion.deleteMany();
await prisma.bidAssignee.deleteMany();
await prisma.bidActionLog.deleteMany();
await prisma.bidReviewer.deleteMany();
await prisma.bidCase.deleteMany();
await prisma.partyContact.deleteMany();
await prisma.businessParty.deleteMany();
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('本地没有 admin');
await purgeBusiness();
await prisma.legalEntity.updateMany({ data: { isDefault: false } });
let entity = await prisma.legalEntity.findFirst({
where: { tenantId: '1', name: '江苏风影随行科技有限公司' },
});
if (!entity) {
entity = await prisma.legalEntity.create({
data: { name: '江苏风影随行科技有限公司', shortName: '江苏风影', isDefault: true },
});
} else {
entity = await prisma.legalEntity.update({ where: { id: entity.id }, data: { isDefault: true } });
}
const tenders = (raw.oa_tender || []).filter((r) => /^TB-\d/.test(str(r.system_number)) && str(r.project_name));
const partyByName = new Map<string, string>();
const partyNames = new Set<string>();
for (const t of tenders) {
const n = realPartyName(t.tender_dan_wei);
if (n) partyNames.add(n);
}
for (const c of raw.oa_contract || []) {
const n = realPartyName(c.party_a_name);
if (n && n !== '江苏风影随行科技有限公司') partyNames.add(n);
}
for (const name of partyNames) {
const row = await prisma.businessParty.create({
data: { name, remark: 'OA 导入', level: 'B' },
});
partyByName.set(name, row.id);
}
for (const t of tenders) {
const n = realPartyName(t.tender_dan_wei);
const person = str(t.bid_contact_person);
const phone = str(t.bid_contact_phone);
if (!n || (!person && !phone)) continue;
const partyId = partyByName.get(n);
if (!partyId) continue;
const exists = await prisma.partyContact.findFirst({ where: { partyId, name: person || '招标联系人' } });
if (!exists) {
await prisma.partyContact.create({
data: { partyId, name: person || '招标联系人', phone: phone || undefined },
});
}
}
const bidByProd = new Map<string, string>();
const bidByNo = new Map<string, string>();
const bidByName = new Map<string, string>();
for (const t of tenders) {
const bidNo = str(t.system_number);
const partyName = realPartyName(t.tender_dan_wei);
const resultBits = [
t.winning_unit ? `中标单位:${t.winning_unit}` : '',
num(t.winning_amount) ? `中标金额:${num(t.winning_amount)}` : '',
str(t.opening_remark),
str(t.bid_summary),
].filter(Boolean);
const row = await prisma.bidCase.create({
data: {
bidNo,
name: clip(t.project_name, 200)!,
externalNo: clip(t.project_number, 120) || undefined,
projectType: mapProjectType(t.project_type, t.project_name),
source: clip(t.project_source, 50) || undefined,
legalEntityId: entity.id,
partyId: partyName ? partyByName.get(partyName) : undefined,
tenderMethod: clip(t.tender_method, 50) || undefined,
applyEndAt: when(t.file_apply_deadline) || undefined,
openAt: when(t.bid_open_date) || undefined,
openPlace: clip(t.tender_address, 200) || undefined,
securityLevel: str(t.security_level) || 'INTERNAL',
projectNature: clip(t.project_nature, 50) || undefined,
contactName: clip(t.bid_contact_person, 64) || undefined,
contactPhone: clip(t.bid_contact_phone, 32) || undefined,
bondMethod: clip(t.deposit_pay_method, 20) || undefined,
bondStatus: clip(t.deposit_status, 20) || undefined,
bondPaidAt: when(t.deposit_pay_date) || undefined,
delivererName: clip(t.bid_delivery_person, 64) || undefined,
priceCap: t.price_limit == null ? undefined : String(t.price_limit),
quoteAmount: t.bid_quotation == null ? undefined : String(t.bid_quotation),
summary: clip(t.project_intro, 2000) || undefined,
remark: clip(t.remark, 1000) || undefined,
resultNote: resultBits.join('') || undefined,
openedResultAt: when(t.winning_date) || when(t.bid_open_date) || undefined,
status: mapBidStatus(t),
createdById: admin.id,
createdAt: when(t.create_time) || undefined,
},
});
bidByProd.set(String(t.id), row.id);
bidByNo.set(bidNo, row.id);
bidByName.set(str(t.project_name), row.id);
const deposit = num(t.bid_deposit);
if (deposit > 0) {
const paid = Boolean(when(t.deposit_pay_date));
const returned = Boolean(when(t.deposit_refund_date));
await prisma.bondRecord.create({
data: {
bondNo: `BZJ-${bidNo}`,
bidCaseId: row.id,
bondType: 'TENDER',
amount: new Prisma.Decimal(deposit),
paidAt: when(t.deposit_pay_date) || undefined,
returnedAt: when(t.deposit_refund_date) || undefined,
status: returned ? 'RETURNED' : paid ? 'PAID' : 'UNPAID',
remark: 'OA 投标保证金',
},
});
}
}
const contractByProd = new Map<string, string>();
const contractByNo = new Map<string, string>();
const contractByName = new Map<string, string>();
for (const c of raw.oa_contract || []) {
const contractNo = str(c.contract_no);
if (!contractNo) continue;
const tb = contractNo.match(/TB-\d+-\d+/);
const bidCaseId = tb ? bidByNo.get(tb[0]) : undefined;
if (str(c.approval_status) === 'pending_submit' && !c.sign_date && !num(c.amount)) {
continue;
}
const partyA = realPartyName(c.party_a_name);
const partyB = str(c.party_b_name);
if (partyA && !partyByName.has(partyA) && partyA !== '江苏风影随行科技有限公司') {
const p = await prisma.businessParty.create({ data: { name: partyA, remark: 'OA 合同甲方', level: 'B' } });
partyByName.set(partyA, p.id);
}
const remark = [
str(c.payment_method) ? `付款:${c.payment_method}` : '',
str(c.contract_lb) ? `类别:${c.contract_lb}` : '',
str(c.remark),
]
.filter(Boolean)
.join(' / ');
const row = await prisma.contract.create({
data: {
contractNo,
name: clip(c.contract_name, 200)!,
bidCaseId,
legalEntityId: entity.id,
amount: new Prisma.Decimal(num(c.amount)),
status: mapContractStatus(c.approval_status),
partyA: partyA || undefined,
partyB: partyB || undefined,
remark: remark || undefined,
signedAt: when(c.sign_date) || undefined,
createdById: admin.id,
createdAt: when(c.create_time) || undefined,
},
});
contractByProd.set(String(c.id), row.id);
contractByNo.set(contractNo, row.id);
contractByName.set(str(c.contract_name), row.id);
}
const projectByProd = new Map<string, string>();
const projectByName = new Map<string, string>();
for (const p of raw.project_list || []) {
const projectNo = str(p.system_number) || str(p.project_code);
if (!projectNo || !str(p.project_name)) continue;
const contractId =
(p.contract_id != null ? contractByProd.get(String(p.contract_id)) : undefined) ||
(str(p.contract_name) ? contractByName.get(str(p.contract_name)) : undefined);
const row = await prisma.project.create({
data: {
projectNo,
name: clip(p.project_name, 200)!,
contractId,
status: mapProjectStatus(p.project_status),
createdAt: when(p.create_time) || undefined,
},
});
projectByProd.set(String(p.project_id), row.id);
projectByName.set(str(p.project_name), row.id);
const budget = num(p.project_budget);
if (budget > 0) {
await prisma.projectBudget.create({
data: { projectId: row.id, category: 'PURCHASE', amount: new Prisma.Decimal(budget) },
});
}
const bzj = num(p.project_bzj);
if (bzj > 0) {
await prisma.bondRecord.create({
data: {
bondNo: `BZJ-${projectNo}`,
projectId: row.id,
contractId,
bondType: 'WARRANTY',
amount: new Prisma.Decimal(bzj),
dueBackAt: when(p.bzj_date) || undefined,
status: 'UNPAID',
remark: str(p.bzj_type) ? `OA 项目质保金 ${p.bzj_type}` : 'OA 项目质保金',
},
});
}
}
const itemsByReimb = new Map<string, Row[]>();
for (const item of raw.reimbursement_item || []) {
const key = String(item.reimbursement_id);
if (!itemsByReimb.has(key)) itemsByReimb.set(key, []);
itemsByReimb.get(key)!.push(item);
}
for (const r of raw.reimbursement || []) {
const claimNo = str(r.reimburse_no);
if (!claimNo) continue;
const lines = itemsByReimb.get(String(r.id)) || [];
const main = [...lines].sort((a, b) => num(b.amount) - num(a.amount))[0];
const itemType = str(main?.item_type);
const cat = mapItemCategory(itemType);
const projectId = r.project_id != null ? projectByProd.get(String(r.project_id)) : projectByName.get(str(r.project_name));
const bidCaseId = isBidExpense(itemType)
? bidByName.get(str(r.project_name))
: !projectId
? bidByName.get(str(r.project_name))
: undefined;
const costType = isBidExpense(itemType) || bidCaseId ? 'BID' : projectId ? 'PROJECT' : 'DEPT';
await prisma.expenseClaim.create({
data: {
claimNo,
applicantId: admin.id,
costType,
budgetCategory: costType === 'PROJECT' ? (cat === 'office' ? 'PURCHASE' : 'TRAVEL') : undefined,
projectId: costType === 'PROJECT' ? projectId : undefined,
bidCaseId: costType === 'BID' ? bidCaseId : undefined,
expenseCategory: cat,
purpose: clip(r.subject || r.project_name, 200) || undefined,
amount: new Prisma.Decimal(num(r.total_amount)),
status: mapExpenseStatus(r.approval_status),
remark: clip(r.remark, 1000) || undefined,
submittedAt: when(r.created_time) || undefined,
decidedAt: str(r.approval_status) === '2' ? when(r.created_time) || undefined : undefined,
decidedById: str(r.approval_status) === '2' ? admin.id : undefined,
},
});
}
for (const r of raw.borrow_advance || []) {
const loanNo = str(r.advance_no);
if (!loanNo) continue;
const projectId = r.project_id != null ? projectByProd.get(String(r.project_id)) : projectByName.get(str(r.project_name));
await prisma.loanRecord.create({
data: {
loanNo,
applicantId: admin.id,
costType: projectId ? 'PROJECT' : 'DEPT',
projectId,
amount: new Prisma.Decimal(num(r.amount)),
offsetAmount: new Prisma.Decimal(num(r.offset_amount)),
purpose: clip(r.reason || r.subject, 200) || 'OA 借款',
status: mapLoanStatus(r),
remark: undefined,
submittedAt: when(r.apply_time) || undefined,
},
});
}
for (const r of raw.oa_seal_apply || []) {
const reason = clip(r.content || r.apply_subject, 500) || '用章';
const bidHit = tenders.find((t) => str(t.project_name) && reason.includes(str(t.project_name)));
await prisma.sealRequest.create({
data: {
sealType: clip(r.seal_type, 40) || '公章',
reason,
bidCaseId: bidHit ? bidByProd.get(String(bidHit.id)) : undefined,
applicantId: admin.id,
takeOut: str(r.is_borrow) === '是',
returnAt: when(r.borrow_end) || undefined,
status: mapSealStatus(r.approval_status),
createdAt: when(r.create_time) || undefined,
},
});
}
for (const r of raw.oa_purchasing || []) {
const title = [str(r.contract_no), str(r.contract_name)].filter(Boolean).join(' ');
if (!title) continue;
await prisma.purchaseRequest.create({
data: {
title: clip(title, 200)!,
amount: new Prisma.Decimal(num(r.amount)),
status: str(r.approval_status) === '已通过' ? 'APPROVED' : str(r.approval_status) === '审批中' ? 'PENDING' : 'DRAFT',
remark: [r.party_a_name, r.payment_method, r.sign_date, r.remark].map(str).filter(Boolean).join(' / ') || undefined,
applicantId: admin.id,
},
});
}
const counts = {
parties: partyByName.size,
bids: tenders.length,
contracts: (raw.oa_contract || []).length,
projects: (raw.project_list || []).length,
expenses: (raw.reimbursement || []).length,
loans: (raw.borrow_advance || []).length,
seals: (raw.oa_seal_apply || []).length,
purchases: (raw.oa_purchasing || []).length,
};
console.log(JSON.stringify(counts, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+239
View File
@@ -0,0 +1,239 @@
import { createReadStream } from 'fs';
import { createGunzip } from 'zlib';
import { PrismaClient } from '@prisma/client';
import { parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const DUMP = process.env.OA_DUMP || '/opt/backup/cloud-20260830/oa.sql.gz';
const HR = '/opt/import/prod/oa_hr.json';
function n(v: unknown) {
if (v == null || v === '') return 0;
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
function parseMysqlTuples(s: string): unknown[][] {
const rows: unknown[][] = [];
let i = 0;
while (i < s.length) {
while (i < s.length && ' \n\r\t,'.includes(s[i])) i += 1;
if (i >= s.length) break;
if (s[i] !== '(') throw new Error(`expected ( at ${i}`);
i += 1;
const row: unknown[] = [];
while (true) {
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s.startsWith('NULL', i) && (i + 4 === s.length || ',) \n\r\t'.includes(s[i + 4]))) {
row.push(null);
i += 4;
} else if (s[i] === "'" || s[i] === '"') {
const q = s[i];
i += 1;
let out = '';
while (i < s.length) {
const ch = s[i];
if (ch === '\\') {
out += s[i + 1];
i += 2;
continue;
}
if (ch === q) {
if (s[i + 1] === q) {
out += q;
i += 2;
continue;
}
i += 1;
break;
}
out += ch;
i += 1;
}
row.push(out);
} else {
const start = i;
while (i < s.length && s[i] !== ',' && s[i] !== ')') i += 1;
const raw = s.slice(start, i).trim();
row.push(raw === '' ? null : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw);
}
while (i < s.length && ' \n\r\t'.includes(s[i])) i += 1;
if (s[i] === ',') {
i += 1;
continue;
}
if (s[i] === ')') {
i += 1;
rows.push(row);
break;
}
throw new Error(`bad token at ${i}`);
}
}
return rows;
}
function insertsFor(dump: string, table: string) {
const rows: unknown[][] = [];
const needle = `INSERT INTO \`${table}\` VALUES `;
let from = 0;
while (true) {
const start = dump.indexOf(needle, from);
if (start < 0) break;
let i = start + needle.length;
while (i < dump.length) {
while (i < dump.length && ' \n\r\t'.includes(dump[i])) i += 1;
if (dump[i] === ';') {
i += 1;
break;
}
if (dump[i] === ',') {
i += 1;
continue;
}
if (dump[i] !== '(') break;
const sliceStart = i;
let depth = 0;
let inStr = false;
let q = '';
while (i < dump.length) {
const ch = dump[i];
if (inStr) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === q) inStr = false;
i += 1;
continue;
}
if (ch === "'" || ch === '"') {
inStr = true;
q = ch;
i += 1;
continue;
}
if (ch === '(') depth += 1;
if (ch === ')') {
depth -= 1;
if (depth === 0) {
i += 1;
break;
}
}
i += 1;
}
rows.push(...parseMysqlTuples(dump.slice(sliceStart, i)));
}
from = i;
}
return rows;
}
async function readDump() {
return new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
createReadStream(DUMP)
.pipe(createGunzip())
.on('data', (c: Buffer) => chunks.push(c))
.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
.on('error', reject);
});
}
async function main() {
const dump = await readDump();
const hr = parseOaJson(HR) as { users?: { id: number; nickName?: string; phone?: string }[] };
const employees = await prisma.employee.findMany({ select: { id: true, name: true, mobile: true } });
const byName = new Map(employees.map((e) => [e.name, e]));
const byMobile = new Map(employees.filter((e) => e.mobile).map((e) => [e.mobile as string, e]));
const oaUserToEmp = new Map<number, string>();
for (const u of hr.users || []) {
const hit = (u.phone && byMobile.get(String(u.phone))) || (u.nickName && byName.get(u.nickName));
if (hit) oaUserToEmp.set(Number(u.id), hit.id);
}
const months = insertsFor(dump, 'oa_salary_month');
const slips = insertsFor(dump, 'oa_salary_slip');
const admin = await prisma.user.findFirst({ where: { username: '18115604840' } });
await prisma.payrollItem.deleteMany();
await prisma.payrollRun.deleteMany();
const runByYm = new Map<string, string>();
for (const row of months) {
const ym = String(row[1] || '');
if (!/^\d{4}-\d{2}$/.test(ym)) continue;
const confirmed = Number(row[2]) === 1;
const run = await prisma.payrollRun.create({
data: {
tenantId: '1',
yearMonth: ym,
status: confirmed ? 'CONFIRMED' : 'DRAFT',
createdById: admin?.id,
confirmedAt: confirmed ? (row[7] ? new Date(String(row[7])) : new Date()) : null,
},
});
runByYm.set(ym, run.id);
}
let ok = 0;
let skip = 0;
for (const row of slips) {
const ym = String(row[2] || '');
const oaUserId = Number(row[3]);
const name = String(row[4] || '');
let empId = oaUserToEmp.get(oaUserId);
if (!empId && name) empId = byName.get(name)?.id;
if (!empId || !runByYm.has(ym)) {
skip += 1;
continue;
}
await prisma.payrollItem.upsert({
where: { runId_employeeId: { runId: runByYm.get(ym) as string, employeeId: empId } },
update: {
basePay: n(row[9]) + n(row[10]),
performancePay: n(row[18] || row[11]),
leaveDeduct: n(row[22]),
sickDeduct: n(row[23]),
socialPersonal: n(row[27]),
tax: n(row[28]),
grossPay: n(row[16]),
netPay: n(row[30]),
scheduledDays: n(row[31]),
prorationFactor: n(row[29]) || 1,
},
create: {
runId: runByYm.get(ym) as string,
employeeId: empId,
basePay: n(row[9]) + n(row[10]),
performancePay: n(row[18] || row[11]),
leaveDeduct: n(row[22]),
sickDeduct: n(row[23]),
socialPersonal: n(row[27]),
tax: n(row[28]),
grossPay: n(row[16]),
netPay: n(row[30]),
scheduledDays: n(row[31]),
prorationFactor: n(row[29]) || 1,
},
});
ok += 1;
}
console.log(
JSON.stringify({
months: runByYm.size,
slips: slips.length,
imported: ok,
skipped: skip,
}),
);
}
main()
.then(async () => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+201
View File
@@ -0,0 +1,201 @@
export type MenuNode = {
name: string;
code: string;
path: string;
icon?: string;
children?: MenuNode[];
};
/** 人事业务在「组织人事」;组织/班次/参数进「系统设置 · 人事设置」。 */
export const MENUS: MenuNode[] = [
{
name: '工作台',
code: 'office:overview',
path: '/workbench',
icon: 'HomeOutlined',
},
{
name: '个人办公',
code: 'office',
path: '/office/todos',
icon: 'AppstoreOutlined',
children: [
{ name: '待办事项', code: 'office:todos', path: '/office/todos' },
{ name: '待我审批', code: 'office:approvals', path: '/office/approvals' },
{ name: '我的申请', code: 'office:flow', path: '/office/flow' },
{ name: '我的日程', code: 'office:calendar', path: '/office/calendar' },
{ name: '工作汇报', code: 'office:reports', path: '/office/reports' },
{ name: '我的费用', code: 'office:expenses', path: '/office/expenses' },
{ name: '人事申请', code: 'office:hr-apply', path: '/office/hr-apply' },
{ name: '用章申请', code: 'office:seal-apply', path: '/office/seal-apply' },
{ name: '办公申请', code: 'office:apply', path: '/office/apply' },
{ name: '公司公告', code: 'office:notices', path: '/office/notices' },
{ name: '发送短信', code: 'office:sms', path: '/office/sms' },
{ name: '工作安排', code: 'office:assign', path: '/office/assign' },
{ name: '消息', code: 'office:im', path: '/office/im' },
{ name: '员工通讯', code: 'office:directory', path: '/office/directory' },
],
},
{
name: '往来单位',
code: 'party',
path: '/party/tenders',
icon: 'ClusterOutlined',
children: [
{ name: '招标单位', code: 'party:tenders', path: '/party/tenders' },
{ name: '参与主体', code: 'party:entities', path: '/party/entities' },
{ name: '联系名录', code: 'party:contacts', path: '/party/contacts' },
],
},
{
name: '证照用章',
code: 'seal',
path: '/seal/qualifications',
icon: 'SafetyCertificateOutlined',
children: [
{ name: '资质台账', code: 'seal:qualifications', path: '/seal/qualifications' },
{ name: '证照借用', code: 'seal:borrows', path: '/seal/borrows' },
{ name: '用章登记', code: 'seal:requests', path: '/seal/requests' },
{ name: '印章台账', code: 'seal:registry', path: '/seal/registry' },
],
},
{
name: '投标管理',
code: 'bid',
path: '/bid/screening',
icon: 'AuditOutlined',
children: [
{ name: '项目筛选', code: 'bid:screening', path: '/bid/screening' },
{ name: '特殊筛选', code: 'bid:special-screening', path: '/bid/special-screening' },
{ name: '标书制作', code: 'bid:drafting', path: '/bid/drafting' },
{ name: '特殊制标', code: 'bid:special-drafting', path: '/bid/special-drafting' },
{ name: '投标执行', code: 'bid:execution', path: '/bid/execution' },
{ name: '开标结果', code: 'bid:result', path: '/bid/result' },
{ name: '终止投标', code: 'bid:terminated', path: '/bid/terminated' },
],
},
{
name: '合同管理',
code: 'contract',
path: '/contract/tender',
icon: 'FileProtectOutlined',
children: [
{ name: '投标合同', code: 'contract:approval', path: '/contract/tender' },
{ name: '直签合同', code: 'contract:direct', path: '/contract/direct' },
{ name: '其他合同', code: 'contract:list', path: '/contract/incremental' },
{ name: '人事合同', code: 'contract:hr', path: '/contract/hr' },
],
},
{
name: '资产管理',
code: 'asset',
path: '/asset/digital',
icon: 'DatabaseOutlined',
children: [
{ name: '数字资产', code: 'asset:digital', path: '/asset/digital' },
{ name: '固定资产', code: 'asset:fixed', path: '/asset/fixed' },
],
},
{
name: '项目管理',
code: 'project',
path: '/project/list',
icon: 'ProjectOutlined',
children: [
{ name: '项目台账', code: 'project:list', path: '/project/list' },
{ name: '任务分解', code: 'project:tasks', path: '/project/tasks' },
{ name: '工时填报', code: 'project:timesheets', path: '/project/timesheets' },
{ name: '资源看板', code: 'project:resources', path: '/project/resources' },
{ name: '项目验收', code: 'project:acceptance', path: '/project/acceptance' },
{ name: '采购申请', code: 'project:purchases', path: '/project/purchases' },
{ name: '器材占用', code: 'project:assets', path: '/project/assets' },
],
},
{
name: '财务收支',
code: 'finance',
path: '/finance/expenses',
icon: 'AccountBookOutlined',
children: [
{ name: '费用报销', code: 'finance:expenses', path: '/finance/expenses' },
{ name: '借支记录', code: 'finance:loans', path: '/finance/loans' },
{ name: '资产购置台账', code: 'finance:asset-purchases', path: '/finance/asset-purchases' },
{ name: '投标保证金', code: 'finance:tender-bonds', path: '/finance/bonds/tender' },
{ name: '履约保证金', code: 'finance:perf-bonds', path: '/finance/bonds/performance' },
{ name: '项目质保金', code: 'finance:warranty-bonds', path: '/finance/bonds/warranty' },
{ name: '开票收款', code: 'finance:invoices', path: '/finance/invoices' },
{ name: '付款执行', code: 'finance:payments', path: '/finance/payments' },
],
},
{
name: '组织人事',
code: 'hr',
path: '/hr/employees',
icon: 'TeamOutlined',
children: [
{ name: '在职员工', code: 'hr:employees', path: '/hr/employees' },
{ name: '组织管理', code: 'hr:org', path: '/hr/org' },
{ name: '入转调离', code: 'hr:lifecycle', path: '/hr/lifecycle' },
{ name: '假勤结果', code: 'hr:attendance', path: '/hr/attendance' },
{ name: '薪酬核算', code: 'hr:payroll', path: '/hr/payroll' },
{ name: '绩效发放', code: 'hr:performance', path: '/hr/performance' },
{ name: '发布公告', code: 'hr:notices', path: '/hr/notices' },
{ name: '规章制度', code: 'hr:policies', path: '/hr/policies' },
],
},
{
name: '经营报表',
code: 'report',
path: '/report/conversion',
icon: 'BarChartOutlined',
children: [
{ name: '投标转化', code: 'report:conversion', path: '/report/conversion' },
{ name: '落标对照', code: 'report:lost', path: '/report/lost' },
{ name: '项目利润', code: 'report:profit', path: '/report/profit' },
{ name: '部门人效', code: 'report:efficiency', path: '/report/efficiency' },
{ name: '资金占用', code: 'report:cash', path: '/report/cash' },
],
},
{
name: '系统设置',
code: 'system',
path: '/system/acl',
icon: 'SettingOutlined',
children: [
{
name: '人事设置',
code: 'system:hr',
path: '/system/hr-params',
children: [
{ name: '员工账号', code: 'system:acl', path: '/system/acl' },
{ name: '人事参数', code: 'system:hr-params', path: '/system/hr-params' },
{ name: '考勤设置', code: 'system:attendance-rules', path: '/system/attendance-rules' },
],
},
{ name: '数据字典', code: 'system:dicts', path: '/system/dicts' },
{ name: '权限设置', code: 'system:perms', path: '/system/perms' },
{ name: '编号规则', code: 'system:numbers', path: '/system/numbers' },
{ name: '短信服务', code: 'system:sms', path: '/system/sms' },
{ name: '消息通道', code: 'system:messages', path: '/system/messages' },
{ name: '手机端升级', code: 'system:app-release', path: '/system/app-release' },
{ name: '操作日志', code: 'system:logs', path: '/system/logs' },
],
},
];
export function walkMenus(nodes: MenuNode[], visit: (node: MenuNode, parent: MenuNode | null, sortNo: number) => void, parent: MenuNode | null = null) {
nodes.forEach((node, i) => {
visit(node, parent, parent ? i + 1 : (i + 1) * 10);
if (node.children?.length) walkMenus(node.children, visit, node);
});
}
export function isHrSettingCode(code: string) {
return (
code === 'system:acl' ||
code === 'system:hr' ||
code.startsWith('system:hr') ||
code === 'system:org' ||
code === 'system:attendance-rules'
);
}
@@ -0,0 +1,567 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"employeeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Department" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"parentId" TEXT,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"deptType" TEXT NOT NULL,
"categoryCode" TEXT,
"sortNo" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Employee" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"employeeNo" TEXT NOT NULL,
"departmentId" TEXT NOT NULL,
"title" TEXT,
"restSchedule" TEXT NOT NULL DEFAULT 'SINGLE',
"employmentStatus" TEXT NOT NULL DEFAULT 'REGULAR',
"hiredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Employee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"permType" TEXT NOT NULL,
"path" TEXT,
"icon" TEXT,
"sortNo" INTEGER NOT NULL DEFAULT 0,
"visible" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId","permissionId")
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","roleId")
);
-- CreateTable
CREATE TABLE "DictItem" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"dictType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"value" TEXT NOT NULL,
"sortNo" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "DictItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LegalEntity" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"shortName" TEXT NOT NULL,
"creditNo" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LegalEntity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessParty" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"creditNo" TEXT,
"level" TEXT NOT NULL DEFAULT 'B',
"riskFlag" BOOLEAN NOT NULL DEFAULT false,
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessParty_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartyContact" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"partyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"title" TEXT,
CONSTRAINT "PartyContact_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Qualification" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3),
"keeper" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Qualification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SealRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"sealType" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"bidCaseId" TEXT,
"takeOut" BOOLEAN NOT NULL DEFAULT false,
"returnAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SealRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CredentialBorrow" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"itemName" TEXT NOT NULL,
"borrower" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"returnedAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'BORROWED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CredentialBorrow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BidCase" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"bidNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"externalNo" TEXT,
"projectType" TEXT NOT NULL,
"sourceType" TEXT NOT NULL DEFAULT 'FLOW',
"source" TEXT,
"qualificationNeed" TEXT,
"legalEntityId" TEXT,
"tenderMethod" TEXT,
"applyStartAt" TIMESTAMP(3),
"applyEndAt" TIMESTAMP(3),
"applyMethod" TEXT,
"openAt" TIMESTAMP(3),
"openMethod" TEXT,
"openPlace" TEXT,
"securityLevel" TEXT NOT NULL DEFAULT 'INTERNAL',
"projectNature" TEXT,
"partyId" TEXT,
"contactName" TEXT,
"contactPhone" TEXT,
"bondMethod" TEXT,
"bondStatus" TEXT,
"bondPaidAt" TIMESTAMP(3),
"delivererName" TEXT,
"priceCap" TEXT,
"quoteAmount" TEXT,
"summary" TEXT,
"remark" TEXT,
"status" TEXT NOT NULL DEFAULT 'TECH_INITIAL',
"nasPath" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BidCase_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BidReviewer" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"stage" TEXT NOT NULL,
"result" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
CONSTRAINT "BidReviewer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TodoTask" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"bizType" TEXT NOT NULL,
"bizId" TEXT,
"assigneeId" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TodoTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApprovalTask" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"bizType" TEXT NOT NULL,
"bizId" TEXT,
"assigneeId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApprovalTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkReport" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"authorId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Contract" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"contractNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bidCaseId" TEXT,
"amount" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"signedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Contract_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PaymentMilestone" (
"id" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ratio" DECIMAL(5,2) NOT NULL,
"dueAt" TIMESTAMP(3),
"trigger" TEXT,
CONSTRAINT "PaymentMilestone_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"projectNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"contractId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectTask" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"department" TEXT,
"dueAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'OPEN',
CONSTRAINT "ProjectTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Timesheet" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"projectId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"workDate" TIMESTAMP(3) NOT NULL,
"hours" DECIMAL(6,2) NOT NULL,
"note" TEXT,
CONSTRAINT "Timesheet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExpenseClaim" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"claimNo" TEXT NOT NULL,
"applicantId" TEXT NOT NULL,
"costType" TEXT NOT NULL,
"bidCaseId" TEXT,
"projectId" TEXT,
"amount" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExpenseClaim_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoanRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"applicantId" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"purpose" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoanRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BondRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"bidCaseId" TEXT NOT NULL,
"bondType" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"paidAt" TIMESTAMP(3),
"dueBackAt" TIMESTAMP(3),
"returnedAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'UNPAID',
CONSTRAINT "BondRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InvoiceRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"invoiceNo" TEXT,
"amount" DECIMAL(18,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'TO_ISSUE',
"issuedAt" TIMESTAMP(3),
"receivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InvoiceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PurchaseRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"projectId" TEXT,
"amount" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PurchaseRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssetOccupancy" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"assetName" TEXT NOT NULL,
"holder" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'IN_USE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssetOccupancy_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OperationLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"userId" TEXT,
"action" TEXT NOT NULL,
"path" TEXT NOT NULL,
"requestId" TEXT,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OperationLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_tenantId_username_key" ON "User"("tenantId", "username");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Department_tenantId_code_key" ON "Department"("tenantId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Employee_tenantId_employeeNo_key" ON "Employee"("tenantId", "employeeNo");
-- CreateIndex
CREATE UNIQUE INDEX "Role_tenantId_code_key" ON "Role"("tenantId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_code_key" ON "Permission"("code");
-- CreateIndex
CREATE UNIQUE INDEX "DictItem_tenantId_dictType_value_key" ON "DictItem"("tenantId", "dictType", "value");
-- CreateIndex
CREATE UNIQUE INDEX "LegalEntity_tenantId_name_key" ON "LegalEntity"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessParty_tenantId_name_key" ON "BusinessParty"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "BidCase_tenantId_bidNo_key" ON "BidCase"("tenantId", "bidNo");
-- CreateIndex
CREATE INDEX "BidReviewer_bidCaseId_idx" ON "BidReviewer"("bidCaseId");
-- CreateIndex
CREATE UNIQUE INDEX "Contract_tenantId_contractNo_key" ON "Contract"("tenantId", "contractNo");
-- CreateIndex
CREATE UNIQUE INDEX "Project_tenantId_projectNo_key" ON "Project"("tenantId", "projectNo");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseClaim_tenantId_claimNo_key" ON "ExpenseClaim"("tenantId", "claimNo");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Department" ADD CONSTRAINT "Department_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Permission"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartyContact" ADD CONSTRAINT "PartyContact_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "BusinessParty"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "BusinessParty"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidCase" ADD CONSTRAINT "BidCase_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidReviewer" ADD CONSTRAINT "BidReviewer_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TodoTask" ADD CONSTRAINT "TodoTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApprovalTask" ADD CONSTRAINT "ApprovalTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Contract" ADD CONSTRAINT "Contract_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentMilestone" ADD CONSTRAINT "PaymentMilestone_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- AlterTable
ALTER TABLE "BidCase" ADD COLUMN "fetchedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "BidReviewer" ADD COLUMN "decidedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "BidActionLog" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BidActionLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BidReviewer_bidCaseId_userId_stage_key" ON "BidReviewer"("bidCaseId", "userId", "stage");
-- CreateIndex
CREATE INDEX "BidActionLog_bidCaseId_idx" ON "BidActionLog"("bidCaseId");
-- AddForeignKey
ALTER TABLE "BidReviewer" ADD CONSTRAINT "BidReviewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidActionLog" ADD CONSTRAINT "BidActionLog_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BidActionLog" ADD CONSTRAINT "BidActionLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,50 @@
-- AlterTable
ALTER TABLE "BidCase" ADD COLUMN "reviewCycle" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "BidCase" ADD COLUMN "printCost" DECIMAL(18,2);
ALTER TABLE "BidCase" ADD COLUMN "printNote" TEXT;
ALTER TABLE "BidCase" ADD COLUMN "printedAt" TIMESTAMP(3);
ALTER TABLE "BidCase" ADD COLUMN "costDisposition" TEXT;
ALTER TABLE "BidCase" ADD COLUMN "resultNote" TEXT;
ALTER TABLE "BidCase" ADD COLUMN "openedResultAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "BidReviewer" ADD COLUMN "cycle" INTEGER NOT NULL DEFAULT 1;
DROP INDEX IF EXISTS "BidReviewer_bidCaseId_userId_stage_key";
CREATE UNIQUE INDEX "BidReviewer_bidCaseId_userId_stage_cycle_key" ON "BidReviewer"("bidCaseId", "userId", "stage", "cycle");
-- CreateTable
CREATE TABLE "BidAssignee" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BidAssignee_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BidAssignee_bidCaseId_userId_role_key" ON "BidAssignee"("bidCaseId", "userId", "role");
CREATE INDEX "BidAssignee_bidCaseId_idx" ON "BidAssignee"("bidCaseId");
ALTER TABLE "BidAssignee" ADD CONSTRAINT "BidAssignee_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BidAssignee" ADD CONSTRAINT "BidAssignee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "BidDocVersion" (
"id" TEXT NOT NULL,
"bidCaseId" TEXT NOT NULL,
"versionNo" INTEGER NOT NULL,
"nasPath" TEXT NOT NULL,
"note" TEXT,
"submittedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BidDocVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BidDocVersion_bidCaseId_versionNo_key" ON "BidDocVersion"("bidCaseId", "versionNo");
CREATE INDEX "BidDocVersion_bidCaseId_idx" ON "BidDocVersion"("bidCaseId");
ALTER TABLE "BidDocVersion" ADD CONSTRAINT "BidDocVersion_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BidDocVersion" ADD CONSTRAINT "BidDocVersion_submittedById_fkey" FOREIGN KEY ("submittedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,56 @@
-- AlterTable
ALTER TABLE "Contract" ADD COLUMN "legalEntityId" TEXT;
ALTER TABLE "Contract" ADD COLUMN "nasPath" TEXT;
ALTER TABLE "Contract" ADD COLUMN "remark" TEXT;
ALTER TABLE "Contract" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "Contract" ADD COLUMN "approvedAt" TIMESTAMP(3);
ALTER TABLE "Contract" ADD COLUMN "createdById" TEXT;
ALTER TABLE "Contract" ADD CONSTRAINT "Contract_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Contract" ADD CONSTRAINT "Contract_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "SealRequest" ADD COLUMN "contractId" TEXT;
ALTER TABLE "SealRequest" ADD COLUMN "applicantId" TEXT;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "presalesCost" DECIMAL(18,2) NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "ContractReviewer" (
"id" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"result" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
"decidedAt" TIMESTAMP(3),
CONSTRAINT "ContractReviewer_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ContractReviewer_contractId_userId_key" ON "ContractReviewer"("contractId", "userId");
CREATE INDEX "ContractReviewer_contractId_idx" ON "ContractReviewer"("contractId");
ALTER TABLE "ContractReviewer" ADD CONSTRAINT "ContractReviewer_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ContractReviewer" ADD CONSTRAINT "ContractReviewer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "ContractActionLog" (
"id" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContractActionLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ContractActionLog_contractId_idx" ON "ContractActionLog"("contractId");
ALTER TABLE "ContractActionLog" ADD CONSTRAINT "ContractActionLog_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ContractActionLog" ADD CONSTRAINT "ContractActionLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,67 @@
-- ExpenseClaim
ALTER TABLE "ExpenseClaim" ADD COLUMN "loanId" TEXT;
ALTER TABLE "ExpenseClaim" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "ExpenseClaim" ADD COLUMN "decidedAt" TIMESTAMP(3);
ALTER TABLE "ExpenseClaim" ADD COLUMN "decidedById" TEXT;
ALTER TABLE "ExpenseClaim" ADD COLUMN "rejectComment" TEXT;
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ExpenseClaim" ADD CONSTRAINT "ExpenseClaim_loanId_fkey" FOREIGN KEY ("loanId") REFERENCES "LoanRecord"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "ExpenseClaim_applicantId_idx" ON "ExpenseClaim"("applicantId");
CREATE INDEX "ExpenseClaim_loanId_idx" ON "ExpenseClaim"("loanId");
-- LoanRecord
ALTER TABLE "LoanRecord" ADD COLUMN "loanNo" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "costType" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "bidCaseId" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "projectId" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "offsetAmount" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "LoanRecord" ADD COLUMN "remark" TEXT;
ALTER TABLE "LoanRecord" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "LoanRecord" ADD COLUMN "decidedAt" TIMESTAMP(3);
ALTER TABLE "LoanRecord" ADD COLUMN "decidedById" TEXT;
UPDATE "LoanRecord" SET "loanNo" = 'JK-LEGACY-' || substr("id", 1, 8) WHERE "loanNo" IS NULL;
UPDATE "LoanRecord" SET "costType" = 'ADMIN' WHERE "costType" IS NULL;
ALTER TABLE "LoanRecord" ALTER COLUMN "loanNo" SET NOT NULL;
ALTER TABLE "LoanRecord" ALTER COLUMN "costType" SET NOT NULL;
ALTER TABLE "LoanRecord" ALTER COLUMN "status" SET DEFAULT 'DRAFT';
CREATE UNIQUE INDEX "LoanRecord_tenantId_loanNo_key" ON "LoanRecord"("tenantId", "loanNo");
CREATE INDEX "LoanRecord_applicantId_idx" ON "LoanRecord"("applicantId");
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "LoanRecord" ADD CONSTRAINT "LoanRecord_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- BondRecord
ALTER TABLE "BondRecord" ADD COLUMN "bondNo" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "projectId" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "contractId" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "remark" TEXT;
ALTER TABLE "BondRecord" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
UPDATE "BondRecord" SET "bondNo" = 'BZ-LEGACY-' || substr("id", 1, 8) WHERE "bondNo" IS NULL;
ALTER TABLE "BondRecord" ALTER COLUMN "bondNo" SET NOT NULL;
ALTER TABLE "BondRecord" ALTER COLUMN "bidCaseId" DROP NOT NULL;
ALTER TABLE "BondRecord" DROP CONSTRAINT "BondRecord_bidCaseId_fkey";
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BondRecord" ADD CONSTRAINT "BondRecord_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE UNIQUE INDEX "BondRecord_tenantId_bondNo_key" ON "BondRecord"("tenantId", "bondNo");
-- InvoiceRecord
DELETE FROM "InvoiceRecord";
ALTER TABLE "InvoiceRecord" ADD COLUMN "contractId" TEXT NOT NULL;
ALTER TABLE "InvoiceRecord" ADD COLUMN "milestoneId" TEXT NOT NULL;
ALTER TABLE "InvoiceRecord" ADD COLUMN "dueAt" TIMESTAMP(3);
ALTER TABLE "InvoiceRecord" ADD COLUMN "remark" TEXT;
ALTER TABLE "InvoiceRecord" ADD COLUMN "createdById" TEXT;
CREATE UNIQUE INDEX "InvoiceRecord_milestoneId_key" ON "InvoiceRecord"("milestoneId");
ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "Contract"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "PaymentMilestone"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "InvoiceRecord" ADD CONSTRAINT "InvoiceRecord_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- Project hour rate and acceptance timestamps
ALTER TABLE "Project" ADD COLUMN "hourRate" DECIMAL(18,2) NOT NULL DEFAULT 200;
ALTER TABLE "Project" ADD COLUMN "internalAcceptedAt" TIMESTAMP(3);
ALTER TABLE "Project" ADD COLUMN "customerAcceptedAt" TIMESTAMP(3);
-- ProjectTask WBS
ALTER TABLE "ProjectTask" ADD COLUMN "departmentId" TEXT;
ALTER TABLE "ProjectTask" ADD COLUMN "assigneeId" TEXT;
ALTER TABLE "ProjectTask" ADD COLUMN "sortNo" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "ProjectTask" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX "ProjectTask_projectId_idx" ON "ProjectTask"("projectId");
ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProjectTask" ADD CONSTRAINT "ProjectTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Timesheet
ALTER TABLE "Timesheet" ADD COLUMN "taskId" TEXT;
ALTER TABLE "Timesheet" ADD COLUMN "costAmount" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Timesheet" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX "Timesheet_projectId_idx" ON "Timesheet"("projectId");
CREATE INDEX "Timesheet_userId_idx" ON "Timesheet"("userId");
ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "ProjectTask"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Timesheet" ADD CONSTRAINT "Timesheet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Expense budget category (travel / purchase)
ALTER TABLE "ExpenseClaim" ADD COLUMN "budgetCategory" TEXT;
-- ProjectBudget
CREATE TABLE "ProjectBudget" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"category" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
CONSTRAINT "ProjectBudget_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProjectBudget_projectId_category_key" ON "ProjectBudget"("projectId", "category");
ALTER TABLE "ProjectBudget" ADD CONSTRAINT "ProjectBudget_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ProjectAcceptance
CREATE TABLE "ProjectAcceptance" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"result" TEXT NOT NULL,
"comment" TEXT,
"actorId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectAcceptance_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProjectAcceptance_projectId_idx" ON "ProjectAcceptance"("projectId");
ALTER TABLE "ProjectAcceptance" ADD CONSTRAINT "ProjectAcceptance_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectAcceptance" ADD CONSTRAINT "ProjectAcceptance_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- SealRequest: actual return and decision
ALTER TABLE "SealRequest" ADD COLUMN "returnedAt" TIMESTAMP(3);
ALTER TABLE "SealRequest" ADD COLUMN "decidedAt" TIMESTAMP(3);
ALTER TABLE "SealRequest" ADD COLUMN "decidedById" TEXT;
ALTER TABLE "SealRequest" ADD COLUMN "rejectComment" TEXT;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SealRequest" ADD CONSTRAINT "SealRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CredentialBorrow: link to person, qualification, bid
ALTER TABLE "CredentialBorrow" ADD COLUMN "borrowerId" TEXT;
ALTER TABLE "CredentialBorrow" ADD COLUMN "qualificationId" TEXT;
ALTER TABLE "CredentialBorrow" ADD COLUMN "bidCaseId" TEXT;
ALTER TABLE "CredentialBorrow" ADD COLUMN "remark" TEXT;
ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_borrowerId_fkey" FOREIGN KEY ("borrowerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_qualificationId_fkey" FOREIGN KEY ("qualificationId") REFERENCES "Qualification"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "CredentialBorrow" ADD CONSTRAINT "CredentialBorrow_bidCaseId_fkey" FOREIGN KEY ("bidCaseId") REFERENCES "BidCase"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,134 @@
-- Employee: salary, reporting line, lifecycle dates, compensatory leave
ALTER TABLE "Employee" ADD COLUMN "internStartAt" TIMESTAMP(3);
ALTER TABLE "Employee" ADD COLUMN "probationEndAt" TIMESTAMP(3);
ALTER TABLE "Employee" ADD COLUMN "leftAt" TIMESTAMP(3);
ALTER TABLE "Employee" ADD COLUMN "managerId" TEXT;
ALTER TABLE "Employee" ADD COLUMN "baseSalary" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Employee" ADD COLUMN "perfBase" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Employee" ADD COLUMN "socialBase" DECIMAL(18,2) NOT NULL DEFAULT 0;
ALTER TABLE "Employee" ADD COLUMN "compLeaveHours" DECIMAL(10,2) NOT NULL DEFAULT 0;
CREATE INDEX "Employee_departmentId_idx" ON "Employee"("departmentId");
CREATE INDEX "Employee_managerId_idx" ON "Employee"("managerId");
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
UPDATE "Employee" SET "baseSalary" = 8000, "perfBase" = 0, "socialBase" = 8000 WHERE "employeeNo" = 'E000';
UPDATE "Employee" SET "baseSalary" = 25000, "perfBase" = 10000, "socialBase" = 20000 WHERE "employeeNo" = 'E001';
UPDATE "Employee" SET "baseSalary" = 18000, "perfBase" = 8000, "socialBase" = 18000 WHERE "employeeNo" = 'E010';
UPDATE "Employee" SET "baseSalary" = 9000, "perfBase" = 2500, "socialBase" = 9000 WHERE "employeeNo" = 'E011';
UPDATE "Employee" SET "baseSalary" = 15000, "perfBase" = 5000, "socialBase" = 15000 WHERE "employeeNo" IN ('E020','E021','E022','E023');
UPDATE "Employee" SET "baseSalary" = 10000, "perfBase" = 2000, "socialBase" = 10000 WHERE "employeeNo" = 'E030';
UPDATE "Employee" SET "baseSalary" = 9000, "perfBase" = 2000, "socialBase" = 9000 WHERE "employeeNo" = 'E040';
UPDATE "Employee" SET "baseSalary" = 10000, "perfBase" = 3000, "socialBase" = 10000 WHERE "employeeNo" = 'E100';
UPDATE "Employee" AS e
SET "managerId" = d.id
FROM "Employee" AS d
WHERE e."employeeNo" = 'E100' AND d."employeeNo" = 'E023';
-- CreateTable
CREATE TABLE "EmploymentEvent" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT,
"fromDeptId" TEXT,
"toDeptId" TEXT,
"effectiveAt" TIMESTAMP(3) NOT NULL,
"comment" TEXT,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmploymentEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "EmploymentEvent_employeeId_idx" ON "EmploymentEvent"("employeeId");
CREATE INDEX "EmploymentEvent_effectiveAt_idx" ON "EmploymentEvent"("effectiveAt");
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_fromDeptId_fkey" FOREIGN KEY ("fromDeptId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_toDeptId_fkey" FOREIGN KEY ("toDeptId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "EmploymentEvent" ADD CONSTRAINT "EmploymentEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "AttendanceMonth" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"yearMonth" TEXT NOT NULL,
"workDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"leaveDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"sickDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"overtimeHours" DECIMAL(6,2) NOT NULL DEFAULT 0,
"compLeaveDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"remark" TEXT,
"importedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"importedById" TEXT,
CONSTRAINT "AttendanceMonth_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AttendanceMonth_employeeId_yearMonth_key" ON "AttendanceMonth"("employeeId", "yearMonth");
CREATE INDEX "AttendanceMonth_yearMonth_idx" ON "AttendanceMonth"("yearMonth");
ALTER TABLE "AttendanceMonth" ADD CONSTRAINT "AttendanceMonth_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AttendanceMonth" ADD CONSTRAINT "AttendanceMonth_importedById_fkey" FOREIGN KEY ("importedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "PayrollRun" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"yearMonth" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmedAt" TIMESTAMP(3),
CONSTRAINT "PayrollRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PayrollRun_tenantId_yearMonth_key" ON "PayrollRun"("tenantId", "yearMonth");
ALTER TABLE "PayrollRun" ADD CONSTRAINT "PayrollRun_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "PayrollItem" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"prorationFactor" DECIMAL(8,6) NOT NULL DEFAULT 1,
"scheduledDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"dailyRate" DECIMAL(18,2) NOT NULL DEFAULT 0,
"basePay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"performancePay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"performanceRatio" DECIMAL(5,2) NOT NULL DEFAULT 100,
"leaveDeduct" DECIMAL(18,2) NOT NULL DEFAULT 0,
"sickDeduct" DECIMAL(18,2) NOT NULL DEFAULT 0,
"socialPersonal" DECIMAL(18,2) NOT NULL DEFAULT 0,
"housingFund" DECIMAL(18,2) NOT NULL DEFAULT 0,
"tax" DECIMAL(18,2) NOT NULL DEFAULT 0,
"grossPay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"netPay" DECIMAL(18,2) NOT NULL DEFAULT 0,
"overtimeHours" DECIMAL(6,2) NOT NULL DEFAULT 0,
"compLeaveUsedDays" DECIMAL(6,2) NOT NULL DEFAULT 0,
"compLeaveHours" DECIMAL(10,2) NOT NULL DEFAULT 0,
CONSTRAINT "PayrollItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PayrollItem_runId_employeeId_key" ON "PayrollItem"("runId", "employeeId");
ALTER TABLE "PayrollItem" ADD CONSTRAINT "PayrollItem_runId_fkey" FOREIGN KEY ("runId") REFERENCES "PayrollRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PayrollItem" ADD CONSTRAINT "PayrollItem_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Directors only see 绩效发放 under 组织人事, not the full HR module
INSERT INTO "RolePermission" ("roleId", "permissionId")
SELECT r.id, p.id
FROM "Role" r
CROSS JOIN "Permission" p
WHERE r.code IN ('tech_director', 'biz_director')
AND p.code = 'hr:performance'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,98 @@
ALTER TABLE "Role" ADD COLUMN "dataScope" TEXT NOT NULL DEFAULT 'ALL';
ALTER TABLE "WorkReport" ADD COLUMN "periodType" TEXT NOT NULL DEFAULT 'DAY';
ALTER TABLE "WorkReport" ADD COLUMN "periodStart" TIMESTAMP(3);
ALTER TABLE "WorkReport" ADD COLUMN "hours" DECIMAL(8,2) NOT NULL DEFAULT 0;
DELETE FROM "WorkReport" WHERE "authorId" IS NOT NULL AND "authorId" NOT IN (SELECT "id" FROM "User");
ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "WorkReport_authorId_idx" ON "WorkReport"("authorId");
ALTER TABLE "PurchaseRequest" ADD COLUMN "remark" TEXT;
ALTER TABLE "PurchaseRequest" ADD COLUMN "applicantId" TEXT;
ALTER TABLE "PurchaseRequest" ADD COLUMN "decidedById" TEXT;
ALTER TABLE "PurchaseRequest" ADD COLUMN "decidedAt" TIMESTAMP(3);
UPDATE "PurchaseRequest" SET "projectId" = NULL WHERE "projectId" IS NOT NULL AND "projectId" NOT IN (SELECT "id" FROM "Project");
ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "PurchaseRequest" ADD CONSTRAINT "PurchaseRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssetOccupancy" ADD COLUMN "holderId" TEXT;
ALTER TABLE "AssetOccupancy" ADD COLUMN "projectId" TEXT;
ALTER TABLE "AssetOccupancy" ADD COLUMN "dueAt" TIMESTAMP(3);
ALTER TABLE "AssetOccupancy" ADD COLUMN "returnedAt" TIMESTAMP(3);
UPDATE "AssetOccupancy" SET "projectId" = NULL WHERE "projectId" IS NOT NULL AND "projectId" NOT IN (SELECT "id" FROM "Project");
ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_holderId_fkey" FOREIGN KEY ("holderId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OperationLog" ADD COLUMN "username" TEXT;
ALTER TABLE "OperationLog" ADD COLUMN "detail" TEXT;
ALTER TABLE "OperationLog" ADD COLUMN "success" BOOLEAN NOT NULL DEFAULT true;
UPDATE "OperationLog" SET "userId" = NULL WHERE "userId" IS NOT NULL AND "userId" NOT IN (SELECT "id" FROM "User");
ALTER TABLE "OperationLog" ADD CONSTRAINT "OperationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "OperationLog_createdAt_idx" ON "OperationLog"("createdAt");
CREATE INDEX "OperationLog_action_idx" ON "OperationLog"("action");
CREATE TABLE "NumberRule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"padLength" INTEGER NOT NULL DEFAULT 5,
CONSTRAINT "NumberRule_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "NumberRule_tenantId_code_key" ON "NumberRule"("tenantId", "code");
INSERT INTO "NumberRule" ("id", "code", "name", "prefix", "padLength") VALUES
('nr-bid', 'BID', '投标编号', 'BID', 5),
('nr-ht', 'HT', '合同编号', 'HT', 5),
('nr-prj', 'PRJ', '项目编号', 'PRJ', 5);
CREATE TABLE "SealItem" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"keeper" TEXT NOT NULL DEFAULT '行政',
"status" TEXT NOT NULL DEFAULT 'IN_STOCK',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SealItem_pkey" PRIMARY KEY ("id")
);
INSERT INTO "SealItem" ("id", "name", "keeper", "status") VALUES
('seal-official', '公章', '行政', 'IN_STOCK'),
('seal-legal', '法人章', '行政', 'IN_STOCK');
CREATE TABLE "ProjectChange" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"projectId" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"amountDelta" DECIMAL(18,2) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"decidedAt" TIMESTAMP(3),
CONSTRAINT "ProjectChange_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProjectChange_projectId_idx" ON "ProjectChange"("projectId");
ALTER TABLE "ProjectChange" ADD CONSTRAINT "ProjectChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectChange" ADD CONSTRAINT "ProjectChange_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE "PaymentRecord" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"payNo" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"refId" TEXT,
"amount" DECIMAL(18,2) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'TODO',
"remark" TEXT,
"paidAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PaymentRecord_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PaymentRecord_tenantId_payNo_key" ON "PaymentRecord"("tenantId", "payNo");
ALTER TABLE "PaymentRecord" ADD CONSTRAINT "PaymentRecord_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
ALTER TABLE "WorkReport" ADD COLUMN "toUserId" TEXT;
ALTER TABLE "WorkReport" ADD COLUMN "ccUserId" TEXT;
ALTER TABLE "WorkReport" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'DRAFT';
ALTER TABLE "WorkReport" ADD COLUMN "submittedAt" TIMESTAMP(3);
ALTER TABLE "WorkReport" ALTER COLUMN "content" SET DEFAULT '';
UPDATE "WorkReport" SET "toUserId" = NULL WHERE "toUserId" IS NOT NULL AND "toUserId" NOT IN (SELECT "id" FROM "User");
UPDATE "WorkReport" SET "ccUserId" = NULL WHERE "ccUserId" IS NOT NULL AND "ccUserId" NOT IN (SELECT "id" FROM "User");
ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "WorkReport" ADD CONSTRAINT "WorkReport_ccUserId_fkey" FOREIGN KEY ("ccUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "WorkReport_toUserId_idx" ON "WorkReport"("toUserId");
CREATE INDEX "WorkReport_ccUserId_idx" ON "WorkReport"("ccUserId");
@@ -0,0 +1,29 @@
ALTER TABLE "ExpenseClaim" ADD COLUMN IF NOT EXISTS "expenseCategory" TEXT;
CREATE TABLE IF NOT EXISTS "FileAsset" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"bizType" TEXT NOT NULL,
"bizId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"uploadedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileAsset_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "FileAsset_bizType_bizId_idx" ON "FileAsset"("bizType", "bizId");
CREATE INDEX IF NOT EXISTS "FileAsset_uploadedById_idx" ON "FileAsset"("uploadedById");
INSERT INTO "DictItem" ("id", "tenantId", "dictType", "label", "value", "sortNo")
VALUES
('e1c10000-0000-4000-8000-000000000001', '1', 'expenseCategory', '交通', '交通', 1),
('e1c10000-0000-4000-8000-000000000002', '1', 'expenseCategory', '餐饮', '餐饮', 2),
('e1c10000-0000-4000-8000-000000000003', '1', 'expenseCategory', '住宿', '住宿', 3),
('e1c10000-0000-4000-8000-000000000004', '1', 'expenseCategory', '办公', '办公', 4),
('e1c10000-0000-4000-8000-000000000005', '1', 'expenseCategory', '招待', '招待', 5),
('e1c10000-0000-4000-8000-000000000006', '1', 'expenseCategory', '其他', '其他', 6)
ON CONFLICT ("tenantId", "dictType", "value") DO NOTHING;
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "SmsSendLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"phones" TEXT NOT NULL,
"signName" TEXT NOT NULL,
"templateCode" TEXT NOT NULL,
"templateParam" TEXT,
"bizId" TEXT,
"code" TEXT NOT NULL,
"message" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsSendLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SmsSendLog_createdAt_idx" ON "SmsSendLog"("createdAt");
@@ -0,0 +1,67 @@
-- Employee archive fields aligned with personnel files
ALTER TABLE "Employee" ADD COLUMN "gender" TEXT;
ALTER TABLE "Employee" ADD COLUMN "email" TEXT;
ALTER TABLE "Employee" ADD COLUMN "mobile" TEXT;
ALTER TABLE "Employee" ADD COLUMN "idNo" TEXT;
ALTER TABLE "Employee" ADD COLUMN "education" TEXT;
ALTER TABLE "Employee" ADD COLUMN "bankName" TEXT;
ALTER TABLE "Employee" ADD COLUMN "bankAccount" TEXT;
ALTER TABLE "Employee" ADD COLUMN "remark" TEXT;
CREATE TABLE "HrSetting" (
"id" TEXT NOT NULL DEFAULT 'default',
"tenantId" TEXT NOT NULL DEFAULT '1',
"idMask" BOOLEAN NOT NULL DEFAULT true,
"autoAccount" BOOLEAN NOT NULL DEFAULT false,
"probationMonths" INTEGER NOT NULL DEFAULT 3,
"defaultRest" TEXT NOT NULL DEFAULT 'SINGLE',
"workStart" TEXT NOT NULL DEFAULT '09:00',
"workEnd" TEXT NOT NULL DEFAULT '18:00',
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HrSetting_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HrSetting_tenantId_key" ON "HrSetting"("tenantId");
INSERT INTO "HrSetting" ("id", "tenantId", "idMask", "autoAccount", "probationMonths", "defaultRest", "workStart", "workEnd", "updatedAt")
VALUES ('default', '1', true, false, 3, 'SINGLE', '09:00', '18:00', CURRENT_TIMESTAMP);
CREATE TABLE "AttendanceRule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"name" TEXT NOT NULL,
"restSchedule" TEXT NOT NULL,
"workStart" TEXT NOT NULL,
"workEnd" TEXT NOT NULL,
"lateGraceMinutes" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sortNo" INTEGER NOT NULL DEFAULT 0,
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AttendanceRule_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AttendanceRule_tenantId_name_key" ON "AttendanceRule"("tenantId", "name");
INSERT INTO "AttendanceRule" ("id", "tenantId", "name", "restSchedule", "workStart", "workEnd", "lateGraceMinutes", "enabled", "sortNo", "remark", "createdAt", "updatedAt")
VALUES
('att-rule-single-000000000001', '1', '单休班次', 'SINGLE', '09:00', '18:00', 0, true, 1, '每周休一天,薪酬按 26.09 天核算', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('att-rule-double-000000000001', '1', '双休班次', 'DOUBLE', '09:00', '18:00', 0, true, 2, '每周休两天,薪酬按 21.75 天核算', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
INSERT INTO "DictItem" ("id", "tenantId", "dictType", "label", "value", "sortNo")
SELECT d."id", '1', d."dictType", d."label", d."value", d."sortNo"
FROM (VALUES
('dict-gender-male-00000000001', 'gender', '', 'MALE', 1),
('dict-gender-female-000000001', 'gender', '', 'FEMALE', 2),
('dict-edu-high-0000000000001', 'education', '高中及以下', 'HIGH_SCHOOL', 1),
('dict-edu-college-00000000001', 'education', '专科', 'COLLEGE', 2),
('dict-edu-bachelor-0000000001', 'education', '本科', 'BACHELOR', 3),
('dict-edu-master-000000000001', 'education', '硕士', 'MASTER', 4),
('dict-edu-doctor-000000000001', 'education', '博士', 'DOCTOR', 5)
) AS d("id", "dictType", "label", "value", "sortNo")
WHERE NOT EXISTS (
SELECT 1 FROM "DictItem" x WHERE x."tenantId" = '1' AND x."dictType" = d."dictType" AND x."value" = d."value"
);
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Contract" ADD COLUMN "partyA" TEXT;
ALTER TABLE "Contract" ADD COLUMN "partyB" TEXT;
@@ -0,0 +1,48 @@
-- CreateTable
CREATE TABLE "AttendancePunch" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"punchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"remark" TEXT,
CONSTRAINT "AttendancePunch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeaveRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"days" DECIMAL(6,2) NOT NULL,
"reason" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"comment" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeaveRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AttendancePunch_employeeId_punchedAt_idx" ON "AttendancePunch"("employeeId", "punchedAt");
-- CreateIndex
CREATE INDEX "LeaveRequest_employeeId_idx" ON "LeaveRequest"("employeeId");
-- CreateIndex
CREATE INDEX "LeaveRequest_status_idx" ON "LeaveRequest"("status");
-- AddForeignKey
ALTER TABLE "AttendancePunch" ADD CONSTRAINT "AttendancePunch_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "LaborContract" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"employeeId" TEXT NOT NULL,
"contractNo" TEXT,
"startAt" TIMESTAMP(3),
"endAt" TIMESTAMP(3),
"remark" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LaborContract_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LaborContract_employeeId_idx" ON "LaborContract"("employeeId");
-- CreateIndex
CREATE INDEX "LaborContract_tenantId_createdAt_idx" ON "LaborContract"("tenantId", "createdAt");
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,70 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "mustChangePassword" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "profileCompletedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Contract" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'OTHER';
UPDATE "Contract" SET "kind" = 'TENDER' WHERE "bidCaseId" IS NOT NULL;
-- AlterTable
ALTER TABLE "BidCase" ADD COLUMN IF NOT EXISTS "needSurvey" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "SealItem" ADD COLUMN IF NOT EXISTS "sealType" TEXT NOT NULL DEFAULT '公章';
ALTER TABLE "SealItem" ADD COLUMN IF NOT EXISTS "legalEntityId" TEXT;
-- CreateTable
CREATE TABLE IF NOT EXISTS "WorkAssignment" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"content" TEXT NOT NULL DEFAULT '',
"assigneeId" TEXT NOT NULL,
"dueAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'OPEN',
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "RegisteredAsset" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"kind" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"spec" TEXT,
"keeper" TEXT,
"location" TEXT,
"amount" DECIMAL(18,2),
"remark" TEXT,
"legalEntityId" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisteredAsset_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "WorkAssignment_assigneeId_idx" ON "WorkAssignment"("assigneeId");
CREATE INDEX IF NOT EXISTS "WorkAssignment_createdById_idx" ON "WorkAssignment"("createdById");
CREATE INDEX IF NOT EXISTS "RegisteredAsset_kind_idx" ON "RegisteredAsset"("kind");
CREATE INDEX IF NOT EXISTS "RegisteredAsset_legalEntityId_idx" ON "RegisteredAsset"("legalEntityId");
ALTER TABLE "WorkAssignment" DROP CONSTRAINT IF EXISTS "WorkAssignment_assigneeId_fkey";
ALTER TABLE "WorkAssignment" ADD CONSTRAINT "WorkAssignment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "WorkAssignment" DROP CONSTRAINT IF EXISTS "WorkAssignment_createdById_fkey";
ALTER TABLE "WorkAssignment" ADD CONSTRAINT "WorkAssignment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "RegisteredAsset" DROP CONSTRAINT IF EXISTS "RegisteredAsset_legalEntityId_fkey";
ALTER TABLE "RegisteredAsset" ADD CONSTRAINT "RegisteredAsset_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "RegisteredAsset" DROP CONSTRAINT IF EXISTS "RegisteredAsset_createdById_fkey";
ALTER TABLE "RegisteredAsset" ADD CONSTRAINT "RegisteredAsset_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SealItem" DROP CONSTRAINT IF EXISTS "SealItem_legalEntityId_fkey";
ALTER TABLE "SealItem" ADD CONSTRAINT "SealItem_legalEntityId_fkey" FOREIGN KEY ("legalEntityId") REFERENCES "LegalEntity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 系统管理员不强制改密/补档
UPDATE "User" u SET "mustChangePassword" = false, "profileCompletedAt" = CURRENT_TIMESTAMP
WHERE EXISTS (SELECT 1 FROM "UserRole" ur JOIN "Role" r ON r.id = ur."roleId" WHERE ur."userId" = u.id AND r.code = 'admin');
@@ -0,0 +1 @@
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "partyName" TEXT;
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS "ProjectAcceptMaterial" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"remark" TEXT,
"required" BOOLEAN NOT NULL DEFAULT true,
"sortNo" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectAcceptMaterial_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "ProjectAcceptMaterial_projectId_idx" ON "ProjectAcceptMaterial"("projectId");
ALTER TABLE "ProjectAcceptMaterial" DROP CONSTRAINT IF EXISTS "ProjectAcceptMaterial_projectId_fkey";
ALTER TABLE "ProjectAcceptMaterial" ADD CONSTRAINT "ProjectAcceptMaterial_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,51 @@
-- AlterTable
ALTER TABLE "AssetOccupancy" ADD COLUMN IF NOT EXISTS "registeredAssetId" TEXT;
CREATE INDEX IF NOT EXISTS "AssetOccupancy_registeredAssetId_idx" ON "AssetOccupancy"("registeredAssetId");
ALTER TABLE "AssetOccupancy" DROP CONSTRAINT IF EXISTS "AssetOccupancy_registeredAssetId_fkey";
ALTER TABLE "AssetOccupancy" ADD CONSTRAINT "AssetOccupancy_registeredAssetId_fkey" FOREIGN KEY ("registeredAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE IF NOT EXISTS "OfficeApply" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"assetKind" TEXT,
"registeredAssetId" TEXT,
"amount" DECIMAL(18,2),
"destination" TEXT,
"startAt" TIMESTAMP(3),
"endAt" TIMESTAMP(3),
"projectId" TEXT,
"purchaseId" TEXT,
"occupancyId" TEXT,
"applicantId" TEXT NOT NULL,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OfficeApply_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "OfficeApply_applicantId_idx" ON "OfficeApply"("applicantId");
CREATE INDEX IF NOT EXISTS "OfficeApply_status_idx" ON "OfficeApply"("status");
CREATE INDEX IF NOT EXISTS "OfficeApply_kind_idx" ON "OfficeApply"("kind");
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_applicantId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_applicantId_fkey" FOREIGN KEY ("applicantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_decidedById_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_registeredAssetId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_registeredAssetId_fkey" FOREIGN KEY ("registeredAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_projectId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_purchaseId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_purchaseId_fkey" FOREIGN KEY ("purchaseId") REFERENCES "PurchaseRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_occupancyId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_occupancyId_fkey" FOREIGN KEY ("occupancyId") REFERENCES "AssetOccupancy"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
ALTER TABLE "RegisteredAsset" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "RegisteredAsset" ADD COLUMN IF NOT EXISTS "usage" TEXT NOT NULL DEFAULT 'GENERAL';
ALTER TABLE "OfficeApply" ADD COLUMN IF NOT EXISTS "inboundAssetId" TEXT;
ALTER TABLE "OfficeApply" DROP CONSTRAINT IF EXISTS "OfficeApply_inboundAssetId_fkey";
ALTER TABLE "OfficeApply" ADD CONSTRAINT "OfficeApply_inboundAssetId_fkey" FOREIGN KEY ("inboundAssetId") REFERENCES "RegisteredAsset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE IF NOT EXISTS "Notice" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PUBLISHED',
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Notice_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "Notice_status_idx" ON "Notice"("status");
CREATE INDEX IF NOT EXISTS "Notice_createdAt_idx" ON "Notice"("createdAt");
ALTER TABLE "Notice" DROP CONSTRAINT IF EXISTS "Notice_createdById_fkey";
ALTER TABLE "Notice" ADD CONSTRAINT "Notice_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,29 @@
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'ANNOUNCEMENT';
CREATE INDEX IF NOT EXISTS "Notice_kind_idx" ON "Notice"("kind");
ALTER TABLE "SmsSendLog" ADD COLUMN IF NOT EXISTS "eventKey" TEXT;
CREATE INDEX IF NOT EXISTS "SmsSendLog_eventKey_idx" ON "SmsSendLog"("eventKey");
CREATE TABLE IF NOT EXISTS "SmsEventBind" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL DEFAULT '1',
"eventKey" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"templateCode" TEXT NOT NULL DEFAULT '',
"signName" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsEventBind_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "SmsEventBind_eventKey_key" ON "SmsEventBind"("eventKey");
CREATE TABLE IF NOT EXISTS "SmsOtp" (
"id" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsOtp_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "SmsOtp_phone_purpose_idx" ON "SmsOtp"("phone", "purpose");
@@ -0,0 +1,2 @@
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "projectType" TEXT NOT NULL DEFAULT '';
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "amount" DECIMAL(18,2) NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "audience" TEXT NOT NULL DEFAULT 'ALL';
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "departmentIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "Notice" ADD COLUMN IF NOT EXISTS "userIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1 @@
ALTER TABLE "Role" ADD COLUMN IF NOT EXISTS "aclCustom" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "PushDevice" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"vendor" TEXT NOT NULL,
"regId" TEXT NOT NULL,
"brand" TEXT,
"model" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushDevice_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PushDevice_vendor_regId_key" ON "PushDevice"("vendor", "regId");
CREATE INDEX IF NOT EXISTS "PushDevice_userId_vendor_idx" ON "PushDevice"("userId", "vendor");
ALTER TABLE "PushDevice" ADD CONSTRAINT "PushDevice_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "recalledAt" TIMESTAMP(3);
ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "editedAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "ImMessage_recalledAt_idx" ON "ImMessage"("recalledAt");
@@ -0,0 +1,3 @@
-- 项目筛选编号是人工填写的公告编号,不是系统单号;允许重复公告/分包再次筛选。
DROP INDEX IF EXISTS "BidCase_tenantId_bidNo_key";
CREATE INDEX IF NOT EXISTS "BidCase_tenantId_bidNo_idx" ON "BidCase"("tenantId", "bidNo");
@@ -0,0 +1,26 @@
-- Phase 1.1: 会话级单调递增 seq,用于消息排序、去重与断线补拉
ALTER TABLE "ImConversation" ADD COLUMN IF NOT EXISTS "nextSeq" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "ImMessage" ADD COLUMN IF NOT EXISTS "seq" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "ImReadCursor" ADD COLUMN IF NOT EXISTS "lastReadSeq" INTEGER NOT NULL DEFAULT 0;
-- 按 createdAt 回填历史 seq
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "conversationId" ORDER BY "createdAt", id) AS rn
FROM "ImMessage"
)
UPDATE "ImMessage" m SET seq = ranked.rn FROM ranked WHERE m.id = ranked.id;
UPDATE "ImConversation" c
SET "nextSeq" = COALESCE((
SELECT MAX(seq) + 1 FROM "ImMessage" WHERE "conversationId" = c.id
), 1);
UPDATE "ImReadCursor" rc
SET "lastReadSeq" = COALESCE((
SELECT MAX(seq) FROM "ImMessage" m
WHERE m."conversationId" = rc."conversationId" AND m."createdAt" <= rc."lastReadAt"
), 0);
CREATE INDEX IF NOT EXISTS "ImMessage_conversationId_seq_idx" ON "ImMessage"("conversationId", "seq");
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+23
View File
@@ -0,0 +1,23 @@
import * as fs from 'fs';
/** OA 雪花 ID 超过 Number.MAX_SAFE_INTEGERJSON.parse 会丢精度导致报销明细串单。 */
export function parseOaJson<T = Record<string, unknown>>(file: string): T {
const text = fs.readFileSync(file, 'utf8');
const safe = text.replace(
/"(id|reimbursement_id|project_id|employee_id|payer_id|created_by|updated_by|department_id|cc_user_id|offset_advance_id|current_approver_id|user_id|post_id|dept_id|apply_id|tender_id|contract_id)"\s*:\s*(-?\d+)/g,
'"$1":"$2"',
);
return JSON.parse(safe) as T;
}
export function mapExpenseLineKind(itemType: string, remark = '') {
const t = `${itemType}${remark}`;
if (t.includes('机票') || t.includes('航班')) return 'AIR';
if (t.includes('高铁')) return 'HSR';
if (t.includes('火车') || t.includes('卧铺')) return 'TRAIN';
if (t.includes('出租') || t.includes('打车') || t.includes('交通') || t.includes('高速')) return 'TAXI';
if (t.includes('住宿') || t.includes('酒店')) return 'HOTEL';
if (t.includes('餐')) return 'MEAL';
if (t.includes('办公') || t.includes('胶装')) return 'OFFICE';
return 'OTHER';
}
@@ -0,0 +1,38 @@
\set ON_ERROR_STOP on
BEGIN;
DO $$
DECLARE
orphan_count integer;
mirrored_count integer;
BEGIN
SELECT count(*) INTO orphan_count
FROM "FileAsset" file
WHERE file."bizType" LIKE 'CONTRACT%'
AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = file."bizId");
SELECT count(*) INTO mirrored_count
FROM "FileAsset" orphan
WHERE orphan."bizType" LIKE 'CONTRACT%'
AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = orphan."bizId")
AND EXISTS (
SELECT 1
FROM "FileAsset" current_file
JOIN "Contract" current_contract ON current_contract.id = current_file."bizId"
WHERE current_file."bizType" = orphan."bizType"
AND current_file."fileName" = orphan."fileName"
AND current_file.size = orphan.size
);
IF orphan_count <> 29 OR mirrored_count <> orphan_count THEN
RAISE EXCEPTION 'Unexpected orphan contract files: orphan %, mirrored %',
orphan_count, mirrored_count;
END IF;
END $$;
DELETE FROM "FileAsset" file
WHERE file."bizType" LIKE 'CONTRACT%'
AND NOT EXISTS (SELECT 1 FROM "Contract" contract WHERE contract.id = file."bizId");
COMMIT;
@@ -0,0 +1,111 @@
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE expense_dedup_map (
old_id text PRIMARY KEY,
new_id text NOT NULL UNIQUE
) ON COMMIT DROP;
INSERT INTO expense_dedup_map (old_id, new_id)
SELECT old_claim.id, formal_claim.id
FROM "ExpenseClaim" old_claim
JOIN "ExpenseClaim" formal_claim
ON formal_claim."claimNo" ~ '^BX-[0-9]{8}-[0-9]{3}$'
AND formal_claim."applicantId" = old_claim."applicantId"
AND formal_claim.amount = old_claim.amount
AND formal_claim."projectId" IS NOT DISTINCT FROM old_claim."projectId"
AND formal_claim."bidCaseId" IS NOT DISTINCT FROM old_claim."bidCaseId"
WHERE old_claim."createdAt" >= TIMESTAMP '2026-08-29 11:00:30'
AND old_claim."createdAt" < TIMESTAMP '2026-08-29 11:00:31';
INSERT INTO expense_dedup_map (old_id, new_id)
SELECT old_claim.id, formal_claim.id
FROM "ExpenseClaim" old_claim
JOIN "ExpenseClaim" formal_claim
ON formal_claim."claimNo" = CASE old_claim."claimNo"
WHEN 'BX-2026-00043' THEN 'BX-20260617-004'
WHEN 'BX-2026-00044' THEN 'BX-20260617-005'
END
WHERE old_claim."claimNo" IN ('BX-2026-00043', 'BX-2026-00044');
DO $$
BEGIN
IF (SELECT count(*) FROM expense_dedup_map) <> 83 THEN
RAISE EXCEPTION 'Expense dedup mapping is incomplete: expected 83, got %',
(SELECT count(*) FROM expense_dedup_map);
END IF;
END $$;
-- FileAsset is intentionally reassigned before duplicate claims are removed.
UPDATE "FileAsset" file
SET "bizId" = mapping.new_id
FROM expense_dedup_map mapping
WHERE file."bizType" = 'EXPENSE'
AND file."bizId" = mapping.old_id;
DELETE FROM "ApprovalTask" task
USING expense_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
DELETE FROM "TodoTask" task
USING expense_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
DELETE FROM "ExpenseClaim" claim
USING expense_dedup_map mapping
WHERE claim.id = mapping.old_id;
CREATE TEMP TABLE loan_dedup_map (
old_id text PRIMARY KEY,
new_id text NOT NULL UNIQUE,
new_status text NOT NULL
) ON COMMIT DROP;
INSERT INTO loan_dedup_map (old_id, new_id, new_status)
SELECT old_loan.id, formal_loan.id, formal_loan.status
FROM "LoanRecord" old_loan
JOIN "LoanRecord" formal_loan
ON formal_loan."loanNo" ~ '^JZ-[0-9]{8}-[0-9]{3}$'
AND formal_loan."applicantId" = old_loan."applicantId"
AND formal_loan.amount = old_loan.amount
AND formal_loan."projectId" IS NOT DISTINCT FROM old_loan."projectId"
AND formal_loan.purpose = old_loan.purpose
WHERE old_loan."loanNo" ~ '^JK-2026-[0-9]{5}$'
AND old_loan."createdAt" >= TIMESTAMP '2026-08-29 11:00:30'
AND old_loan."createdAt" < TIMESTAMP '2026-08-29 11:00:31';
DO $$
BEGIN
IF (SELECT count(*) FROM loan_dedup_map) <> 9 THEN
RAISE EXCEPTION 'Loan dedup mapping is incomplete: expected 9, got %',
(SELECT count(*) FROM loan_dedup_map);
END IF;
END $$;
-- Keep approval tasks only when the surviving formal loan is still pending.
UPDATE "ApprovalTask" task
SET "bizId" = mapping.new_id
FROM loan_dedup_map mapping
WHERE task."bizId" = mapping.old_id
AND mapping.new_status = 'PENDING';
DELETE FROM "ApprovalTask" task
USING loan_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
DELETE FROM "TodoTask" task
USING loan_dedup_map mapping
WHERE task."bizId" = mapping.old_id;
UPDATE "FileAsset" file
SET "bizId" = mapping.new_id
FROM loan_dedup_map mapping
WHERE file."bizType" = 'LOAN'
AND file."bizId" = mapping.old_id;
DELETE FROM "LoanRecord" loan
USING loan_dedup_map mapping
WHERE loan.id = mapping.old_id;
COMMIT;
@@ -0,0 +1,61 @@
\set ON_ERROR_STOP on
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "ExpenseClaim"
WHERE "tenantId" = '1' AND "claimNo" = 'BX-20260809-002'
) THEN
RAISE EXCEPTION 'Expense BX-20260809-002 already exists';
END IF;
END $$;
WITH restored AS (
INSERT INTO "ExpenseClaim" (
id, "tenantId", "claimNo", "applicantId", "costType",
"budgetCategory", "projectId", "expenseCategory", purpose,
amount, status, "submittedAt", "createdAt"
)
SELECT
gen_random_uuid()::text,
'1',
'BX-20260809-002',
applicant.id,
'PROJECT',
'TRAVEL',
project.id,
'办公',
'日常办公(江苏风影)',
689.08,
'DRAFT',
TIMESTAMP '2026-08-09 21:39:30',
TIMESTAMP '2026-08-09 21:39:30'
FROM "User" applicant
CROSS JOIN "Project" project
WHERE applicant."displayName" = '戴永康'
AND project."projectNo" = 'XM-20260609-002'
RETURNING id
)
INSERT INTO "ExpenseLine" (
id, "tenantId", "claimId", kind, amount, remark, "sortNo", "createdAt"
)
SELECT gen_random_uuid()::text, '1', restored.id, line.kind, line.amount,
line.remark, line.sort_no, TIMESTAMP '2026-08-09 21:39:30'
FROM restored
CROSS JOIN (
VALUES
('OFFICE', 122.65::numeric, 'OA平台短信通知费用', 1),
('OFFICE', 566.43::numeric, 'OA云服务器及流量费用', 2)
) AS line(kind, amount, remark, sort_no);
DO $$
BEGIN
IF (SELECT count(*) FROM "ExpenseClaim" WHERE "claimNo" = 'BX-20260809-002') <> 1
OR (SELECT count(*) FROM "ExpenseLine" line JOIN "ExpenseClaim" claim ON claim.id = line."claimId" WHERE claim."claimNo" = 'BX-20260809-002') <> 2 THEN
RAISE EXCEPTION 'Expense BX-20260809-002 restore verification failed';
END IF;
END $$;
COMMIT;
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import { MENUS, type MenuNode } from './menus';
const prisma = new PrismaClient();
async function main() {
const passwordHash = await argon2.hash('Demo@123456');
const adminHash = await argon2.hash('Admin@123456');
await prisma.$transaction(async (tx) => {
await tx.operationLog.deleteMany();
await tx.paymentRecord.deleteMany();
await tx.projectChange.deleteMany();
await tx.sealItem.deleteMany();
await tx.numberRule.deleteMany();
await tx.todoTask.deleteMany();
await tx.approvalTask.deleteMany();
await tx.workReport.deleteMany();
await tx.payrollItem.deleteMany();
await tx.payrollRun.deleteMany();
await tx.attendanceMonth.deleteMany();
await tx.employmentEvent.deleteMany();
await tx.expenseClaim.deleteMany();
await tx.loanRecord.deleteMany();
await tx.bondRecord.deleteMany();
await tx.invoiceRecord.deleteMany();
await tx.timesheet.deleteMany();
await tx.projectAcceptance.deleteMany();
await tx.projectBudget.deleteMany();
await tx.projectTask.deleteMany();
await tx.purchaseRequest.deleteMany();
await tx.assetOccupancy.deleteMany();
await tx.contractReviewer.deleteMany();
await tx.contractActionLog.deleteMany();
await tx.paymentMilestone.deleteMany();
await tx.project.deleteMany();
await tx.sealRequest.deleteMany();
await tx.contract.deleteMany();
await tx.bidDocVersion.deleteMany();
await tx.bidAssignee.deleteMany();
await tx.bidActionLog.deleteMany();
await tx.bidReviewer.deleteMany();
await tx.credentialBorrow.deleteMany();
await tx.bidCase.deleteMany();
await tx.partyContact.deleteMany();
await tx.qualification.deleteMany();
await tx.userRole.deleteMany();
await tx.rolePermission.deleteMany();
await tx.refreshToken.deleteMany();
await tx.user.deleteMany();
await tx.employee.deleteMany();
await tx.department.deleteMany();
await tx.permission.deleteMany();
await tx.role.deleteMany();
await tx.dictItem.deleteMany();
await tx.attendanceRule.deleteMany();
await tx.hrSetting.deleteMany();
await tx.legalEntity.deleteMany();
await tx.businessParty.deleteMany();
const root = await tx.department.create({
data: {
name: '演示科技有限公司',
code: 'ROOT',
deptType: 'ADMIN',
sortNo: 0,
},
});
const depts = await Promise.all(
[
['商务部', 'BIZ', 'BUSINESS', null, 10],
['三维部门', 'TECH_3D', 'TECH', 'CAT_3D', 20],
['摄制部门', 'TECH_FILM', 'TECH', 'CAT_FILM', 30],
['物资部门', 'TECH_MATERIAL', 'TECH', 'CAT_MATERIAL', 40],
['软件研发部门', 'TECH_SOFTWARE', 'TECH', 'CAT_SOFTWARE', 50],
['财务部', 'FIN', 'FINANCE', null, 60],
['人事行政', 'HR', 'HR', null, 70],
].map(([name, code, deptType, categoryCode, sortNo]) =>
tx.department.create({
data: {
parentId: root.id,
name: name as string,
code: code as string,
deptType: deptType as string,
categoryCode: categoryCode as string | null,
sortNo: sortNo as number,
},
}),
),
);
const byCode = Object.fromEntries(depts.map((d) => [d.code, d]));
const permIds: Record<string, string> = {};
const writeMenus = async (nodes: MenuNode[], parentId: string | null, root = true) => {
for (let i = 0; i < nodes.length; i++) {
const menu = nodes[i];
const row = await tx.permission.create({
data: {
parentId,
name: menu.name,
code: menu.code,
permType: 'MENU',
path: menu.path,
icon: menu.icon,
sortNo: root ? (i + 1) * 10 : i + 1,
},
});
permIds[menu.code] = row.id;
if (menu.children?.length) await writeMenus(menu.children, row.id, false);
}
};
await writeMenus(MENUS, null);
const roles = {
admin: await tx.role.create({ data: { name: '系统管理员', code: 'admin', dataScope: 'ALL' } }),
owner: await tx.role.create({ data: { name: '企业负责人', code: 'owner', dataScope: 'ALL' } }),
biz_director: await tx.role.create({ data: { name: '商务总监', code: 'biz_director', dataScope: 'DEPT' } }),
biz_staff: await tx.role.create({ data: { name: '商务专员', code: 'biz_staff', dataScope: 'SELF' } }),
tech_director: await tx.role.create({ data: { name: '技术总监', code: 'tech_director', dataScope: 'DEPT' } }),
finance: await tx.role.create({ data: { name: '财务人员', code: 'finance', dataScope: 'ALL' } }),
hr: await tx.role.create({ data: { name: '人事', code: 'hr', dataScope: 'ALL' } }),
employee: await tx.role.create({ data: { name: '员工', code: 'employee', dataScope: 'SELF' } }),
};
const allPerms = await tx.permission.findMany();
const grant = async (roleId: string, codes: string[]) => {
const selected =
codes[0] === '*'
? allPerms
: allPerms.filter((p) => codes.some((c) => p.code === c || p.code.startsWith(c)));
await tx.rolePermission.createMany({
data: selected.map((p) => ({ roleId, permissionId: p.id })),
skipDuplicates: true,
});
};
await grant(roles.admin.id, ['*']);
await grant(roles.owner.id, ['*']);
await grant(roles.biz_director.id, ['office', 'party', 'seal', 'bid', 'contract', 'finance', 'report', 'hr:performance']);
await grant(roles.biz_staff.id, ['office', 'party', 'seal', 'bid', 'contract']);
await grant(roles.tech_director.id, ['office', 'bid', 'project', 'report', 'hr:performance']);
await grant(roles.finance.id, ['office', 'finance', 'contract', 'report', 'bid:result']);
await grant(roles.hr.id, ['office', 'hr', 'system:hr', 'system:org', 'system:attendance-rules']);
await grant(roles.employee.id, ['office', 'project:timesheets']);
const mkEmp = (
name: string,
no: string,
deptCode: string,
title: string,
rest: string,
baseSalary: number,
perfBase: number,
socialBase: number,
) =>
tx.employee.create({
data: {
name,
employeeNo: no,
departmentId: byCode[deptCode].id,
title,
restSchedule: rest,
employmentStatus: 'REGULAR',
hiredAt: new Date('2022-01-01'),
baseSalary,
perfBase,
socialBase,
},
});
const emps = {
admin: await mkEmp('系统管理员', 'E000', 'HR', '管理员', 'DOUBLE', 8000, 0, 8000),
owner: await mkEmp('陈总', 'E001', 'BIZ', '企业负责人', 'SINGLE', 25000, 10000, 20000),
bizdir: await mkEmp('王商务', 'E010', 'BIZ', '商务总监', 'SINGLE', 18000, 8000, 18000),
bizstaff: await mkEmp('李专员', 'E011', 'BIZ', '商务专员', 'SINGLE', 9000, 2500, 9000),
dir3d: await mkEmp('赵三维', 'E020', 'TECH_3D', '三维总监', 'SINGLE', 15000, 5000, 15000),
dirfilm: await mkEmp('孙摄制', 'E021', 'TECH_FILM', '摄制总监', 'SINGLE', 15000, 5000, 15000),
dirmaterial: await mkEmp('周物资', 'E022', 'TECH_MATERIAL', '物资总监', 'SINGLE', 15000, 5000, 15000),
dirsoft: await mkEmp('吴软件', 'E023', 'TECH_SOFTWARE', '软件总监', 'DOUBLE', 15000, 5000, 15000),
finance: await mkEmp('郑财务', 'E030', 'FIN', '会计', 'SINGLE', 10000, 2000, 10000),
hr: await mkEmp('冯人事', 'E040', 'HR', '人事专员', 'SINGLE', 9000, 2000, 9000),
employee: await mkEmp('钱员工', 'E100', 'TECH_SOFTWARE', '工程师', 'DOUBLE', 10000, 3000, 10000),
};
const softpm = await mkEmp('软件项目经理', 'E101', 'TECH_SOFTWARE', '项目经理', 'DOUBLE', 12000, 4000, 12000);
await tx.employee.update({
where: { id: emps.employee.id },
data: { managerId: softpm.id },
});
await tx.employee.update({
where: { id: softpm.id },
data: { managerId: emps.dirsoft.id },
});
const mkUser = async (
username: string,
hash: string,
emp: { id: string; name: string },
roleId: string,
) => {
const u = await tx.user.create({
data: {
username,
passwordHash: hash,
displayName: emp.name,
employeeId: emp.id,
},
});
await tx.userRole.create({ data: { userId: u.id, roleId } });
return u;
};
const admin = await mkUser('admin', adminHash, emps.admin, roles.admin.id);
await mkUser('owner', passwordHash, emps.owner, roles.owner.id);
await mkUser('bizdir', passwordHash, emps.bizdir, roles.biz_director.id);
await mkUser('bizstaff', passwordHash, emps.bizstaff, roles.biz_staff.id);
const dirsoft = await mkUser('dirsoft', passwordHash, emps.dirsoft, roles.tech_director.id);
const dir3d = await mkUser('dir3d', passwordHash, emps.dir3d, roles.tech_director.id);
await mkUser('dirfilm', passwordHash, emps.dirfilm, roles.tech_director.id);
await mkUser('dirmaterial', passwordHash, emps.dirmaterial, roles.tech_director.id);
await mkUser('finance', passwordHash, emps.finance, roles.finance.id);
await mkUser('hr', passwordHash, emps.hr, roles.hr.id);
await mkUser('employee', passwordHash, emps.employee, roles.employee.id);
await mkUser('softpm', passwordHash, softpm, roles.employee.id);
const demoEntity = await tx.legalEntity.create({
data: { name: '演示科技有限公司', shortName: '演示科技', creditNo: '91110000MA0000001X', isDefault: true },
});
const party = await tx.businessParty.create({
data: { name: '某市城投集团', creditNo: '91110000MA9999999X', level: 'A' },
});
await tx.partyContact.create({
data: { partyId: party.id, name: '招标办张工', phone: '13800001111', title: '项目联系人' },
});
await tx.qualification.createMany({
data: [
{
name: '电子与智能化工程专业承包',
code: 'QUAL_INTEL',
keeper: '行政',
expiresAt: new Date(Date.now() + 400 * 86400000),
},
{
name: '涉密信息系统集成乙级',
code: 'SECRET_B',
keeper: '行政',
expiresAt: new Date(Date.now() + 20 * 86400000),
},
{
name: '建筑装修装饰工程专业承包',
code: 'QUAL_DECOR',
keeper: '行政',
expiresAt: new Date(Date.now() - 10 * 86400000),
},
],
});
const dicts: [string, string, string, number][] = [
['projectType', '软件', '软件', 1],
['projectType', '三维', '三维', 2],
['projectType', '摄制', '摄制', 3],
['projectType', '物资', '物资', 4],
['projectType', '集成', '集成', 5],
['tenderMethod', '公开招标', '公开招标', 1],
['tenderMethod', '邀请招标', '邀请招标', 2],
['tenderMethod', '询价', '询价', 3],
['tenderMethod', '竞谈【综合评分】', '竞谈【综合评分】', 4],
['tenderMethod', '竞谈【最低价中】', '竞谈【最低价中】', 5],
['tenderMethod', '单一来源', '单一来源', 6],
['tenderMethod', '其他', '其他', 7],
['qualificationNeed', '无需资质', '无需资质', 1],
['qualificationNeed', '军二', '军二', 2],
['qualificationNeed', '涉乙', '涉乙', 3],
['qualificationNeed', '其他', '其他', 4],
['expenseCategory', '交通', '交通', 1],
['expenseCategory', '餐饮', '餐饮', 2],
['expenseCategory', '住宿', '住宿', 3],
['expenseCategory', '办公', '办公', 4],
['expenseCategory', '招待', '招待', 5],
['expenseCategory', '其他', '其他', 6],
['gender', '男', 'MALE', 1],
['gender', '女', 'FEMALE', 2],
['education', '高中及以下', 'HIGH_SCHOOL', 1],
['education', '专科', 'COLLEGE', 2],
['education', '本科', 'BACHELOR', 3],
['education', '硕士', 'MASTER', 4],
['education', '博士', 'DOCTOR', 5],
];
await tx.dictItem.createMany({
data: dicts.map(([dictType, label, value, sortNo]) => ({ dictType, label, value, sortNo })),
});
await tx.numberRule.createMany({
data: [
{ code: 'BID', name: '投标编号', prefix: 'BID', padLength: 5 },
{ code: 'HT', name: '合同编号', prefix: 'HT', padLength: 5 },
{ code: 'PRJ', name: '项目编号', prefix: 'PRJ', padLength: 5 },
{ code: 'BX', name: '报销编号', prefix: 'BX', padLength: 5 },
{ code: 'JK', name: '借款编号', prefix: 'JK', padLength: 5 },
{ code: 'BZ', name: '保证金编号', prefix: 'BZ', padLength: 5 },
],
});
await tx.hrSetting.create({
data: { id: 'default', tenantId: '1', idMask: true, autoAccount: false, probationMonths: 3, defaultRest: 'SINGLE', workStart: '09:00', workEnd: '18:00' },
});
await tx.attendanceRule.createMany({
data: [
{ name: '单休班次', restSchedule: 'SINGLE', workStart: '09:00', workEnd: '18:00', sortNo: 1, remark: '每周休一天,薪酬按 26.09 天核算' },
{ name: '双休班次', restSchedule: 'DOUBLE', workStart: '09:00', workEnd: '18:00', sortNo: 2, remark: '每周休两天,薪酬按 21.75 天核算' },
],
});
const sealTypes = ['公章', '合同章', '法人章', '法人签名章', '项目负责人章', '其他章'];
await tx.sealItem.createMany({
data: sealTypes.map((sealType) => ({
name: `${demoEntity.shortName}${sealType}`,
sealType,
keeper: '行政',
legalEntityId: demoEntity.id,
status: 'IN_STOCK',
})),
});
const bid = await tx.bidCase.create({
data: {
bidNo: 'BID-2026-00001',
name: '某市展厅数字化升级',
externalNo: 'ZFCG-2026-088',
projectType: '集成',
source: '招标公告',
qualificationNeed: '涉乙',
tenderMethod: '公开招标',
applyMethod: '网上申领',
openMethod: '线下开标',
openPlace: '市公共资源交易中心',
openAt: new Date(Date.now() + 7 * 86400000),
partyId: party.id,
status: 'TECH_INITIAL',
createdById: admin.id,
reviewers: {
create: [
{ userId: dirsoft.id, stage: 'TECH_INITIAL' },
{ userId: dir3d.id, stage: 'TECH_INITIAL' },
],
},
},
});
await tx.bidActionLog.create({
data: {
bidCaseId: bid.id,
actorId: admin.id,
action: 'CREATE',
toStatus: 'TECH_INITIAL',
comment: `发起筛选 ${bid.bidNo}`,
},
});
await tx.approvalTask.createMany({
data: [
{
title: `技术初审会签:${bid.name}`,
bizType: 'BID_TECH_INITIAL',
bizId: bid.id,
assigneeId: dirsoft.id,
},
{
title: `技术初审会签:${bid.name}`,
bizType: 'BID_TECH_INITIAL',
bizId: bid.id,
assigneeId: dir3d.id,
},
],
});
});
}
main()
.then(async () => {
await prisma.$disconnect();
console.log('seed ok');
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+165
View File
@@ -0,0 +1,165 @@
import { PrismaClient } from '@prisma/client';
import { DIRECTOR_ROLES, WORK_ASSIGN_ROLES } from '../src/common/role-groups';
import { MENUS, isHrSettingCode, type MenuNode } from './menus';
const prisma = new PrismaClient();
async function upsertNode(node: MenuNode, parentId: string | null, sortNo: number) {
const existing = await prisma.permission.findUnique({ where: { code: node.code } });
const data = {
parentId,
name: node.name,
permType: 'MENU',
path: node.path,
icon: node.icon ?? null,
sortNo,
visible: true,
};
const row = existing
? await prisma.permission.update({ where: { id: existing.id }, data })
: await prisma.permission.create({ data: { ...data, code: node.code } });
return row;
}
const ACTION_DEFS = [
{ name: '查看', suffix: 'view' },
{ name: '新增', suffix: 'create' },
{ name: '编辑', suffix: 'edit' },
{ name: '删除', suffix: 'delete' },
{ name: '审批', suffix: 'approve' },
];
async function upsertActions(parentCode: string, parentId: string) {
const ids: string[] = [];
for (let i = 0; i < ACTION_DEFS.length; i++) {
const a = ACTION_DEFS[i];
const code = `${parentCode}:${a.suffix}`;
const existing = await prisma.permission.findUnique({ where: { code } });
const data = {
parentId,
name: a.name,
permType: 'ACTION',
path: null as string | null,
icon: null as string | null,
sortNo: i + 1,
visible: true,
};
const row = existing
? await prisma.permission.update({ where: { id: existing.id }, data })
: await prisma.permission.create({ data: { ...data, code } });
ids.push(row.id);
}
return ids;
}
async function walk(nodes: MenuNode[], parentId: string | null, root = true) {
const ids: string[] = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const row = await upsertNode(node, parentId, root ? (i + 1) * 10 : i + 1);
ids.push(row.id);
if (node.children?.length) ids.push(...(await walk(node.children, row.id, false)));
else ids.push(...(await upsertActions(node.code, row.id)));
}
return ids;
}
async function main() {
const kept = await walk(MENUS, null);
const stale = await prisma.permission.findMany({
where: {
permType: 'MENU',
OR: [
{ id: { notIn: kept }, code: { in: ['system:org'] } },
{ code: { in: ['office:loan-approve', 'finance:expense-approve', 'finance:loan-approve', 'finance:bonds', 'contract:milestones', 'office:attendance', 'office:policies', 'project:budgets', 'project:changes'] } },
],
},
});
if (stale.length) {
await prisma.permission.updateMany({
where: { id: { in: stale.map((s) => s.id) } },
data: { visible: false },
});
}
const all = await prisma.permission.findMany({ where: { visible: true } });
const grantByCodes = (codes: string[]) => {
if (codes[0] === '*') return all;
return all.filter((p) =>
codes.some((c) => {
if (c.endsWith(':')) return p.code === c.slice(0, -1) || p.code.startsWith(c);
return p.code === c || p.code.startsWith(`${c}:`);
}),
);
};
const STAFF_OFFICE = [
'office:overview',
'office',
'office:todos',
'office:approvals',
'office:calendar',
'office:reports',
'office:expenses',
'office:flow',
'office:hr-apply',
'office:seal-apply',
'office:apply',
'office:notices',
'office:directory',
'office:im',
];
const ROLE_GRANT: Record<string, string[]> = {
admin: ['*'],
owner: ['*'],
biz_director: ['office:', 'party:', 'seal:', 'bid:', 'contract:', 'finance:', 'report:', 'hr:', 'asset:', 'system:hr', 'system:org', 'system:attendance-rules'],
biz_staff: ['office:', 'party:', 'seal:', 'bid:', 'contract:', 'finance:', 'asset:', 'hr:employees', 'hr:payroll', 'hr:performance', 'hr:attendance', 'hr:lifecycle'],
tech_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
rd_director: ['*'],
'3d_director': ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
video_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
material_director: ['office:', 'bid:', 'project:', 'report:', 'hr:performance', 'hr:notices'],
finance: ['office:', 'finance:', 'contract:', 'report:', 'bid:result', 'asset:'],
hr: ['office:', 'hr:', 'contract:hr', 'system:hr', 'system:org', 'system:attendance-rules', 'system:acl'],
employee: [...STAFF_OFFICE, 'project:timesheets'],
bid_screener: ['office', 'office:overview', 'bid', 'bid:screening'],
pm: [...STAFF_OFFICE, 'office:assign', 'project:'],
rd_staff: [...STAFF_OFFICE, 'project:timesheets'],
'3d_staff': [...STAFF_OFFICE, 'project:timesheets'],
video_staff: [...STAFF_OFFICE, 'project:timesheets'],
material_staff: [...STAFF_OFFICE, 'project:timesheets'],
tech: [...STAFF_OFFICE, 'project:timesheets'],
};
const roles = await prisma.role.findMany();
for (const role of roles) {
if (role.aclCustom) continue;
const codes = ROLE_GRANT[role.code];
if (!codes) continue;
const wanted = grantByCodes(codes);
const extra = role.code === 'hr' ? all.filter((p) => isHrSettingCode(p.code)) : [];
const smsRoles = ['admin', 'owner', 'hr', ...DIRECTOR_ROLES];
const merged = [...new Map([...wanted, ...extra].map((p) => [p.id, p])).values()].filter((p) => {
if (p.code === 'office:assign') return WORK_ASSIGN_ROLES.includes(role.code);
if (p.code === 'office:sms') return smsRoles.includes(role.code);
return true;
});
const wantedIds = merged.map((p) => p.id);
await prisma.rolePermission.deleteMany({
where: { roleId: role.id, permissionId: { notIn: wantedIds.length ? wantedIds : [''] } },
});
await prisma.rolePermission.createMany({
data: merged.map((p) => ({ roleId: role.id, permissionId: p.id })),
skipDuplicates: true,
});
}
console.log(`menus synced: ${kept.length} visible, hid ${stale.length}`);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+329
View File
@@ -0,0 +1,329 @@
import { PrismaClient } from '@prisma/client';
import { createHash, randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const CORE = '/opt/import/prod/oa_core.json';
const FLOW = '/opt/import/prod/oa_flow_finance.json';
const BID_FILES = '/opt/import/prod/oa_bid_files.json';
const HR = '/opt/import/prod/oa_hr.json';
const OA_FILES = '/opt/import/prod/oa-files';
const UPLOAD = path.join(process.cwd(), 'uploads');
type Row = Record<string, unknown>;
const MIME: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
zip: 'application/zip',
rar: 'application/x-rar-compressed',
};
const NAME_ALIAS: Record<string, string> = { : '张婧妍' };
const SKIP_DOC_ROLE = new Set(['draft', 'final', 'review1']);
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function profilePaths(v: unknown) {
const out: string[] = [];
if (!v) return out;
for (const chunk of str(v).split(/[,;]/)) {
const piece = chunk.trim();
const first = piece.split('|')[0].trim();
const idx = first.indexOf('/profile/upload/');
const p = idx >= 0 ? first.slice(idx) : first;
if (p.startsWith('/profile/upload/')) out.push(p);
}
return out;
}
function relOf(p: string) {
return p.replace(/^\/profile\/upload\//, '');
}
function displayName(p: string) {
const pipe = str(p).split('|')[1];
if (pipe && !pipe.includes('/')) return path.basename(pipe);
return path.basename(relOf(p.split('|')[0]));
}
function mimeOf(name: string) {
const ext = name.split('.').pop()?.toLowerCase() || 'bin';
return MIME[ext] || 'application/octet-stream';
}
function prettyName(name: string) {
try {
return decodeURIComponent(name);
} catch {
return name;
}
}
async function attach(
bizType: string,
bizId: string,
urls: unknown,
uploadedById: string,
seen: Set<string>,
) {
let n = 0;
let missing = 0;
for (const raw of profilePaths(urls)) {
const rel = relOf(raw);
const src = path.join(OA_FILES, rel);
if (!fs.existsSync(src)) {
console.warn(`[missing-source] ${bizType} ${rel}`);
missing += 1;
continue;
}
const buf = fs.readFileSync(src);
const hash = createHash('sha1').update(buf).digest('hex').slice(0, 16);
const key = `${bizId}:${hash}`;
if (seen.has(key)) continue;
seen.add(key);
const id = randomUUID();
const ext = (path.extname(rel).replace('.', '') || 'bin').replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
const storageKey = `OA/${bizType}/${bizId}/${id}.${ext || 'bin'}`;
const dest = path.join(UPLOAD, storageKey);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
const name = prettyName(displayName(raw) || path.basename(rel));
await prisma.fileAsset.create({
data: {
id,
tenantId: '1',
bizType,
bizId,
fileName: name.slice(0, 180),
mimeType: mimeOf(name),
size: buf.length,
storageKey,
uploadedById,
},
});
n += 1;
}
return { n, missing };
}
async function main() {
const core = parseOaJson<Record<string, Row[]>>(CORE);
const flow = parseOaJson<Record<string, Row[]>>(FLOW);
const docFiles = JSON.parse(fs.readFileSync(BID_FILES, 'utf8')) as Row[];
const hr = JSON.parse(fs.readFileSync(HR, 'utf8')) as { users: { id: number; userName: string; nickName: string }[] };
const admin = await prisma.user.findFirst({
where: { OR: [{ username: 'admin' }, { displayName: '系统管理员' }] },
orderBy: { createdAt: 'asc' },
});
if (!admin) throw new Error('没有可用于迁移附件的系统管理员账号');
fs.mkdirSync(UPLOAD, { recursive: true });
const users = await prisma.user.findMany();
const byUsername = new Map(users.map((u) => [u.username, u]));
const byName = new Map(users.map((u) => [u.displayName, u]));
for (const [a, b] of Object.entries(NAME_ALIAS)) {
const u = byName.get(b);
if (u) byName.set(a, u);
}
const oaUser = new Map(hr.users.map((u) => [String(u.id), u.userName]));
const userFromOaId = (id: unknown) => {
const uname = oaUser.get(str(id));
return uname ? byUsername.get(uname) : undefined;
};
const userFromName = (name: unknown) => {
const n = NAME_ALIAS[str(name)] || str(name);
return byName.get(n);
};
const existing = await prisma.fileAsset.findMany({ select: { bizId: true, storageKey: true, size: true, fileName: true } });
const seen = new Set<string>();
for (const f of existing) {
const src = path.join(process.cwd(), 'uploads', f.storageKey);
if (!fs.existsSync(src)) continue;
const hash = createHash('sha1').update(fs.readFileSync(src)).digest('hex').slice(0, 16);
seen.add(`${f.bizId}:${hash}`);
}
const bids = await prisma.bidCase.findMany();
const findBids = (nos: string[], name?: string) => {
const wanted = new Set(nos.filter(Boolean));
const hits = bids.filter(
(b) => wanted.has(b.bidNo) || (b.externalNo && wanted.has(b.externalNo)),
);
if (hits.length) return hits;
if (name) return bids.filter((b) => b.name === name);
return [];
};
const stats = {
bidAnnounce: 0,
bidFile: 0,
bidProof: 0,
contracts: 0,
expenses: 0,
projects: 0,
purchases: 0,
missing: 0,
unmatchedBids: 0,
};
for (const t of core.oa_tender || []) {
const localHits = findBids(
[officialNo(t.project_number), str(t.system_number)],
str(t.project_name),
);
if (!localHits.length) continue;
const who = userFromOaId(t.create_by) || userFromName(t.bid_prepare_person) || admin;
for (const local of localHits) {
stats.bidAnnounce += (await attach('BID_ANNOUNCE', local.id, t.announcement_file, who.id, seen)).n;
const extra = await attach('BID_FILE', local.id, t.attachment, who.id, seen);
stats.bidFile += extra.n;
stats.missing += extra.missing;
stats.bidProof += (await attach('BID_PROOF', local.id, t.submit_proof_file, who.id, seen)).n;
}
}
for (const p of flow.project_preinvest_apply || []) {
const nos = [str(p.apply_no), officialNo(p.project_number)];
const localHits = findBids(nos, str(p.project_name));
if (!localHits.length) {
if (profilePaths(p.attachment).length || profilePaths(p.business_attachment).length) stats.unmatchedBids += 1;
continue;
}
const who = userFromName(p.applicant_name) || userFromName(p.business_handler_name) || admin;
for (const local of localHits) {
const a = await attach('BID_ANNOUNCE', local.id, p.attachment, who.id, seen);
const b = await attach('BID_FILE', local.id, p.business_attachment, who.id, seen);
const c = await attach('BID_PROOF', local.id, p.material_decision_proof, who.id, seen);
const d = await attach('BID_FILE', local.id, p.business_prep_attachment, who.id, seen);
stats.bidAnnounce += a.n;
stats.bidFile += b.n + d.n;
stats.bidProof += c.n;
stats.missing += a.missing + b.missing + c.missing + d.missing;
}
}
const taskById = new Map((flow.bid_document_task || []).map((t) => [String(t.id), t]));
for (const f of docFiles) {
if (SKIP_DOC_ROLE.has(str(f.file_role))) continue;
const task = taskById.get(str(f.task_id));
const nos = task
? [str(task.task_no), officialNo(task.project_number), str(task.preinvest_apply_no)]
: [];
const localHits = findBids(nos, str(task?.project_name));
if (!localHits.length) {
stats.unmatchedBids += 1;
continue;
}
const who = userFromName(f.uploaded_by_name) || userFromName(task?.dispatcher_name) || admin;
const role = str(f.file_role);
const from = str(f.source_preinvest_field);
const bizType =
role === 'submit_proof' || from === 'material_decision_proof'
? 'BID_PROOF'
: from === 'attachment'
? 'BID_ANNOUNCE'
: 'BID_FILE';
for (const local of localHits) {
const r = await attach(bizType, local.id, f.file_url, who.id, seen);
if (bizType === 'BID_ANNOUNCE') stats.bidAnnounce += r.n;
else if (bizType === 'BID_PROOF') stats.bidProof += r.n;
else stats.bidFile += r.n;
stats.missing += r.missing;
}
}
const contracts = await prisma.contract.findMany();
const contractByName = new Map(contracts.map((c) => [c.name, c]));
for (const c of core.oa_contract || []) {
const local = contractByName.get(str(c.contract_name));
if (!local) continue;
const who = userFromOaId(c.create_by) || userFromOaId(c.creator_id) || admin;
stats.contracts += (await attach('CONTRACT', local.id, c.contract_original_url, who.id, seen)).n;
stats.contracts += (await attach('CONTRACT_ACCEPT', local.id, c.acceptance_url, who.id, seen)).n;
stats.contracts += (await attach('CONTRACT_AWARD', local.id, c.award_notice_url, who.id, seen)).n;
}
const expenses = await prisma.expenseClaim.findMany();
for (const r of flow.reimbursement || core.reimbursement || []) {
const amount = num(r.total_amount);
const name = str(r.employee_name);
const claimNo = str(r.reimburse_no);
const oaId = str(r.id);
if (!oaId) continue;
let hits = claimNo ? expenses.filter((e) => e.claimNo === claimNo) : [];
if (!hits.length) {
hits = expenses.filter((e) => Math.abs(Number(e.amount) - amount) < 0.009 && name && (e.remark || '').includes(name));
}
const local = hits[0];
if (!local) continue;
const who = userFromName(name) || users.find((u) => u.id === local.applicantId) || admin;
const items = (flow.reimbursement_item || core.reimbursement_item || []).filter(
(i) => str(i.reimbursement_id) && str(i.reimbursement_id) === oaId,
);
for (const item of items) {
const x = await attach('EXPENSE', local.id, item.url, who.id, seen);
stats.expenses += x.n;
stats.missing += x.missing;
}
const x = await attach('EXPENSE', local.id, r.attachment_url, who.id, seen);
stats.expenses += x.n;
stats.missing += x.missing;
}
const projects = await prisma.project.findMany();
const projectByName = new Map(projects.map((p) => [p.name, p]));
for (const p of core.project_list || []) {
const local =
projects.find((x) => x.projectNo === str(p.system_number)) || projectByName.get(str(p.project_name));
if (!local) continue;
stats.projects += (await attach('PROJECT', local.id, p.bzj_attachment, admin.id, seen)).n;
}
const purchases = await prisma.purchaseRequest.findMany();
for (const r of core.oa_purchasing || []) {
const titleBits = [str(r.contract_no), str(r.contract_name)].filter(Boolean);
const local = purchases.find((p) => titleBits.some((b) => p.title.includes(b)));
if (!local) continue;
const who = userFromOaId(r.create_by) || userFromOaId(r.creator_id) || admin;
stats.purchases += (await attach('PURCHASE', local.id, r.contract_original_url, who.id, seen)).n;
stats.purchases += (await attach('PURCHASE_ACCEPT', local.id, r.acceptance_url, who.id, seen)).n;
}
const fileCount = await prisma.fileAsset.count();
const byType = await prisma.fileAsset.groupBy({ by: ['bizType'], _count: true });
console.log(JSON.stringify({ added: stats, fileAssetTotal: fileCount, byType }, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+35
View File
@@ -0,0 +1,35 @@
import { PrismaClient } from '@prisma/client';
const ITEMS = [
['公开招标', 1],
['邀请招标', 2],
['询价', 3],
['竞谈【综合评分】', 4],
['竞谈【最低价中】', 5],
['单一来源', 6],
['其他', 7],
] as const;
const prisma = new PrismaClient();
async function main() {
await prisma.dictItem.deleteMany({ where: { tenantId: '1', dictType: 'tenderMethod' } });
await prisma.dictItem.createMany({
data: ITEMS.map(([value, sortNo]) => ({
tenantId: '1',
dictType: 'tenderMethod',
label: value,
value,
sortNo,
})),
});
console.log('tenderMethod dict synced', ITEMS.length);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+131
View File
@@ -0,0 +1,131 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_core.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function mapQual(raw: unknown) {
const s = str(raw);
if (!s) return '';
try {
const arr = JSON.parse(s) as { item?: string }[];
const item = str(arr?.[0]?.item);
if (!item || item === '无') return '无需资质';
if (item.includes('军')) return '军二';
if (item.includes('涉')) return '涉乙';
return item.slice(0, 20);
} catch {
if (s.includes('军')) return '军二';
if (s.includes('涉')) return '涉乙';
return '';
}
}
function mapStatus(row: Row) {
const st = str(row.project_status);
const win = str(row.winning_status);
if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON';
if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST';
if (st === '流标') return 'FAILED';
if (st === '弃标' || win.includes('弃标')) return 'TERMINATED';
return '';
}
function isRealTender(row: Row) {
return /^TB-\d/.test(str(row.system_number)) && Boolean(str(row.project_name));
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const tenders = (raw.oa_tender || []).filter(isRealTender);
const bids = await prisma.bidCase.findMany();
const users = await prisma.user.findMany({ select: { id: true, displayName: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
userByName.set('张靖妍', userByName.get('张婧妍') || '');
const testNos = ['SP-TEST-1788068192', 'SP-FIX-1788068273'];
const tests = bids.filter((b) => testNos.includes(b.bidNo) || testNos.includes(b.externalNo || ''));
const testIds = tests.map((b) => b.id);
if (testIds.length) {
await prisma.todoTask.deleteMany({ where: { bizId: { in: testIds } } });
await prisma.approvalTask.deleteMany({ where: { bizId: { in: testIds } } });
await prisma.bidCase.deleteMany({ where: { id: { in: testIds } } });
}
const used = new Set<string>();
const remaining = await prisma.bidCase.findMany();
let patched = 0;
const moves: { name: string; from: string; to: string; no: string }[] = [];
for (const t of tenders) {
const name = str(t.project_name);
const sys = str(t.system_number);
const official = officialNo(t.project_number);
const row =
remaining.find((b) => b.name === name) ||
remaining.find((b) => b.bidNo === sys || b.externalNo === official || b.externalNo === str(t.project_number));
if (!row) continue;
const status = mapStatus(t) || row.status;
const creatorName = str(t.bid_prepare_person) === '张靖妍' ? '张婧妍' : str(t.bid_prepare_person);
let bidNo = official || sys;
if (used.has(bidNo)) bidNo = `${official || sys}-${sys.slice(-3)}`;
used.add(bidNo);
if (row.status !== status) {
moves.push({ name: name.slice(0, 24), from: row.status, to: status, no: official || sys });
}
const qual = mapQual(t.bid_doc_requirement);
await prisma.bidCase.update({
where: { id: row.id },
data: {
bidNo: `TMP-${row.id.replace(/-/g, '').slice(0, 18)}`,
},
});
await prisma.bidCase.update({
where: { id: row.id },
data: {
bidNo,
externalNo: official || null,
status,
sourceType: 'FLOW',
qualificationNeed: qual || row.qualificationNeed,
tenderMethod: str(t.tender_method) || row.tenderMethod,
applyMethod: row.applyMethod || (str(t.announcement_file) ? '网上申领' : row.applyMethod),
delivererName: str(t.bid_delivery_person) || row.delivererName,
createdById: userByName.get(creatorName) || row.createdById,
},
});
patched += 1;
}
const after = await prisma.bidCase.groupBy({ by: ['status'], _count: true });
console.log(
JSON.stringify(
{ deletedTests: testIds.length, patched, moves, counts: after },
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+142
View File
@@ -0,0 +1,142 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_clean.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
const CANON = [
{ name: '江苏风影随行科技有限公司', shortName: '随行科技', aliases: ['江苏风影随行科技有限公司', '江苏风影随行', '随行科技'] },
{ name: '江苏风影软件有限公司', shortName: '风影软件', aliases: ['江苏风影软件有限公司', '风影软件'] },
{ name: '江苏风影工程科技有限公司', shortName: '风影工程', aliases: ['江苏风影工程科技有限公司', '风影工程'] },
{ name: '山东鲁兵', shortName: '山东鲁兵', aliases: ['山东鲁兵', '鲁兵'] },
{ name: '隆创', shortName: '隆创', aliases: ['隆创'] },
] as const;
function canonOf(raw: string) {
const s = str(raw);
if (!s) return null;
if (/软件/.test(s) && /风影/.test(s)) return CANON[1];
if (/工程/.test(s) && /风影/.test(s)) return CANON[2];
if (/随行/.test(s) || s === '江苏风影' || (s.includes('风影') && !/软件|工程/.test(s))) return CANON[0];
if (s.includes('鲁兵')) return CANON[3];
if (s.includes('隆创')) return CANON[4];
for (const c of CANON) {
if (c.aliases.some((a) => s === a || s.includes(a))) return c;
}
return null;
}
async function main() {
const ids = new Map<string, string>();
for (const c of CANON) {
let row =
(await prisma.legalEntity.findFirst({ where: { tenantId: '1', name: c.name } })) ||
(await prisma.legalEntity.findFirst({
where: { tenantId: '1', OR: c.aliases.map((a) => ({ name: a })) },
}));
if (row) {
row = await prisma.legalEntity.update({
where: { id: row.id },
data: { name: c.name, shortName: c.shortName, isDefault: c.name === '江苏风影随行科技有限公司' },
});
} else {
row = await prisma.legalEntity.create({
data: {
name: c.name,
shortName: c.shortName,
isDefault: c.name === '江苏风影随行科技有限公司',
},
});
}
ids.set(c.name, row.id);
}
const all = await prisma.legalEntity.findMany({ where: { tenantId: '1' } });
const keep = new Set(ids.values());
let merged = 0;
for (const extra of all) {
if (keep.has(extra.id)) continue;
const hit = canonOf(extra.name) || canonOf(extra.shortName);
const target = hit ? ids.get(hit.name) : ids.get('江苏风影随行科技有限公司');
if (!target || target === extra.id) continue;
const n = await prisma.bidCase.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } });
await prisma.contract.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null);
await prisma.sealItem.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null);
await prisma.registeredAsset.updateMany({ where: { legalEntityId: extra.id }, data: { legalEntityId: target } }).catch(() => null);
await prisma.legalEntity.delete({ where: { id: extra.id } }).catch(() => null);
merged += 1;
console.log('merged', extra.name, '->', hit?.name, 'bids', n.count);
}
await prisma.legalEntity.updateMany({
where: { name: { not: '江苏风影随行科技有限公司' } },
data: { isDefault: false },
});
await prisma.legalEntity.updateMany({
where: { name: '江苏风影随行科技有限公司' },
data: { isDefault: true },
});
let remapped = 0;
if (fs.existsSync(DUMP)) {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const tenders = raw.oa_tender || [];
const bids = await prisma.bidCase.findMany({ select: { id: true, name: true, bidNo: true, externalNo: true } });
for (const t of tenders) {
const name = str(t.project_name);
const sys = str(t.system_number);
const official = str(t.project_number);
const row =
bids.find((b) => b.name === name) ||
bids.find((b) => b.bidNo === sys || b.externalNo === official || b.bidNo === official);
const hit = canonOf(str(t.participating_unit));
const legalEntityId = hit ? ids.get(hit.name) : undefined;
const winner = str(t.winning_unit);
const amtRaw = str(t.winning_amount);
const status = str(t.winning_status);
if (!row) continue;
const data: { legalEntityId?: string; winnerName?: string; winnerAmount?: number } = {};
if (legalEntityId) data.legalEntityId = legalEntityId;
if (winner && status && !/中标/.test(status)) {
data.winnerName = winner;
if (amtRaw && !Number.isNaN(Number(amtRaw))) data.winnerAmount = Number(amtRaw);
}
if (!Object.keys(data).length) continue;
await prisma.bidCase.update({ where: { id: row.id }, data });
remapped += 1;
}
}
const leftover = await prisma.legalEntity.findMany({ where: { tenantId: '1' }, orderBy: { name: 'asc' } });
const grouped = await prisma.bidCase.groupBy({ by: ['legalEntityId'], _count: true });
console.log(
JSON.stringify(
{
entities: leftover.map((e) => ({ name: e.name, shortName: e.shortName, isDefault: e.isDefault })),
bids: grouped.map((g) => ({
id: g.legalEntityId,
count: g._count,
name: leftover.find((e) => e.id === g.legalEntityId)?.name || '(空)',
})),
merged,
remapped,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+466
View File
@@ -0,0 +1,466 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { mapExpenseLineKind, parseOaJson } from './oa-json';
const prisma = new PrismaClient();
const FLOW = '/opt/import/prod/oa_flow_finance.json';
const FRESH = '/opt/import/prod/oa_fresh.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s;
}
function compactName(name: string) {
return str(name)
.replace(/[()()\[\]【】\s\-—_.,,、]/g, '')
.replace(/招标公告|采购项目|技术服务合同|合同/g, '');
}
function namesOverlap(a: string, b: string) {
const na = compactName(a);
const nb = compactName(b);
if (!na || !nb) return false;
if (na === nb) return true;
const shorter = na.length <= nb.length ? na : nb;
const longer = na.length <= nb.length ? nb : na;
return shorter.length >= 10 && longer.includes(shorter);
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资') && s.includes('软件')) return '集成';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
if (s === '软件') return '软件';
return '软件';
}
function mapDocStatus(stage: number, approval: number) {
if (stage === 8 || approval === 3) return 'TERMINATED';
if (stage === 1 || stage === 2 || stage === 3 || stage === 4 || stage === 5 || stage === 6) return 'DRAFTING';
if (stage === 7 && approval === 2) return 'PENDING';
return 'DRAFTING';
}
function mapPreinvestStatus(stage: number, approval: number) {
if (stage === 6 || approval === 3) return 'TERMINATED';
if (stage === 3) return 'TECH_FINAL';
if (stage === 1) return 'TECH_INITIAL';
if (stage === 5 && approval === 2) return '';
return 'FETCH';
}
function mapExpenseStatus(v: unknown) {
const s = str(v);
if (s === '2') return 'APPROVED';
if (s === '3') return 'REJECTED';
if (s === '4') return 'DRAFT';
return 'PENDING';
}
function mapLoanStatus(row: Row) {
const st = str(row.approval_status);
const offset = num(row.offset_amount);
if (st === '2') return offset >= num(row.amount) && offset > 0 ? 'OFFSET' : 'OPEN';
if (st === '1') return 'PENDING';
if (st === '3') return 'REJECTED';
return 'DRAFT';
}
function mapItemKind(itemType: string, remark = '') {
return mapExpenseLineKind(itemType, remark);
}
function mapItemCategory(itemType: string) {
if (itemType.includes('交通') || itemType.includes('差旅') || itemType.includes('机票') || itemType.includes('高铁')) return '交通';
if (itemType.includes('餐')) return '餐饮';
if (itemType.includes('住宿')) return '住宿';
if (itemType.includes('办公')) return '办公';
if (itemType.includes('招待')) return '招待';
return '其他';
}
function isBidExpense(itemType: string) {
return itemType.includes('投标') || itemType.includes('制标') || itemType.includes('取标') || itemType.includes('印');
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function inferTenderMethod(name: string, stored?: string) {
const s = str(stored);
if (s) return s;
const n = name || '';
if (n.includes('询价')) return '询价';
if (n.includes('邀请招标')) return '邀请招标';
if (n.includes('单一来源')) return '单一来源';
if (n.includes('竞谈最低') || n.includes('竞争性谈判(最低') || n.includes('竞争性谈判(最低')) return '竞谈最低价';
if (n.includes('竞谈综合') || n.includes('综合评审')) return '竞谈综合评审';
if (n.includes('竞争性谈判') || n.includes('竞谈')) return '竞争性谈判';
if (n.includes('竞争性磋商') || n.includes('磋商')) return '竞争性磋商';
if (n.includes('公开招标')) return '公开招标';
return '';
}
async function main() {
const flow = parseOaJson<Record<string, Row[]>>(FLOW);
const fresh = parseOaJson<Record<string, Row[]>>(FRESH);
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('本地没有 admin');
const entity =
(await prisma.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) ||
(await prisma.legalEntity.findFirst({ where: { tenantId: '1' } }));
if (!entity) throw new Error('本地没有主体');
const users = await prisma.user.findMany({ select: { id: true, displayName: true, username: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
userByName.set('张靖妍', userByName.get('张婧妍') || '');
const entities = await prisma.legalEntity.findMany();
const entityByName = new Map(entities.map((e) => [e.name, e.id]));
for (const e of entities) if (e.shortName) entityByName.set(e.shortName, e.id);
const projects = await prisma.project.findMany({ select: { id: true, name: true, projectNo: true } });
const projectByName = new Map(projects.map((p) => [p.name, p.id]));
const projectByOaId = new Map<string, string>();
for (const p of fresh.project_list || []) {
const local = projects.find((x) => x.projectNo === str(p.system_number) || namesOverlap(x.name, str(p.project_name)));
if (local && p.project_id != null) projectByOaId.set(String(p.project_id), local.id);
}
const tenders = (fresh.oa_tender || []).filter((t) => /^TB-\d/.test(str(t.system_number)));
const tenderById = new Map(tenders.map((t) => [String(t.id), t]));
const allPreinvests = flow.project_preinvest_apply || [];
const preByTask = new Map<string, Row>();
const preByTender = new Map<string, Row>();
const preByName = new Map<string, Row>();
for (const p of [...allPreinvests].sort((a, b) => num(a.deleted) - num(b.deleted))) {
if (p.bid_document_task_id != null) preByTask.set(String(p.bid_document_task_id), p);
if (p.tender_id != null) preByTender.set(String(p.tender_id), p);
if (str(p.project_name) && !preByName.has(str(p.project_name))) preByName.set(str(p.project_name), p);
}
const bids = await prisma.bidCase.findMany();
const usedNos = new Set(bids.map((b) => b.bidNo));
function findBid(_name: string, nos: string[]) {
const wanted = new Set(nos.filter(Boolean));
if (!wanted.size) return undefined;
return bids.find((b) => wanted.has(b.bidNo) || (b.externalNo && wanted.has(b.externalNo)));
}
let applyPatched = 0;
for (const t of tenders) {
const pre = preByTender.get(String(t.id)) || preByName.get(str(t.project_name));
const start = when(pre?.file_apply_time_start) || when(t.bid_prepare_date) || when(t.create_time);
const end = when(pre?.file_apply_time_end) || when(t.file_apply_deadline);
const row = findBid(str(t.project_name), [officialNo(t.project_number), str(t.system_number)]);
if (!row || (!start && !end)) continue;
await prisma.bidCase.update({
where: { id: row.id },
data: {
applyStartAt: start || row.applyStartAt,
applyEndAt: end || row.applyEndAt,
applyMethod: clip(pre?.apply_method, 50) || row.applyMethod,
openMethod: clip(pre?.bid_open_method, 50) || row.openMethod,
},
});
applyPatched += 1;
}
async function ensureBid(opts: {
bidNo: string;
name: string;
official?: string;
status: string;
openAt?: Date | null;
openPlace?: string;
applyStart?: Date | null;
applyEnd?: Date | null;
applyMethod?: string;
openMethod?: string;
projectType?: string;
source?: string;
qualification?: string;
entityName?: string;
createdAt?: Date | null;
createdByName?: string;
tenderMethod?: string;
}) {
if (!opts.name || /^test/i.test(opts.name)) return 'skip';
const existing = findBid(opts.name, [opts.bidNo, opts.official || '']);
if (existing) {
const result = ['WON', 'LOST', 'FAILED'].includes(existing.status);
const sameNo = existing.bidNo === opts.bidNo;
if (opts.status === 'TERMINATED' && !sameNo && !result && existing.status !== 'TERMINATED') {
// 同名在途单不因回收站记录被误杀,下面另建终止单
} else {
const nextStatus = result ? existing.status : opts.status === 'TERMINATED' || existing.status === 'TERMINATED' ? 'TERMINATED' : opts.status;
await prisma.bidCase.update({
where: { id: existing.id },
data: {
applyStartAt: opts.applyStart || existing.applyStartAt,
applyEndAt: opts.applyEnd || existing.applyEndAt,
applyMethod: opts.applyMethod || existing.applyMethod,
openMethod: opts.openMethod || existing.openMethod,
openAt: opts.openAt || existing.openAt,
openPlace: opts.openPlace || existing.openPlace,
projectType: mapProjectType(opts.projectType, opts.name) || existing.projectType,
tenderMethod: inferTenderMethod(opts.name, opts.tenderMethod) || existing.tenderMethod,
status: nextStatus,
},
});
return 'updated';
}
}
let bidNo = opts.bidNo;
if (!bidNo || usedNos.has(bidNo)) bidNo = opts.official || opts.bidNo;
if (usedNos.has(bidNo)) bidNo = `${opts.bidNo}-${Date.now().toString().slice(-4)}`;
usedNos.add(bidNo);
const created = await prisma.bidCase.create({
data: {
bidNo,
name: clip(opts.name, 200),
externalNo: opts.official || undefined,
projectType: mapProjectType(opts.projectType, opts.name),
sourceType: 'FLOW',
source: clip(opts.source, 50) || undefined,
qualificationNeed: clip(opts.qualification, 40) || undefined,
legalEntityId: (opts.entityName && entityByName.get(opts.entityName)) || entity.id,
applyStartAt: opts.applyStart || undefined,
applyEndAt: opts.applyEnd || undefined,
applyMethod: clip(opts.applyMethod, 50) || undefined,
openAt: opts.openAt || undefined,
openMethod: clip(opts.openMethod, 50) || undefined,
openPlace: clip(opts.openPlace, 200) || undefined,
tenderMethod: inferTenderMethod(opts.name, opts.tenderMethod) || undefined,
status: opts.status,
createdById: userByName.get(opts.createdByName || '') || admin.id,
createdAt: opts.createdAt || undefined,
},
});
bids.push(created);
return 'created';
}
const created = { drafting: 0, screening: 0, terminated: 0, other: 0, updated: 0, skipped: 0 };
const tasks = (flow.bid_document_task || []).filter((t) => str(t.project_name));
for (const t of tasks) {
const pre = preByTask.get(String(t.id)) || preByName.get(str(t.project_name));
const tender = t.tender_id != null ? tenderById.get(String(t.tender_id)) : undefined;
const deleted = num(t.is_deleted) === 1;
const status = deleted
? 'TERMINATED'
: tender
? undefined
: mapDocStatus(num(t.workflow_stage), num(t.approval_status));
if (!status && tender) {
const r = await ensureBid({
bidNo: str(t.task_no),
name: str(t.project_name),
official: officialNo(t.project_number) || officialNo(tender.project_number),
status: 'PENDING',
openAt: when(t.bid_open_time) || when(tender.bid_open_date),
openPlace: clip(t.bid_open_location, 200),
applyStart: when(pre?.file_apply_time_start) || when(tender.bid_prepare_date),
applyEnd: when(pre?.file_apply_time_end) || when(t.file_apply_time_end) || when(tender.file_apply_deadline),
applyMethod: str(pre?.apply_method),
openMethod: str(pre?.bid_open_method),
projectType: str(pre?.screening_category || t.screening_category || t.project_type || tender.project_type),
source: str(t.project_source || pre?.project_source),
qualification: str(pre?.qualification_require),
entityName: str(pre?.bid_entity),
createdAt: when(t.created_time),
createdByName: str(t.dispatcher_name),
tenderMethod: str(t.bid_method || tender.tender_method),
});
if (r === 'updated') created.updated += 1;
else if (r === 'skip') created.skipped += 1;
continue;
}
const next = status || 'DRAFTING';
const r = await ensureBid({
bidNo: str(t.task_no),
name: str(t.project_name),
official: officialNo(t.project_number),
status: next,
openAt: when(t.bid_open_time),
openPlace: clip(t.bid_open_location, 200),
applyStart: when(pre?.file_apply_time_start),
applyEnd: when(pre?.file_apply_time_end) || when(t.file_apply_time_end),
applyMethod: str(pre?.apply_method),
openMethod: str(pre?.bid_open_method),
projectType: str(pre?.screening_category || t.screening_category || t.project_type || pre?.project_type),
source: str(t.project_source || pre?.project_source),
qualification: str(pre?.qualification_require),
entityName: str(pre?.bid_entity),
createdAt: when(t.created_time),
createdByName: str(t.dispatcher_name),
tenderMethod: str(t.bid_method),
});
if (r === 'created') {
if (next === 'DRAFTING') created.drafting += 1;
else if (next === 'TERMINATED') created.terminated += 1;
else created.other += 1;
} else if (r === 'updated') created.updated += 1;
else created.skipped += 1;
}
for (const p of allPreinvests) {
if (!str(p.project_name) || !str(p.apply_no)) continue;
const deleted = num(p.deleted) === 1;
const status = deleted ? 'TERMINATED' : mapPreinvestStatus(num(p.workflow_stage), num(p.approval_status));
if (!status) continue;
const r = await ensureBid({
bidNo: str(p.apply_no),
name: str(p.project_name),
official: officialNo(p.project_number),
status,
openAt: when(p.bid_open_time),
openPlace: clip(p.bid_open_location, 200),
applyStart: when(p.file_apply_time_start),
applyEnd: when(p.file_apply_time_end),
applyMethod: str(p.apply_method),
openMethod: str(p.bid_open_method),
projectType: str(p.screening_category || p.project_type),
source: str(p.project_source || p.entry_source),
qualification: str(p.qualification_require),
entityName: str(p.bid_entity),
createdAt: when(p.created_time),
createdByName: str(p.applicant_name),
tenderMethod: str(p.bid_method),
});
if (r === 'created') {
if (status === 'TERMINATED') created.terminated += 1;
else created.screening += 1;
} else if (r === 'updated') created.updated += 1;
else created.skipped += 1;
}
const itemsByReimb = new Map<string, Row[]>();
for (const item of flow.reimbursement_item || []) {
const key = String(item.reimbursement_id);
if (!itemsByReimb.has(key)) itemsByReimb.set(key, []);
itemsByReimb.get(key)!.push(item);
}
let expensesUpserted = 0;
for (const r of flow.reimbursement || []) {
const claimNo = str(r.reimburse_no);
if (!claimNo) continue;
const lines = itemsByReimb.get(String(r.id)) || [];
const main = [...lines].sort((a, b) => num(b.amount) - num(a.amount))[0];
const itemType = str(main?.item_type);
const applicantId = userByName.get(str(r.employee_name)) || admin.id;
const payerId = userByName.get(str(r.payer_name)) || undefined;
const projectId = r.project_id != null ? projectByOaId.get(String(r.project_id)) : projectByName.get(str(r.project_name));
const bidHit = findBid(str(r.project_name), []);
const bidCaseId = isBidExpense(itemType) || (!projectId && bidHit) ? bidHit?.id : undefined;
const costType = bidCaseId ? 'BID' : projectId ? 'PROJECT' : 'DEPT';
const data = {
applicantId,
costType,
budgetCategory: costType === 'PROJECT' ? 'TRAVEL' : undefined,
projectId: costType === 'PROJECT' ? projectId : undefined,
bidCaseId: costType === 'BID' ? bidCaseId : undefined,
expenseCategory: mapItemCategory(itemType),
purpose: clip(r.subject || r.project_name, 200) || undefined,
amount: new Prisma.Decimal(num(r.total_amount)),
status: mapExpenseStatus(r.approval_status),
remark: clip(r.remark, 1000) || undefined,
submittedAt: when(r.created_time) || undefined,
decidedAt: str(r.approval_status) === '2' ? when(r.updated_time) || when(r.created_time) : undefined,
decidedById: str(r.approval_status) === '2' ? admin.id : undefined,
prepaid: Boolean(payerId && payerId !== applicantId),
payerId: payerId && payerId !== applicantId ? payerId : undefined,
};
const existed = await prisma.expenseClaim.findFirst({ where: { tenantId: '1', claimNo } });
const row = existed
? await prisma.expenseClaim.update({ where: { id: existed.id }, data })
: await prisma.expenseClaim.create({ data: { claimNo, ...data } });
await prisma.expenseLine.deleteMany({ where: { claimId: row.id } });
for (const [i, line] of lines.entries()) {
await prisma.expenseLine.create({
data: {
claimId: row.id,
kind: mapItemKind(str(line.item_type), str(line.remark)),
amount: new Prisma.Decimal(num(line.amount)),
invoiceNo: clip(line.invoice_no, 80) || undefined,
occurAt: when(line.expense_date) || undefined,
remark: clip(line.remark || line.item_type, 200) || undefined,
sortNo: num(line.sort_order) || i,
},
});
}
expensesUpserted += 1;
}
let loansUpserted = 0;
for (const r of flow.borrow_advance || []) {
const loanNo = str(r.advance_no);
if (!loanNo) continue;
const applicantId = userByName.get(str(r.employee_name)) || admin.id;
const projectId = r.project_id != null ? projectByOaId.get(String(r.project_id)) : projectByName.get(str(r.project_name));
const data = {
applicantId,
costType: projectId ? 'PROJECT' : 'DEPT',
projectId,
amount: new Prisma.Decimal(num(r.amount)),
offsetAmount: new Prisma.Decimal(num(r.offset_amount)),
purpose: clip(r.reason || r.subject, 200) || 'OA 借款',
status: mapLoanStatus(r),
submittedAt: when(r.apply_time) || when(r.created_time) || undefined,
};
const existed = await prisma.loanRecord.findFirst({ where: { tenantId: '1', loanNo } });
if (existed) await prisma.loanRecord.update({ where: { id: existed.id }, data });
else await prisma.loanRecord.create({ data: { loanNo, ...data } });
loansUpserted += 1;
}
const bidCounts = await prisma.bidCase.groupBy({ by: ['status'], _count: true });
const expCounts = await prisma.expenseClaim.groupBy({ by: ['status'], _count: true });
const loanCounts = await prisma.loanRecord.groupBy({ by: ['status'], _count: true });
console.log(
JSON.stringify(
{ applyPatched, created, expensesUpserted, loansUpserted, bidCounts, expCounts, loanCounts },
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+563
View File
@@ -0,0 +1,563 @@
import { Prisma, PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_fresh.json';
type Row = Record<string, unknown>;
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
function when(v: unknown) {
const s = str(v);
if (!s || s.startsWith('0000')) return null;
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? null : d;
}
function clip(v: unknown, n = 200) {
const s = str(v);
return s.length > n ? s.slice(0, n) : s;
}
function realPartyName(raw: unknown) {
let name = str(raw).replace(/\s+/g, ' ');
if (!name) return '';
const alias: Record<string, string> = {
'73047': '中国人民解放军73047部队保障部',
'中国人民解放军 32272 部队': '中国人民解放军32272部队',
};
name = alias[name] || name;
if (/^未指定/.test(name)) return '';
if (/^\d+$/.test(name)) return '';
if (name.startsWith('/') || name.endsWith('.pdf')) return '';
if (name.length < 2) return '';
return name;
}
function compactName(name: string) {
return str(name)
.replace(/[()()\[\]【】\s\-—_.,,、]/g, '')
.replace(/招标公告|采购项目|技术服务合同|合同/g, '')
.replace(/检查院/g, '检察院');
}
function namesOverlap(a: string, b: string) {
const na = compactName(a);
const nb = compactName(b);
if (!na || !nb) return false;
if (na === nb) return true;
const ka = groupKey(a);
const kb = groupKey(b);
if (ka && ka === kb && ka.length >= 3 && ka !== na && ka !== nb) return true;
const shorter = na.length <= nb.length ? na : nb;
const longer = na.length <= nb.length ? nb : na;
return shorter.length >= 8 && longer.includes(shorter);
}
function groupKey(name: string) {
const anchors = [
'信息化教材管理服务平台',
'教务管理系统国产化',
'中船建模',
'课程录播资源管理平台',
'九江市国动办',
'北京顺义',
'天长市',
'国防科技大学试验训练',
'银川低空',
'无锡联勤',
'南京军代',
'日常办公(江苏',
'日常办公(南京',
'数字人',
'沉浸式体验区',
'某演练',
'某单位陈列室',
'OA网络教学',
'某部软件系统定制',
'院感及互联互通',
'财经管控',
'智能控制系统',
'某部门户网站',
];
const hit = anchors.find((a) => name.includes(a));
return hit || compactName(name) || name;
}
function mapBidStatus(row: Row) {
const st = str(row.project_status);
const win = str(row.winning_status);
if (st === '中标' || (st === '' && win.includes('中标'))) return 'WON';
if (st === '未中' || (st === '' && win.includes('未中'))) return 'LOST';
if (st === '流标') return 'FAILED';
if (st === '弃标' || win.includes('弃标')) return 'TERMINATED';
return '';
}
function mapContractStatus(v: unknown) {
const s = str(v);
if (s === '已通过') return 'APPROVED';
if (s === '审批中') return 'PENDING';
return 'DRAFT';
}
function mapProjectStatus(v: unknown) {
const s = str(v);
if (s === '验收通过') return 'ACCEPTED';
if (s === '已完成') return 'DONE';
if (s === '待验收') return 'INTERNAL_ACCEPTED';
if (s === '未开始') return 'NOT_STARTED';
return 'ACTIVE';
}
const STATUS_RANK: Record<string, number> = {
验收通过: 5,
已完成: 4,
待验收: 3,
进行中: 2,
未开始: 1,
};
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function mapProjectType(v: unknown, projectName?: unknown) {
const name = str(projectName);
if (name.includes('三维')) return '三维';
if (/(视频.{0,12}(制作|摄制|拍摄)|摄制|拍摄|摄影|宣传片|专题片|纪录片|微电影|影视制作)/.test(name)) return '摄制';
const s = str(v);
if (s.includes('三维')) return '三维';
if (s.includes('摄制') || s.includes('视频')) return '摄制';
if (s.includes('物资')) return '物资';
if (s.includes('集成')) return '集成';
return '软件';
}
function isRealTender(row: Row) {
return /^TB-\d/.test(str(row.system_number)) && Boolean(str(row.project_name)) && !str(row.project_name).startsWith('/profile/');
}
function isGenericContractName(name: string) {
return compactName(name) === '采购' || str(name) === '采购合同';
}
async function ensureParty(name: string, partyByName: Map<string, string>, remark: string) {
const existing = partyByName.get(name);
if (existing) return existing;
const row = await prisma.businessParty.create({ data: { name, remark, level: 'B' } });
partyByName.set(name, row.id);
return row.id;
}
type MergedProject = {
systemNumber: string;
name: string;
status: string;
oaStatus: string;
contractProdId: string;
contractName: string;
budget: number;
createdAt: Date | null;
};
function mergeProjects(rows: Row[]): MergedProject[] {
const groups = new Map<string, Row[]>();
for (const p of rows) {
const name = str(p.project_name);
if (!name || !str(p.system_number)) continue;
const key = groupKey(name);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(p);
}
const out: MergedProject[] = [];
for (const items of groups.values()) {
const withContract = items.find((p) => p.contract_id != null && str(p.contract_id));
const progressed = [...items].sort((a, b) => (STATUS_RANK[str(b.project_status)] || 0) - (STATUS_RANK[str(a.project_status)] || 0))[0];
const pick = withContract || progressed;
out.push({
systemNumber: str(pick.system_number),
name: str(progressed.project_name),
status: mapProjectStatus(progressed.project_status),
oaStatus: str(progressed.project_status),
contractProdId: withContract ? str(withContract.contract_id) : '',
contractName: str((withContract || pick).contract_name),
budget: Math.max(...items.map((p) => num(p.project_budget))),
createdAt: when(pick.create_time),
});
}
return out;
}
async function main() {
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const admin = await prisma.user.findFirst({ where: { username: 'admin' } });
if (!admin) throw new Error('本地没有 admin');
const users = await prisma.user.findMany({ select: { id: true, displayName: true, username: true } });
const userByName = new Map(users.map((u) => [u.displayName, u.id]));
const xia = userByName.get('夏雨晴');
const entity =
(await prisma.legalEntity.findFirst({ where: { tenantId: '1', isDefault: true } })) ||
(await prisma.legalEntity.findFirst({ where: { tenantId: '1' } }));
if (!entity) throw new Error('本地没有主体');
const stale = await prisma.contract.findMany({
where: { contractNo: { startsWith: 'HT-2026-00' } },
include: {
_count: {
select: {
projects: true,
invoices: true,
bonds: true,
sealRequests: true,
milestones: true,
reviewers: true,
actionLogs: true,
},
},
},
});
const staleIds = stale.filter((c) => Object.values(c._count).every((n) => n === 0)).map((c) => c.id);
if (staleIds.length) await prisma.contract.deleteMany({ where: { id: { in: staleIds } } });
const tenders = (raw.oa_tender || []).filter(isRealTender);
const contracts = raw.oa_contract || [];
const mergedProjects = mergeProjects(raw.project_list || []);
const parties = await prisma.businessParty.findMany();
const partyByName = new Map(parties.map((p) => [p.name, p.id]));
for (const t of tenders) {
const n = realPartyName(t.tender_dan_wei);
if (n) await ensureParty(n, partyByName, 'OA 招标单位');
}
for (const c of contracts) {
const n = realPartyName(c.party_a_name);
if (n && n !== '江苏风影随行科技有限公司') await ensureParty(n, partyByName, 'OA 合同甲方');
}
const bids = await prisma.bidCase.findMany();
const bidByNo = new Map(bids.map((b) => [b.bidNo, b]));
let bidsUpdated = 0;
let bidsCreated = 0;
const usedNos = new Set(bids.map((b) => b.bidNo));
function findExistingBid(t: Row) {
const sys = str(t.system_number);
const official = officialNo(t.project_number);
const suffix = sys.split('-').pop() || '';
const name = str(t.project_name);
const all = [...bidByNo.values()];
return (
bidByNo.get(sys) ||
all.find((b) => b.externalNo === sys) ||
all.find((b) => b.bidNo === `${official}-${suffix}` || (official && b.bidNo.endsWith(`-${suffix}`) && b.name === name)) ||
(official && all.filter((b) => b.bidNo === official || b.externalNo === official).length === 1
? all.find((b) => b.bidNo === official || b.externalNo === official)
: undefined)
);
}
const testNos = ['SP-TEST-1788068192', 'SP-FIX-1788068273'];
const tests = bids.filter((b) => testNos.includes(b.bidNo) || testNos.includes(b.externalNo || ''));
if (tests.length) {
const ids = tests.map((b) => b.id);
await prisma.todoTask.deleteMany({ where: { bizId: { in: ids } } });
await prisma.approvalTask.deleteMany({ where: { bizId: { in: ids } } });
await prisma.bidCase.deleteMany({ where: { id: { in: ids } } });
}
for (const t of tenders) {
const sys = str(t.system_number);
const official = officialNo(t.project_number);
const name = clip(t.project_name, 200);
const partyName = realPartyName(t.tender_dan_wei);
const status = mapBidStatus(t) || 'PENDING';
const existing = findExistingBid(t);
const resultBits = [
t.winning_unit ? `中标单位:${t.winning_unit}` : '',
num(t.winning_amount) ? `中标金额:${num(t.winning_amount)}` : '',
str(t.opening_remark),
str(t.bid_summary),
].filter(Boolean);
if (existing) {
let bidNo = official || sys;
if (usedNos.has(bidNo) && bidNo !== existing.bidNo) bidNo = `${official || sys}-${sys.slice(-3)}`;
usedNos.delete(existing.bidNo);
usedNos.add(bidNo);
await prisma.bidCase.update({
where: { id: existing.id },
data: {
bidNo,
name,
externalNo: official || null,
status,
sourceType: 'FLOW',
partyId: partyName ? partyByName.get(partyName) : existing.partyId,
tenderMethod: clip(t.tender_method, 50) || existing.tenderMethod,
openPlace: clip(t.tender_address, 200) || existing.openPlace,
priceCap: t.price_limit == null ? existing.priceCap : String(t.price_limit),
quoteAmount: t.bid_quotation == null ? existing.quoteAmount : String(t.bid_quotation),
resultNote: resultBits.join('') || existing.resultNote,
openedResultAt: when(t.winning_date) || when(t.bid_open_date) || existing.openedResultAt,
},
});
existing.bidNo = bidNo;
existing.name = name;
existing.status = status;
existing.externalNo = official || existing.externalNo;
bidByNo.set(bidNo, existing);
bidByNo.set(sys, existing);
bidsUpdated += 1;
} else {
let bidNo = official || sys;
if (usedNos.has(bidNo)) bidNo = `${sys}`;
if (usedNos.has(bidNo)) bidNo = `${sys}-${Date.now().toString().slice(-4)}`;
usedNos.add(bidNo);
const row = await prisma.bidCase.create({
data: {
bidNo,
name,
externalNo: official || undefined,
projectType: mapProjectType(t.project_type, t.project_name),
legalEntityId: entity.id,
partyId: partyName ? partyByName.get(partyName) : undefined,
tenderMethod: clip(t.tender_method, 50) || undefined,
applyEndAt: when(t.file_apply_deadline) || undefined,
openAt: when(t.bid_open_date) || undefined,
openPlace: clip(t.tender_address, 200) || undefined,
securityLevel: str(t.security_level) || 'INTERNAL',
projectNature: clip(t.project_nature, 50) || undefined,
contactName: clip(t.bid_contact_person, 64) || undefined,
contactPhone: clip(t.bid_contact_phone, 32) || undefined,
priceCap: t.price_limit == null ? undefined : String(t.price_limit),
quoteAmount: t.bid_quotation == null ? undefined : String(t.bid_quotation),
resultNote: resultBits.join('') || undefined,
openedResultAt: when(t.winning_date) || when(t.bid_open_date) || undefined,
status,
createdById: admin.id,
createdAt: when(t.create_time) || undefined,
},
});
bidByNo.set(bidNo, row);
bidByNo.set(sys, row);
bidsCreated += 1;
}
}
const localContracts = await prisma.contract.findMany();
const contractByNo = new Map(localContracts.map((c) => [c.contractNo, c]));
const contractByProd = new Map<string, string>();
let contractsUpdated = 0;
let contractsCreated = 0;
for (const c of contracts) {
const contractNo = str(c.contract_no);
if (!contractNo) continue;
const tb = contractNo.match(/TB-\d+-\d+/);
const bid =
(tb && (bidByNo.get(tb[0]) || [...bidByNo.values()].find((b) => b.bidNo === tb[0] || b.bidNo.startsWith(tb[0])))) ||
[...bidByNo.values()].find((b) => namesOverlap(b.name, str(c.contract_name)));
if (str(c.approval_status) === 'pending_submit' && !c.sign_date && !num(c.amount)) {
continue;
}
const partyA = realPartyName(c.party_a_name);
const partyB = str(c.party_b_name);
const remark = [
str(c.payment_method) ? `付款:${c.payment_method}` : '',
str(c.contract_lb) ? `类别:${c.contract_lb}` : '',
str(c.remark),
]
.filter(Boolean)
.join(' / ');
const handlerId = Number(c.create_by) === 107 ? xia : bid?.createdById || xia || admin.id;
const data = {
name: clip(c.contract_name, 200),
bidCaseId: bid?.id,
legalEntityId: entity.id,
amount: new Prisma.Decimal(num(c.amount)),
status: mapContractStatus(c.approval_status),
partyA: partyA || undefined,
partyB: partyB || undefined,
remark: remark || undefined,
signedAt: when(c.sign_date) || undefined,
createdById: handlerId,
};
const existing =
contractByNo.get(contractNo) ||
localContracts.find(
(row) => row.name === clip(c.contract_name, 200) && /^HT-2026-00\d+$/.test(row.contractNo),
);
if (existing) {
const nextNo = contractByNo.has(contractNo) && existing.contractNo !== contractNo ? existing.contractNo : contractNo;
await prisma.contract.update({
where: { id: existing.id },
data: { ...data, contractNo: nextNo },
});
existing.contractNo = nextNo;
contractByNo.set(nextNo, existing);
contractByProd.set(String(c.id), existing.id);
contractsUpdated += 1;
} else {
const row = await prisma.contract.create({
data: {
contractNo,
createdAt: when(c.create_time) || undefined,
...data,
},
});
contractByNo.set(contractNo, row);
contractByProd.set(String(c.id), row.id);
contractsCreated += 1;
}
}
const localProjects = await prisma.project.findMany({ include: { budgets: true } });
const usedProjectNos = new Set(localProjects.map((p) => p.projectNo));
let projectsUpdated = 0;
let projectsCreated = 0;
const projectMoves: { name: string; from: string; to: string; no: string }[] = [];
function findLocalProject(p: MergedProject) {
return (
localProjects.find((row) => row.projectNo === p.systemNumber) ||
localProjects.find((row) => namesOverlap(row.name, p.name) || groupKey(row.name) === groupKey(p.name))
);
}
function findContractId(p: MergedProject) {
if (p.contractProdId && contractByProd.has(p.contractProdId)) return contractByProd.get(p.contractProdId);
if (p.contractName) {
const exact = [...contractByNo.values()].find((c) => c.name === p.contractName);
if (exact) return exact.id;
}
const fuzzy = [...contractByNo.values()].find(
(c) => !isGenericContractName(c.name) && namesOverlap(c.name, p.name),
);
return fuzzy?.id;
}
for (const p of mergedProjects) {
const existing = findLocalProject(p);
const contractId = findContractId(p);
if (existing) {
let projectNo = p.systemNumber;
if (usedProjectNos.has(projectNo) && projectNo !== existing.projectNo) {
projectNo = existing.projectNo;
}
usedProjectNos.delete(existing.projectNo);
usedProjectNos.add(projectNo);
if (existing.status !== p.status || existing.projectNo !== projectNo) {
projectMoves.push({ name: p.name.slice(0, 24), from: `${existing.projectNo}/${existing.status}`, to: `${projectNo}/${p.status}`, no: p.oaStatus });
}
await prisma.project.update({
where: { id: existing.id },
data: {
projectNo,
name: clip(p.name, 200),
contractId,
status: p.status,
},
});
if (p.budget > 0) {
await prisma.projectBudget.upsert({
where: { projectId_category: { projectId: existing.id, category: 'PURCHASE' } },
update: { amount: new Prisma.Decimal(p.budget) },
create: { projectId: existing.id, category: 'PURCHASE', amount: new Prisma.Decimal(p.budget) },
});
}
existing.projectNo = projectNo;
existing.name = p.name;
existing.status = p.status;
projectsUpdated += 1;
} else {
if (!contractId && /招标公告/.test(p.name) && p.status === 'NOT_STARTED') {
continue;
}
let projectNo = p.systemNumber;
if (usedProjectNos.has(projectNo)) projectNo = `${p.systemNumber}-2`;
usedProjectNos.add(projectNo);
const row = await prisma.project.create({
data: {
projectNo,
name: clip(p.name, 200),
contractId,
status: p.status,
createdAt: p.createdAt || undefined,
},
});
if (p.budget > 0) {
await prisma.projectBudget.create({
data: { projectId: row.id, category: 'PURCHASE', amount: new Prisma.Decimal(p.budget) },
});
}
localProjects.push({ ...row, budgets: [] });
projectsCreated += 1;
}
}
const caijing = await prisma.bidCase.findMany({
where: { name: { contains: '财经管控' } },
include: { _count: { select: { contracts: true, expenses: true, bonds: true, assignees: true, versions: true } } },
});
if (caijing.length) {
const won = caijing.find((b) => /196/.test(b.bidNo) || b._count.contracts > 0);
const extras = caijing.filter((b) => b.id !== won?.id);
const keepFailed = extras.find((b) => Object.values(b._count).some((n) => n > 0)) || extras[0];
if (keepFailed) {
await prisma.bidCase.update({
where: { id: keepFailed.id },
data: { status: 'FAILED', bidNo: keepFailed.bidNo.includes('182') ? keepFailed.bidNo : '2026-JLDJAE-W3006-182' },
});
}
if (won && won.status !== 'WON') {
await prisma.bidCase.update({ where: { id: won.id }, data: { status: 'WON' } });
}
const drop = extras.filter((b) => b.id !== keepFailed?.id && Object.values(b._count).every((n) => n === 0)).map((b) => b.id);
if (drop.length) await prisma.bidCase.deleteMany({ where: { id: { in: drop } } });
}
const bidCounts = await prisma.bidCase.groupBy({ by: ['status'], _count: true });
const projectCounts = await prisma.project.groupBy({ by: ['status'], _count: true });
const linked = await prisma.project.count({ where: { contractId: { not: null } } });
console.log(
JSON.stringify(
{
tenders: tenders.length,
bidsUpdated,
bidsCreated,
contractsUpdated,
contractsCreated,
projectsMerged: mergedProjects.length,
projectsUpdated,
projectsCreated,
projectMoves,
bidCounts,
projectCounts,
projectsWithContract: linked,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+186
View File
@@ -0,0 +1,186 @@
import { Prisma, PrismaClient } from '@prisma/client';
import * as fs from 'fs';
const prisma = new PrismaClient();
const DUMP = '/opt/import/prod/oa_flow_finance.json';
const APPLY = process.argv.includes('--apply');
type Row = Record<string, unknown>;
type Bid = {
id: string;
bidNo: string;
name: string;
externalNo: string | null;
status: string;
winnerName: string | null;
winnerAmount: Prisma.Decimal | null;
winnerNote: string | null;
};
function str(v: unknown) {
if (v == null) return '';
return String(v).trim();
}
function num(v: unknown) {
if (v == null || v === '') return null;
const n = Number(String(v).replace(/,/g, ''));
if (!Number.isFinite(n) || n === 0) return null;
return n;
}
function compactName(name: string) {
return str(name)
.replace(/\s+/g, '')
.replace(/[(].*?[)]/g, '')
.replace(/招标公告|采购公告|询价公告|竞争性谈判公告|谈判公告|比价公告|需求公示|意向公开/g, '');
}
function isOwnWinner(name: string) {
return /风影|随行科技|山东鲁兵/.test(name) || name === '隆创';
}
function officialNo(raw: unknown) {
const n = str(raw).replace(/[)]+$/, '').trim();
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
}
function pickUnused(cands: Bid[], used: Set<string>) {
const open = cands.filter((b) => !used.has(b.id));
if (!open.length) return null;
const yt = open.find((b) => b.bidNo.startsWith('YT-'));
return yt || open[0];
}
async function main() {
if (!fs.existsSync(DUMP)) {
throw new Error(`找不到 OA 备份 ${DUMP}`);
}
const raw = JSON.parse(fs.readFileSync(DUMP, 'utf8')) as Record<string, Row[]>;
const pres = raw.project_preinvest_apply || [];
const sources = pres.filter((p) => {
const deleted = str(p.deleted) === '1';
const winner = str(p.winning_unit);
const cat = str(p.screening_category) || str(p.project_type);
return deleted && winner && cat.includes('软件');
});
const bids = await prisma.bidCase.findMany({
where: { tenantId: '1', status: 'TERMINATED' },
select: {
id: true,
bidNo: true,
name: true,
externalNo: true,
status: true,
winnerName: true,
winnerAmount: true,
winnerNote: true,
},
});
const byNo = new Map<string, Bid[]>();
const byName = new Map<string, Bid[]>();
const byCompact = new Map<string, Bid[]>();
const add = (map: Map<string, Bid[]>, key: string, row: Bid) => {
if (!key) return;
const list = map.get(key) || [];
list.push(row);
map.set(key, list);
};
for (const b of bids) {
add(byNo, b.bidNo, b);
add(byNo, str(b.externalNo), b);
add(byName, b.name, b);
add(byCompact, compactName(b.name), b);
}
const used = new Set<string>();
const updated: { bidNo: string; name: string; winner: string; amount: number | null; via: string }[] = [];
const skippedSame: string[] = [];
const conflicts: { bidNo: string; have: string; want: string }[] = [];
const unmatched: { applyNo: string; name: string; winner: string }[] = [];
const ownSkipped: string[] = [];
for (const p of sources) {
const applyNo = str(p.apply_no);
const name = str(p.project_name);
const official = officialNo(p.project_number);
const winner = str(p.winning_unit);
const amount = num(p.winning_amount);
if (isOwnWinner(winner)) {
ownSkipped.push(`${applyNo} ${winner}`);
continue;
}
const hit =
pickUnused(byNo.get(applyNo) || [], used) ||
pickUnused(byNo.get(official) || [], used) ||
pickUnused(byName.get(name) || [], used) ||
pickUnused(byCompact.get(compactName(name)) || [], used);
if (!hit) {
unmatched.push({ applyNo, name, winner });
continue;
}
used.add(hit.id);
const haveName = str(hit.winnerName);
const haveAmt = hit.winnerAmount != null ? Number(hit.winnerAmount) : null;
if (haveName) {
const sameName = haveName === winner;
const sameAmt = amount == null || haveAmt === amount;
if (sameName && sameAmt) skippedSame.push(hit.bidNo);
else conflicts.push({ bidNo: hit.bidNo, have: `${haveName}/${haveAmt}`, want: `${winner}/${amount}` });
continue;
}
const note = str(hit.winnerNote) || 'OA 软件已删除迁入';
if (APPLY) {
await prisma.bidCase.update({
where: { id: hit.id },
data: {
winnerName: winner,
winnerAmount: amount == null ? undefined : new Prisma.Decimal(amount),
winnerNote: note,
},
});
}
updated.push({
bidNo: hit.bidNo,
name: hit.name,
winner,
amount,
via: hit.bidNo === applyNo ? 'bidNo' : str(hit.externalNo) === applyNo || hit.bidNo === official ? 'official' : 'name',
});
}
console.log(
JSON.stringify(
{
apply: APPLY,
dumpSources: sources.length,
terminated: bids.length,
filled: updated.length,
skippedSame: skippedSame.length,
conflicts: conflicts.length,
unmatched: unmatched.length,
ownSkipped: ownSkipped.length,
filledSample: updated.slice(0, 8),
conflictRows: conflicts,
unmatchedRows: unmatched.slice(0, 20),
tinyAmounts: updated.filter((u) => u.amount != null && u.amount < 100),
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+93
View File
@@ -0,0 +1,93 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { loadEnv } from './config/env';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { SmsModule } from './sms/sms.module';
import { ImModule } from './im/im.module';
import { PushModule } from './push/push.module';
import { HealthController } from './health/health.controller';
import { ReportController } from './report/report.controller';
import { ReportService } from './report/report.service';
import { SystemController } from './system/system.controller';
import { SystemService } from './system/system.service';
import { OfficeController } from './office/office.controller';
import { MasterController } from './master/master.controller';
import { BiddingController } from './bidding/bidding.controller';
import { BiddingService } from './bidding/bidding.service';
import { ContractController } from './contract/contract.controller';
import { ContractService } from './contract/contract.service';
import { ProjectController } from './project/project.controller';
import { ProjectService } from './project/project.service';
import { SealController } from './seal/seal.controller';
import { SealService } from './seal/seal.service';
import { FinanceController } from './finance/finance.controller';
import { FinanceService } from './finance/finance.service';
import { BusinessController } from './business/business.controller';
import { HrController } from './hr/hr.controller';
import { HrService } from './hr/hr.service';
import { MasterService } from './master/master.service';
import { FilesController } from './files/files.controller';
import { FilesModule } from './files/files.module';
import { JwtAuthGuard, PermissionsGuard } from './common/guards/auth.guards';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { HttpErrorFilter } from './common/filters/http-exception.filter';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { LegalController } from './legal/legal.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env', '../../.env'],
validate: (raw) => loadEnv(raw),
}),
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 120 }],
}),
PrismaModule,
AuthModule,
SmsModule,
PushModule,
ImModule,
FilesModule,
],
controllers: [
HealthController,
SystemController,
OfficeController,
MasterController,
BiddingController,
ContractController,
ProjectController,
SealController,
FinanceController,
BusinessController,
HrController,
ReportController,
LegalController,
],
providers: [
BiddingService,
ContractService,
ProjectService,
SealService,
FinanceService,
HrService,
ReportService,
SystemService,
MasterService,
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
{ provide: APP_FILTER, useClass: HttpErrorFilter },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
+199
View File
@@ -0,0 +1,199 @@
import { BadRequestException, Body, Controller, Get, Patch, Post, Query, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { Equals, IsOptional, IsString, MinLength } from 'class-validator';
import type { Request } from 'express';
import { AuthService } from './auth.service';
import { Public } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthUser } from '../common/decorators/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import { LOG_ACTION, writeOpLog } from '../system/op-log';
import { REQUEST_ID_HEADER } from '../common/middleware/request-id.middleware';
class LoginDto {
@IsString()
username!: string;
@IsString()
@MinLength(6)
password!: string;
@IsOptional()
@IsString()
smsCode?: string;
@Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1')
@Equals(true, { message: '请先阅读并同意用户协议和隐私政策' })
agreeTerms!: boolean;
@IsOptional()
@IsString()
clientKind?: string;
}
class RefreshDto {
@IsString()
refreshToken!: string;
@IsOptional()
@IsString()
clientKind?: string;
}
class ProfileDto {
@IsOptional()
@IsString()
displayName?: string;
}
class PasswordDto {
@IsString()
oldPassword!: string;
@IsString()
@MinLength(6, { message: '新密码至少 6 位' })
newPassword!: string;
}
class OnboardProfileDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() gender?: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() mobile?: string;
@IsOptional() @IsString() birthday?: string;
@IsOptional() @IsString() ethnicity?: string;
@IsOptional() @IsString() maritalStatus?: string;
@IsOptional() @IsString() hukouAddress?: string;
@IsOptional() @IsString() liveAddress?: string;
@IsOptional() @IsString() emergencyName?: string;
@IsOptional() @IsString() emergencyPhone?: string;
@IsOptional() @IsString() education?: string;
@IsOptional() @IsString() school?: string;
@IsOptional() @IsString() major?: string;
@IsOptional() @IsString() graduatedAt?: string;
@IsOptional() @IsString() bankName?: string;
@IsOptional() @IsString() bankAccount?: string;
@IsOptional() @IsString() idType?: string;
@IsOptional() @IsString() idNo?: string;
@IsOptional() @IsString() idIssuer?: string;
@IsOptional() @IsString() idValidFrom?: string;
@IsOptional() @IsString() idValidTo?: string;
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly prisma: PrismaService,
private readonly sms: SmsService,
) {}
@Public()
@Get('login-options')
loginOptions() {
return this.sms.loginOptions();
}
@Public()
@Post('sms-code')
smsCode(@Body() body: { username?: string }) {
return this.sms.sendLoginCode(body.username || '');
}
@Public()
@Post('login')
@ApiOperation({ summary: '登录' })
async login(@Body() dto: LoginDto, @Req() req: Request) {
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
try {
if (!dto.agreeTerms) {
throw new BadRequestException('请先阅读并同意用户协议和隐私政策');
}
const result = await this.auth.login(dto.username, dto.password, dto.smsCode, dto.clientKind);
await writeOpLog(this.prisma, {
userId: result.user.id,
username: result.user.username,
action: LOG_ACTION.LOGIN,
path: '/auth/login',
detail: '登录成功',
requestId,
ip: req.ip,
});
return result;
} catch (e) {
await writeOpLog(this.prisma, {
username: dto.username,
action: LOG_ACTION.LOGIN_FAIL,
path: '/auth/login',
detail: '用户名或密码错误',
success: false,
requestId,
ip: req.ip,
});
throw e;
}
}
@Public()
@Post('refresh')
@ApiOperation({ summary: '刷新令牌' })
refresh(@Body() dto: RefreshDto) {
return this.auth.refresh(dto.refreshToken, dto.clientKind);
}
@Post('logout')
@ApiBearerAuth()
logout(@CurrentUser() user: AuthUser, @Body() dto: Partial<RefreshDto>) {
return this.auth.logout(user.id, dto.refreshToken);
}
@Public()
@Post('qr/start')
qrStart() {
return this.auth.startQrLogin();
}
@Public()
@Get('qr/status')
qrStatus(@Query('ticket') ticket?: string) {
return this.auth.qrStatus(ticket || '');
}
@Public()
@Post('qr/complete')
qrComplete(@Body() body: { ticket?: string; code?: string }) {
return this.auth.completeQrLogin(body.ticket || '', body.code || '');
}
@Post('qr/confirm')
@ApiBearerAuth()
qrConfirm(@CurrentUser() user: AuthUser, @Body() body: { ticket?: string }) {
return this.auth.confirmQrLogin(user.id, body.ticket || '');
}
@Get('me')
@ApiBearerAuth()
me(@CurrentUser() user: AuthUser) {
return this.auth.me(user.id);
}
@Patch('profile')
@ApiBearerAuth()
updateProfile(@CurrentUser() user: AuthUser, @Body() dto: ProfileDto) {
return this.auth.updateProfile(user.id, dto);
}
@Patch('password')
@ApiBearerAuth()
updatePassword(@CurrentUser() user: AuthUser, @Body() dto: PasswordDto) {
return this.auth.updatePassword(user.id, dto);
}
@Patch('onboard-profile')
@ApiBearerAuth()
onboardProfile(@CurrentUser() user: AuthUser, @Body() dto: OnboardProfileDto) {
return this.auth.onboardProfile(user.id, dto);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [PassportModule, JwtModule.register({})],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+532
View File
@@ -0,0 +1,532 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { createHash, randomInt, randomUUID } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { SmsService } from '../sms/sms.service';
import { elevateToAdmin } from '../common/role-groups';
function sha256(value: string) {
return createHash('sha256').update(value).digest('hex');
}
function needsOnboardGate(input: {
isAdmin: boolean;
mustChangePassword?: boolean | null;
profileCompletedAt?: Date | string | null;
employeeId?: string | null;
idPhotoFileId?: string | null;
idFrontFileId?: string | null;
idBackFileId?: string | null;
employee?: { idNo?: string | null; idType?: string | null } | null;
}) {
if (input.isAdmin) return false;
if (input.mustChangePassword) return true;
if (!input.profileCompletedAt) return true;
if (!input.employeeId) return false;
if (!input.idPhotoFileId || !input.idFrontFileId || !input.idBackFileId) return true;
if (input.employee && (!input.employee.idNo || !input.employee.idType)) return true;
return false;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
) {}
async login(username: string, password: string, smsCode?: string, clientKind = 'web') {
const account = username.trim();
const kind = normalizeClientKind(clientKind);
if (kind === 'desktop') {
throw new BadRequestException('电脑端请用手机扫码登录');
}
const user = await this.prisma.user.findFirst({
where: {
tenantId: '1',
OR: [{ username: account }, { employee: { is: { mobile: account } } }],
},
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('手机号或密码错误');
}
const ok = await argon2.verify(user.passwordHash, password);
if (!ok) {
throw new UnauthorizedException('用户名或密码错误');
}
await this.sms.assertLoginCode(account, smsCode);
return this.issueTokens(user, kind);
}
async refresh(refreshToken: string, clientKindHint?: string) {
if (!refreshToken) throw new BadRequestException('缺少刷新令牌');
let payload: { sub: string; typ: string; ck?: string };
try {
payload = await this.jwt.verifyAsync(refreshToken, {
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException('刷新令牌无效');
}
if (payload.typ !== 'refresh') {
throw new UnauthorizedException('刷新令牌无效');
}
const hash = sha256(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({
where: { userId: payload.sub, tokenHash: hash, revokedAt: null },
});
if (!stored || stored.expiresAt < new Date()) {
throw new UnauthorizedException('刷新令牌已失效');
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user) throw new UnauthorizedException('用户不存在');
const kind = normalizeClientKind(clientKindHint || stored.clientKind || payload.ck);
if (stored.clientKind !== kind) {
await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { clientKind: kind } });
}
if (kind === 'mobile') {
return this.issueTokens(user, kind, { keepRefresh: stored });
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
return this.issueTokens(user, kind);
}
async logout(userId: string, refreshToken?: string) {
if (refreshToken) {
await this.prisma.refreshToken.updateMany({
where: { userId, tokenHash: sha256(refreshToken), revokedAt: null },
data: { revokedAt: new Date() },
});
} else {
await this.prisma.refreshToken.updateMany({
where: { userId, clientKind: { in: ['web', 'desktop'] }, revokedAt: null },
data: { revokedAt: new Date() },
});
}
return { ok: true };
}
async updateProfile(userId: string, dto: { displayName?: string }) {
const name = dto.displayName?.trim();
if (!name) throw new BadRequestException('请填写姓名');
const user = await this.prisma.user.update({
where: { id: userId },
data: { displayName: name },
include: { employee: true },
});
if (user.employeeId) {
await this.prisma.employee.update({
where: { id: user.employeeId },
data: { name },
});
}
return this.me(userId);
}
async updatePassword(userId: string, dto: { oldPassword: string; newPassword: string }) {
if (!dto.newPassword || dto.newPassword.length < 6) {
throw new BadRequestException('新密码至少 6 位');
}
const user = await this.prisma.user.findFirst({ where: { id: userId, tenantId: '1' } });
if (!user) throw new UnauthorizedException();
const ok = await argon2.verify(user.passwordHash, dto.oldPassword || '');
if (!ok) throw new BadRequestException('当前密码不正确');
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: await argon2.hash(dto.newPassword), mustChangePassword: false },
});
return { ok: true };
}
async onboardProfile(
userId: string,
dto: {
name?: string;
gender?: string;
email?: string;
mobile?: string;
birthday?: string;
ethnicity?: string;
maritalStatus?: string;
hukouAddress?: string;
liveAddress?: string;
emergencyName?: string;
emergencyPhone?: string;
education?: string;
school?: string;
major?: string;
graduatedAt?: string;
bankName?: string;
bankAccount?: string;
idType?: string;
idNo?: string;
idIssuer?: string;
idValidFrom?: string;
idValidTo?: string;
},
) {
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId: '1' },
include: { roles: { include: { role: true } }, employee: true },
});
if (!user) throw new UnauthorizedException();
const isAdmin = user.roles.some((r) => r.role.code === 'admin');
if (isAdmin) return this.me(userId);
if (user.employeeId && user.employee) {
const name = dto.name?.trim() || user.employee.name;
if (!name) throw new BadRequestException('请填写姓名');
if (!dto.mobile?.trim() && !user.employee.mobile) throw new BadRequestException('请填写手机号码');
if (!dto.idType?.trim() && !user.employee.idType) throw new BadRequestException('请选择证件类型');
if (!dto.idNo?.trim() && !user.employee.idNo) throw new BadRequestException('请填写证件号码');
if (!dto.idIssuer?.trim() && !user.employee.idIssuer) throw new BadRequestException('请填写发证机关');
if (!dto.idValidFrom && !user.employee.idValidFrom) throw new BadRequestException('请选择证件有效期起');
if (!dto.idValidTo && !user.employee.idValidTo) throw new BadRequestException('请选择证件有效期止');
if (!dto.education?.trim() && !user.employee.education) throw new BadRequestException('请填写学历');
if (!dto.bankName?.trim() && !user.employee.bankName) throw new BadRequestException('请填写南京银行卡开户行');
if (!dto.bankAccount?.trim() && !user.employee.bankAccount) throw new BadRequestException('请填写南京银行卡号');
const scans = await this.prisma.fileAsset.findMany({
where: {
tenantId: '1',
OR: [
{ bizType: 'USER_ID_PHOTO', bizId: userId },
{ bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] }, bizId: user.employeeId },
],
},
});
const hasPhoto = scans.some((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO');
const hasFront = scans.some((f) => f.bizType === 'EMPLOYEE_ID_FRONT');
const hasBack = scans.some((f) => f.bizType === 'EMPLOYEE_ID_BACK');
if (!hasPhoto) throw new BadRequestException('请上传证件照');
if (!hasFront) throw new BadRequestException('请上传身份证正面');
if (!hasBack) throw new BadRequestException('请上传身份证反面');
await this.prisma.employee.update({
where: { id: user.employeeId },
data: {
name,
gender: dto.gender?.trim() || user.employee.gender,
email: dto.email?.trim() || user.employee.email,
mobile: dto.mobile?.trim() || user.employee.mobile,
birthday: dto.birthday ? new Date(dto.birthday) : user.employee.birthday,
ethnicity: dto.ethnicity?.trim() || user.employee.ethnicity,
maritalStatus: dto.maritalStatus?.trim() || user.employee.maritalStatus,
hukouAddress: dto.hukouAddress?.trim() || user.employee.hukouAddress,
liveAddress: dto.liveAddress?.trim() || user.employee.liveAddress,
emergencyName: dto.emergencyName?.trim() || user.employee.emergencyName,
emergencyPhone: dto.emergencyPhone?.trim() || user.employee.emergencyPhone,
idType: dto.idType?.trim() || user.employee.idType,
idNo: dto.idNo?.trim() || user.employee.idNo,
idIssuer: dto.idIssuer?.trim() || user.employee.idIssuer,
idValidFrom: dto.idValidFrom ? new Date(dto.idValidFrom) : user.employee.idValidFrom,
idValidTo: dto.idValidTo ? new Date(dto.idValidTo) : user.employee.idValidTo,
education: dto.education?.trim() || user.employee.education,
school: dto.school?.trim() || user.employee.school,
major: dto.major?.trim() || user.employee.major,
graduatedAt: dto.graduatedAt ? new Date(dto.graduatedAt) : user.employee.graduatedAt,
bankName: dto.bankName?.trim() || user.employee.bankName,
bankAccount: dto.bankAccount?.trim() || user.employee.bankAccount,
},
});
if (name !== user.displayName) {
await this.prisma.user.update({ where: { id: userId }, data: { displayName: name } });
}
}
await this.prisma.user.update({
where: { id: userId },
data: { profileCompletedAt: new Date() },
});
return this.me(userId);
}
async me(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user) throw new UnauthorizedException();
const roles = elevateToAdmin(user.roles.map((r) => r.role.code));
const permissions = [
...new Set(user.roles.flatMap((r) => r.role.permissions.map((p) => p.permission.code))),
];
const photos = await this.prisma.fileAsset.findMany({
where: {
tenantId: '1',
OR: [
{ bizId: user.id, bizType: { in: ['USER_AVATAR', 'USER_ID_PHOTO'] } },
user.employeeId
? { bizId: user.employeeId, bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] } }
: undefined,
].filter(Boolean) as Prisma.FileAssetWhereInput[],
},
orderBy: { createdAt: 'desc' },
});
const isAdmin = roles.includes('admin');
const idPhotoFileId =
photos.find((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO')?.id || null;
const idFrontFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_FRONT')?.id || null;
const idBackFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_BACK')?.id || null;
return {
id: user.id,
username: user.username,
displayName: user.displayName,
avatarFileId: photos.find((f) => f.bizType === 'USER_AVATAR')?.id || null,
idPhotoFileId,
idFrontFileId,
idBackFileId,
roles,
permissions,
employeeId: user.employeeId,
mustChangePassword: isAdmin ? false : user.mustChangePassword,
profileCompletedAt: user.profileCompletedAt,
needsOnboard: needsOnboardGate({
isAdmin,
mustChangePassword: user.mustChangePassword,
profileCompletedAt: user.profileCompletedAt,
employeeId: user.employeeId,
idPhotoFileId,
idFrontFileId,
idBackFileId,
employee: user.employee,
}),
dataScope: isAdmin || roles.includes('owner')
? 'ALL'
: user.roles.reduce(
(best, r) => {
const rank = { SELF: 0, DEPT: 1, ALL: 2 } as Record<string, number>;
return (rank[r.role.dataScope] || 0) > (rank[best] || 0) ? r.role.dataScope : best;
},
'SELF',
),
employee: user.employee,
};
}
private async issueTokens(
user: {
id: string;
username: string;
displayName: string;
employeeId?: string | null;
mustChangePassword?: boolean;
profileCompletedAt?: Date | null;
roles: { role: { code: string; permissions: { permission: { code: string; permType: string; path: string | null; name: string; icon: string | null; parentId: string | null; sortNo: number; visible: boolean; id: string } }[] } }[];
employee: unknown;
},
clientKind: ClientKind,
opts?: { keepRefresh?: { id: string; tokenHash: string; expiresAt: Date } },
) {
if (clientKind === 'web' || clientKind === 'desktop') {
await this.prisma.refreshToken.updateMany({
where: { userId: user.id, clientKind, revokedAt: null },
data: { revokedAt: new Date() },
});
}
const accessTtl = clientKind === 'mobile' ? '7d' : '12h';
const refreshTtl = clientKind === 'mobile' ? '3650d' : '2d';
const sessionId = opts?.keepRefresh?.id || randomUUID();
const accessToken = await this.jwt.signAsync(
{ sub: user.id, username: user.username, typ: 'access', sid: sessionId, ck: clientKind },
{
secret: this.config.get<string>('JWT_ACCESS_SECRET'),
expiresIn: accessTtl,
},
);
let refreshToken = '';
if (opts?.keepRefresh) {
refreshToken = '';
} else {
refreshToken = await this.jwt.signAsync(
{ sub: user.id, typ: 'refresh', jti: randomUUID(), ck: clientKind },
{
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
expiresIn: refreshTtl,
},
);
const days = clientKind === 'mobile' ? 3650 : 2;
await this.prisma.refreshToken.create({
data: {
id: sessionId,
userId: user.id,
tokenHash: sha256(refreshToken),
clientKind,
expiresAt: new Date(Date.now() + days * 24 * 3600 * 1000),
},
});
}
const permissions = [
...new Set(
user.roles.flatMap((r) => r.role.permissions.map((p) => p.permission)),
),
];
const photos = await this.prisma.fileAsset.findMany({
where: {
tenantId: '1',
OR: [
{ bizId: user.id, bizType: { in: ['USER_AVATAR', 'USER_ID_PHOTO'] } },
user.employeeId
? { bizId: user.employeeId, bizType: { in: ['EMPLOYEE_ID_PHOTO', 'EMPLOYEE_ID_FRONT', 'EMPLOYEE_ID_BACK'] } }
: undefined,
].filter(Boolean) as Prisma.FileAssetWhereInput[],
},
orderBy: { createdAt: 'desc' },
});
const roles = elevateToAdmin(user.roles.map((r) => r.role.code));
const isAdmin = roles.includes('admin');
const idPhotoFileId =
photos.find((f) => f.bizType === 'USER_ID_PHOTO' || f.bizType === 'EMPLOYEE_ID_PHOTO')?.id || null;
const idFrontFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_FRONT')?.id || null;
const idBackFileId = photos.find((f) => f.bizType === 'EMPLOYEE_ID_BACK')?.id || null;
return {
accessToken,
refreshToken: refreshToken || undefined,
clientKind,
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
avatarFileId: photos.find((f) => f.bizType === 'USER_AVATAR')?.id || null,
idPhotoFileId,
idFrontFileId,
idBackFileId,
roles,
employeeId: user.employeeId,
mustChangePassword: isAdmin ? false : Boolean(user.mustChangePassword),
profileCompletedAt: user.profileCompletedAt,
needsOnboard: needsOnboardGate({
isAdmin,
mustChangePassword: user.mustChangePassword,
profileCompletedAt: user.profileCompletedAt,
employeeId: user.employeeId,
idPhotoFileId,
idFrontFileId,
idBackFileId,
employee: user.employee as { idNo?: string | null; idType?: string | null } | null,
}),
employee: user.employee,
permissions: permissions.map((p) => p.code),
},
};
}
async startQrLogin() {
const ticket = randomUUID().replace(/-/g, '').slice(0, 16);
const expiresAt = new Date(Date.now() + 2 * 60 * 1000);
await this.prisma.authQrTicket.create({
data: { ticket, expiresAt },
});
return {
ticket,
expiresAt,
payload: `fengyingoa://qr?ticket=${ticket}`,
};
}
async qrStatus(ticket: string) {
const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: ticket.trim() } });
if (!row) throw new UnauthorizedException('二维码无效');
if (row.consumedAt || row.expiresAt < new Date()) {
if (row.status === 'pending') {
await this.prisma.authQrTicket.update({ where: { id: row.id }, data: { status: 'expired' } });
}
return { status: row.consumedAt ? 'consumed' : 'expired' };
}
if (row.status !== 'confirmed' || !row.userId) {
return { status: row.status };
}
// 扫码只证明是本人的手机;还需把手机屏幕上的一次性六码填到电脑,
// 才签发桌面端令牌,避免旁人代扫后直接登录电脑。
return { status: 'code_required', expiresAt: row.expiresAt };
}
async completeQrLogin(ticket: string, code: string) {
const rawTicket = ticket.trim();
const rawCode = code.trim();
if (!/^\d{6}$/.test(rawCode)) throw new BadRequestException('请输入手机显示的六位登录码');
const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: rawTicket } });
if (!row || row.expiresAt < new Date()) {
throw new BadRequestException('二维码已过期,请刷新后重新扫码');
}
if (row.status !== 'confirmed' || !row.userId || !row.confirmCodeHash) {
throw new BadRequestException('请先在手机端扫码确认');
}
if (row.confirmCodeAttempts >= 5) {
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: { status: 'expired' },
});
throw new BadRequestException('登录码错误次数过多,请刷新二维码');
}
if (sha256(`${rawTicket}:${rawCode}`) !== row.confirmCodeHash) {
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: { confirmCodeAttempts: { increment: 1 } },
});
throw new BadRequestException('六位登录码不正确');
}
const user = await this.prisma.user.findUnique({
where: { id: row.userId },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: { include: { department: true } },
},
});
if (!user || user.status !== 'ACTIVE') throw new UnauthorizedException('账号不可用');
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: { status: 'consumed', consumedAt: new Date() },
});
const tokens = await this.issueTokens(user, 'desktop');
return { status: 'ok', ...tokens };
}
async confirmQrLogin(userId: string, ticket: string) {
const row = await this.prisma.authQrTicket.findFirst({ where: { ticket: ticket.trim() } });
if (!row || row.expiresAt < new Date()) throw new BadRequestException('二维码已过期,请在电脑上刷新');
if (row.status !== 'pending') throw new BadRequestException('这个码已经用过了');
const code = String(randomInt(0, 1_000_000)).padStart(6, '0');
await this.prisma.authQrTicket.update({
where: { id: row.id },
data: {
status: 'confirmed',
userId,
confirmCodeHash: sha256(`${row.ticket}:${code}`),
confirmCodeIssuedAt: new Date(),
confirmCodeAttempts: 0,
},
});
return { ok: true, code, expiresAt: row.expiresAt };
}
}
type ClientKind = 'web' | 'mobile' | 'desktop';
function normalizeClientKind(raw?: string | null): ClientKind {
const v = (raw || 'web').toLowerCase();
if (v === 'mobile' || v === 'android' || v === 'ios' || v === 'app') return 'mobile';
if (v === 'desktop' || v === 'windows' || v === 'pc') return 'desktop';
return 'web';
}
+71
View File
@@ -0,0 +1,71 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '../common/decorators/current-user.decorator';
import { mergeDataScope } from '../common/data-scope';
import { elevateToAdmin } from '../common/role-groups';
export interface JwtPayload {
sub: string;
username: string;
typ: 'access';
sid?: string;
ck?: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>('JWT_ACCESS_SECRET')!,
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
include: {
roles: { include: { role: { include: { permissions: { include: { permission: true } } } } } },
employee: true,
},
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('登录已失效');
}
if (payload.sid) {
const session = await this.prisma.refreshToken.findFirst({
where: { id: payload.sid, userId: user.id, revokedAt: null },
});
if (!session || session.expiresAt < new Date()) {
throw new UnauthorizedException('登录已失效');
}
}
const roles = elevateToAdmin(user.roles.map((r: { role: { code: string } }) => r.role.code));
const permissions = [
...new Set(
user.roles.flatMap((r: { role: { permissions: { permission: { code: string } }[] } }) =>
r.role.permissions.map((p) => p.permission.code),
),
),
];
const dataScope = roles.includes('admin')
? 'ALL'
: mergeDataScope(user.roles.map((r: { role: { dataScope: string } }) => r.role.dataScope));
return {
id: user.id,
username: user.username,
displayName: user.displayName,
roles,
permissions,
employeeId: user.employeeId,
departmentId: user.employee?.departmentId ?? null,
dataScope,
};
}
}
+161
View File
@@ -0,0 +1,161 @@
import {
ASSIGN_ROLE,
canAccessBidModule,
canAssignFetcher,
canCreateBid,
canCreateSpecialBid,
canFetchBid,
canFetchThisBid,
canManageDraft,
canManageThisDraft,
canSeeAllBids,
canSeeTerminated,
canTerminateBid,
canTerminateFrom,
currentHandlerNames,
isCommercialStage,
isSpecialBid,
isSpecialCoordinatorUser,
stageForStatus,
previousBidStatus,
inferLegacyRestoreStatus,
} from './bid-workflow';
describe('bid-workflow', () => {
it('maps status to review stage', () => {
expect(stageForStatus('TECH_INITIAL')).toBe('TECH_INITIAL');
expect(stageForStatus('TECH_FINAL')).toBe('TECH_FINAL');
expect(stageForStatus('REVIEW1')).toBe('REVIEW1');
expect(stageForStatus('REVIEW2')).toBe('REVIEW2');
expect(stageForStatus('FETCH')).toBeNull();
expect(stageForStatus('DRAFTING')).toBeNull();
});
it('treats commercial reject as bounce, not terminate', () => {
expect(isCommercialStage('REVIEW1')).toBe(true);
expect(isCommercialStage('REVIEW2')).toBe(true);
expect(isCommercialStage('TECH_INITIAL')).toBe(false);
});
it('lets every director see all bids', () => {
expect(canSeeAllBids(['rd_director'])).toBe(true);
expect(canSeeAllBids(['3d_director'])).toBe(true);
expect(canSeeAllBids(['video_director'])).toBe(true);
expect(canSeeAllBids(['material_director'])).toBe(true);
expect(canSeeAllBids(['biz_director'])).toBe(true);
expect(canSeeAllBids(['tech_director'])).toBe(true);
expect(canSeeAllBids(['owner'])).toBe(true);
expect(canSeeAllBids(['admin'])).toBe(true);
expect(canSeeAllBids(['biz_staff'])).toBe(true);
expect(canSeeAllBids(['employee'])).toBe(false);
expect(canSeeAllBids(['pm'])).toBe(false);
});
it('hides terminated bids from ordinary employees', () => {
expect(canSeeTerminated(['employee'], ['office'])).toBe(false);
expect(canSeeTerminated(['hr'], ['office', 'hr'])).toBe(false);
expect(canSeeTerminated(['finance'], ['bid:result'])).toBe(false);
expect(canSeeTerminated(['biz_staff'], ['bid:screening'])).toBe(false);
expect(canSeeTerminated(['biz_director'], ['bid:terminated'])).toBe(true);
expect(canSeeTerminated(['tech_director'], ['bid'])).toBe(true);
expect(canSeeTerminated(['owner'], [])).toBe(true);
expect(canSeeTerminated(['admin'], [])).toBe(true);
});
it('restricts create / fetch / terminate by role', () => {
expect(canCreateBid(['owner'])).toBe(true);
expect(canCreateBid(['tech_director'])).toBe(false);
expect(canCreateBid(['employee'], ['bid:screening'])).toBe(true);
expect(canFetchBid(['biz_staff'])).toBe(true);
expect(canFetchBid(['tech_director'])).toBe(false);
expect(canTerminateBid(['biz_director'])).toBe(true);
expect(canTerminateBid(['biz_staff'])).toBe(false);
expect(canManageDraft(['biz_director'])).toBe(true);
expect(canManageDraft(['biz_staff'])).toBe(false);
});
it('keeps special screening on named coordinators', () => {
expect(isSpecialBid('SPECIAL')).toBe(true);
expect(isSpecialBid('FLOW')).toBe(false);
expect(canCreateSpecialBid(['owner'])).toBe(true);
expect(canCreateSpecialBid(['biz_director'])).toBe(false);
expect(isSpecialCoordinatorUser({ username: 'liuyunze', displayName: '刘运泽' })).toBe(true);
expect(isSpecialCoordinatorUser({ username: 'chencheng', displayName: '陈铖' })).toBe(true);
expect(isSpecialCoordinatorUser({ username: 'other', displayName: '其他人' })).toBe(false);
const coordinators = [{ userId: 'liu', role: ASSIGN_ROLE.COORDINATOR }];
const fetchers = [{ userId: 'staff', role: ASSIGN_ROLE.FETCHER }];
expect(canAssignFetcher(['3d_director'], 'SPECIAL', coordinators, 'liu', 'FETCH')).toBe(true);
expect(canAssignFetcher(['3d_director'], 'SPECIAL', coordinators, 'other', 'FETCH')).toBe(false);
expect(canAssignFetcher(['biz_director'], 'FLOW', [], 'x', 'FETCH')).toBe(true);
expect(canAssignFetcher(['biz_staff'], 'FLOW', [], 'x', 'FETCH')).toBe(false);
expect(canFetchThisBid(['employee'], 'SPECIAL', fetchers, 'staff')).toBe(true);
expect(canFetchThisBid(['biz_staff'], 'SPECIAL', fetchers, 'other')).toBe(false);
expect(canFetchThisBid(['biz_staff'], 'FLOW', [], 'other')).toBe(false);
expect(canFetchThisBid(['employee'], 'FLOW', fetchers, 'staff')).toBe(true);
expect(canManageThisDraft(['3d_director'], 'SPECIAL', coordinators, 'liu')).toBe(true);
expect(canManageThisDraft(['biz_director'], 'SPECIAL', coordinators, 'biz')).toBe(false);
expect(canManageThisDraft(['biz_director'], 'FLOW', [], 'biz')).toBe(true);
});
it('allows terminate until execution, not after open', () => {
expect(canTerminateFrom('TECH_INITIAL')).toBe(true);
expect(canTerminateFrom('DRAFTING')).toBe(true);
expect(canTerminateFrom('LIST')).toBe(true);
expect(canTerminateFrom('PENDING')).toBe(false);
expect(canTerminateFrom('WON')).toBe(false);
});
it('shows the person who should act on the current step', () => {
expect(
currentHandlerNames({
status: 'TECH_INITIAL',
reviewCycle: 1,
createdBy: { displayName: '潘兴辉' },
reviewers: [
{ stage: 'TECH_INITIAL', result: 'PENDING', cycle: 1, user: { displayName: '戴永康' } },
],
}),
).toBe('戴永康');
expect(
currentHandlerNames({
status: 'FETCH',
assignees: [{ role: 'FETCHER', user: { displayName: '何烨' } }],
createdBy: { displayName: '潘兴辉' },
}),
).toBe('何烨');
expect(
currentHandlerNames({
status: 'DRAFTING',
assignees: [{ role: 'DRAFTER', user: { displayName: '张婧妍' } }],
}),
).toBe('张婧妍');
expect(currentHandlerNames({ status: 'PRINT', createdBy: { displayName: '潘兴辉' } })).toBe('商务部');
expect(currentHandlerNames({ status: 'FETCH', createdBy: { displayName: '潘兴辉' } })).toBe('待派取标人');
});
it('rolls back one process step, except special fetch', () => {
expect(previousBidStatus('FETCH', 'FLOW')).toBe('TECH_INITIAL');
expect(previousBidStatus('FETCH', 'SPECIAL')).toBeNull();
expect(previousBidStatus('TECH_FINAL')).toBe('FETCH');
expect(previousBidStatus('DRAFTING')).toBe('TECH_FINAL');
expect(previousBidStatus('REVIEW1')).toBe('DRAFTING');
expect(previousBidStatus('REVIEW2')).toBe('REVIEW1');
expect(previousBidStatus('PRINT')).toBe('REVIEW2');
expect(previousBidStatus('LIST')).toBe('PRINT');
expect(previousBidStatus('PENDING')).toBe('LIST');
expect(previousBidStatus('TECH_INITIAL')).toBeNull();
expect(previousBidStatus('WON')).toBeNull();
});
it('infers the restore step for legacy terminated records without an action log', () => {
expect(inferLegacyRestoreStatus([], [])).toBe('DRAFTING');
expect(
inferLegacyRestoreStatus(
[{ stage: 'TECH_INITIAL', result: 'APPROVED' }],
[{ role: 'FETCHER' }, { role: 'DRAFTER' }],
),
).toBe('DRAFTING');
expect(inferLegacyRestoreStatus([], [{ role: 'BIDDER' }])).toBe('PENDING');
expect(inferLegacyRestoreStatus([{ stage: 'REVIEW2', result: 'PENDING' }], [])).toBe('REVIEW2');
});
});
+336
View File
@@ -0,0 +1,336 @@
import { DIRECTOR_ROLES, hasAnyRole, isTechLead } from '../common/role-groups';
export const BID_STATUS = {
TECH_INITIAL: 'TECH_INITIAL',
FETCH: 'FETCH',
TECH_FINAL: 'TECH_FINAL',
DRAFTING: 'DRAFTING',
REVIEW1: 'REVIEW1',
REVIEW2: 'REVIEW2',
PRINT: 'PRINT',
LIST: 'LIST',
PENDING: 'PENDING',
WON: 'WON',
LOST: 'LOST',
FAILED: 'FAILED',
TERMINATED: 'TERMINATED',
} as const;
export type BidStatus = (typeof BID_STATUS)[keyof typeof BID_STATUS];
export const STATUS_BUCKET: Record<string, string[]> = {
screening: [BID_STATUS.TECH_INITIAL, BID_STATUS.FETCH, BID_STATUS.TECH_FINAL],
drafting: [BID_STATUS.DRAFTING, BID_STATUS.REVIEW1, BID_STATUS.REVIEW2, BID_STATUS.PRINT],
execution: [BID_STATUS.LIST, BID_STATUS.PENDING],
result: [BID_STATUS.WON, BID_STATUS.LOST, BID_STATUS.FAILED],
terminated: [BID_STATUS.TERMINATED],
};
export const REVIEW_STAGE = {
TECH_INITIAL: 'TECH_INITIAL',
TECH_FINAL: 'TECH_FINAL',
REVIEW1: 'REVIEW1',
REVIEW2: 'REVIEW2',
} as const;
export type ReviewStage = (typeof REVIEW_STAGE)[keyof typeof REVIEW_STAGE];
export const BID_SOURCE = {
FLOW: 'FLOW',
SPECIAL: 'SPECIAL',
} as const;
export const ASSIGN_ROLE = {
DRAFTER: 'DRAFTER',
REVIEW1: 'REVIEW1',
BIDDER: 'BIDDER',
FETCHER: 'FETCHER',
COORDINATOR: 'COORDINATOR',
} as const;
/** 特殊筛选只发给这两人,由他们派取标人 / 制标人。 */
export const SPECIAL_COORDINATORS = [
{ username: 'liuyunze', name: '刘运泽' },
{ username: 'chencheng', name: '陈铖' },
] as const;
export const BIZ_TYPE = {
TECH_INITIAL: 'BID_TECH_INITIAL',
TECH_FINAL: 'BID_TECH_FINAL',
FETCH: 'BID_FETCH',
ASSIGN_FETCH: 'BID_ASSIGN_FETCH',
ASSIGN_DRAFT: 'BID_ASSIGN_DRAFT',
DRAFT: 'BID_DRAFT',
REVIEW1: 'BID_REVIEW1',
REVIEW2: 'BID_REVIEW2',
PRINT: 'BID_PRINT',
COMPLETE: 'BID_COMPLETE',
OPEN: 'BID_OPEN',
CONVERT_CONTRACT: 'CONTRACT_DRAFT',
} as const;
export const OPEN_RESULT = {
WON: 'WON',
LOST: 'LOST',
FAILED: 'FAILED',
} as const;
export const COST_DISPOSITION = {
DEPT: 'DEPT',
MARKETING: 'MARKETING',
} as const;
export function stageForStatus(status: string): ReviewStage | null {
if (status === BID_STATUS.TECH_INITIAL) return REVIEW_STAGE.TECH_INITIAL;
if (status === BID_STATUS.TECH_FINAL) return REVIEW_STAGE.TECH_FINAL;
if (status === BID_STATUS.REVIEW1) return REVIEW_STAGE.REVIEW1;
if (status === BID_STATUS.REVIEW2) return REVIEW_STAGE.REVIEW2;
return null;
}
/**
* Old OA imports did not always contain the TERMINATED action log. Infer the
* furthest assigned workflow step so those records can still be restored.
*/
export function inferLegacyRestoreStatus(
reviewers: { stage: string; result?: string | null }[],
assignees: { role: string }[],
): BidStatus {
const candidates: BidStatus[] = [];
for (const reviewer of reviewers) {
if (reviewer.result && reviewer.result !== 'PENDING') continue;
if (reviewer.stage === REVIEW_STAGE.TECH_INITIAL) candidates.push(BID_STATUS.TECH_INITIAL);
if (reviewer.stage === REVIEW_STAGE.TECH_FINAL) candidates.push(BID_STATUS.TECH_FINAL);
if (reviewer.stage === REVIEW_STAGE.REVIEW1) candidates.push(BID_STATUS.REVIEW1);
if (reviewer.stage === REVIEW_STAGE.REVIEW2) candidates.push(BID_STATUS.REVIEW2);
}
for (const assignee of assignees) {
if (assignee.role === ASSIGN_ROLE.FETCHER) candidates.push(BID_STATUS.FETCH);
if (assignee.role === ASSIGN_ROLE.DRAFTER) candidates.push(BID_STATUS.DRAFTING);
if (assignee.role === ASSIGN_ROLE.REVIEW1) candidates.push(BID_STATUS.REVIEW1);
if (assignee.role === ASSIGN_ROLE.BIDDER) candidates.push(BID_STATUS.PENDING);
}
const order: BidStatus[] = [
BID_STATUS.TECH_INITIAL,
BID_STATUS.FETCH,
BID_STATUS.TECH_FINAL,
BID_STATUS.DRAFTING,
BID_STATUS.REVIEW1,
BID_STATUS.REVIEW2,
BID_STATUS.PRINT,
BID_STATUS.LIST,
BID_STATUS.PENDING,
];
return candidates.sort((a, b) => order.indexOf(b) - order.indexOf(a))[0] ?? BID_STATUS.DRAFTING;
}
export function isCommercialStage(stage: string) {
return stage === REVIEW_STAGE.REVIEW1 || stage === REVIEW_STAGE.REVIEW2;
}
export function approvalBizType(stage: string) {
if (stage === REVIEW_STAGE.TECH_FINAL) return BIZ_TYPE.TECH_FINAL;
if (stage === REVIEW_STAGE.REVIEW1) return BIZ_TYPE.REVIEW1;
if (stage === REVIEW_STAGE.REVIEW2) return BIZ_TYPE.REVIEW2;
return BIZ_TYPE.TECH_INITIAL;
}
export function canAccessBidModule(roles: string[], permissions: string[]) {
if (roles.includes('admin')) return true;
return permissions.some((p) => p === 'bid' || p.startsWith('bid:'));
}
/** 管理员、负责人、商务部(总监/专员)、全体技术总监可看全部投标。 */
export function canSeeAllBids(roles: string[]) {
return hasAnyRole(roles, ['admin', 'owner', 'biz_staff', ...DIRECTOR_ROLES]);
}
export function canSeeTerminated(roles: string[], permissions: string[]) {
if (roles.includes('admin') || roles.includes('owner') || roles.includes('biz_director')) {
return true;
}
if (isTechLead(roles)) return true;
return permissions.includes('bid:terminated');
}
export function canCreateBid(roles: string[], permissions: string[] = []) {
if (['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r))) return true;
return permissions.includes('bid:screening');
}
export function canCreateSpecialBid(roles: string[]) {
return ['admin', 'owner'].some((r) => roles.includes(r));
}
export function isSpecialBid(sourceType?: string | null) {
return sourceType === BID_SOURCE.SPECIAL;
}
export function isSpecialCoordinatorUser(user: { username?: string | null; displayName?: string | null }) {
const username = (user.username || '').trim().toLowerCase();
const name = (user.displayName || '').trim();
return SPECIAL_COORDINATORS.some(
(c) => c.username === username || c.name === name,
);
}
export function hasAssigneeRole(
assignees: { userId: string; role: string }[],
userId: string,
role: string,
) {
return assignees.some((a) => a.userId === userId && a.role === role);
}
export function canFetchBid(roles: string[]) {
return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r));
}
export function canFetchThisBid(
roles: string[],
sourceType: string | null | undefined,
assignees: { userId: string; role: string }[],
userId: string,
) {
// 普通/特殊筛选都必须由被派的取标人办理。过去普通筛选把整个商务部
// 当成取标人,既会群发短信,也会让任意人越权上传或完成取标。
return hasAssigneeRole(assignees, userId, ASSIGN_ROLE.FETCHER) || roles.includes('admin');
}
export function canManageDraft(roles: string[]) {
return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r));
}
export function canAssignFetcher(
roles: string[],
sourceType: string | null | undefined,
assignees: { userId: string; role: string }[],
userId: string,
status: string,
) {
if (status !== BID_STATUS.FETCH) return false;
if (isSpecialBid(sourceType)) {
return roles.includes('admin') || hasAssigneeRole(assignees, userId, ASSIGN_ROLE.COORDINATOR);
}
return ['admin', 'owner', 'biz_director'].some((role) => roles.includes(role));
}
export function canManageThisDraft(
roles: string[],
sourceType: string | null | undefined,
assignees: { userId: string; role: string }[],
userId: string,
) {
if (isSpecialBid(sourceType)) {
return roles.includes('admin') || hasAssigneeRole(assignees, userId, ASSIGN_ROLE.COORDINATOR);
}
return canManageDraft(roles);
}
export function canBizOperate(roles: string[]) {
return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r));
}
export function canTerminateBid(roles: string[]) {
return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r));
}
const TERMINABLE_STATUSES: string[] = [
BID_STATUS.TECH_INITIAL,
BID_STATUS.FETCH,
BID_STATUS.TECH_FINAL,
BID_STATUS.DRAFTING,
BID_STATUS.REVIEW1,
BID_STATUS.REVIEW2,
BID_STATUS.PRINT,
BID_STATUS.LIST,
];
export function canTerminateFrom(status: string) {
return TERMINABLE_STATUSES.includes(status);
}
const PREVIOUS_STATUS: Partial<Record<BidStatus, BidStatus>> = {
[BID_STATUS.FETCH]: BID_STATUS.TECH_INITIAL,
[BID_STATUS.TECH_FINAL]: BID_STATUS.FETCH,
[BID_STATUS.DRAFTING]: BID_STATUS.TECH_FINAL,
[BID_STATUS.REVIEW1]: BID_STATUS.DRAFTING,
[BID_STATUS.REVIEW2]: BID_STATUS.REVIEW1,
[BID_STATUS.PRINT]: BID_STATUS.REVIEW2,
[BID_STATUS.LIST]: BID_STATUS.PRINT,
[BID_STATUS.PENDING]: BID_STATUS.LIST,
};
/** 当前步骤办错了,退回上一步重做。特殊筛选没有技术初审,取标不能再往回退。 */
export function previousBidStatus(status: string, sourceType?: string | null): BidStatus | null {
if (status === BID_STATUS.FETCH && isSpecialBid(sourceType)) return null;
return PREVIOUS_STATUS[status as BidStatus] || null;
}
export function canRollbackBid(roles: string[]) {
return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r));
}
type Named = { displayName?: string | null } | null | undefined;
function uniqNames(names: (string | null | undefined)[]) {
return [...new Set(names.map((n) => (n || '').trim()).filter(Boolean))];
}
/** 列表「经办」:当前这一步该谁办,就显示谁。 */
export function currentHandlerNames(bid: {
status: string;
sourceType?: string | null;
reviewCycle?: number;
createdBy?: Named;
assignees?: { role: string; user?: Named }[];
reviewers?: { stage: string; result: string; cycle: number; user?: Named }[];
}) {
const cycle = bid.reviewCycle ?? 1;
const byRole = (role: string) =>
uniqNames((bid.assignees || []).filter((a) => a.role === role).map((a) => a.user?.displayName));
const pending = (stage: string) => {
const rows = bid.reviewers || [];
const atCycle = rows.filter((r) => r.stage === stage && r.result === 'PENDING' && r.cycle === cycle);
const anyPending = rows.filter((r) => r.stage === stage && r.result === 'PENDING');
const pick = atCycle.length ? atCycle : anyPending;
return uniqNames(pick.map((r) => r.user?.displayName));
};
const show = (names: string[], fallback = '') => names.join('、') || fallback;
switch (bid.status) {
case BID_STATUS.TECH_INITIAL:
return show(pending(REVIEW_STAGE.TECH_INITIAL), bid.createdBy?.displayName || '—');
case BID_STATUS.FETCH: {
const fetchers = byRole(ASSIGN_ROLE.FETCHER);
if (fetchers.length) return show(fetchers);
if (isSpecialBid(bid.sourceType)) return show(byRole(ASSIGN_ROLE.COORDINATOR), '待派取标人');
return '待派取标人';
}
case BID_STATUS.TECH_FINAL:
return show(pending(REVIEW_STAGE.TECH_FINAL), '待终审');
case BID_STATUS.DRAFTING: {
const drafters = byRole(ASSIGN_ROLE.DRAFTER);
if (drafters.length) return show(drafters);
if (isSpecialBid(bid.sourceType)) return show(byRole(ASSIGN_ROLE.COORDINATOR), '待派制标人');
return '待派制标人';
}
case BID_STATUS.REVIEW1:
return show([...pending(REVIEW_STAGE.REVIEW1), ...byRole(ASSIGN_ROLE.REVIEW1)], '商务一审');
case BID_STATUS.REVIEW2:
return show(pending(REVIEW_STAGE.REVIEW2), '商务二审');
case BID_STATUS.PRINT:
return '商务部';
case BID_STATUS.LIST:
return show(byRole(ASSIGN_ROLE.BIDDER), '待派投标人');
case BID_STATUS.PENDING:
case BID_STATUS.WON:
case BID_STATUS.LOST:
case BID_STATUS.FAILED:
return show(byRole(ASSIGN_ROLE.BIDDER), bid.createdBy?.displayName || '—');
case BID_STATUS.TERMINATED:
return bid.createdBy?.displayName || '—';
default:
return bid.createdBy?.displayName || '—';
}
}
+389
View File
@@ -0,0 +1,389 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsDefined,
IsIn,
MinLength,
IsOptional,
IsString,
} from 'class-validator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthUser } from '../common/decorators/current-user.decorator';
import { PageQueryDto } from '../common/dto/page-query.dto';
import { BiddingService } from './bidding.service';
class CreateBidDto {
@IsString()
name!: string;
@IsString()
externalNo!: string;
@IsString()
projectType!: string;
@IsString()
source!: string;
@IsString()
qualificationNeed!: string;
@IsString()
legalEntityId!: string;
@IsOptional()
@IsString()
partyId?: string;
@IsOptional()
@IsString()
tenderMethod?: string;
@IsOptional()
applyStartAt?: string;
@IsOptional()
applyEndAt?: string;
@IsOptional()
@IsString()
applyMethod?: string;
@IsOptional()
openAt?: string;
@IsOptional()
@IsString()
openMethod?: string;
@IsOptional()
@IsString()
openPlace?: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
@IsString()
summary?: string;
@IsOptional()
@IsString()
nasPath?: string;
@IsOptional()
@IsString()
bondMethod?: string;
@IsOptional()
@IsString()
quoteAmount?: string;
@IsOptional()
@IsString()
priceCap?: string;
@IsArray()
@ArrayNotEmpty({ message: '请选择项目初审人或终审总监' })
@IsString({ each: true })
reviewerIds!: string[];
@IsOptional()
@IsIn(['FLOW', 'SPECIAL'])
sourceType?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
coordinatorIds?: string[];
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
needSurvey?: boolean;
}
class ReviewDto {
@IsIn(['APPROVED', 'REJECTED'])
result!: 'APPROVED' | 'REJECTED';
@IsOptional()
@IsString()
comment?: string;
}
class FetchDto {
@IsOptional()
@IsString()
nasPath?: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
openAt?: string;
@IsOptional()
@IsString()
openMethod?: string;
@IsOptional()
@IsString()
openPlace?: string;
@IsOptional()
@IsString()
applyMethod?: string;
}
class TerminateDto {
@IsString()
@MinLength(1, { message: '终止请填写原因' })
comment!: string;
}
class RollbackDto {
@IsString()
@MinLength(1, { message: '退回请填写原因' })
comment!: string;
}
class AssignFetcherDto {
@IsString()
fetcherId!: string;
}
class AssignDrafterDto {
@IsString()
drafterId!: string;
@IsOptional()
@IsString()
review1Id?: string;
}
class SubmitDraftDto {
@IsString()
@MinLength(1, { message: '请填写标书 NAS 路径' })
nasPath!: string;
@IsOptional()
@IsString()
note?: string;
}
class PrintDto {
@IsOptional()
amount?: number;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsString()
nasPath?: string;
}
class CompleteDto {
@IsOptional()
@IsString()
bondMethod?: string;
@IsOptional()
@IsString()
bondStatus?: string;
@IsOptional()
bondAmount?: number;
@IsOptional()
@IsString()
delivererName?: string;
@IsOptional()
@IsString()
priceCap?: string;
@IsOptional()
@IsString()
quoteAmount?: string;
@IsOptional()
@IsString()
securityLevel?: string;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
contactPhone?: string;
@IsArray()
@ArrayNotEmpty({ message: '请派投标人' })
@IsString({ each: true })
bidderIds!: string[];
@IsOptional()
openAt?: string;
@IsOptional()
@IsString()
openPlace?: string;
@IsOptional()
@IsString()
openMethod?: string;
}
class OpenResultDto {
@IsIn(['WON', 'LOST', 'FAILED'])
result!: 'WON' | 'LOST' | 'FAILED';
@IsOptional()
@IsString()
comment?: string;
@IsOptional()
@IsIn(['DEPT', 'MARKETING'])
costDisposition?: 'DEPT' | 'MARKETING';
@IsOptional()
@IsString()
winnerName?: string;
@IsOptional()
winnerAmount?: number | string;
}
class CompetitorDto {
@IsString()
@MinLength(1, { message: '请填写中标单位' })
winnerName!: string;
@IsDefined({ message: '请填写中标金额' })
winnerAmount!: number | string;
@IsOptional()
@IsString()
winnerNote?: string;
}
class UpdateLegalEntityDto {
@IsString()
legalEntityId!: string;
}
@ApiTags('bidding')
@ApiBearerAuth()
@Controller('bid-cases')
export class BiddingController {
constructor(private readonly bidding: BiddingService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() q: PageQueryDto,
@Query('bucket') bucket?: string,
@Query('sourceType') sourceType?: string,
@Query('projectType') projectType?: string,
@Query('status') status?: string,
@Query('legalEntityId') legalEntityId?: string,
) {
return this.bidding.list(user, q.page, q.pageSize, q.keyword, bucket, sourceType, projectType, status, legalEntityId);
}
@Get('special-coordinators')
specialCoordinators(@CurrentUser() user: AuthUser) {
return this.bidding.specialCoordinators(user);
}
@Get(':id')
get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.bidding.get(id, user);
}
@Post()
create(@Body() dto: CreateBidDto, @CurrentUser() user: AuthUser) {
return this.bidding.create(dto, user);
}
@Patch(':id/survey')
setSurvey(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { needSurvey?: boolean },
@CurrentUser() user: AuthUser,
) {
return this.bidding.setNeedSurvey(id, user, Boolean(body.needSurvey));
}
@Post(':id/reviews')
review(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.review(id, user, dto);
}
@Post(':id/fetch')
fetch(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: FetchDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.fetch(id, user, dto);
}
@Post(':id/terminate')
terminate(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TerminateDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.terminate(id, user, dto.comment);
}
@Post(':id/rollback')
rollback(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RollbackDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.rollback(id, user, dto.comment);
}
@Post(':id/restore')
restore(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.bidding.restoreTerminated(id, user);
}
@Post(':id/assign-fetcher')
assignFetcher(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignFetcherDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.assignFetcher(id, user, dto);
}
@Post(':id/assign-drafter')
assignDrafter(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignDrafterDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.assignDrafter(id, user, dto);
}
@Post(':id/submit-draft')
submitDraft(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SubmitDraftDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.submitDraft(id, user, dto);
}
@Post(':id/print')
print(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PrintDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.print(id, user, dto);
}
@Post(':id/complete')
complete(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CompleteDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.completeExecution(id, user, dto);
}
@Post(':id/open-result')
openResult(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: OpenResultDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.openResult(id, user, dto);
}
@Patch(':id/competitor')
competitor(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CompetitorDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.recordCompetitor(id, user, dto);
}
@Patch(':id/legal-entity')
updateLegalEntity(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateLegalEntityDto,
@CurrentUser() user: AuthUser,
) {
return this.bidding.updateLegalEntity(id, user, dto.legalEntityId);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TECH_LEAD_ROLES } from '../common/role-groups';
import { PrismaService } from '../prisma/prisma.service';
@ApiTags('business')
@ApiBearerAuth()
@Controller()
export class BusinessController {
constructor(private readonly prisma: PrismaService) {}
@Get('directors')
async directors() {
const users = await this.prisma.user.findMany({
where: {
tenantId: '1',
roles: { some: { role: { code: { in: ['admin', 'tech_director', ...TECH_LEAD_ROLES] } } } },
},
include: { employee: { include: { department: true } }, roles: { include: { role: true } } },
});
return users.map((u) => ({
id: u.id,
name: u.displayName,
department: u.employee?.department?.name,
roles: u.roles.map((r) => r.role.code),
}));
}
@Get('staff')
async staff() {
const users = await this.prisma.user.findMany({
where: { tenantId: '1', status: 'ACTIVE' },
include: { employee: { include: { department: true } } },
orderBy: { displayName: 'asc' },
});
const avatars = await this.prisma.fileAsset.findMany({
where: { tenantId: '1', bizType: 'USER_AVATAR', bizId: { in: users.map((u) => u.id) } },
select: { id: true, bizId: true },
orderBy: { createdAt: 'desc' },
});
const avatarByUser = new Map<string, string>();
for (const a of avatars) {
if (!avatarByUser.has(a.bizId)) avatarByUser.set(a.bizId, a.id);
}
return users.map((u) => {
const acc = u.employee?.bankAccount?.replace(/\s/g, '') || '';
return {
id: u.id,
name: u.displayName,
username: u.username,
department: u.employee?.department?.name,
title: u.employee?.title,
mobile: u.employee?.mobile || null,
email: u.employee?.email || null,
avatarFileId: avatarByUser.get(u.id) || null,
bankReady: Boolean(acc),
bankName: u.employee?.bankName || null,
bankAccountMasked: acc ? `${'*'.repeat(Math.max(4, acc.length - 4))}${acc.slice(-4)}` : null,
};
});
}
}
+105
View File
@@ -0,0 +1,105 @@
import { ForbiddenException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { AuthUser } from './decorators/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
export type DataScope = 'ALL' | 'DEPT' | 'SELF';
const RANK: Record<DataScope, number> = { SELF: 0, DEPT: 1, ALL: 2 };
export function mergeDataScope(scopes: string[]): DataScope {
let best: DataScope = 'SELF';
for (const s of scopes) {
const v = s as DataScope;
if (RANK[v] > RANK[best]) best = v;
}
return best;
}
export function effectiveScope(user: AuthUser): DataScope {
if (user.roles.includes('admin') || user.roles.includes('owner')) return 'ALL';
return user.dataScope || 'SELF';
}
export async function descendantDeptIds(prisma: PrismaService, rootId?: string | null) {
if (!rootId) return [] as string[];
const all = await prisma.department.findMany({
where: { tenantId: '1' },
select: { id: true, parentId: true },
});
const ids = new Set<string>([rootId]);
let grew = true;
while (grew) {
grew = false;
for (const d of all) {
if (d.parentId && ids.has(d.parentId) && !ids.has(d.id)) {
ids.add(d.id);
grew = true;
}
}
}
return [...ids];
}
function empInScope(user: AuthUser, deptIds: string[]): Prisma.EmployeeWhereInput {
return {
OR: [
{ departmentId: { in: deptIds } },
...(user.employeeId
? [{ managerId: user.employeeId }, { managerIds: { contains: user.employeeId } }]
: []),
],
};
}
/** 本人 / 本部门及下级 / 全公司 */
export async function ownerScope(
prisma: PrismaService,
user: AuthUser,
owner: 'createdById' | 'applicantId',
): Promise<Prisma.BidCaseWhereInput | Prisma.ExpenseClaimWhereInput | Record<string, never>> {
const scope = effectiveScope(user);
if (scope === 'ALL') return {};
if (scope === 'SELF' || !user.departmentId) return { [owner]: user.id };
const deptIds = await descendantDeptIds(prisma, user.departmentId);
const rel = owner === 'createdById' ? 'createdBy' : 'applicant';
return {
OR: [{ [owner]: user.id }, { [rel]: { employee: empInScope(user, deptIds) } }],
};
}
export function withAnd<T extends { AND?: unknown }>(where: T, extra: Record<string, unknown>): T {
if (!extra || !Object.keys(extra).length) return where;
const prev = where.AND;
const list = Array.isArray(prev) ? [...prev] : prev ? [prev] : [];
list.push(extra);
where.AND = list;
return where;
}
export async function canSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) {
if (!ownerId) return true;
const scope = effectiveScope(user);
if (scope === 'ALL') return true;
if (ownerId === user.id) return true;
if (scope !== 'DEPT' || !user.departmentId) return false;
const owner = await prisma.user.findUnique({
where: { id: ownerId },
include: { employee: true },
});
if (!owner?.employee) return false;
const deptIds = await descendantDeptIds(prisma, user.departmentId);
if (owner.employee.departmentId && deptIds.includes(owner.employee.departmentId)) return true;
const extra = String(owner.employee.managerIds || '')
.split(/[,,、\s]+/)
.filter(Boolean);
return Boolean(
user.employeeId &&
(owner.employee.managerId === user.employeeId || extra.includes(user.employeeId)),
);
}
export async function assertCanSeeOwner(prisma: PrismaService, user: AuthUser, ownerId?: string | null) {
if (await canSeeOwner(prisma, user, ownerId)) return;
throw new ForbiddenException('超出数据范围,无权查看');
}
@@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...codes: string[]) =>
SetMetadata(PERMISSIONS_KEY, codes);
@@ -0,0 +1,18 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthUser {
id: string;
username: string;
displayName: string;
permissions: string[];
roles: string[];
employeeId?: string | null;
departmentId?: string | null;
dataScope: 'ALL' | 'DEPT' | 'SELF';
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
return ctx.switchToHttp().getRequest().user as AuthUser;
},
);
+15
View File
@@ -0,0 +1,15 @@
import { nextSerial } from './doc-no';
describe('nextSerial', () => {
it('starts at 00001 when empty', () => {
expect(nextSerial([], 'BX-2026-')).toBe('BX-2026-00001');
});
it('uses max+1 so a hole in the middle does not collide', () => {
expect(nextSerial(['BX-2026-00001', 'BX-2026-00005'], 'BX-2026-')).toBe('BX-2026-00006');
});
it('ignores old date-style numbers that share the year digits', () => {
expect(nextSerial(['BX-20260617-005', 'BX-2026-00002'], 'BX-2026-')).toBe('BX-2026-00003');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { Prisma } from '@prisma/client';
const DOC_NO_LOCK: Record<string, number> = { BX: 91001, JK: 91002, BZ: 91003 };
export function nextSerial(existing: string[], prefix: string, pad = 5) {
let max = 0;
for (const no of existing) {
if (!no.startsWith(prefix)) continue;
const tail = no.slice(prefix.length);
if (/^\d+$/.test(tail)) {
const n = Number(tail);
if (Number.isFinite(n)) max = Math.max(max, n);
}
}
return `${prefix}${String(max + 1).padStart(pad, '0')}`;
}
export function isUniqueConflict(e: unknown) {
return e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002';
}
export async function withUniqueRetry<T>(fn: () => Promise<T>, times = 8): Promise<T> {
let last: unknown;
for (let i = 0; i < times; i += 1) {
try {
return await fn();
} catch (e) {
last = e;
if (!isUniqueConflict(e)) throw e;
}
}
throw last instanceof Error ? last : new Error('单号冲突,请重试');
}
type LockTx = { $executeRaw: Prisma.TransactionClient['$executeRaw'] };
export async function lockDocNo(tx: LockTx, prefix: 'BX' | 'JK' | 'BZ') {
const key = DOC_NO_LOCK[prefix];
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${key}::bigint)`;
}
type ClaimDb = {
expenseClaim: {
findMany: (args: {
where: { tenantId: string; claimNo: { startsWith: string } };
select: { claimNo: true };
}) => Promise<{ claimNo: string }[]>;
};
};
export async function nextExpenseClaimNo(tx: LockTx & ClaimDb, year = new Date().getFullYear()) {
await lockDocNo(tx, 'BX');
const prefix = `BX-${year}-`;
const rows = await tx.expenseClaim.findMany({
where: { tenantId: '1', claimNo: { startsWith: prefix } },
select: { claimNo: true },
});
return nextSerial(
rows.map((r) => r.claimNo),
prefix,
);
}
type BondDb = {
bondRecord: {
findMany: (args: {
where: { tenantId: string; bondNo: { startsWith: string } };
select: { bondNo: true };
}) => Promise<{ bondNo: string }[]>;
};
};
export async function nextBondNo(tx: LockTx & BondDb, year = new Date().getFullYear()) {
await lockDocNo(tx, 'BZ');
const prefix = `BZ-${year}-`;
const rows = await tx.bondRecord.findMany({
where: { tenantId: '1', bondNo: { startsWith: prefix } },
select: { bondNo: true },
});
return nextSerial(
rows.map((r) => r.bondNo),
prefix,
);
}
+24
View File
@@ -0,0 +1,24 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class PageQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 10;
@IsOptional()
keyword?: string;
}
export function pageMeta(page: number, pageSize: number, total: number) {
return { page, pageSize, total };
}
@@ -0,0 +1,84 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Prisma } from '@prisma/client';
import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware';
import { PrismaService } from '../../prisma/prisma.service';
import { LOG_ACTION, writeOpLog } from '../../system/op-log';
import type { AuthUser } from '../decorators/current-user.decorator';
function publicErrorMessage(exception: unknown) {
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === 'P2002') return '单号已被占用,请再试一次';
return '保存失败,请稍后重试';
}
if (exception instanceof Error) {
if (/Invalid `prisma\.|Unique constraint failed/i.test(exception.message)) {
return '单号已被占用,请再试一次';
}
return exception.message;
}
return '服务器内部错误';
}
@Catch()
@Injectable()
export class HttpErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpErrorFilter.name);
constructor(private readonly prisma: PrismaService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request & { user?: AuthUser }>();
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
if (exception instanceof HttpException) {
status = exception.getStatus();
const payload = exception.getResponse();
if (typeof payload === 'string') {
message = payload;
} else if (typeof payload === 'object' && payload && 'message' in payload) {
const raw = (payload as { message: string | string[] }).message;
message = Array.isArray(raw) ? raw.join('; ') : raw;
}
} else {
message = publicErrorMessage(exception);
}
this.logger.error(
`${req.method} ${req.url} ${status} ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
if (status === HttpStatus.FORBIDDEN) {
void writeOpLog(this.prisma, {
userId: req.user?.id,
username: req.user?.username,
action: LOG_ACTION.FORBIDDEN,
path: `${req.method} ${req.originalUrl || req.url}`,
detail: message,
success: false,
requestId,
ip: req.ip,
});
}
res.status(status).json({
code: status,
message,
data: null,
requestId,
});
}
}
+37
View File
@@ -0,0 +1,37 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/auth.decorators';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>('permissions', [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const user = context.switchToHttp().getRequest().user;
if (!user?.permissions) return false;
if (user.roles?.includes('admin')) return true;
return required.every((code) => user.permissions.includes(code));
}
}
@@ -0,0 +1,40 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { REQUEST_ID_HEADER } from '../middleware/request-id.middleware';
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
requestId: string;
}
@Injectable()
export class ResponseInterceptor<T>
implements NestInterceptor<T, ApiResponse<T> | StreamableFile>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T> | StreamableFile> {
const req = context.switchToHttp().getRequest();
const requestId = (req.headers[REQUEST_ID_HEADER] as string) || '';
return next.handle().pipe(
map((data) => {
if (data instanceof StreamableFile) return data;
return {
code: 0,
message: 'ok',
data: (data ?? null) as T,
requestId,
};
}),
);
}
}
@@ -0,0 +1,15 @@
import { randomUUID } from 'crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
export const REQUEST_ID_HEADER = 'x-request-id';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const id = (req.headers[REQUEST_ID_HEADER] as string) || randomUUID();
req.headers[REQUEST_ID_HEADER] = id;
res.setHeader(REQUEST_ID_HEADER, id);
next();
}
}
+42
View File
@@ -0,0 +1,42 @@
import { assembleApprovalChain } from './org-chain';
const staff = { id: 'staff', displayName: '员工甲', username: 'a' };
const pm = { id: 'pm', displayName: '项目经理', username: 'pm' };
const dir = { id: 'dir', displayName: '研发总监', username: 'rd' };
const pan = { id: 'pan', displayName: '潘兴辉', username: 'pan' };
const biz = { id: 'biz', displayName: '王迪', username: 'wangdi' };
describe('assembleApprovalChain', () => {
it('staff goes PM → dept director → 潘兴辉 → 商务总监', () => {
expect(
assembleApprovalChain({
applicantId: staff.id,
manager: pm,
director: dir,
pan,
bizDirector: biz,
}).map((s) => `${s.role}:${s.user.displayName}`),
).toEqual(['项目经理:项目经理', '部门总监:研发总监', '潘兴辉:潘兴辉', '商务总监:王迪']);
});
it('skips the applicant and duplicate people', () => {
expect(
assembleApprovalChain({
applicantId: pm.id,
manager: pm,
director: dir,
pan,
bizDirector: biz,
}).map((s) => s.role),
).toEqual(['部门总监', '潘兴辉', '商务总监']);
expect(
assembleApprovalChain({
applicantId: staff.id,
manager: pm,
director: biz,
pan,
bizDirector: biz,
}).map((s) => s.role),
).toEqual(['项目经理', '部门总监', '潘兴辉']);
});
});
+209
View File
@@ -0,0 +1,209 @@
import type { Prisma } from '@prisma/client';
import { DIRECTOR_ROLES, TECH_LEAD_ROLES } from './role-groups';
import { PrismaService } from '../prisma/prisma.service';
export const PERSON_SELECT = { id: true, displayName: true, username: true } as const;
export type OrgPerson = { id: string; displayName: string; username: string };
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parsePeopleIds(raw?: string | null) {
if (!raw) return [] as string[];
return [
...new Set(
raw
.split(/[,,、\s]+/)
.map((s) => s.trim())
.filter((s) => UUID_RE.test(s)),
),
];
}
type OrgDb = PrismaService | Prisma.TransactionClient;
const PAN_NAME = '潘兴辉';
export function assembleApprovalChain(input: {
applicantId: string;
manager: OrgPerson | null;
director: OrgPerson | null;
pan: OrgPerson | null;
bizDirector: OrgPerson | null;
}): { role: string; user: OrgPerson }[] {
const steps: { role: string; user: OrgPerson }[] = [];
const push = (role: string, user: OrgPerson | null | undefined) => {
if (!user) return;
if (user.id === input.applicantId) return;
if (steps.some((s) => s.user.id === user.id)) return;
steps.push({ role, user });
};
push('项目经理', input.manager);
push('部门总监', input.director);
push('潘兴辉', input.pan);
push('商务总监', input.bizDirector);
return steps;
}
export async function ancestorDeptIds(db: OrgDb, startId?: string | null) {
if (!startId) return [] as string[];
const all = await db.department.findMany({
where: { tenantId: '1' },
select: { id: true, parentId: true },
});
const ids: string[] = [];
const seen = new Set<string>();
let cur: string | null | undefined = startId;
while (cur && !seen.has(cur)) {
seen.add(cur);
ids.push(cur);
cur = all.find((d) => d.id === cur)?.parentId;
}
return ids;
}
export async function findDeptDirector(db: OrgDb, departmentId?: string | null): Promise<OrgPerson | null> {
const chain = await ancestorDeptIds(db, departmentId);
for (const deptId of chain) {
const tech = await db.user.findFirst({
where: {
tenantId: '1',
status: 'ACTIVE',
employee: { departmentId: deptId },
roles: { some: { role: { code: { in: [...TECH_LEAD_ROLES] } } } },
},
select: PERSON_SELECT,
});
if (tech) return tech;
const any = await db.user.findFirst({
where: {
tenantId: '1',
status: 'ACTIVE',
employee: { departmentId: deptId },
roles: { some: { role: { code: { in: [...DIRECTOR_ROLES] } } } },
},
select: PERSON_SELECT,
});
if (any) return any;
}
return null;
}
export async function findNamedPerson(db: OrgDb, name: string): Promise<OrgPerson | null> {
const users = await db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
OR: [{ displayName: { contains: name } }, { employee: { name: { contains: name } } }],
},
select: { ...PERSON_SELECT, employee: { select: { name: true } } },
});
const exact = users.find((u) => u.displayName === name || u.employee?.name === name);
const hit = exact || users[0];
return hit ? { id: hit.id, displayName: hit.displayName, username: hit.username } : null;
}
/** 投标/报销里的「商务总监」指商务部负责人,不含其他部门误挂 biz_director 的同事。 */
export async function findBizDirectors(db: OrgDb): Promise<OrgPerson[]> {
const inBizDept = await db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: 'biz_director' } } },
employee: {
department: {
OR: [{ name: { contains: '商务' } }, { code: { contains: 'BIZ' } }, { code: { startsWith: 'OA_4' } }],
},
},
},
select: PERSON_SELECT,
orderBy: { displayName: 'asc' },
});
if (inBizDept.length) return inBizDept;
return db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: 'biz_director' } } },
},
select: PERSON_SELECT,
orderBy: { displayName: 'asc' },
});
}
export async function findRolePerson(db: OrgDb, roleCode: string, exceptId?: string | null): Promise<OrgPerson | null> {
if (roleCode === 'biz_director') {
const users = await findBizDirectors(db);
return users.find((u) => u.id !== exceptId) || users[0] || null;
}
const users = await db.user.findMany({
where: {
tenantId: '1',
status: 'ACTIVE',
roles: { some: { role: { code: roleCode } } },
},
select: PERSON_SELECT,
});
return users.find((u) => u.id !== exceptId) || users[0] || null;
}
function managerUserOf(emp: {
manager?: { users: Array<{ id: string; displayName: string; username: string }> } | null;
} | null): OrgPerson | null {
const u = emp?.manager?.users[0];
return u ? { id: u.id, displayName: u.displayName, username: u.username } : null;
}
export async function resolveExpenseApproverChain(db: OrgDb, applicantId: string) {
const applicant = await db.user.findFirst({
where: { id: applicantId },
include: {
employee: {
include: {
manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } },
},
},
},
});
const [director, pan, bizDirector] = await Promise.all([
findDeptDirector(db, applicant?.employee?.departmentId),
findNamedPerson(db, PAN_NAME),
findRolePerson(db, 'biz_director'),
]);
return assembleApprovalChain({
applicantId,
manager: managerUserOf(applicant?.employee || null),
director,
pan,
bizDirector,
});
}
export async function resolveReportPeople(db: OrgDb, userId: string) {
const me = await db.user.findFirst({
where: { id: userId },
include: {
employee: {
include: {
manager: { include: { users: { where: { status: 'ACTIVE' }, take: 1 } } },
reports: true,
},
},
},
});
const director = await findDeptDirector(db, me?.employee?.departmentId);
const extraIds = parsePeopleIds((me?.employee as { managerIds?: string | null } | undefined)?.managerIds).filter(
(id) => id !== me?.employee?.managerId,
);
const extraManagers = extraIds.length
? await db.user.findMany({
where: { tenantId: '1', status: 'ACTIVE', employeeId: { in: extraIds } },
select: PERSON_SELECT,
})
: [];
return {
me,
manager: managerUserOf(me?.employee || null),
extraManagers,
director,
};
}
+60
View File
@@ -0,0 +1,60 @@
export const TECH_LEAD_ROLES = [
'tech_director',
'rd_director',
'3d_director',
'video_director',
'material_director',
];
/** 商务总监 + 各技术条线总监 */
export const DIRECTOR_ROLES = ['biz_director', ...TECH_LEAD_ROLES];
/** 手工发短信:管理员、人事、各总监。 */
export const SMS_SEND_ROLES = ['admin', 'owner', 'hr', ...DIRECTOR_ROLES];
export function canSendSms(roles: string[], permissions: string[] = []) {
if (hasAnyRole(roles, SMS_SEND_ROLES)) return true;
return permissions.includes('office:sms') || permissions.includes('system:sms');
}
export const TECH_STAFF_ROLES = ['employee', 'pm', 'rd_staff', '3d_staff', 'video_staff', 'material_staff', 'tech'];
export const APPROVER_ROLES = [
'admin',
'owner',
'biz_director',
'biz_staff',
'hr',
'finance',
...TECH_LEAD_ROLES,
];
export function isStaffRole(roles: string[]) {
return roles.length > 0 && roles.every((r) => TECH_STAFF_ROLES.includes(r));
}
export function canApproveOthers(roles: string[], permissions: string[]) {
if (hasAnyRole(roles, APPROVER_ROLES)) return true;
return permissions.includes('office:approvals');
}
export function hasAnyRole(roles: string[], codes: readonly string[]) {
return codes.some((c) => roles.includes(c));
}
export function isTechLead(roles: string[]) {
return hasAnyRole(roles, TECH_LEAD_ROLES);
}
/** 研发总监与系统管理员同等。登录后补上 admin,后端原有管理员判断都会生效。 */
export function elevateToAdmin(roles: string[]) {
if (roles.includes('rd_director') && !roles.includes('admin')) return [...roles, 'admin'];
return roles;
}
/** 总监、项目经理、公司负责人可给下属分配工作。 */
export const WORK_ASSIGN_ROLES = ['admin', 'owner', 'biz_director', 'pm', ...TECH_LEAD_ROLES];
export function canAssignWork(roles: string[]) {
return hasAnyRole(roles, WORK_ASSIGN_ROLES);
}
+288
View File
@@ -0,0 +1,288 @@
function key() {
return process.env.TENCENT_MAP_KEY || '';
}
const REFERER = 'https://oa.fysxkj.com/';
async function tencentGet(path: string, params: Record<string, string>) {
const k = key();
if (!k) return null;
const q = new URLSearchParams({ key: k, output: 'json', ...params });
const res = await fetch(`https://apis.map.qq.com${path}?${q.toString()}`, {
headers: { Referer: REFERER },
});
if (!res.ok) return null;
return (await res.json()) as Record<string, unknown>;
}
export async function locateByIp(ip?: string) {
const json = await tencentGet('/ws/location/v1/ip', ip ? { ip } : {});
const result = json?.result as
| { location?: { lat?: number; lng?: number }; ad_info?: { city?: string; province?: string; district?: string } }
| undefined;
if (json?.status !== 0 || !result?.location) return null;
return {
lat: Number(result.location.lat),
lng: Number(result.location.lng),
city: String(result.ad_info?.city || result.ad_info?.province || '').replace(/市$/, ''),
district: String(result.ad_info?.district || ''),
};
}
export function tencentMapKey() {
return key();
}
export type PlaceHit = {
lat: number;
lng: number;
title: string;
address: string;
city?: string;
distance?: number;
};
function asPoi(p: {
title?: string;
address?: string;
location?: { lat?: number; lng?: number };
_distance?: number | string;
ad_info?: { city?: string };
}): PlaceHit | null {
const lat = Number(p.location?.lat);
const lng = Number(p.location?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const dist = Number(p._distance);
return {
lat,
lng,
title: String(p.title || p.address || '位置'),
address: String(p.address || ''),
city: String(p.ad_info?.city || '').replace(/市$/, ''),
distance: Number.isFinite(dist) ? dist : undefined,
};
}
export async function reverseGeocode(lat: number, lng: number) {
const json = await tencentGet('/ws/geocoder/v1/', {
location: `${lat},${lng}`,
get_poi: '1',
poi_options: 'address_format=short;radius=1000;page_size=20;policy=2',
});
const result = json?.result as
| {
address?: string;
formatted_addresses?: { recommend?: string; rough?: string };
address_component?: { city?: string; province?: string; district?: string };
pois?: Array<{
title?: string;
address?: string;
location?: { lat?: number; lng?: number };
_distance?: number | string;
}>;
}
| undefined;
if (json?.status !== 0 || !result) return null;
const address = String(result.address || '');
const title = String(result.formatted_addresses?.recommend || result.formatted_addresses?.rough || address);
return {
address,
title: title || address,
city: String(result.address_component?.city || result.address_component?.province || '').replace(/市$/, ''),
district: String(result.address_component?.district || ''),
lat,
lng,
pois: (result.pois || []).map(asPoi).filter((p): p is PlaceHit => !!p),
};
}
export async function suggestPlaces(keyword: string, lat?: number, lng?: number) {
const q = keyword.trim();
if (!q) return [] as PlaceHit[];
const params: Record<string, string> = { keyword: q, page_size: '20' };
if (Number.isFinite(Number(lat)) && Number.isFinite(Number(lng))) params.location = `${lat},${lng}`;
const json = await tencentGet('/ws/place/v1/suggestion/', params);
const rows = (json?.data as Array<Parameters<typeof asPoi>[0]>) || [];
return rows.map(asPoi).filter((p): p is PlaceHit => !!p);
}
const CITIES = [
'合肥',
'南京',
'北京',
'上海',
'杭州',
'苏州',
'无锡',
'宁波',
'武汉',
'成都',
'西安',
'广州',
'深圳',
'青岛',
'济南',
'郑州',
'长沙',
'南昌',
'福州',
'厦门',
'天津',
'重庆',
'沈阳',
'大连',
'哈尔滨',
'长春',
'昆明',
'贵阳',
'南宁',
'海口',
'兰州',
'银川',
'西宁',
'太原',
'石家庄',
'呼和浩特',
'乌鲁木齐',
];
function cityHint(text: string) {
return CITIES.find((c) => text.includes(c)) || '';
}
function poiScore(query: string, title: string, addr: string) {
const hay = `${title}${addr}`;
if (title === query) return 1000;
if (title.includes(query) || query.includes(title)) return 800;
let s = 0;
for (const part of query.match(/[\u4e00-\u9fff]{2,}/g) || []) {
if (hay.includes(part)) s += part.length * 10;
}
return s;
}
type GeoHit = {
lat: number;
lng: number;
title: string;
address: string;
city: string;
};
function asHit(lat: number, lng: number, title: string, address: string, city: string): GeoHit {
return { lat, lng, title, address, city };
}
export async function geocodeAddress(address: string) {
const q = address.trim();
if (!q) return null;
const geo = await tencentGet('/ws/geocoder/v1/', { address: q });
const geoResult = geo?.result as
| { location?: { lat?: number; lng?: number }; title?: string; address?: string }
| undefined;
if (geo?.status === 0 && geoResult?.location) {
const extra = await reverseGeocode(Number(geoResult.location.lat), Number(geoResult.location.lng));
return asHit(
Number(geoResult.location.lat),
Number(geoResult.location.lng),
String(geoResult.title || q),
extra?.address || String(geoResult.address || q),
extra?.city || '',
);
}
const city = cityHint(q);
const [sug, search] = await Promise.all([
tencentGet('/ws/place/v1/suggestion/', city ? { keyword: q, region: city } : { keyword: q }),
tencentGet('/ws/place/v1/search/', {
keyword: q,
boundary: city ? `region(${city},0)` : 'region(全国,0)',
page_size: '10',
}),
]);
type Poi = { title?: string; address?: string; location?: { lat?: number; lng?: number }; ad_info?: { city?: string } };
const pois = [...((sug?.data as Poi[]) || []), ...((search?.data as Poi[]) || [])].filter(
(p) => p?.location && Number.isFinite(Number(p.location.lat)),
);
if (!pois.length) return null;
const ranked = [...pois].sort(
(a, b) =>
poiScore(q, String(b.title || ''), String(b.address || '')) -
poiScore(q, String(a.title || ''), String(a.address || '')),
);
const best = ranked[0];
const extra = await reverseGeocode(Number(best.location!.lat), Number(best.location!.lng));
return asHit(
Number(best.location!.lat),
Number(best.location!.lng),
String(best.title || q),
extra?.address || String(best.address || q),
extra?.city || String(best.ad_info?.city || city).replace(/市$/, ''),
);
}
type WeatherNow = {
city: string;
weather: string;
temperature?: number;
humidity?: number;
wind?: string;
advice: string;
};
function adviceFrom(info: string, temp?: number) {
const tips: string[] = [];
if (/雨|雪|雷/.test(info)) tips.push('注意下雨,出门带伞');
if (/晴/.test(info) || (typeof temp === 'number' && temp >= 30)) tips.push('注意紫外线,做好防晒');
if (typeof temp === 'number' && temp >= 35) tips.push('注意防暑降温');
if (typeof temp === 'number' && temp <= 5) tips.push('注意保暖');
if (/雾|霾|沙/.test(info)) tips.push('注意出行能见度');
if (/风/.test(info) && !/微风/.test(info)) tips.push('注意大风');
if (!tips.length) tips.push('注意劳逸结合');
return [...new Set(tips)].slice(0, 2).join('');
}
export async function weatherByLocation(lat: number, lng: number, cityHint?: string): Promise<WeatherNow | null> {
const json = await tencentGet('/ws/weather/v1/', {
location: `${lat},${lng}`,
type: 'now',
added_fields: 'alarm',
});
if (json?.status !== 0) return null;
const result = json.result as { realtime?: Array<Record<string, unknown>> } | undefined;
const row = result?.realtime?.[0];
if (!row) return null;
const infos = (row.infos || {}) as Record<string, unknown>;
const info = String(infos.weather || '—');
const temperature = Number(infos.temperature);
const humidity = Number(infos.humidity);
const windDir = String(infos.wind_direction || '');
const windPower = String(infos.wind_power_v2 || infos.wind_power || '');
const city = String(row.city || cityHint || '').replace(/市$/, '');
return {
city: city || '当地',
weather: info,
temperature: Number.isFinite(temperature) ? temperature : undefined,
humidity: Number.isFinite(humidity) ? humidity : undefined,
wind: [windDir, windPower].filter(Boolean).join(' ') || undefined,
advice: adviceFrom(info, Number.isFinite(temperature) ? temperature : undefined),
};
}
const GREETINGS = [
(n: string) => `尊敬的${n}您好,美好的一天开始了,祝您工作愉快。`,
(n: string) => `尊敬的${n},新的一天已就绪,愿今天经手的每一件事都顺利落地。`,
(n: string) => `${n},早上好。保持节奏,今天也会很出色。`,
(n: string) => `尊敬的${n}您好,愿今天思路清晰、协作顺畅。`,
(n: string) => `${n},新的一天值得认真对待,也值得对自己温柔一点。`,
(n: string) => `尊敬的${n},把难事先拆小步,今天就会轻松很多。`,
(n: string) => `${n}您好,专注当下这一件事,成果会自己跟上。`,
];
export function dailyGreeting(name: string, seed: string) {
const day = new Date().toISOString().slice(0, 10);
let h = 0;
const s = `${day}:${seed}`;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return GREETINGS[h % GREETINGS.length](name || '同事');
}
+22
View File
@@ -0,0 +1,22 @@
import { loadEnv } from './env';
describe('loadEnv', () => {
const base = {
DATABASE_URL: 'postgresql://ops:ops@localhost:5432/enterprise_ops',
REDIS_URL: 'redis://localhost:6379',
MINIO_ACCESS_KEY: 'opsadmin',
MINIO_SECRET_KEY: 'opsadmin_dev_only',
JWT_ACCESS_SECRET: 'change-me-access-secret-dev-only',
JWT_REFRESH_SECRET: 'change-me-refresh-secret-dev-only',
};
it('accepts valid development env', () => {
const env = loadEnv({ ...base, NODE_ENV: 'development' });
expect(env.APP_PORT).toBe(3000);
expect(env.API_PREFIX).toBe('api/v1');
});
it('rejects missing database url', () => {
expect(() => loadEnv({ ...base, DATABASE_URL: '' })).toThrow(/环境变量校验失败/);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
APP_PORT: z.coerce.number().default(3000),
APP_NAME: z.string().default('enterprise-ops'),
API_PREFIX: z.string().default('api/v1'),
CORS_ORIGINS: z.string().default('http://localhost:5173'),
DATABASE_URL: z.string().min(1),
REDIS_URL: z.string().min(1),
MINIO_ENDPOINT: z.string().default('localhost'),
MINIO_PORT: z.coerce.number().default(9000),
MINIO_USE_SSL: z.coerce.boolean().default(false),
MINIO_ACCESS_KEY: z.string().min(1),
MINIO_SECRET_KEY: z.string().min(1),
MINIO_BUCKET: z.string().default('enterprise-ops'),
JWT_ACCESS_SECRET: z.string().min(16),
JWT_REFRESH_SECRET: z.string().min(16),
JWT_ACCESS_EXPIRES: z.string().default('15m'),
JWT_REFRESH_EXPIRES: z.string().default('7d'),
ALIYUN_SMS_ACCESS_KEY_ID: z.string().optional(),
ALIYUN_SMS_ACCESS_KEY_SECRET: z.string().optional(),
ALIYUN_SMS_ENDPOINT: z.string().optional(),
ALIYUN_SMS_REGION: z.string().optional(),
ALIYUN_SMS_DEFAULT_SIGN: z.string().optional(),
ALIYUN_RAM_LOGIN: z.string().optional(),
ALIYUN_RAM_SECURITY_PHONE: z.string().optional(),
ALIYUN_RAM_SECURITY_EMAIL: z.string().optional(),
TENCENT_MAP_KEY: z.string().optional(),
IM_TCP_PORT: z.coerce.number().optional(),
IM_HTTP_PORT: z.coerce.number().optional(),
IM_INTERNAL_TOKEN: z.string().optional(),
VIVO_PUSH_APP_ID: z.string().optional(),
VIVO_PUSH_APP_KEY: z.string().optional(),
VIVO_PUSH_APP_SECRET: z.string().optional(),
OPPO_PUSH_APP_ID: z.string().optional(),
OPPO_PUSH_APP_KEY: z.string().optional(),
OPPO_PUSH_APP_SECRET: z.string().optional(),
OPPO_PUSH_MASTER_SECRET: z.string().optional(),
MEIZU_PUSH_APP_ID: z.string().optional(),
MEIZU_PUSH_APP_KEY: z.string().optional(),
MEIZU_PUSH_APP_SECRET: z.string().optional(),
HUAWEI_PUSH_APP_ID: z.string().optional(),
HUAWEI_PUSH_CLIENT_ID: z.string().optional(),
HUAWEI_PUSH_CLIENT_SECRET: z.string().optional(),
HUAWEI_PUSH_PROJECT_ID: z.string().optional(),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(raw: NodeJS.ProcessEnv = process.env): Env {
const parsed = envSchema.safeParse(raw);
if (!parsed.success) {
const details = parsed.error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ');
throw new Error(`环境变量校验失败: ${details}`);
}
return parsed.data;
}
@@ -0,0 +1,26 @@
import {
canAccessContract,
canApproveContract,
canEditContract,
milestoneSumOk,
} from './contract-workflow';
describe('contract-workflow', () => {
it('allows biz roles to draft, and owner/director to approve', () => {
expect(canEditContract(['biz_staff'])).toBe(true);
expect(canEditContract(['tech_director'])).toBe(false);
expect(canApproveContract(['owner'])).toBe(true);
expect(canApproveContract(['biz_staff'])).toBe(false);
});
it('requires milestone ratios to sum to 100', () => {
expect(milestoneSumOk([60, 40])).toBe(true);
expect(milestoneSumOk([30, 30, 40])).toBe(true);
expect(milestoneSumOk([50, 40])).toBe(false);
});
it('lets finance see contracts via permission', () => {
expect(canAccessContract(['finance'], ['contract:list'])).toBe(true);
expect(canAccessContract(['employee'], ['office'])).toBe(false);
});
});
@@ -0,0 +1,29 @@
export const CONTRACT_STATUS = {
DRAFT: 'DRAFT',
PENDING: 'PENDING',
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
} as const;
export const CONTRACT_BIZ = {
APPROVAL: 'CONTRACT_APPROVAL',
DRAFT: 'CONTRACT_DRAFT',
} as const;
export function canAccessContract(roles: string[], permissions: string[]) {
if (roles.includes('admin')) return true;
return permissions.some((p) => p === 'contract' || p.startsWith('contract:') || p === 'bid' || p.startsWith('bid:'));
}
export function canEditContract(roles: string[]) {
return ['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r));
}
export function canApproveContract(roles: string[]) {
return ['admin', 'owner', 'biz_director'].some((r) => roles.includes(r));
}
export function milestoneSumOk(ratios: number[]) {
const sum = ratios.reduce((a, b) => a + b, 0);
return Math.abs(sum - 100) < 0.01;
}
@@ -0,0 +1,102 @@
import { Body, Controller, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator';
import { ContractService } from './contract.service';
@ApiTags('contract')
@ApiBearerAuth()
@Controller()
export class ContractController {
constructor(private readonly contracts: ContractService) {}
@Get('contracts')
list(
@CurrentUser() user: AuthUser,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
@Query('keyword') keyword?: string,
@Query('bucket') bucket?: string,
) {
return this.contracts.list(user, Number(page) || 1, Number(pageSize) || 20, keyword, bucket);
}
@Get('contracts/:id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.contracts.get(id, user);
}
@Post('contracts')
create(
@CurrentUser() user: AuthUser,
@Body()
body: {
bidCaseId?: string;
legalEntityId?: string;
amount: number;
name?: string;
partyA?: string;
partyB?: string;
remark?: string;
contractNo?: string;
kind?: string;
},
) {
if (body.bidCaseId && !body.kind) return this.contracts.createFromBid(body, user);
return this.contracts.createIncremental(body, user);
}
@Patch('contracts/:id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body()
body: {
name?: string;
amount?: number;
partyA?: string;
partyB?: string;
remark?: string;
nasPath?: string;
contractNo?: string;
},
) {
return this.contracts.update(id, user, body);
}
@Patch('contracts/:id/kind')
moveKind(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() body: { kind?: string }) {
return this.contracts.moveKind(id, user, body.kind || 'OTHER');
}
@Put('contracts/:id/milestones')
milestones(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { items: Array<{ name: string; ratio: number; dueAt?: string; trigger?: string }> },
) {
return this.contracts.replaceMilestones(id, user, body.items || []);
}
@Post('contracts/:id/seal')
seal(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { sealType: string; reason: string; takeOut?: boolean; returnAt?: string },
) {
return this.contracts.createSeal(id, user, body);
}
@Post('contracts/:id/submit')
submit(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.contracts.submit(id, user);
}
@Post('contracts/:id/reviews')
review(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { result: 'APPROVED' | 'REJECTED'; comment?: string },
) {
return this.contracts.review(id, user, body);
}
}
+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'),
),
};
}
}

Some files were not shown because too many files have changed in this diff Show More