Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
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());