feat(berths): CM-2 — bulk price-reconcile service (parse + apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
166
src/lib/services/berth-price-reconcile.service.ts
Normal file
166
src/lib/services/berth-price-reconcile.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Bulk berth price reconciliation (CM-2 Part A).
|
||||
*
|
||||
* Re-parses each berth's CURRENT spec-sheet PDF (stored parseResults are
|
||||
* stale/wrong — the old purchase-price regex matched 0/113 real sheets),
|
||||
* surfaces old→new price diffs for an admin review page, and applies only the
|
||||
* rows a rep explicitly approves. Nothing is written until apply.
|
||||
*/
|
||||
|
||||
import pLimit from 'p-limit';
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths, berthPdfVersions } from '@/lib/db/schema/berths';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
import { parseBerthPdf, extractPurchasePrice } from './berth-pdf-parser';
|
||||
|
||||
export interface PriceReconcileRow {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
currentPrice: number | null;
|
||||
currentCurrency: string;
|
||||
parsedPrice: number | null;
|
||||
parsedCurrency: string | null;
|
||||
versionId: string | null;
|
||||
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<Buffer | string>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* For every active berth in the port, re-parse the current spec-sheet PDF and
|
||||
* report the parsed main price alongside the stored price. Tenant-scoped by
|
||||
* `portId`. Bounded concurrency keeps the S3/filesystem round-trips in check.
|
||||
*/
|
||||
export async function listPriceReconciliation(portId: string): Promise<PriceReconcileRow[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
berthId: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
currentPrice: berths.price,
|
||||
currentCurrency: berths.priceCurrency,
|
||||
versionId: berths.currentPdfVersionId,
|
||||
storageKey: berthPdfVersions.storageKey,
|
||||
})
|
||||
.from(berths)
|
||||
.leftJoin(berthPdfVersions, eq(berthPdfVersions.id, berths.currentPdfVersionId))
|
||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
|
||||
.orderBy(berths.mooringNumber);
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
const limit = pLimit(8);
|
||||
|
||||
return Promise.all(
|
||||
rows.map((r) =>
|
||||
limit(async (): Promise<PriceReconcileRow> => {
|
||||
const currentPrice = r.currentPrice == null ? null : Number(r.currentPrice);
|
||||
const base = {
|
||||
berthId: r.berthId,
|
||||
mooringNumber: r.mooringNumber,
|
||||
area: r.area,
|
||||
currentPrice,
|
||||
currentCurrency: r.currentCurrency,
|
||||
versionId: r.versionId,
|
||||
};
|
||||
if (!r.versionId || !r.storageKey) {
|
||||
return { ...base, parsedPrice: null, parsedCurrency: null, status: 'no_pdf' };
|
||||
}
|
||||
try {
|
||||
const buffer = await streamToBuffer(
|
||||
(await backend.get(r.storageKey)) as AsyncIterable<Buffer | string>,
|
||||
);
|
||||
const parse = await parseBerthPdf(buffer);
|
||||
const price = extractPurchasePrice(parse.rawText ?? '');
|
||||
if (price.value == null) {
|
||||
return {
|
||||
...base,
|
||||
parsedPrice: null,
|
||||
parsedCurrency: null,
|
||||
status: 'needs_review',
|
||||
warning: price.warning,
|
||||
};
|
||||
}
|
||||
const status = currentPrice === price.value ? 'matched' : 'changed';
|
||||
return { ...base, parsedPrice: price.value, parsedCurrency: price.currency, status };
|
||||
} catch (err) {
|
||||
logger.warn({ berthId: r.berthId, err }, 'price-reconcile: parse failed');
|
||||
return {
|
||||
...base,
|
||||
parsedPrice: null,
|
||||
parsedCurrency: null,
|
||||
status: 'needs_review',
|
||||
warning: 'PDF could not be parsed.',
|
||||
};
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a rep-approved slice of parsed prices to `berths.price`/`priceCurrency`.
|
||||
* Tenant-scoped: cross-port berth ids are silently skipped (defense in depth on
|
||||
* top of the route's permission gate). Stamps each berth's current PDF version
|
||||
* `parseResults.bulkPriceApplied` for audit.
|
||||
*/
|
||||
export async function applyBulkBerthPrices(
|
||||
portId: string,
|
||||
approvals: Array<{ berthId: string; price: number; currency: string }>,
|
||||
actingUserId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
if (approvals.length === 0) return { updated: 0 };
|
||||
const ids = approvals.map((a) => a.berthId);
|
||||
const owned = await db
|
||||
.select({ id: berths.id, vid: berths.currentPdfVersionId })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), inArray(berths.id, ids)));
|
||||
const ownedVid = new Map(owned.map((b) => [b.id, b.vid]));
|
||||
|
||||
let updated = 0;
|
||||
await db.transaction(async (tx) => {
|
||||
for (const a of approvals) {
|
||||
if (!ownedVid.has(a.berthId)) continue; // cross-port → skip
|
||||
if (!Number.isFinite(a.price) || a.price < 0) continue;
|
||||
await tx
|
||||
.update(berths)
|
||||
.set({ price: String(a.price), priceCurrency: a.currency, updatedAt: new Date() })
|
||||
.where(and(eq(berths.id, a.berthId), eq(berths.portId, portId)));
|
||||
const vid = ownedVid.get(a.berthId);
|
||||
if (vid) {
|
||||
const [ver] = await tx
|
||||
.select({ pr: berthPdfVersions.parseResults })
|
||||
.from(berthPdfVersions)
|
||||
.where(eq(berthPdfVersions.id, vid));
|
||||
const prior = (ver?.pr as Record<string, unknown> | null) ?? {};
|
||||
await tx
|
||||
.update(berthPdfVersions)
|
||||
.set({
|
||||
parseResults: {
|
||||
...prior,
|
||||
bulkPriceApplied: {
|
||||
price: a.price,
|
||||
currency: a.currency,
|
||||
by: actingUserId,
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
.where(eq(berthPdfVersions.id, vid));
|
||||
}
|
||||
updated += 1;
|
||||
}
|
||||
});
|
||||
return { updated };
|
||||
}
|
||||
Reference in New Issue
Block a user