Fas 2 steg 5: Scan-to-scan-diff + konfliktlösning, samt buggfix depleted-transaktion i reconciliation

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 01:30:31 +07:00
parent 218bc44d08
commit 4a4f448e1c
26 changed files with 10590 additions and 13 deletions
@@ -0,0 +1,130 @@
import { useMemo, useState } from "react";
import { View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
type DiffRow = {
kind: "new_item" | "moved" | "quantity_changed" | "vanished" | "unchanged";
previousItemId?: string;
displayName: string;
unit: string;
previousQuantity?: number;
newQuantity?: number;
previousLocationId?: string;
newLocationId?: string;
confidence: number;
reason: string;
};
export default function ScanDiffReviewScreen() {
const { jobId } = useLocalSearchParams<{ jobId: string }>();
const [rejectedKinds] = useState<Set<string>>(new Set());
const query = useQuery({
queryKey: ["scan-diff", jobId],
queryFn: () => api<{ rows: DiffRow[] }>(`/v1/scans/${jobId}/diff`, { method: "POST", body: {} }),
});
const apply = useMutation({
mutationFn: (rows: DiffRow[]) =>
api<{ appliedItemIds: string[] }>(`/v1/scans/${jobId}/diff/apply`, {
method: "POST",
body: { idempotencyKey: `scan-diff-${jobId}-${Date.now()}`, rows },
}),
onSuccess: () => {
router.back();
},
});
const rows = useMemo(() => query.data?.rows ?? [], [query.data]);
const actionable = rows.filter((r) => r.kind !== "unchanged");
const accepted = actionable.filter((r) => !rejectedKinds.has(rowKey(r)));
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
if (actionable.length === 0) {
return (
<Screen>
<Heading>{t("scan.diff.title")}</Heading>
<EmptyState text={t("scan.diff.noChanges")} />
<Button label={t("common.back")} variant="secondary" onPress={() => router.back()} />
</Screen>
);
}
return (
<Screen>
<Heading>{t("scan.diff.title")}</Heading>
<Small>{t("scan.diff.subtitle")}</Small>
<Spacer size={spacing.sm} />
{actionable.map((row) => (
<Card key={rowKey(row)}>
<Row style={{ justifyContent: "space-between" }}>
<Body>{row.displayName}</Body>
<Tag label={t(`scan.diff.${row.kind}`)} tone={toneForKind(row.kind)} />
</Row>
<Small>{row.reason}</Small>
{(row.kind === "quantity_changed" || row.kind === "moved") && (
<Small>
{formatQty(row.previousQuantity)} {formatQty(row.newQuantity)} {row.unit}
</Small>
)}
{row.kind === "vanished" && (
<Small style={{ opacity: 0.8 }}>{t("reconciliation.tasteHint")}</Small>
)}
</Card>
))}
<Small style={{ opacity: 0.7 }}>{t("scan.diff.undoHint")}</Small>
<Spacer size={spacing.sm} />
<Button
label={t("scan.diff.acceptAll")}
loading={apply.isPending}
onPress={() => apply.mutate(accepted)}
/>
</Screen>
);
}
function rowKey(row: DiffRow): string {
return `${row.kind}:${row.previousItemId ?? "new"}:${row.displayName}`;
}
function toneForKind(kind: DiffRow["kind"]) {
switch (kind) {
case "vanished":
return "warning";
case "new_item":
return "success";
case "quantity_changed":
case "moved":
return "accent";
default:
return "neutral";
}
}
function formatQty(q?: number): string {
if (q == null) return "-";
return Number.isInteger(q) ? String(q) : q.toFixed(1);
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Færdig",
"reconciliation.next": "Næste",
"reconciliation.empty": "Intet behøver kontrol lige nu. Godt arbejde!",
"reconciliation.tasteHint": "Overskredet mindstholdbarhedsdato? lugt og smag først smid aldrig mad ud unødigt."
"reconciliation.tasteHint": "Overskredet mindstholdbarhedsdato? lugt og smag først smid aldrig mad ud unødigt.",
"scan.diff.title": "Gennemgå forskelle",
"scan.diff.subtitle": "Bekræft ændringer fra seneste scanning.",
"scan.diff.newItem": "Ny vare",
"scan.diff.quantityChanged": "Mængde ændret",
"scan.diff.moved": "Flyttet",
"scan.diff.vanished": "Mangler på billedet",
"scan.diff.unchanged": "Uændret",
"scan.diff.accept": "Bekræft",
"scan.diff.reject": "Ignorer",
"scan.diff.acceptAll": "Bekræft alle",
"scan.diff.noChanges": "Ingen forskelle fundet.",
"scan.diff.undo": "Fortryd",
"scan.diff.undoHint": "Hver ændring kan fortrydes fra varedetaljevisningen."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Fertig",
"reconciliation.next": "Weiter",
"reconciliation.empty": "Aktuell muss nichts kontrolliert werden. Gut gemacht!",
"reconciliation.tasteHint": "Mindesthaltbarkeitsdatum überschritten? Zuerst riechen und schmecken: niemals unbedingt Essen wegwerfen."
"reconciliation.tasteHint": "Mindesthaltbarkeitsdatum überschritten? Zuerst riechen und schmecken: niemals unbedingt Essen wegwerfen.",
"scan.diff.title": "Unterschiede prüfen",
"scan.diff.subtitle": "Änderungen aus dem letzten Scan bestätigen.",
"scan.diff.newItem": "Neuer Artikel",
"scan.diff.quantityChanged": "Menge geändert",
"scan.diff.moved": "Verschoben",
"scan.diff.vanished": "Fehlt auf dem Foto",
"scan.diff.unchanged": "Unverändert",
"scan.diff.accept": "Bestätigen",
"scan.diff.reject": "Ignorieren",
"scan.diff.acceptAll": "Alle bestätigen",
"scan.diff.noChanges": "Keine Unterschiede gefunden.",
"scan.diff.undo": "Rückgängig",
"scan.diff.undoHint": "Jede Änderung kann in der Artikeldetailansicht rückgängig gemacht werden."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Done",
"reconciliation.next": "Next",
"reconciliation.empty": "Nothing needs checking right now. Well done!",
"reconciliation.tasteHint": "Past best before? Smell and taste first never waste food unnecessarily."
"reconciliation.tasteHint": "Past best before? Smell and taste first never waste food unnecessarily.",
"scan.diff.title": "Review differences",
"scan.diff.subtitle": "Confirm changes from the latest scan.",
"scan.diff.newItem": "New item",
"scan.diff.quantityChanged": "Quantity changed",
"scan.diff.moved": "Moved",
"scan.diff.vanished": "Missing in photo",
"scan.diff.unchanged": "Unchanged",
"scan.diff.accept": "Confirm",
"scan.diff.reject": "Ignore",
"scan.diff.acceptAll": "Confirm all",
"scan.diff.noChanges": "No differences found.",
"scan.diff.undo": "Undo",
"scan.diff.undoHint": "Each change can be undone from the item detail view."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Listo",
"reconciliation.next": "Siguiente",
"reconciliation.empty": "No hay nada que revisar ahora. ¡Bien hecho!",
"reconciliation.tasteHint": "¿Pasada la fecha de consumo preferente? Huele y prueba primero: nunca tires comida sin necesidad."
"reconciliation.tasteHint": "¿Pasada la fecha de consumo preferente? Huele y prueba primero: nunca tires comida sin necesidad.",
"scan.diff.title": "Revisar diferencias",
"scan.diff.subtitle": "Confirmar cambios del último escaneo.",
"scan.diff.newItem": "Nuevo producto",
"scan.diff.quantityChanged": "Cantidad cambiada",
"scan.diff.moved": "Movido",
"scan.diff.vanished": "No aparece en la foto",
"scan.diff.unchanged": "Sin cambios",
"scan.diff.accept": "Confirmar",
"scan.diff.reject": "Ignorar",
"scan.diff.acceptAll": "Confirmar todo",
"scan.diff.noChanges": "No se encontraron diferencias.",
"scan.diff.undo": "Deshacer",
"scan.diff.undoHint": "Cada cambio se puede deshacer desde la vista de detalle del producto."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Valmis",
"reconciliation.next": "Seuraava",
"reconciliation.empty": "Mitään ei tarvitse tarkistaa juuri nyt. Hyvää työtä!",
"reconciliation.tasteHint": "Parasta ennen -päiväys umpeutunut? Haista ja maista ensin älä koskaan heitä ruokaa turhaan."
"reconciliation.tasteHint": "Parasta ennen -päiväys umpeutunut? Haista ja maista ensin älä koskaan heitä ruokaa turhaan.",
"scan.diff.title": "Tarkista erot",
"scan.diff.subtitle": "Vahvista viimeisen skannauksen muutokset.",
"scan.diff.newItem": "Uusi tuote",
"scan.diff.quantityChanged": "Määrä muuttunut",
"scan.diff.moved": "Siirretty",
"scan.diff.vanished": "Puuttuu kuvasta",
"scan.diff.unchanged": "Muuttumaton",
"scan.diff.accept": "Vahvista",
"scan.diff.reject": "Ohita",
"scan.diff.acceptAll": "Vahvista kaikki",
"scan.diff.noChanges": "Eroja ei löytynyt.",
"scan.diff.undo": "Kumoa",
"scan.diff.undoHint": "Jokainen muutos voidaan kumota tuotteen tietonäkymästä."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Terminé",
"reconciliation.next": "Suivant",
"reconciliation.empty": "Rien à vérifier pour l'instant. Bien joué !",
"reconciliation.tasteHint": "Dépasse la date de durabilité minimale ? Sentez et goûtez d'abord : ne gaspillez jamais de nourriture inutilement."
"reconciliation.tasteHint": "Dépasse la date de durabilité minimale ? Sentez et goûtez d'abord : ne gaspillez jamais de nourriture inutilement.",
"scan.diff.title": "Vérifier les différences",
"scan.diff.subtitle": "Confirmer les changements du dernier scan.",
"scan.diff.newItem": "Nouvel article",
"scan.diff.quantityChanged": "Quantité modifiée",
"scan.diff.moved": "Déplacé",
"scan.diff.vanished": "Absent sur la photo",
"scan.diff.unchanged": "Inchangé",
"scan.diff.accept": "Confirmer",
"scan.diff.reject": "Ignorer",
"scan.diff.acceptAll": "Tout confirmer",
"scan.diff.noChanges": "Aucune différence trouvée.",
"scan.diff.undo": "Annuler",
"scan.diff.undoHint": "Chaque modification peut être annulée depuis la vue détail de l'article."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Fatto",
"reconciliation.next": "Avanti",
"reconciliation.empty": "Non c'è nulla da controllare. Ottimo lavoro!",
"reconciliation.tasteHint": "Scadenza preferibile superata? Odora e assaggia prima: non buttare mai cibo inutilmente."
"reconciliation.tasteHint": "Scadenza preferibile superata? Odora e assaggia prima: non buttare mai cibo inutilmente.",
"scan.diff.title": "Rivedi differenze",
"scan.diff.subtitle": "Conferma le modifiche dall'ultima scansione.",
"scan.diff.newItem": "Nuovo articolo",
"scan.diff.quantityChanged": "Quantità modificata",
"scan.diff.moved": "Spostato",
"scan.diff.vanished": "Mancante nella foto",
"scan.diff.unchanged": "Invariato",
"scan.diff.accept": "Conferma",
"scan.diff.reject": "Ignora",
"scan.diff.acceptAll": "Conferma tutto",
"scan.diff.noChanges": "Nessuna differenza trovata.",
"scan.diff.undo": "Annulla",
"scan.diff.undoHint": "Ogni modifica può essere annullata dalla vista dettaglio dell'articolo."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Ferdig",
"reconciliation.next": "Neste",
"reconciliation.empty": "Ingenting trenger kontroll akkurat nå. Bra jobbet!",
"reconciliation.tasteHint": "Forbi best før-dato? Lukt og smak først kast aldri mat unødvendig."
"reconciliation.tasteHint": "Forbi best før-dato? Lukt og smak først kast aldri mat unødvendig.",
"scan.diff.title": "Gå gjennom forskjeller",
"scan.diff.subtitle": "Bekreft endringer fra siste skanning.",
"scan.diff.newItem": "Ny vare",
"scan.diff.quantityChanged": "Mengde endret",
"scan.diff.moved": "Flyttet",
"scan.diff.vanished": "Mangler på bildet",
"scan.diff.unchanged": "Uendret",
"scan.diff.accept": "Bekreft",
"scan.diff.reject": "Ignorer",
"scan.diff.acceptAll": "Bekreft alle",
"scan.diff.noChanges": "Ingen forskjeller funnet.",
"scan.diff.undo": "Angre",
"scan.diff.undoHint": "Hver endring kan angres fra varedetaljvisningen."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Klaar",
"reconciliation.next": "Volgende",
"reconciliation.empty": "Er is momenteel niets om te controleren. Goed gedaan!",
"reconciliation.tasteHint": "Tenminste houdbaar tot verstreken? Ruik en proef eerst gooi nooit onnodig eten weg."
"reconciliation.tasteHint": "Tenminste houdbaar tot verstreken? Ruik en proef eerst gooi nooit onnodig eten weg.",
"scan.diff.title": "Verschillen controleren",
"scan.diff.subtitle": "Bevestig wijzigingen uit de laatste scan.",
"scan.diff.newItem": "Nieuw item",
"scan.diff.quantityChanged": "Hoeveelheid gewijzigd",
"scan.diff.moved": "Verplaatst",
"scan.diff.vanished": "Ontbreekt op foto",
"scan.diff.unchanged": "Ongewijzigd",
"scan.diff.accept": "Bevestig",
"scan.diff.reject": "Negeer",
"scan.diff.acceptAll": "Alles bevestigen",
"scan.diff.noChanges": "Geen verschillen gevonden.",
"scan.diff.undo": "Ongedaan maken",
"scan.diff.undoHint": "Elke wijziging kan ongedaan worden gemaakt vanuit de detailweergave."
}
+14 -1
View File
@@ -379,5 +379,18 @@
"reconciliation.done": "Gotowe",
"reconciliation.next": "Dalej",
"reconciliation.empty": "Teraz nic nie wymaga kontroli. Dobra robota!",
"reconciliation.tasteHint": "Po terminie przydatności do spożycia? Najpierw powąchaj i posmakuj nigdy nie wyrzucaj jedzenia bez potrzeby."
"reconciliation.tasteHint": "Po terminie przydatności do spożycia? Najpierw powąchaj i posmakuj nigdy nie wyrzucaj jedzenia bez potrzeby.",
"scan.diff.title": "Sprawdź różnice",
"scan.diff.subtitle": "Potwierdź zmiany z ostatniego skanu.",
"scan.diff.newItem": "Nowy produkt",
"scan.diff.quantityChanged": "Zmieniona ilość",
"scan.diff.moved": "Przeniesiony",
"scan.diff.vanished": "Brakuje na zdjęciu",
"scan.diff.unchanged": "Bez zmian",
"scan.diff.accept": "Potwierdź",
"scan.diff.reject": "Ignoruj",
"scan.diff.acceptAll": "Potwierdź wszystko",
"scan.diff.noChanges": "Nie znaleziono różnic.",
"scan.diff.undo": "Cofnij",
"scan.diff.undoHint": "Każdą zmianę można cofnąć z widoku szczegółów produktu."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Concluído",
"reconciliation.next": "Próximo",
"reconciliation.empty": "Nada precisa de verificação agora. Muito bem!",
"reconciliation.tasteHint": "Passou do prazo de validade? Cheire e prove primeiro nunca desperdice comida desnecessariamente."
"reconciliation.tasteHint": "Passou do prazo de validade? Cheire e prove primeiro nunca desperdice comida desnecessariamente.",
"scan.diff.title": "Rever diferenças",
"scan.diff.subtitle": "Confirmar alterações do último scan.",
"scan.diff.newItem": "Novo item",
"scan.diff.quantityChanged": "Quantidade alterada",
"scan.diff.moved": "Movido",
"scan.diff.vanished": "Ausente na foto",
"scan.diff.unchanged": "Inalterado",
"scan.diff.accept": "Confirmar",
"scan.diff.reject": "Ignorar",
"scan.diff.acceptAll": "Confirmar tudo",
"scan.diff.noChanges": "Nenhuma diferença encontrada.",
"scan.diff.undo": "Desfazer",
"scan.diff.undoHint": "Cada alteração pode ser desfeita a partir da vista de detalhes do item."
}
+14 -1
View File
@@ -365,5 +365,18 @@
"reconciliation.done": "Klart",
"reconciliation.next": "Nästa",
"reconciliation.empty": "Inget behöver avstämning just nu. Bra jobbat!",
"reconciliation.tasteHint": "Passerat bäst före? Lukta och smaka först mat kastas inte i onödan."
"reconciliation.tasteHint": "Passerat bäst före? Lukta och smaka först mat kastas inte i onödan.",
"scan.diff.title": "Granska skillnader",
"scan.diff.subtitle": "Bekrätta ändringar från senaste skanningen.",
"scan.diff.newItem": "Ny vara",
"scan.diff.quantityChanged": "Ändrad mängd",
"scan.diff.moved": "Flyttad",
"scan.diff.vanished": "Saknas på bilden",
"scan.diff.unchanged": "Oförändrad",
"scan.diff.accept": "Bekräfta",
"scan.diff.reject": "Ignorera",
"scan.diff.acceptAll": "Bekräfta alla",
"scan.diff.noChanges": "Inga skillnader hittades.",
"scan.diff.undo": "Ångra",
"scan.diff.undoHint": "Varje ändring kan ångras från varans detaljvy."
}