404 lines
12 KiB
TypeScript
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)
|
|
}
|