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
+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({