Files
Public/apps/api/prisma/wash-terminated-winners.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

187 lines
5.4 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 * 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());