Admin c858f4e252
Some checks failed
CI / verify (push) Has been cancelled
fix: sync scans immediately and mark acknowledgements
2026-09-23 09:44:08 +00:00

404 lines
12 KiB
TypeScript

import Dexie, { type EntityTable } from 'dexie'
import type { CartonLabelMajorityField, CartonLabelScan } from './cartonLabelConformity'
import type { LabelFields } from './labelOcr'
export const FIELDLENS_SCHEMA_VERSION = 1 as const
export const FIELDLENS_SYNC_REQUEST_EVENT = 'fieldlens:sync-request' as const
export type ScanRecord = {
id: string
rawValue: string
format: string
isDuplicate: boolean
scannedAt: string
syncStatus: 'local' | 'synced'
}
export type EanPalletOcrRecord = {
ean: string
fields: LabelFields
rawText: string
confidence: number
comparisonSource: string
comparisonStatus: 'not-run' | 'pass' | 'fail' | 'review'
}
export type EanPalletSessionRecord = {
id: string
targetCount: number
scans: string[]
startedAt: string
updatedAt: string
completedAt?: string
status: 'active' | 'complete'
conforms?: boolean
consensusEan?: string
uniqueEanCount?: number
ocr?: EanPalletOcrRecord
}
export type PalletLabelSessionRecord = {
id: string
targetCount: number
labels: CartonLabelScan[]
startedAt: string
updatedAt: string
completedAt?: string
status: 'active' | 'complete'
conforms?: boolean
resultStatus?: 'pass' | 'fail' | 'review'
mismatchCartons?: number
majorityFields?: CartonLabelMajorityField[]
}
export type OutboxRecord = {
id: string
aggregateId: string
eventType: 'scan.captured' | 'pallet.session.started' | 'pallet.observation.captured' | 'pallet.session.completed'
schemaVersion: number
payload: unknown
createdAt: string
attempts: number
nextAttemptAt?: string
syncedAt?: string
lastError?: string
}
export type MetadataRecord = {
key: string
value: unknown
}
class FieldLensDatabase extends Dexie {
scans!: EntityTable<ScanRecord, 'id'>
eanPalletSessions!: EntityTable<EanPalletSessionRecord, 'id'>
palletLabelSessions!: EntityTable<PalletLabelSessionRecord, 'id'>
outbox!: EntityTable<OutboxRecord, 'id'>
metadata!: EntityTable<MetadataRecord, 'key'>
constructor() {
super('fieldlens-pwa')
this.version(1).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
})
this.version(2).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status',
})
this.version(3).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status',
palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status',
})
this.version(4).stores({
scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus',
eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status',
palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status',
outbox: 'id, aggregateId, eventType, createdAt, attempts, nextAttemptAt, syncedAt',
metadata: 'key',
})
}
}
export const database = new FieldLensDatabase()
function requestBackgroundSync() {
if (typeof window === 'undefined') return
window.dispatchEvent(new Event(FIELDLENS_SYNC_REQUEST_EVENT))
}
function outboxEvent(
aggregateId: string,
eventType: OutboxRecord['eventType'],
payload: unknown,
createdAt = new Date().toISOString(),
): OutboxRecord {
return {
id: crypto.randomUUID(),
aggregateId,
eventType,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
payload,
createdAt,
attempts: 0,
}
}
export async function recordScan(rawValue: string, format: string) {
const record = await database.transaction('rw', database.scans, database.outbox, async () => {
const existing = await database.scans.where('rawValue').equals(rawValue).first()
const nextRecord: ScanRecord = {
id: crypto.randomUUID(),
rawValue,
format,
isDuplicate: Boolean(existing),
scannedAt: new Date().toISOString(),
syncStatus: 'local',
}
await database.scans.add(nextRecord)
await database.outbox.add(outboxEvent(nextRecord.id, 'scan.captured', {
id: nextRecord.id,
rawValue: nextRecord.rawValue,
format: nextRecord.format,
duplicate: nextRecord.isDuplicate,
capturedAt: nextRecord.scannedAt,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, nextRecord.scannedAt))
return nextRecord
})
requestBackgroundSync()
return record
}
export async function startEanPalletSession(targetCount: number) {
const now = new Date().toISOString()
const record: EanPalletSessionRecord = {
id: crypto.randomUUID(),
targetCount,
scans: [],
startedAt: now,
updatedAt: now,
status: 'active',
}
await database.transaction('rw', database.eanPalletSessions, database.outbox, async () => {
await database.eanPalletSessions.add(record)
await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', {
id: record.id,
workflowType: 'ean',
targetCount,
startedAt: now,
status: 'active',
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
})
return record
}
export async function saveEanPalletScans(id: string, scans: string[]) {
const now = new Date().toISOString()
return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => {
const existing = await database.eanPalletSessions.get(id)
const previousCount = existing?.scans.length ?? 0
await database.eanPalletSessions.update(id, { scans, updatedAt: now })
for (let index = previousCount; index < scans.length; index += 1) {
const ean = scans[index]
if (!ean) continue
const observationId = crypto.randomUUID()
await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', {
id: observationId,
palletSessionId: id,
cartonIndex: index + 1,
ean,
capturedAt: now,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
}
})
}
export async function finishEanPalletSession(
id: string,
details: Pick<EanPalletSessionRecord, 'conforms' | 'consensusEan' | 'uniqueEanCount'>,
) {
const now = new Date().toISOString()
return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => {
const session = await database.eanPalletSessions.get(id)
const updated = {
...details,
status: 'complete' as const,
completedAt: now,
updatedAt: now,
}
const result = await database.eanPalletSessions.update(id, updated)
await database.outbox.add(outboxEvent(id, 'pallet.session.completed', {
id,
workflowType: 'ean',
targetCount: session?.targetCount ?? 0,
startedAt: session?.startedAt ?? now,
completedAt: now,
status: 'complete',
conforms: details.conforms,
resultStatus: details.conforms ? 'pass' : 'fail',
consensusEan: details.consensusEan,
uniqueEanCount: details.uniqueEanCount,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
return result
})
}
export function saveEanPalletOcr(id: string, ocr: EanPalletOcrRecord) {
return database.eanPalletSessions.update(id, {
ocr,
updatedAt: new Date().toISOString(),
})
}
export async function startPalletLabelSession(targetCount: number) {
const now = new Date().toISOString()
const record: PalletLabelSessionRecord = {
id: crypto.randomUUID(),
targetCount,
labels: [],
startedAt: now,
updatedAt: now,
status: 'active',
}
await database.transaction('rw', database.palletLabelSessions, database.outbox, async () => {
await database.palletLabelSessions.add(record)
await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', {
id: record.id,
workflowType: 'carton-label',
targetCount,
startedAt: now,
status: 'active',
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
})
return record
}
export async function savePalletLabelScans(id: string, labels: CartonLabelScan[]) {
const now = new Date().toISOString()
return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => {
const existing = await database.palletLabelSessions.get(id)
const previousCount = existing?.labels.length ?? 0
await database.palletLabelSessions.update(id, { labels, updatedAt: now })
for (let index = previousCount; index < labels.length; index += 1) {
const label = labels[index]
if (!label) continue
await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', {
id: label.id,
palletSessionId: id,
cartonIndex: index + 1,
fieldsJson: label.fields,
rawText: label.rawText,
confidence: label.confidence,
capturedAt: label.capturedAt,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, label.capturedAt))
}
})
}
export async function finishPalletLabelSession(
id: string,
details: Pick<PalletLabelSessionRecord, 'conforms' | 'resultStatus' | 'mismatchCartons' | 'majorityFields'>,
) {
const now = new Date().toISOString()
return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => {
const session = await database.palletLabelSessions.get(id)
const updated = {
...details,
status: 'complete' as const,
completedAt: now,
updatedAt: now,
}
const result = await database.palletLabelSessions.update(id, updated)
await database.outbox.add(outboxEvent(id, 'pallet.session.completed', {
id,
workflowType: 'carton-label',
targetCount: session?.targetCount ?? 0,
startedAt: session?.startedAt ?? now,
completedAt: now,
status: 'complete',
conforms: details.conforms,
resultStatus: details.resultStatus,
mismatchCartons: details.mismatchCartons,
majorityFields: details.majorityFields,
schemaVersion: FIELDLENS_SCHEMA_VERSION,
}, now))
return result
})
}
export async function getInstallationId() {
return database.transaction('rw', database.metadata, async () => {
const existing = await database.metadata.get('installationId')
if (typeof existing?.value === 'string' && existing.value) return existing.value
const installationId = crypto.randomUUID()
await database.metadata.put({ key: 'installationId', value: installationId })
return installationId
})
}
export async function setLocalMetadata(key: string, value: unknown) {
await database.metadata.put({ key, value })
}
export async function getLocalMetadata<T>(key: string) {
return (await database.metadata.get(key))?.value as T | undefined
}
export async function getPendingOutbox(limit = 50, now = new Date()) {
const records = await database.outbox.filter((record) =>
!record.syncedAt && (!record.nextAttemptAt || new Date(record.nextAttemptAt) <= now),
).toArray()
return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt)).slice(0, limit)
}
export async function getOutboxStats() {
const records = await database.outbox.toArray()
return {
pending: records.filter((record) => !record.syncedAt).length,
errors: records.filter((record) => !record.syncedAt && Boolean(record.lastError)).length,
synced: records.filter((record) => Boolean(record.syncedAt)).length,
}
}
export async function acknowledgeOutboxEvent(id: string) {
await database.transaction('rw', database.outbox, database.scans, async () => {
const event = await database.outbox.get(id)
if (!event) return
await database.outbox.update(id, {
syncedAt: new Date().toISOString(),
lastError: undefined,
nextAttemptAt: undefined,
})
if (event.eventType === 'scan.captured') {
await database.scans.update(event.aggregateId, { syncStatus: 'synced' })
}
})
}
export async function failOutboxEvent(id: string, error: string) {
const record = await database.outbox.get(id)
if (!record || record.syncedAt) return
const attempts = record.attempts + 1
const delayMs = Math.min(5 * 60_000, 2_000 * (2 ** Math.min(attempts - 1, 7)))
await database.outbox.update(id, {
attempts,
lastError: error.slice(0, 300),
nextAttemptAt: new Date(Date.now() + delayMs).toISOString(),
})
}
export function clearScanHistory() {
return database.scans.clear()
}
export function deleteScan(id: string) {
return database.scans.delete(id)
}
export function restoreScan(scan: ScanRecord) {
return database.scans.put(scan)
}
export function countDistinctScanValues(scans: ScanRecord[]) {
return new Set(scans.map((scan) => scan.rawValue)).size
}
export function serializeScanHistory(scans: ScanRecord[]) {
return JSON.stringify(scans, null, 2)
}