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>
318 lines
12 KiB
TypeScript
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());
|