Files
Public/apps/api/prisma/wash-oa-flow-finance.ts
T
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

467 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());