feat(streckkod): okand produkt -> fota forpackning -> spara i delad katalog

Okand streckkod erbjuder nu 'Fota forpackningen' direkt i streckkodsvyn.
Fotot lases av (naring/allergener/innehall) och produkten UPSERTas i den
DELADE products-katalogen kopplat till GTIN (dataSource=user_scanned,
verificationStatus=unverified, createdByUserId=anvandaren) sa nasta person
som skannar samma kod far den direkt. Produkten laggs INTE till i lagret.

Naringsdeklaration-knappen tas bort som egen ingang - flodet vavs in i
streckkodsskanningen. product_package/nutrition_label -> READ_NUTRITION_LABEL
avslutar som 'completed' med result.product (ingen awaiting_confirmation).

- validation: barcode-falt i createScanInput.context
- worker: ny gren i processScanJob som skriver till products + returnerar tidigt
- mobil: inline contributePhoto (fota -> ladda upp -> polla -> visa info)
- scan: ta bort naringsdeklaration-knappen
- i18n: barcode.contributing.* + uppdaterad unknownBody pa 12 sprak

Verifierat: typecheck (validation/worker/api/mobil), worker 23/23, api 104/104,
och repro mot testdatabas (products-upsert, ingen inventory, idempotent aterskann).
This commit is contained in:
Claude
2026-08-20 11:57:58 +00:00
parent 3fa5a878f1
commit 2f596060b4
16 changed files with 256 additions and 22 deletions
+3 -2
View File
@@ -21,7 +21,9 @@ import { useAnalytics } from "@/lib/analytics";
import { scanStarted } from "@app/analytics";
import { FirstScanCoach, useFirstScanCoach } from "@/components/FirstScanCoach";
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum.
* Näringsdeklaration är INGEN egen knapp den vävs in i streckkodsflödet: okänd kod → fota
* förpackningen → produkten läses av och sparas i den delade katalogen (se barcode.tsx). */
const SCAN_TYPES = [
{ type: "fridge", glyph: "🧊", labelKey: "scan.fridge" },
@@ -32,7 +34,6 @@ const SCAN_TYPES = [
{ type: "receipt", glyph: "🧾", labelKey: "scan.receipt" },
{ type: "barcode", glyph: "🏷️", labelKey: "scan.barcode" },
{ type: "expiry_date", glyph: "📅", labelKey: "scan.expiry" },
{ type: "nutrition_label", glyph: "🔬", labelKey: "scan.nutrition" },
] as const;
/**
+98 -7
View File
@@ -1,9 +1,10 @@
import { useRef, useState } from "react";
import { ScrollView, StyleSheet, View } from "react-native";
import { router } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { CameraView, useCameraPermissions } from "expo-camera";
import { ALLERGEN_LABELS_SV, type Allergen } from "@app/shared-types";
import { api } from "@/lib/api";
import { api, uploadImage, ApiError } from "@/lib/api";
import { t } from "@/lib/i18n";
import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
@@ -48,6 +49,30 @@ interface ProductInfo {
interface BarcodeScanResponse {
product: ProductInfo | null;
}
interface CreateScanResponse {
scan: { id: string };
uploads: Array<{ key: string; uploadUrl: string; headers: Record<string, string> }>;
}
interface ScanJobResponse {
status: string;
result: { product: ProductInfo | null } | null;
error?: string | null;
}
/**
* Pollar en produktskanning tills AAMOS läst av förpackningen. Vid "completed"
* returneras produkten (redan sparad i delade katalogen av workern), vid
* "failed"/timeout returneras null. Max ~60 s (40 × 1,5 s).
*/
async function pollScan(id: string): Promise<ProductInfo | null> {
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 1500));
const job = await api<ScanJobResponse>(`/v1/scans/${id}`);
if (job.status === "completed") return job.result?.product ?? null;
if (job.status === "failed") return null;
}
return null;
}
const fmtG = (n: number) => `${(Math.round(n * 10) / 10).toString().replace(".", ",")} g`;
const allergenLabels = (codes: string[] | null | undefined): string =>
@@ -61,15 +86,19 @@ export default function BarcodeScreen() {
const [busy, setBusy] = useState(false);
const [product, setProduct] = useState<ProductInfo | null>(null);
const [unknown, setUnknown] = useState(false);
const [contributing, setContributing] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Synkron lås: hindrar att kamerans upprepade träffar startar parallella uppslag.
const locked = useRef(false);
// Senast avlästa streckkod används när användaren fotar en okänd produkt.
const lastGtin = useRef<string | null>(null);
const scanning = !busy && !product && !unknown && !errorMsg;
const onScanned = async (gtin: string) => {
if (locked.current) return;
locked.current = true;
lastGtin.current = gtin;
setBusy(true);
setErrorMsg(null);
try {
@@ -86,11 +115,66 @@ export default function BarcodeScreen() {
}
};
/**
* Okänd produkt → fota förpackningen. Bilden läses av (näring/allergener/
* innehåll) och produkten sparas i den DELADE katalogen kopplat till
* streckkoden så nästa person som skannar samma kod får den direkt.
* Lägger INTE till något bland användarens egna varor (spec §11).
*/
const contributePhoto = async () => {
const gtin = lastGtin.current;
if (!gtin) return;
try {
// Fota förpackningen (kamera, med galleri-fallback som i skanningsflödet).
const perm = await ImagePicker.requestCameraPermissionsAsync();
const picked = perm.granted
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
const asset = picked.assets?.[0];
if (picked.canceled || !asset) return; // avbröt stanna kvar i "okänd"-vyn
setContributing(true);
setErrorMsg(null);
// Produktskanning kopplad till streckkoden delad katalog, inte lagret.
const created = await api<CreateScanResponse>("/v1/scans", {
method: "POST",
body: {
scanType: "product_package",
imageCount: 1,
contentType: "image/jpeg",
context: { barcode: gtin },
},
});
const upload = created.uploads[0];
if (!upload) throw new Error(t("common.error"));
await uploadImage(upload, asset.uri);
await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" });
const found = await pollScan(created.scan.id);
if (found) {
setUnknown(false);
setProduct(found);
} else {
setErrorMsg(t("barcode.contributing.failed"));
}
} catch (err) {
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
router.push("/paywall");
} else {
setErrorMsg(err instanceof Error ? err.message : t("common.error"));
}
} finally {
setContributing(false);
}
};
const scanAgain = () => {
setProduct(null);
setUnknown(false);
setErrorMsg(null);
locked.current = false;
lastGtin.current = null;
};
if (!permission) return <Screen><Body>{t("scan.analyzing")}</Body></Screen>;
@@ -203,23 +287,30 @@ export default function BarcodeScreen() {
</View>
)}
{/* Okänd produkt */}
{unknown && (
{/* Okänd produkt → erbjud att fota förpackningen (bidra till delade katalogen) */}
{unknown && !contributing && !errorMsg && (
<View style={styles.panel}>
<View style={{ padding: spacing.lg }}>
<Heading>{t("barcode.unknownTitle")}</Heading>
<Body muted>{t("barcode.unknownBody")}</Body>
<Spacer size={spacing.md} />
<Button
label={t("barcode.photoPackage")}
onPress={() => router.replace("/(tabs)/scan")}
/>
<Button label={t("barcode.photoPackage")} onPress={() => void contributePhoto()} />
<Spacer size={spacing.xs} />
<Button label={t("barcode.info.scanAgain")} variant="ghost" onPress={scanAgain} />
</View>
</View>
)}
{/* Läser av fotad förpackning */}
{contributing && (
<View style={styles.panel}>
<View style={{ padding: spacing.lg }}>
<Heading>{t("barcode.contributing.title")}</Heading>
<Body muted>{t("barcode.contributing.body")}</Body>
</View>
</View>
)}
{/* Fel */}
{errorMsg && (
<View style={styles.panel}>
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Tillad kamera",
"barcode.cameraBody": "{brand} skal bruge kameraet til at scanne stregkoder.",
"barcode.cameraTitle": "Kameraet er nødvendigt",
"barcode.contributing.title": "Aflæser emballagen…",
"barcode.contributing.body": "Vi finder næring, allergener og indhold og gemmer produktet i det delte katalog, så den næste slipper for at fotografere det.",
"barcode.contributing.failed": "Kunne ikke aflæse emballagen. Prøv igen med et skarpt billede og godt lys.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du skal først have en husstand.",
"barcode.noLocation": "Ingen opbevaringssteder fundet.",
"barcode.noNutrition": "Ingen næringsdata",
"barcode.photoPackage": "Tag billede af emballagen",
"barcode.unknownBody": "Produktet er ikke i databasen endnu. Tag et billede af forsiden og næringsdeklarationen, så tilføjer vi det.",
"barcode.unknownBody": "Produktet er ikke i databasen endnu. Tag et billede af forsiden og næringsdeklarationen, så aflæser vi indholdet og gemmer produktet i det delte katalog så slipper den næste. (Det tilføjes ikke til dine varer.)",
"barcode.unknownTitle": "Ukendt produkt",
"common.add": "Tilføj",
"common.back": "Tilbage",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Kamera erlauben",
"barcode.cameraBody": "{brand} benötigt die Kamera, um Barcodes zu scannen.",
"barcode.cameraTitle": "Kamera erforderlich",
"barcode.contributing.title": "Verpackung wird gelesen…",
"barcode.contributing.body": "Wir ermitteln Nährwerte, Allergene und Zutaten und speichern das Produkt im gemeinsamen Katalog, damit es der Nächste nicht fotografieren muss.",
"barcode.contributing.failed": "Die Verpackung konnte nicht gelesen werden. Versuche es mit einem scharfen Foto und gutem Licht erneut.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du brauchst zuerst einen Haushalt.",
"barcode.noLocation": "Kein Lagerort gefunden.",
"barcode.noNutrition": "Keine Nährwertdaten",
"barcode.photoPackage": "Verpackung fotografieren",
"barcode.unknownBody": "Dieses Produkt ist noch nicht in der Datenbank. Fotografiere Vorderseite und Nährwerttabelle, dann fügen wir es hinzu.",
"barcode.unknownBody": "Dieses Produkt ist noch nicht in der Datenbank. Fotografiere die Vorderseite und die Nährwerttabelle wir lesen den Inhalt aus und speichern das Produkt im gemeinsamen Katalog, damit es der Nächste leichter hat. (Es wird nicht zu deinen Artikeln hinzugefügt.)",
"barcode.unknownTitle": "Unbekanntes Produkt",
"common.add": "Hinzufügen",
"common.back": "Zurück",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Allow camera",
"barcode.cameraBody": "{brand} needs the camera to scan barcodes.",
"barcode.cameraTitle": "Camera needed",
"barcode.contributing.title": "Reading the package…",
"barcode.contributing.body": "We're extracting nutrition, allergens and ingredients and saving the product to the shared catalogue so the next person doesn't have to photograph it.",
"barcode.contributing.failed": "Couldn't read the package. Try again with a sharp photo and good lighting.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "You need a household first.",
"barcode.noLocation": "No storage location found.",
"barcode.noNutrition": "No nutrition data",
"barcode.photoPackage": "Photograph the package",
"barcode.unknownBody": "This product isn't in the database yet. Photograph the front and the nutrition label and we'll add it.",
"barcode.unknownBody": "This product isn't in the database yet. Photograph the front and the nutrition label we'll read the contents and save the product to the shared catalogue so the next person doesn't have to. (It won't be added to your items.)",
"barcode.unknownTitle": "Unknown product",
"common.add": "Add",
"common.back": "Back",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Permitir cámara",
"barcode.cameraBody": "{brand} necesita la cámara para escanear códigos de barras.",
"barcode.cameraTitle": "Se necesita la cámara",
"barcode.contributing.title": "Leyendo el envase…",
"barcode.contributing.body": "Estamos extrayendo la información nutricional, los alérgenos y los ingredientes, y guardando el producto en el catálogo compartido para que la próxima persona no tenga que fotografiarlo.",
"barcode.contributing.failed": "No se pudo leer el envase. Inténtalo de nuevo con una foto nítida y buena luz.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Primero necesitas un hogar.",
"barcode.noLocation": "No se encontró ningún lugar de almacenamiento.",
"barcode.noNutrition": "Sin datos nutricionales",
"barcode.photoPackage": "Fotografiar el envase",
"barcode.unknownBody": "Este producto aún no está en la base de datos. Fotografía el frontal y la etiqueta nutricional y lo añadiremos.",
"barcode.unknownBody": "Este producto aún no está en la base de datos. Fotografía la parte delantera y la información nutricional: leeremos el contenido y guardaremos el producto en el catálogo compartido para que la próxima persona no tenga que hacerlo. (No se añadirá a tus productos.)",
"barcode.unknownTitle": "Producto desconocido",
"common.add": "Añadir",
"common.back": "Atrás",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Salli kamera",
"barcode.cameraBody": "{brand} tarvitsee kameraa viivakoodien skannaamiseen.",
"barcode.cameraTitle": "Kamera tarvitaan",
"barcode.contributing.title": "Luetaan pakkausta…",
"barcode.contributing.body": "Poimimme ravintoarvot, allergeenit ja ainesosat ja tallennamme tuotteen jaettuun luetteloon, jottei seuraavan tarvitse kuvata sitä.",
"barcode.contributing.failed": "Pakkausta ei voitu lukea. Yritä uudelleen terävällä kuvalla ja hyvässä valossa.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Tarvitset ensin kotitalouden.",
"barcode.noLocation": "Säilytyspaikkaa ei löytynyt.",
"barcode.noNutrition": "Ei ravintotietoja",
"barcode.photoPackage": "Kuvaa pakkaus",
"barcode.unknownBody": "Tuotetta ei ole vielä tietokannassa. Kuvaa etupuoli ja ravintosisältö, niin lisäämme sen.",
"barcode.unknownBody": "Tuotetta ei ole vielä tietokannassa. Kuvaa etupuoli ja ravintosisältö luemme sisällön ja tallennamme tuotteen jaettuun luetteloon, jotta seuraavan ei tarvitse. (Sitä ei lisätä tavaroihisi.)",
"barcode.unknownTitle": "Tuntematon tuote",
"common.add": "Lisää",
"common.back": "Takaisin",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Autoriser l'appareil photo",
"barcode.cameraBody": "{brand} a besoin de l'appareil photo pour scanner les codes-barres.",
"barcode.cameraTitle": "Appareil photo requis",
"barcode.contributing.title": "Lecture de l'emballage…",
"barcode.contributing.body": "Nous extrayons les valeurs nutritionnelles, les allergènes et les ingrédients, et enregistrons le produit dans le catalogue partagé pour que la prochaine personne n'ait pas à le photographier.",
"barcode.contributing.failed": "Impossible de lire l'emballage. Réessayez avec une photo nette et un bon éclairage.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Il vous faut d'abord un foyer.",
"barcode.noLocation": "Aucun lieu de stockage trouvé.",
"barcode.noNutrition": "Pas de données nutritionnelles",
"barcode.photoPackage": "Photographier l'emballage",
"barcode.unknownBody": "Ce produit n'est pas encore dans la base. Photographiez l'avant et l'étiquette nutritionnelle et nous l'ajouterons.",
"barcode.unknownBody": "Ce produit n'est pas encore dans la base de données. Photographiez l'avant et la déclaration nutritionnelle : nous lirons le contenu et enregistrerons le produit dans le catalogue partagé pour que la prochaine personne n'ait pas à le faire. (Il ne sera pas ajouté à vos articles.)",
"barcode.unknownTitle": "Produit inconnu",
"common.add": "Ajouter",
"common.back": "Retour",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Consenti fotocamera",
"barcode.cameraBody": "{brand} ha bisogno della fotocamera per leggere i codici a barre.",
"barcode.cameraTitle": "Serve la fotocamera",
"barcode.contributing.title": "Lettura della confezione…",
"barcode.contributing.body": "Stiamo estraendo valori nutrizionali, allergeni e ingredienti e salvando il prodotto nel catalogo condiviso, così il prossimo non dovrà fotografarlo.",
"barcode.contributing.failed": "Impossibile leggere la confezione. Riprova con una foto nitida e una buona illuminazione.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Prima ti serve una famiglia.",
"barcode.noLocation": "Nessun luogo di conservazione trovato.",
"barcode.noNutrition": "Dati nutrizionali mancanti",
"barcode.photoPackage": "Fotografa la confezione",
"barcode.unknownBody": "Questo prodotto non è ancora nel database. Fotografa il fronte e l'etichetta nutrizionale e lo aggiungeremo.",
"barcode.unknownBody": "Questo prodotto non è ancora nel database. Fotografa il fronte e la tabella nutrizionale: leggeremo il contenuto e salveremo il prodotto nel catalogo condiviso, così il prossimo non dovrà farlo. (Non verrà aggiunto ai tuoi articoli.)",
"barcode.unknownTitle": "Prodotto sconosciuto",
"common.add": "Aggiungi",
"common.back": "Indietro",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Tillat kamera",
"barcode.cameraBody": "{brand} trenger kameraet for å skanne strekkoder.",
"barcode.cameraTitle": "Kameraet trengs",
"barcode.contributing.title": "Leser av emballasjen…",
"barcode.contributing.body": "Vi henter næring, allergener og innhold og lagrer produktet i den delte katalogen, så den neste slipper å fotografere det.",
"barcode.contributing.failed": "Kunne ikke lese av emballasjen. Prøv igjen med et skarpt bilde og godt lys.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du trenger en husstand først.",
"barcode.noLocation": "Fant ingen oppbevaringssteder.",
"barcode.noNutrition": "Ingen næringsdata",
"barcode.photoPackage": "Ta bilde av pakken",
"barcode.unknownBody": "Produktet er ikke i databasen ennå. Ta bilde av forsiden og næringsdeklarasjonen, så legger vi det til.",
"barcode.unknownBody": "Produktet er ikke i databasen ennå. Ta bilde av forsiden og næringsinnholdet vi leser av innholdet og lagrer produktet i den delte katalogen, så slipper den neste. (Det legges ikke til blant varene dine.)",
"barcode.unknownTitle": "Ukjent produkt",
"common.add": "Legg til",
"common.back": "Tilbake",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Camera toestaan",
"barcode.cameraBody": "{brand} heeft de camera nodig om streepjescodes te scannen.",
"barcode.cameraTitle": "Camera nodig",
"barcode.contributing.title": "Verpakking wordt gelezen…",
"barcode.contributing.body": "We halen voedingswaarde, allergenen en ingrediënten op en slaan het product op in de gedeelde catalogus, zodat de volgende het niet hoeft te fotograferen.",
"barcode.contributing.failed": "Kon de verpakking niet lezen. Probeer het opnieuw met een scherpe foto en goed licht.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Je hebt eerst een huishouden nodig.",
"barcode.noLocation": "Geen bewaarplek gevonden.",
"barcode.noNutrition": "Geen voedingswaarden",
"barcode.photoPackage": "Fotografeer de verpakking",
"barcode.unknownBody": "Dit product staat nog niet in de database. Fotografeer de voorkant en het voedingsetiket, dan voegen we het toe.",
"barcode.unknownBody": "Dit product staat nog niet in de database. Fotografeer de voorkant en de voedingswaarde we lezen de inhoud af en slaan het product op in de gedeelde catalogus, zodat de volgende dat niet hoeft. (Het wordt niet aan je artikelen toegevoegd.)",
"barcode.unknownTitle": "Onbekend product",
"common.add": "Toevoegen",
"common.back": "Terug",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Zezwól na aparat",
"barcode.cameraBody": "{brand} potrzebuje aparatu do skanowania kodów kreskowych.",
"barcode.cameraTitle": "Potrzebny aparat",
"barcode.contributing.title": "Odczytywanie opakowania…",
"barcode.contributing.body": "Odczytujemy wartości odżywcze, alergeny i składniki i zapisujemy produkt we wspólnym katalogu, aby następna osoba nie musiała go fotografować.",
"barcode.contributing.failed": "Nie udało się odczytać opakowania. Spróbuj ponownie z ostrym zdjęciem i dobrym oświetleniem.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Najpierw potrzebujesz gospodarstwa.",
"barcode.noLocation": "Nie znaleziono miejsca przechowywania.",
"barcode.noNutrition": "Brak danych żywieniowych",
"barcode.photoPackage": "Sfotografuj opakowanie",
"barcode.unknownBody": "Tego produktu nie ma jeszcze w bazie. Sfotografuj przód i etykietę wartości odżywczych, a my go dodamy.",
"barcode.unknownBody": "Tego produktu nie ma jeszcze w bazie. Zrób zdjęcie przodu i tabeli wartości odżywczych odczytamy zawartość i zapiszemy produkt we wspólnym katalogu, aby następna osoba nie musiała. (Nie zostanie dodany do Twoich produktów.)",
"barcode.unknownTitle": "Nieznany produkt",
"common.add": "Dodaj",
"common.back": "Wstecz",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Permitir câmara",
"barcode.cameraBody": "O {brand} precisa da câmara para ler códigos de barras.",
"barcode.cameraTitle": "É precisa a câmara",
"barcode.contributing.title": "A ler a embalagem…",
"barcode.contributing.body": "Estamos a extrair informação nutricional, alergénios e ingredientes e a guardar o produto no catálogo partilhado, para que a próxima pessoa não precise de o fotografar.",
"barcode.contributing.failed": "Não foi possível ler a embalagem. Tente novamente com uma foto nítida e boa iluminação.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Primeiro precisa de um agregado.",
"barcode.noLocation": "Nenhum local de armazenamento encontrado.",
"barcode.noNutrition": "Sem dados nutricionais",
"barcode.photoPackage": "Fotografar a embalagem",
"barcode.unknownBody": "Este produto ainda não está na base de dados. Fotografe a frente e o rótulo nutricional e nós adicionamo-lo.",
"barcode.unknownBody": "Este produto ainda não está na base de dados. Fotografe a frente e a declaração nutricional vamos ler o conteúdo e guardar o produto no catálogo partilhado, para que a próxima pessoa não precise. (Não será adicionado aos seus artigos.)",
"barcode.unknownTitle": "Produto desconhecido",
"common.add": "Adicionar",
"common.back": "Voltar",
+4 -1
View File
@@ -30,12 +30,15 @@
"barcode.allowCamera": "Tillåt kamera",
"barcode.cameraBody": "För att skanna streckkoder behöver {brand} kameran.",
"barcode.cameraTitle": "Kameran behövs",
"barcode.contributing.title": "Läser av förpackningen…",
"barcode.contributing.body": "Vi tar fram näring, allergener och innehåll och sparar produkten i den delade katalogen så nästa person slipper fota den.",
"barcode.contributing.failed": "Kunde inte läsa av förpackningen. Försök igen med skarp bild och bra ljus.",
"barcode.kcalPer100": "{kcal} kcal/100 g",
"barcode.needHousehold": "Du behöver ett hushåll först.",
"barcode.noLocation": "Ingen förvaringsplats hittades.",
"barcode.noNutrition": "Näringsdata saknas",
"barcode.photoPackage": "Fota förpackningen",
"barcode.unknownBody": "Produkten finns inte i databasen ännu. Fota framsidan och näringsdeklarationen så lägger vi till den.",
"barcode.unknownBody": "Produkten finns inte i databasen ännu. Fota framsidan och näringsdeklarationen så läser vi av innehållet och sparar produkten i den delade katalogen nästa person slipper fota den. (Läggs inte till bland dina varor.)",
"barcode.unknownTitle": "Okänd produkt",
"common.add": "Lägg till",
"common.back": "Tillbaka",
+102 -1
View File
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import { schema, trackProductAnalytics } from "@app/database";
import { scanCompleted, scanFailed } from "@app/analytics";
import type { AamosResult, AamosTaskType, DetectedItem } from "@app/ai-contracts";
@@ -113,6 +113,107 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
output = await mapDetectedItemsToCanonical(ctx, output);
}
// Produktskanning (okänd streckkod → foto): läs namn/märke/näring och spara i
// den DELADE produktkatalogen kopplat till streckkoden, så nästa person som
// skannar samma kod får den direkt. Visar infon lägger INTE till i lagret.
if (job.scanType === "product_package" || job.scanType === "nutrition_label") {
const o = output as {
basis?: string | null;
values?: Record<string, number | null> | null;
ingredientsText?: string | null;
allergensDeclared?: string[];
gtin?: string | null;
productName?: string | null;
brand?: string | null;
};
const ctxBarcode =
job.context && typeof job.context === "object"
? (job.context as { barcode?: string }).barcode
: undefined;
const gtin = ctxBarcode ?? o.gtin ?? null;
const v = o.values ?? null;
const nutrition =
v && v.kcal != null
? {
basis: o.basis ?? "per_100_g",
values: {
kcal: v.kcal ?? 0,
proteinG: v.proteinG ?? 0,
carbsG: v.carbsG ?? 0,
fatG: v.fatG ?? 0,
saturatedFatG: v.saturatedFatG ?? 0,
fiberG: v.fiberG ?? 0,
sugarG: v.sugarG ?? 0,
saltG: v.saltG ?? 0,
},
}
: null;
let product: Record<string, unknown> | null = null;
if (gtin && o.productName) {
const [existing] = await ctx.db
.select()
.from(schema.products)
.where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo)))
.limit(1);
if (existing) {
const [updated] = await ctx.db
.update(schema.products)
.set({
name: o.productName,
brand: o.brand ?? existing.brand,
ingredientsText: o.ingredientsText ?? existing.ingredientsText,
allergens: (o.allergensDeclared ?? existing.allergens) as never,
nutrition: (nutrition ?? existing.nutrition) as never,
updatedAt: new Date(),
})
.where(eq(schema.products.id, existing.id))
.returning();
product = updated as Record<string, unknown>;
} else {
const [inserted] = await ctx.db
.insert(schema.products)
.values({
gtin,
name: o.productName,
brand: o.brand ?? null,
ingredientsText: o.ingredientsText ?? null,
allergens: (o.allergensDeclared ?? []) as never,
nutrition: nutrition as never,
imageUrls: [],
dataSource: "user_scanned",
verificationStatus: "unverified",
createdByUserId: job.userId,
})
.returning();
product = inserted as Record<string, unknown>;
}
} else if (o.productName) {
product = {
name: o.productName,
brand: o.brand ?? null,
ingredientsText: o.ingredientsText ?? null,
allergens: o.allergensDeclared ?? [],
nutrition,
dataSource: "user_scanned",
};
}
await ctx.db
.update(schema.scanJobs)
.set({
status: "completed",
result: { product },
modelVersion: result.modelVersion ?? null,
promptVersion: result.promptVersion ?? null,
latencyMs: result.latencyMs ?? Date.now() - started,
costUsd: result.costUsd ?? null,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.scanJobs.id, scanJobId));
await recordScanCompleted(ctx, job, result);
return;
}
await ctx.db
.update(schema.scanJobs)
.set({
+5
View File
@@ -26,6 +26,11 @@ export const createScanInputSchema = z.object({
recipeId: uuidSchema.optional(),
storageLocationId: uuidSchema.optional(),
note: z.string().max(300).optional(),
/** Streckkod som fotot gäller produkten sparas i delade katalogen. */
barcode: z
.string()
.regex(/^\d{8,14}$/)
.optional(),
})
.optional(),
});