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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user