76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
// @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());
|