metallkart-erp/scripts/s53a-mig.ts
louis c6eae3a77e fix(s53a-mig): step 1 reconstructs originalQuantity from POLine consumed
Two-pass backfill: cas 1 (non-consumed, origQty=qty) and cas 2
(consumed, origQty=qty+SUM(POLine.consumedQuantity) for active POs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 17:28:25 +03:00

318 lines
12 KiB
TypeScript

#!/usr/bin/env tsx
import { PrismaClient, Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
const prisma = new PrismaClient();
const args = process.argv.slice(2);
const apply = args.includes('--apply');
const verbose = args.includes('--verbose');
const stepArg = args.find(a => a.startsWith('--step='));
const step = stepArg ? parseInt(stepArg.split('=')[1], 10) : 0;
async function main() {
console.log(apply ? '🔥 MODE APPLY (will write to DB)' : '🧪 MODE DRY-RUN (no writes)');
console.log('');
if (step === 0 || step === 1) await step1_backfillOriginalQuantity();
if (step === 0 || step === 2) await step2_backfillPOLineFKs();
if (step === 0 || step === 3) await step3_createRetroactivePRsForOrphanPOLines();
if (step === 0 || step === 4) await step4_mergeResidualPRs();
console.log('');
console.log('✅ Done.');
}
async function step1_backfillOriginalQuantity() {
console.log('--- STEP 1: backfill originalQuantity (2-pass) ---');
const candidates = await prisma.purchaseRequirement.findMany({
where: { originalQuantity: null },
select: { id: true, quantity: true, description: true, orderId: true },
});
console.log(`Candidates: ${candidates.length} PRs without originalQuantity`);
if (candidates.length === 0) { console.log(''); return; }
// Fetch consumed quantities from linked POLines (non-cancelled POs)
const consumed = await prisma.purchaseOrderLine.groupBy({
by: ['purchaseRequirementId'],
where: {
purchaseRequirementId: { in: candidates.map(c => c.id) },
consumedQuantity: { not: null },
purchaseOrder: { status: { not: 'CANCELLED' } },
},
_sum: { consumedQuantity: true },
});
const consumedMap = new Map(consumed.map(c => [c.purchaseRequirementId!, Number(c._sum.consumedQuantity ?? 0)]));
let cas1 = 0, cas2 = 0;
const updates: Array<{ id: number; originalQuantity: Decimal }> = [];
for (const pr of candidates) {
const sumConsumed = consumedMap.get(pr.id) ?? 0;
const reconstructed = Number(pr.quantity) + sumConsumed;
if (sumConsumed > 0) {
cas2++;
if (verbose) console.log(` PR #${pr.id} (${pr.description?.substring(0, 40)}): qty=${pr.quantity} + consumed=${sumConsumed} → originalQuantity=${reconstructed}`);
} else {
cas1++;
if (verbose && cas1 <= 10) console.log(` PR #${pr.id} (${pr.description?.substring(0, 40)}): qty=${pr.quantity} → originalQuantity=${pr.quantity}`);
}
updates.push({ id: pr.id, originalQuantity: new Decimal(reconstructed) });
}
console.log(`Cas 1 (non-consommée, origQty=qty): ${cas1}`);
console.log(`Cas 2 (consommée, origQty=qty+SUM(consumed)): ${cas2}`);
if (verbose && cas1 > 10) console.log(` ... and ${cas1 - 10} more cas 1`);
if (apply) {
await prisma.$transaction(async tx => {
for (const u of updates) {
await tx.purchaseRequirement.update({
where: { id: u.id },
data: { originalQuantity: u.originalQuantity },
});
}
});
console.log(`✅ Updated ${updates.length} PRs (${cas1} cas 1, ${cas2} cas 2)`);
} else {
console.log(`(dry-run) Would update ${updates.length} PRs`);
}
console.log('');
}
async function step2_backfillPOLineFKs() {
console.log('--- STEP 2: backfill POLine.purchaseRequirementId ---');
const orphans = await prisma.purchaseOrderLine.findMany({
where: { purchaseRequirementId: null },
select: {
id: true, purchaseOrderId: true, description: true, material: true, quantity: true,
lotLines: { select: { lot: { select: { requirementId: true } } } },
},
});
console.log(`Candidates: ${orphans.length} POLines without purchaseRequirementId`);
let resolvable = 0;
let stillOrphan = 0;
const resolvedBatch: Array<{ poLineId: number; prId: number; qty: number }> = [];
for (const line of orphans) {
const reqIds = line.lotLines.map((ll: any) => ll.lot.requirementId).filter((x: any): x is number => x !== null);
const uniqueReqIds = [...new Set(reqIds)];
if (uniqueReqIds.length === 1) {
resolvedBatch.push({ poLineId: line.id, prId: uniqueReqIds[0], qty: Number(line.quantity) });
resolvable++;
} else if (uniqueReqIds.length > 1) {
console.warn(`⚠ POLine #${line.id} (${line.description?.substring(0, 40)}) has ${uniqueReqIds.length} candidate PRs: ${uniqueReqIds.join(', ')}. Skipping.`);
stillOrphan++;
} else {
stillOrphan++;
}
}
console.log(`Resolvable: ${resolvable}, Still orphan: ${stillOrphan}`);
if (verbose && resolvable > 0) {
for (const r of resolvedBatch.slice(0, 10)) {
console.log(` POLine #${r.poLineId} → PR #${r.prId}, consumedQuantity=${r.qty}`);
}
if (resolvedBatch.length > 10) console.log(` ... and ${resolvedBatch.length - 10} more`);
}
if (apply && resolvable > 0) {
await prisma.$transaction(async tx => {
for (const r of resolvedBatch) {
await tx.purchaseOrderLine.update({
where: { id: r.poLineId },
data: { purchaseRequirementId: r.prId, consumedQuantity: new Decimal(r.qty) },
});
}
});
console.log(`✅ Linked ${resolvable} POLines to their PRs`);
} else if (!apply) {
console.log(`(dry-run) Would link ${resolvable} POLines`);
}
console.log('');
}
async function step3_createRetroactivePRsForOrphanPOLines() {
console.log('--- STEP 3: create retroactive PRs for orphan POLines ---');
const stillOrphans = await prisma.purchaseOrderLine.findMany({
where: { purchaseRequirementId: null },
select: {
id: true, description: true, material: true, quantity: true, unit: true,
purchaseOrder: { select: { id: true, clientOrderId: true, orderCode: true, status: true } },
},
});
console.log(`Candidates: ${stillOrphans.length} POLines still orphan after step 2`);
if (stillOrphans.length === 0) {
console.log('Nothing to do.');
console.log('');
return;
}
const toCreate: Array<{ poLineId: number; orderId: number; data: any }> = [];
for (const line of stillOrphans) {
if (!line.purchaseOrder.clientOrderId) {
if (verbose) console.warn(`⚠ POLine #${line.id} : PO ${line.purchaseOrder.orderCode} has no clientOrderId. Skipping.`);
continue;
}
if (line.purchaseOrder.status === 'CANCELLED') {
if (verbose) console.log(` Skip POLine #${line.id} : PO ${line.purchaseOrder.orderCode} is CANCELLED`);
continue;
}
toCreate.push({
poLineId: line.id,
orderId: line.purchaseOrder.clientOrderId,
data: {
order: { connect: { id: line.purchaseOrder.clientOrderId } },
materialCategory: line.material as any,
description: line.description,
quantity: new Decimal(0),
originalQuantity: line.quantity,
unit: line.unit,
status: 'ORDERED' as const,
priority: 'NORMAL' as const,
sourceType: 'MIGRATION_RETRO',
sourceId: line.purchaseOrder.id,
sourceReference: `POLine-${line.id}`,
notes: `Migration S53A-MIG: PR rétroactive pour PO ${line.purchaseOrder.orderCode}, ligne ${line.description?.substring(0, 60)}`,
},
});
}
console.log(`To create: ${toCreate.length} retroactive PRs`);
if (verbose) {
for (const c of toCreate.slice(0, 10)) {
console.log(` POLine #${c.poLineId} → PR retro: "${c.data.description?.substring(0, 40)}" qty=0 originalQty=${c.data.originalQuantity}`);
}
if (toCreate.length > 10) console.log(` ... and ${toCreate.length - 10} more`);
}
if (apply && toCreate.length > 0) {
await prisma.$transaction(async tx => {
for (const c of toCreate) {
const newPR = await tx.purchaseRequirement.create({ data: c.data });
await tx.purchaseOrderLine.update({
where: { id: c.poLineId },
data: { purchaseRequirementId: newPR.id, consumedQuantity: c.data.originalQuantity },
});
}
});
console.log(`✅ Created ${toCreate.length} retro PRs and linked them`);
} else if (!apply) {
console.log(`(dry-run) Would create ${toCreate.length} retro PRs`);
}
console.log('');
}
async function step4_mergeResidualPRs() {
console.log('--- STEP 4: merge residual PRs into their main PRs ---');
const residuals = await prisma.purchaseRequirement.findMany({
where: {
notes: { contains: 'Остаток из тендера' },
archived: false,
},
select: {
id: true, orderId: true, description: true, materialCategory: true,
quantity: true, status: true, notes: true,
},
});
console.log(`Residual PRs found: ${residuals.length}`);
let merged = 0;
let unmatched = 0;
const mergeBatch: Array<{ residualId: number; mainId: number; addedToMain: number }> = [];
for (const res of residuals) {
const main = await prisma.purchaseRequirement.findFirst({
where: {
orderId: res.orderId,
description: res.description,
materialCategory: res.materialCategory,
archived: false,
id: { not: res.id },
},
orderBy: { id: 'asc' },
select: { id: true, quantity: true, originalQuantity: true },
});
if (!main) {
if (verbose) console.warn(`⚠ Residual PR #${res.id} (${res.description?.substring(0, 40)}, order ${res.orderId}): no match. Will standalone.`);
unmatched++;
continue;
}
mergeBatch.push({
residualId: res.id,
mainId: main.id,
addedToMain: Number(res.quantity),
});
merged++;
}
console.log(`Merge candidates: ${merged}, Unmatched: ${unmatched}`);
if (verbose && mergeBatch.length > 0) {
for (const m of mergeBatch.slice(0, 10)) {
console.log(` Residual PR #${m.residualId} → merge into main PR #${m.mainId}, +${m.addedToMain}`);
}
if (mergeBatch.length > 10) console.log(` ... and ${mergeBatch.length - 10} more`);
}
if (apply) {
if (mergeBatch.length > 0) {
await prisma.$transaction(async tx => {
for (const m of mergeBatch) {
await tx.purchaseRequirement.update({
where: { id: m.mainId },
data: {
quantity: { increment: new Decimal(m.addedToMain) },
revisionAlert: true,
revisionNote: `Migration S53A-MIG: récupération de ${m.addedToMain} depuis PR résiduelle #${m.residualId}`,
},
});
await tx.purchaseRequirement.update({
where: { id: m.residualId },
data: {
archived: true,
archivedAt: new Date(),
notes: `Migration S53A-MIG: mergée dans PR principale #${m.mainId} le ${new Date().toISOString()}`,
},
});
}
});
console.log(`✅ Merged ${mergeBatch.length} residuals into their main PRs`);
}
const unmatchedList = await prisma.purchaseRequirement.findMany({
where: {
notes: { contains: 'Остаток из тендера' },
archived: false,
originalQuantity: null,
},
select: { id: true, quantity: true },
});
if (unmatchedList.length > 0) {
await prisma.$transaction(async tx => {
for (const u of unmatchedList) {
await tx.purchaseRequirement.update({
where: { id: u.id },
data: { originalQuantity: u.quantity },
});
}
});
console.log(`✅ Set originalQuantity for ${unmatchedList.length} unmatched residuals`);
}
} else {
console.log(`(dry-run) Would merge ${mergeBatch.length} residuals + standalone-ify ${unmatched}`);
}
console.log('');
}
main().catch(e => { console.error(e); process.exit(1); }).finally(() => prisma.$disconnect());