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; 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>(FLOW); const fresh = parseOaJson>(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(); 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(); const preByTender = new Map(); const preByName = new Map(); 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(); 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());