mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-02 21:08:21 +00:00
Merge pull request #366 from Hestia-Homes/feature/uprn-confirmation-page
Confirm UPRNs before finalise: two-tab bulk-upload review (ADR-0057)
This commit is contained in:
commit
4cfbbaabef
13 changed files with 12795 additions and 56 deletions
|
|
@ -0,0 +1,43 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { upsertUprnCorrection } from "@/lib/bulkUpload/addressMatches";
|
||||
|
||||
// Confirm one flagged combiner row (ADR-0057): either assign a UPRN chosen from
|
||||
// OS Places, or assert the address has none. Keyed by `source_row_id` (there is
|
||||
// no property.id yet — the finaliser creates identities). Upsert semantics, so
|
||||
// re-confirming overwrites. The confirmation is overlaid onto the combiner CSV
|
||||
// at dispatchFinaliser.
|
||||
const AssignSchema = z.object({
|
||||
sourceRowId: z.string().min(1),
|
||||
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
|
||||
address: z.string().min(1),
|
||||
postcode: z.string().min(1),
|
||||
});
|
||||
const NoUprnSchema = z.object({
|
||||
sourceRowId: z.string().min(1),
|
||||
markedNoUprn: z.literal(true),
|
||||
});
|
||||
const BodySchema = z.union([NoUprnSchema, AssignSchema]);
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> },
|
||||
) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { uploadId } = await params;
|
||||
const parsed = BodySchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await upsertUprnCorrection(uploadId, parsed.data, session.user?.email ?? undefined);
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getFlaggedAddresses } from "@/lib/bulkUpload/addressMatches";
|
||||
|
||||
// Read-only: the combiner rows address2uprn could not confidently match
|
||||
// (unmatched / ambiguous_duplicate / invalid_postcode), joined with any saved
|
||||
// correction. Drives the pre-finalise "Addresses" review tab (ADR-0057); the
|
||||
// Finalise gate blocks until every flagged row is resolved.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> },
|
||||
) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { uploadId } = await params;
|
||||
const flagged = await getFlaggedAddresses(uploadId);
|
||||
const unresolved = flagged.filter((f) => !f.resolved).length;
|
||||
return NextResponse.json({ flagged, unresolved }, { status: 200 });
|
||||
}
|
||||
|
|
@ -35,6 +35,14 @@ export async function POST(
|
|||
return NextResponse.json({ error: "Combiner not finished" }, { status: 409 });
|
||||
case "missing_task":
|
||||
return NextResponse.json({ error: "Upload has no task to finalise" }, { status: 409 });
|
||||
case "unresolved_addresses":
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${result.count} address${result.count === 1 ? "" : "es"} still need a UPRN. Resolve them on the Addresses tab before finalising.`,
|
||||
unresolvedAddresses: result.count,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
case "wrong_state":
|
||||
return NextResponse.json(
|
||||
{ error: `Upload not ready to finalize (state: ${result.current})` },
|
||||
|
|
|
|||
15
src/app/db/migrations/0261_uprn_corrections.sql
Normal file
15
src/app/db/migrations/0261_uprn_corrections.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
CREATE TABLE "bulk_upload_uprn_corrections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"upload_id" uuid NOT NULL,
|
||||
"source_row_id" text NOT NULL,
|
||||
"uprn" text,
|
||||
"address" text,
|
||||
"postcode" text,
|
||||
"marked_no_uprn" boolean DEFAULT false NOT NULL,
|
||||
"user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "bulk_upload_uprn_corrections_upload_row_unique" UNIQUE("upload_id","source_row_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "bulk_upload_uprn_corrections" ADD CONSTRAINT "bulk_upload_uprn_corrections_upload_id_bulk_address_uploads_id_fk" FOREIGN KEY ("upload_id") REFERENCES "public"."bulk_address_uploads"("id") ON DELETE cascade ON UPDATE no action;
|
||||
11882
src/app/db/migrations/meta/0261_snapshot.json
Normal file
11882
src/app/db/migrations/meta/0261_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1821,6 +1821,13 @@
|
|||
"when": 1783345567793,
|
||||
"tag": "0260_natural_enchantress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 261,
|
||||
"version": "7",
|
||||
"when": 1783439171770,
|
||||
"tag": "0261_uprn_corrections",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
46
src/app/db/schema/bulk_upload_uprn_corrections.ts
Normal file
46
src/app/db/schema/bulk_upload_uprn_corrections.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core";
|
||||
|
||||
import { bulkAddressUploads } from "./bulk_address_uploads";
|
||||
|
||||
// User confirmations of a combiner row's UPRN, captured on the pre-finalise
|
||||
// "Addresses" review tab (ADR-0057). address2uprn withholds ambiguous / no-UPRN
|
||||
// matches; the user resolves each flagged row via OS Places here, keyed by the
|
||||
// combiner CSV's `source_row_id` (there is no `property.id` yet — the finaliser
|
||||
// creates identities). At dispatchFinaliser these corrections are overlaid onto
|
||||
// the combiner CSV before the finaliser reads it, so the confirmed UPRN drives
|
||||
// both the identity insert and property_overrides in one pass — no post-finalise
|
||||
// backfill (which is why the portfolio "Unmatched" tab is retired).
|
||||
//
|
||||
// A row is resolved when it has a numeric `uprn` OR `markedNoUprn = true` (the
|
||||
// user asserts no UPRN exists — an accepted terminal state; the finaliser
|
||||
// leaves it as a no-UPRN property).
|
||||
export const bulkUploadUprnCorrections = pgTable(
|
||||
"bulk_upload_uprn_corrections",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
uploadId: uuid("upload_id")
|
||||
.notNull()
|
||||
.references(() => bulkAddressUploads.id, { onDelete: "cascade" }),
|
||||
// The combiner CSV join key (synthetic UUID minted at start-address-matching).
|
||||
sourceRowId: text("source_row_id").notNull(),
|
||||
// The confirmed UPRN; null when markedNoUprn.
|
||||
uprn: text("uprn"),
|
||||
address: text("address"),
|
||||
postcode: text("postcode"),
|
||||
// The user asserts this address has no UPRN — resolved without one.
|
||||
markedNoUprn: boolean("marked_no_uprn").notNull().default(false),
|
||||
userId: text("user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
// One correction per combiner row — the confirm action upserts on this.
|
||||
uploadRowUnique: unique("bulk_upload_uprn_corrections_upload_row_unique").on(
|
||||
t.uploadId,
|
||||
t.sourceRowId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MagnifyingGlassIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { describeCandidate } from "@/lib/postcodeSearch/model";
|
||||
import { searchAddresses, type SearchCandidate } from "@/lib/properties/client";
|
||||
import { useSaveUprnCorrection, type FlaggedAddress } from "@/lib/bulkUpload/client";
|
||||
|
||||
// The pre-finalise "Addresses" review tab (ADR-0057). Lists the combiner rows
|
||||
// address2uprn could not confidently match; the user resolves each via Ordnance
|
||||
// Survey (residential candidates only → a guaranteed valid UPRN) or marks it as
|
||||
// having no UPRN. Confirmations are saved as corrections (keyed by
|
||||
// source_row_id) and overlaid onto the combiner CSV at finalise.
|
||||
export default function AddressesPanel({
|
||||
flagged,
|
||||
portfolioId,
|
||||
uploadId,
|
||||
}: {
|
||||
flagged: FlaggedAddress[];
|
||||
portfolioId: string;
|
||||
uploadId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/40 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Addresses that need a UPRN
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
We couldn't confidently match these to Ordnance Survey. Pick the
|
||||
right address — or mark it as having no UPRN — so onboarding creates the
|
||||
correct property.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{flagged.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">Nothing to resolve.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-100 rounded-lg border border-amber-100 bg-white">
|
||||
{flagged.map((row) => (
|
||||
<AddressRow
|
||||
key={row.sourceRowId}
|
||||
row={row}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "ambiguous_duplicate":
|
||||
return "Ambiguous match";
|
||||
case "low_score":
|
||||
return "Low-confidence match";
|
||||
case "unmatched":
|
||||
return "No match";
|
||||
case "invalid_postcode":
|
||||
return "Invalid postcode";
|
||||
case "error":
|
||||
return "Match error";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function AddressRow({
|
||||
row,
|
||||
portfolioId,
|
||||
uploadId,
|
||||
}: {
|
||||
row: FlaggedAddress;
|
||||
portfolioId: string;
|
||||
uploadId: string;
|
||||
}) {
|
||||
const save = useSaveUprnCorrection(portfolioId, uploadId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [address, setAddress] = useState(row.address ?? "");
|
||||
const [postcode, setPostcode] = useState(row.postcode ?? "");
|
||||
// null = not searched yet; [] = searched, no residential matches.
|
||||
const [residential, setResidential] = useState<SearchCandidate[] | null>(null);
|
||||
const [selected, setSelected] = useState<SearchCandidate | null>(null);
|
||||
const [source, setSource] = useState<"cache" | "live" | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
|
||||
async function runSearch() {
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
setResidential(null);
|
||||
setSelected(null);
|
||||
try {
|
||||
const result = await searchAddresses(portfolioId, postcode);
|
||||
// Residential only — we never assign a non-residential UPRN.
|
||||
setResidential(result.candidates.filter((c) => c.selectable));
|
||||
setSource(result.source);
|
||||
} catch (e) {
|
||||
setSearchError(e instanceof Error ? e.message : "Search failed.");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onAssign() {
|
||||
if (!selected) return;
|
||||
save.mutate(
|
||||
{
|
||||
sourceRowId: row.sourceRowId,
|
||||
uprn: selected.uprn,
|
||||
address: selected.address,
|
||||
postcode: selected.postcode,
|
||||
},
|
||||
{ onSuccess: () => setOpen(false) },
|
||||
);
|
||||
}
|
||||
|
||||
function onNoUprn() {
|
||||
save.mutate({ sourceRowId: row.sourceRowId, markedNoUprn: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-start justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-800" title={row.address}>
|
||||
{row.address || "(no address)"}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{row.postcode || "no postcode"}
|
||||
{row.lexiscore != null ? ` · score ${row.lexiscore.toFixed(2)}` : ""}
|
||||
{" · "}
|
||||
<span className="font-medium text-amber-700">{statusLabel(row.status)}</span>
|
||||
</p>
|
||||
{row.resolved && (
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-green-700">
|
||||
<CheckCircleIcon className="h-3.5 w-3.5" />
|
||||
{row.markedNoUprn
|
||||
? "Marked: no UPRN"
|
||||
: `UPRN ${row.resolvedUprn}${row.resolvedAddress ? ` · ${row.resolvedAddress}` : ""}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{row.resolved ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
|
||||
Find UPRN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNoUprn}
|
||||
disabled={save.isPending}
|
||||
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
No UPRN
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Match to a UPRN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirm the postcode and pick the matching address. Only residential
|
||||
addresses from Ordnance Survey are shown, so the UPRN is always valid.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-gray-600">
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="e.g. 20 Brenchley Road"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-semibold text-gray-600">
|
||||
Postcode
|
||||
</label>
|
||||
<input
|
||||
value={postcode}
|
||||
onChange={(e) => setPostcode(e.target.value)}
|
||||
placeholder="e.g. BR5 2TD"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && postcode.trim() && !searching)
|
||||
runSearch();
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runSearch}
|
||||
disabled={!postcode.trim() || searching}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||
{searching ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchError && <p className="text-xs text-red-600">{searchError}</p>}
|
||||
|
||||
{residential && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-gray-600">
|
||||
Residential addresses
|
||||
</p>
|
||||
{source === "cache" && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-gray-400">
|
||||
from cache
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-100">
|
||||
{residential.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-gray-400">
|
||||
No residential addresses found for that postcode.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-50">
|
||||
{residential.map((c) => {
|
||||
const { label } = describeCandidate(c);
|
||||
const isSelected = selected?.uprn === c.uprn;
|
||||
return (
|
||||
<li key={c.uprn}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(c)}
|
||||
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-amber-50" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<CheckCircleIcon
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 ${
|
||||
isSelected ? "text-amber-600" : "text-gray-200"
|
||||
}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate text-gray-800"
|
||||
title={c.address}
|
||||
>
|
||||
{c.address}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-gray-500">
|
||||
UPRN {c.uprn} · {label}
|
||||
{c.alreadyInPortfolio ? " · already in portfolio" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{save.error && (
|
||||
<p className="mr-auto self-center text-xs text-red-600">
|
||||
{save.error.message}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAssign}
|
||||
disabled={!selected || save.isPending}
|
||||
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
|
||||
>
|
||||
{save.isPending ? "Assigning…" : "Assign UPRN"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||
import Link from "next/link";
|
||||
import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
useAddressMatches,
|
||||
useBulkUploadProgress,
|
||||
useConfirmMultiEntryOrdering,
|
||||
useConfirmVerification,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
useSampleClassifications,
|
||||
type SampleClassifications,
|
||||
} from "@/lib/bulkUpload/client";
|
||||
import AddressesPanel from "./AddressesPanel";
|
||||
import {
|
||||
partLabel,
|
||||
isPermutation,
|
||||
|
|
@ -82,6 +84,9 @@ export default function OnboardingProgress({
|
|||
});
|
||||
const combine = useRequestCombine(portfolioId, uploadId);
|
||||
const finalize = useFinalize(portfolioId, uploadId);
|
||||
const [reviewTab, setReviewTab] = useState<"classification" | "addresses">(
|
||||
"classification",
|
||||
);
|
||||
|
||||
// Read-only classifications for the multi-entry sample (issue #298). Fetched
|
||||
// only once a sample exists at awaiting_review. Hook stays above the early
|
||||
|
|
@ -90,6 +95,13 @@ export default function OnboardingProgress({
|
|||
progress.data?.upload.status === "awaiting_review" &&
|
||||
!!progress.data.upload.multiEntrySummary?.sample;
|
||||
const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady);
|
||||
// Combiner rows address2uprn could not confidently match (ADR-0057). Fetched
|
||||
// once at awaiting_review; drives the Addresses tab + the Finalise gate.
|
||||
const addressMatches = useAddressMatches(
|
||||
portfolioId,
|
||||
uploadId,
|
||||
progress.data?.upload.status === "awaiting_review",
|
||||
);
|
||||
|
||||
if (progress.isError) return null;
|
||||
if (!progress.data) {
|
||||
|
|
@ -152,11 +164,17 @@ export default function OnboardingProgress({
|
|||
(n, descriptions) => n + descriptions.length,
|
||||
0,
|
||||
);
|
||||
// Flagged addresses (no confident UPRN) must all be resolved before finalise,
|
||||
// else a withheld row lands as an unintended no-UPRN property (ADR-0057).
|
||||
const flaggedCount = addressMatches.data?.flagged.length ?? 0;
|
||||
const unresolvedAddresses = addressMatches.data?.unresolved ?? 0;
|
||||
const showAddressTab = isAwaitingReview && flaggedCount > 0;
|
||||
const canFinalize =
|
||||
isAwaitingReview &&
|
||||
(!needsVerify || verifyAck) &&
|
||||
(!needsOrdering || orderingConfirmed) &&
|
||||
unknownTotal === 0;
|
||||
unknownTotal === 0 &&
|
||||
unresolvedAddresses === 0;
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-3">
|
||||
|
|
@ -224,47 +242,94 @@ export default function OnboardingProgress({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{needsVerify && sample && (
|
||||
<VerifyClassificationPanel
|
||||
sample={sample}
|
||||
classifications={classifications.data?.classifications ?? {}}
|
||||
verified={verifyAck}
|
||||
stepLabel={showStepNumbers ? "Step 1" : undefined}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
{/* Two-tab review at awaiting_review: classification checks | address/UPRN
|
||||
confirmation (ADR-0057). The Addresses tab only appears when there are
|
||||
flagged rows; otherwise the classification panels render as before. */}
|
||||
{showAddressTab && (
|
||||
<div className="flex gap-4 border-b border-gray-200 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReviewTab("classification")}
|
||||
className={`-mb-px border-b-2 px-1 pb-2 transition-colors ${
|
||||
reviewTab === "classification"
|
||||
? "border-[#14163d] font-medium text-[#14163d]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Classification
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReviewTab("addresses")}
|
||||
className={`-mb-px flex items-center gap-1.5 border-b-2 px-1 pb-2 transition-colors ${
|
||||
reviewTab === "addresses"
|
||||
? "border-[#14163d] font-medium text-[#14163d]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Addresses
|
||||
{unresolvedAddresses > 0 && (
|
||||
<span className="rounded-full bg-amber-500 px-1.5 text-[10px] font-semibold text-white">
|
||||
{unresolvedAddresses}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAwaitingReview && unknownTotal > 0 && (
|
||||
<UnresolvedClassificationsPanel
|
||||
unknown={unknownByField}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{needsOrdering && orderingSamples.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{orderingSamples.map(([count, orderSample], i) => (
|
||||
<MultiEntryOrderingPanel
|
||||
key={count}
|
||||
sample={orderSample}
|
||||
ordering={upload.multiEntryOrdering ?? null}
|
||||
{(!showAddressTab || reviewTab === "classification") && (
|
||||
<>
|
||||
{needsVerify && sample && (
|
||||
<VerifyClassificationPanel
|
||||
sample={sample}
|
||||
classifications={classifications.data?.classifications ?? {}}
|
||||
// Number the panels only when there's also a verify step or more
|
||||
// than one count, so a lone ordering panel stays unnumbered.
|
||||
stepLabel={
|
||||
showStepNumbers
|
||||
? `Step ${i + 2}`
|
||||
: orderingSamples.length > 1
|
||||
? `Part group ${i + 1}`
|
||||
: undefined
|
||||
}
|
||||
verified={verifyAck}
|
||||
stepLabel={showStepNumbers ? "Step 1" : undefined}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAwaitingReview && unknownTotal > 0 && (
|
||||
<UnresolvedClassificationsPanel
|
||||
unknown={unknownByField}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{needsOrdering && orderingSamples.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{orderingSamples.map(([count, orderSample], i) => (
|
||||
<MultiEntryOrderingPanel
|
||||
key={count}
|
||||
sample={orderSample}
|
||||
ordering={upload.multiEntryOrdering ?? null}
|
||||
classifications={classifications.data?.classifications ?? {}}
|
||||
// Number the panels only when there's also a verify step or more
|
||||
// than one count, so a lone ordering panel stays unnumbered.
|
||||
stepLabel={
|
||||
showStepNumbers
|
||||
? `Step ${i + 2}`
|
||||
: orderingSamples.length > 1
|
||||
? `Part group ${i + 1}`
|
||||
: undefined
|
||||
}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showAddressTab && reviewTab === "addresses" && (
|
||||
<AddressesPanel
|
||||
flagged={addressMatches.data?.flagged ?? []}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(canRunCombiner || isAwaitingReview) && (
|
||||
|
|
@ -284,11 +349,13 @@ export default function OnboardingProgress({
|
|||
isPending={finalize.isPending}
|
||||
disabled={!canFinalize}
|
||||
disabledReason={
|
||||
unknownTotal > 0
|
||||
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
|
||||
: needsVerify && !verifyAck
|
||||
? "Verify the classification first"
|
||||
: "Confirm the building-part order first"
|
||||
unresolvedAddresses > 0
|
||||
? `Resolve ${unresolvedAddresses} address${unresolvedAddresses === 1 ? "" : "es"} on the Addresses tab first`
|
||||
: unknownTotal > 0
|
||||
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
|
||||
: needsVerify && !verifyAck
|
||||
? "Verify the classification first"
|
||||
: "Confirm the building-part order first"
|
||||
}
|
||||
onClick={() =>
|
||||
finalize.mutate(undefined, { onSuccess: () => router.refresh() })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import PropertyTable from "../components/PropertyTable";
|
||||
import UnmatchedProperties from "../components/UnmatchedProperties";
|
||||
import PortfolioTabs from "../components/PortfolioTabs";
|
||||
import { getUnmatchedProperties } from "@/lib/properties/unmatched";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
|
|
@ -14,15 +11,10 @@ export default async function Page(props: {
|
|||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
|
||||
const unmatched = await getUnmatchedProperties(portfolioId);
|
||||
|
||||
return (
|
||||
<PortfolioTabs
|
||||
needsAttentionCount={unmatched.length}
|
||||
properties={<PropertyTable portfolioId={portfolioId} />}
|
||||
needsAttention={
|
||||
<UnmatchedProperties properties={unmatched} portfolioId={portfolioId} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
// UPRN matching is now confirmed during bulk-upload onboarding (ADR-0057), so
|
||||
// properties arrive already matched — the portfolio-level "Unmatched" tab is
|
||||
// retired. The tab components (PortfolioTabs / UnmatchedProperties /
|
||||
// getUnmatchedProperties) are left in place for now, pending a follow-up that
|
||||
// reconciles legacy no-UPRN properties onboarded before this feature.
|
||||
return <PropertyTable portfolioId={portfolioId} />;
|
||||
}
|
||||
|
|
|
|||
246
src/lib/bulkUpload/addressMatches.ts
Normal file
246
src/lib/bulkUpload/addressMatches.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
|
||||
import { bulkUploadUprnCorrections } from "@/app/db/schema/bulk_upload_uprn_corrections";
|
||||
import { createRetrofitDataS3Client } from "@/app/utils/s3";
|
||||
import { SOURCE_ROW_ID_COLUMN } from "./s3Keys";
|
||||
|
||||
// Columns address2uprn writes onto each combiner row (ADR-0057). `status` is the
|
||||
// per-row outcome; a withheld/ambiguous/no-match row has an empty `uprn`.
|
||||
const UPRN_COL = "address2uprn_uprn";
|
||||
const STATUS_COL = "address2uprn_status";
|
||||
const LEXISCORE_COL = "address2uprn_lexiscore";
|
||||
|
||||
// A combiner row is confidently matched only when address2uprn kept a UPRN AND
|
||||
// did not flag it. Everything else — unmatched, ambiguous_duplicate,
|
||||
// invalid_postcode, error, or a legacy row with no status but no UPRN — needs
|
||||
// the user to resolve it on the Addresses tab before finalise.
|
||||
function isConfidentlyMatched(row: Record<string, string>): boolean {
|
||||
const uprn = (row[UPRN_COL] ?? "").trim();
|
||||
const status = (row[STATUS_COL] ?? "").trim();
|
||||
if (!uprn) return false;
|
||||
return status === "" || status === "matched";
|
||||
}
|
||||
|
||||
export interface FlaggedAddressRow {
|
||||
sourceRowId: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
status: string;
|
||||
lexiscore: number | null;
|
||||
// The candidate address2uprn found but did not trust (null when none).
|
||||
suggestedUprn: string | null;
|
||||
// The user's confirmation (from bulk_upload_uprn_corrections), if any.
|
||||
resolvedUprn: string | null;
|
||||
resolvedAddress: string | null;
|
||||
markedNoUprn: boolean;
|
||||
// Resolved = user picked a UPRN OR asserted there is none.
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
function parseS3Uri(uri: string): { bucket: string; key: string } {
|
||||
const url = new URL(uri); // s3://bucket/key...
|
||||
const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname;
|
||||
if (!url.hostname || !key) throw new Error(`Malformed S3 URI: ${uri}`);
|
||||
return { bucket: url.hostname, key };
|
||||
}
|
||||
|
||||
async function readCombinerRows(s3Uri: string): Promise<Record<string, string>[]> {
|
||||
const { bucket, key } = parseS3Uri(s3Uri);
|
||||
const s3 = createRetrofitDataS3Client();
|
||||
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
|
||||
const body = result.Body?.toString("utf-8");
|
||||
if (!body) return [];
|
||||
const wb = XLSX.read(body, { type: "string" });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
// Every cell as a string so a numeric UPRN never arrives as a float.
|
||||
return XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: "", raw: false });
|
||||
}
|
||||
|
||||
function buildAddress(row: Record<string, string>): string {
|
||||
return ["Address 1", "Address 2", "Address 3"]
|
||||
.map((c) => (row[c] ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The combiner rows that need user confirmation, joined with any saved
|
||||
* correction. Reads the combiner CSV from `combinedOutputS3Uri` (ADR-0057).
|
||||
* Returns [] until the combiner has run.
|
||||
*/
|
||||
export async function getFlaggedAddresses(
|
||||
uploadId: string,
|
||||
): Promise<FlaggedAddressRow[]> {
|
||||
const [row] = await db
|
||||
.select({ combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri })
|
||||
.from(bulkAddressUploads)
|
||||
.where(eq(bulkAddressUploads.id, uploadId));
|
||||
if (!row?.combinedOutputS3Uri) return [];
|
||||
|
||||
const [rows, corrections] = await Promise.all([
|
||||
readCombinerRows(row.combinedOutputS3Uri),
|
||||
db
|
||||
.select()
|
||||
.from(bulkUploadUprnCorrections)
|
||||
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId)),
|
||||
]);
|
||||
|
||||
const correctionByRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
|
||||
|
||||
return rows
|
||||
.filter((r) => !isConfidentlyMatched(r))
|
||||
.map((r) => {
|
||||
const sourceRowId = (r[SOURCE_ROW_ID_COLUMN] ?? "").trim();
|
||||
const uprn = (r[UPRN_COL] ?? "").trim();
|
||||
const rawScore = (r[LEXISCORE_COL] ?? "").trim();
|
||||
const lexiscore = rawScore === "" ? null : Number(rawScore);
|
||||
const c = correctionByRow.get(sourceRowId);
|
||||
const resolvedUprn = c?.uprn ?? null;
|
||||
const markedNoUprn = c?.markedNoUprn ?? false;
|
||||
return {
|
||||
sourceRowId,
|
||||
address: buildAddress(r),
|
||||
postcode: (r["postcode"] ?? "").trim(),
|
||||
status: (r[STATUS_COL] ?? (uprn ? "matched" : "unmatched")).trim(),
|
||||
lexiscore: lexiscore !== null && Number.isFinite(lexiscore) ? lexiscore : null,
|
||||
suggestedUprn: uprn || null,
|
||||
resolvedUprn,
|
||||
resolvedAddress: c?.address ?? null,
|
||||
markedNoUprn,
|
||||
resolved: resolvedUprn !== null || markedNoUprn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Count of flagged rows the user has not yet resolved — gates Finalise. */
|
||||
export async function countUnresolvedFlaggedAddresses(uploadId: string): Promise<number> {
|
||||
const flagged = await getFlaggedAddresses(uploadId);
|
||||
return flagged.filter((f) => !f.resolved).length;
|
||||
}
|
||||
|
||||
const ADDRESS_COL = "address2uprn_address";
|
||||
const OVERLAY_STATUS_CONFIRMED = "user_confirmed";
|
||||
const OVERLAY_STATUS_NO_UPRN = "user_no_uprn";
|
||||
|
||||
/**
|
||||
* Overlay the user's saved corrections onto the combiner CSV and write the
|
||||
* result to a sibling `….confirmed.csv`, returning its S3 URI (ADR-0057). The
|
||||
* finaliser reads this so a confirmed UPRN drives both the identity insert and
|
||||
* property_overrides in one pass. No corrections ⇒ the original URI is returned
|
||||
* unchanged (no rewrite). A `markedNoUprn` row keeps an empty UPRN (accepted
|
||||
* terminal state — the finaliser leaves it as a no-UPRN property).
|
||||
*/
|
||||
export async function buildConfirmedCombinerUri(
|
||||
uploadId: string,
|
||||
combinedOutputS3Uri: string,
|
||||
): Promise<string> {
|
||||
const corrections = await db
|
||||
.select()
|
||||
.from(bulkUploadUprnCorrections)
|
||||
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId));
|
||||
if (corrections.length === 0) return combinedOutputS3Uri;
|
||||
const byRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
|
||||
|
||||
const { bucket, key } = parseS3Uri(combinedOutputS3Uri);
|
||||
const s3 = createRetrofitDataS3Client();
|
||||
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
|
||||
const body = result.Body?.toString("utf-8");
|
||||
if (!body) return combinedOutputS3Uri;
|
||||
|
||||
const wb = XLSX.read(body, { type: "string" });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
// Array-of-arrays so every column + its order is preserved exactly; we patch
|
||||
// only the address2uprn cells of corrected rows.
|
||||
const aoa = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
defval: "",
|
||||
raw: false,
|
||||
blankrows: false,
|
||||
});
|
||||
const headers = (aoa[0] ?? []).map((h) => String(h));
|
||||
const iRow = headers.indexOf(SOURCE_ROW_ID_COLUMN);
|
||||
const iUprn = headers.indexOf(UPRN_COL);
|
||||
const iStatus = headers.indexOf(STATUS_COL);
|
||||
const iAddr = headers.indexOf(ADDRESS_COL);
|
||||
if (iRow < 0 || iUprn < 0) return combinedOutputS3Uri; // not an address2uprn CSV
|
||||
|
||||
for (let r = 1; r < aoa.length; r++) {
|
||||
const line = aoa[r];
|
||||
const srid = String(line[iRow] ?? "").trim();
|
||||
const c = byRow.get(srid);
|
||||
if (!c) continue;
|
||||
if (c.markedNoUprn) {
|
||||
line[iUprn] = "";
|
||||
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_NO_UPRN;
|
||||
} else {
|
||||
line[iUprn] = c.uprn ?? "";
|
||||
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_CONFIRMED;
|
||||
if (iAddr >= 0 && c.address) line[iAddr] = c.address;
|
||||
}
|
||||
}
|
||||
|
||||
const csv = XLSX.utils.sheet_to_csv(XLSX.utils.aoa_to_sheet(aoa));
|
||||
const confirmedKey = `${key.replace(/\.csv$/i, "")}.confirmed.csv`;
|
||||
await s3
|
||||
.putObject({ Bucket: bucket, Key: confirmedKey, Body: csv, ContentType: "text/csv" })
|
||||
.promise();
|
||||
return `s3://${bucket}/${confirmedKey}`;
|
||||
}
|
||||
|
||||
export type UprnCorrectionInput =
|
||||
| {
|
||||
sourceRowId: string;
|
||||
uprn: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
markedNoUprn?: false;
|
||||
}
|
||||
| { sourceRowId: string; markedNoUprn: true };
|
||||
|
||||
/** Upsert one combiner row's confirmation (assign a UPRN, or mark no-UPRN). */
|
||||
export async function upsertUprnCorrection(
|
||||
uploadId: string,
|
||||
input: UprnCorrectionInput,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
const values = input.markedNoUprn
|
||||
? {
|
||||
uploadId,
|
||||
sourceRowId: input.sourceRowId,
|
||||
uprn: null,
|
||||
address: null,
|
||||
postcode: null,
|
||||
markedNoUprn: true as const,
|
||||
userId: userId ?? null,
|
||||
}
|
||||
: {
|
||||
uploadId,
|
||||
sourceRowId: input.sourceRowId,
|
||||
uprn: input.uprn,
|
||||
address: input.address,
|
||||
postcode: input.postcode,
|
||||
markedNoUprn: false as const,
|
||||
userId: userId ?? null,
|
||||
};
|
||||
|
||||
await db
|
||||
.insert(bulkUploadUprnCorrections)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
bulkUploadUprnCorrections.uploadId,
|
||||
bulkUploadUprnCorrections.sourceRowId,
|
||||
],
|
||||
set: {
|
||||
uprn: values.uprn,
|
||||
address: values.address,
|
||||
postcode: values.postcode,
|
||||
markedNoUprn: values.markedNoUprn,
|
||||
userId: values.userId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -150,6 +150,74 @@ export function useSampleClassifications(
|
|||
});
|
||||
}
|
||||
|
||||
// One combiner row that address2uprn could not confidently match (ADR-0057),
|
||||
// plus any saved correction. Mirrors the server FlaggedAddressRow.
|
||||
export interface FlaggedAddress {
|
||||
sourceRowId: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
status: string;
|
||||
lexiscore: number | null;
|
||||
suggestedUprn: string | null;
|
||||
resolvedUprn: string | null;
|
||||
resolvedAddress: string | null;
|
||||
markedNoUprn: boolean;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export interface AddressMatchesView {
|
||||
flagged: FlaggedAddress[];
|
||||
unresolved: number;
|
||||
}
|
||||
|
||||
export function useAddressMatches(
|
||||
portfolioId: string,
|
||||
uploadId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<AddressMatchesView, Error>({
|
||||
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-matches`,
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Failed to load address matches.");
|
||||
const body = await res.json();
|
||||
return {
|
||||
flagged: (body.flagged ?? []) as FlaggedAddress[],
|
||||
unresolved: Number(body.unresolved ?? 0),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type SaveCorrectionInput =
|
||||
| { sourceRowId: string; uprn: string; address: string; postcode: string }
|
||||
| { sourceRowId: string; markedNoUprn: true };
|
||||
|
||||
export function useSaveUprnCorrection(portfolioId: string, uploadId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, SaveCorrectionInput>({
|
||||
mutationFn: async (input) => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-corrections`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Failed to save address.");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<BulkUpload, Error, { permutations: Record<string, number[]> }>({
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import { retrofitDataS3Bucket } from "@/app/utils/s3";
|
|||
import { SUBTASK_SERVICE } from "./types";
|
||||
import type { MultiEntrySummary } from "./multiEntry";
|
||||
import { isPermutation } from "./multiEntry";
|
||||
import {
|
||||
buildConfirmedCombinerUri,
|
||||
countUnresolvedFlaggedAddresses,
|
||||
} from "./addressMatches";
|
||||
|
||||
const REMAP_ALLOWED: ReadonlySet<BulkUploadStatus> = new Set([
|
||||
"ready_for_processing",
|
||||
|
|
@ -677,6 +681,7 @@ export type DispatchFinaliserOutcome =
|
|||
| { kind: "not_yet_combined" }
|
||||
| { kind: "wrong_state"; current: string }
|
||||
| { kind: "missing_task" }
|
||||
| { kind: "unresolved_addresses"; count: number }
|
||||
| { kind: "trigger_failed"; status: number; message: string };
|
||||
|
||||
// Dispatch the async bulk_upload_finaliser (ADR-0005). Replaces the old
|
||||
|
|
@ -705,6 +710,14 @@ export async function dispatchFinaliser(args: {
|
|||
const upload = guarded.upload;
|
||||
if (!upload.taskId) return { kind: "missing_task" };
|
||||
|
||||
// ADR-0057: every combiner row address2uprn could not confidently match must
|
||||
// be resolved on the Addresses tab (a chosen UPRN or an explicit no-UPRN)
|
||||
// before finalise — otherwise a withheld row would land as an unintended
|
||||
// no-UPRN property. Gate before the CAS claim so a blocked upload stays in
|
||||
// awaiting_review.
|
||||
const unresolved = await countUnresolvedFlaggedAddresses(args.uploadId);
|
||||
if (unresolved > 0) return { kind: "unresolved_addresses", count: unresolved };
|
||||
|
||||
// CAS: atomically claim the dispatch. Only the request that flips
|
||||
// awaiting_review → finalising proceeds; a concurrent one updates 0 rows.
|
||||
const claimed = await db
|
||||
|
|
@ -748,10 +761,18 @@ export async function dispatchFinaliser(args: {
|
|||
? `s3://${retrofitDataS3Bucket()}/${classifierCsvKey(upload.portfolioId, args.uploadId)}`
|
||||
: null;
|
||||
|
||||
// Overlay the user's confirmed UPRNs onto the combiner CSV and point the
|
||||
// finaliser at the corrected copy (ADR-0057). No corrections ⇒ the original
|
||||
// URI is returned unchanged.
|
||||
const confirmedS3Uri = await buildConfirmedCombinerUri(
|
||||
args.uploadId,
|
||||
upload.combinedOutputS3Uri!,
|
||||
);
|
||||
|
||||
const payload = {
|
||||
task_id: upload.taskId,
|
||||
sub_task_id: subTask.id,
|
||||
s3_uri: upload.combinedOutputS3Uri,
|
||||
s3_uri: confirmedS3Uri,
|
||||
portfolio_id: Number(upload.portfolioId),
|
||||
bulk_upload_id: args.uploadId,
|
||||
classifier_s3_uri: classifierS3Uri,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue