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