Files
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

588 lines
21 KiB
TypeScript

// @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());