feat: add backend migration and API for maintenance receipt linking (refs #151)
Add receipt_document_id FK on maintenance_records, update types/repo/service to support receipt linking on create and return document metadata on GET. Add OCR proxy endpoint POST /api/ocr/extract/maintenance-receipt with tier gating (maintenance.receiptScan) through full chain: routes -> controller -> service -> client. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,11 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
|||||||
name: 'Receipt Scan',
|
name: 'Receipt Scan',
|
||||||
upgradePrompt: 'Upgrade to Pro to scan fuel receipts and auto-fill your fuel log entries.',
|
upgradePrompt: 'Upgrade to Pro to scan fuel receipts and auto-fill your fuel log entries.',
|
||||||
},
|
},
|
||||||
|
'maintenance.receiptScan': {
|
||||||
|
minTier: 'pro',
|
||||||
|
name: 'Maintenance Receipt Scan',
|
||||||
|
upgradePrompt: 'Upgrade to Pro to scan maintenance receipts and extract service details automatically.',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class MaintenanceRepository {
|
|||||||
cost: row.cost,
|
cost: row.cost,
|
||||||
shopName: row.shop_name,
|
shopName: row.shop_name,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
|
receiptDocumentId: row.receipt_document_id,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
};
|
};
|
||||||
@@ -66,11 +67,12 @@ export class MaintenanceRepository {
|
|||||||
cost?: number | null;
|
cost?: number | null;
|
||||||
shopName?: string | null;
|
shopName?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
receiptDocumentId?: string | null;
|
||||||
}): Promise<MaintenanceRecord> {
|
}): Promise<MaintenanceRecord> {
|
||||||
const res = await this.db.query(
|
const res = await this.db.query(
|
||||||
`INSERT INTO maintenance_records (
|
`INSERT INTO maintenance_records (
|
||||||
id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes
|
id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes, receipt_document_id
|
||||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10)
|
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
record.id,
|
record.id,
|
||||||
@@ -83,6 +85,7 @@ export class MaintenanceRepository {
|
|||||||
record.cost ?? null,
|
record.cost ?? null,
|
||||||
record.shopName ?? null,
|
record.shopName ?? null,
|
||||||
record.notes ?? null,
|
record.notes ?? null,
|
||||||
|
record.receiptDocumentId ?? null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return this.mapMaintenanceRecord(res.rows[0]);
|
return this.mapMaintenanceRecord(res.rows[0]);
|
||||||
@@ -96,6 +99,26 @@ export class MaintenanceRepository {
|
|||||||
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
|
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findRecordByIdWithDocument(id: string, userId: string): Promise<{ record: MaintenanceRecord; receiptDocument: { documentId: string; fileName: string; contentType: string; storageKey: string } | null } | null> {
|
||||||
|
const res = await this.db.query(
|
||||||
|
`SELECT mr.*, d.id AS doc_id, d.file_name AS doc_file_name, d.content_type AS doc_content_type, d.storage_key AS doc_storage_key
|
||||||
|
FROM maintenance_records mr
|
||||||
|
LEFT JOIN documents d ON mr.receipt_document_id = d.id
|
||||||
|
WHERE mr.id = $1 AND mr.user_id = $2`,
|
||||||
|
[id, userId]
|
||||||
|
);
|
||||||
|
if (!res.rows[0]) return null;
|
||||||
|
const row = res.rows[0];
|
||||||
|
const record = this.mapMaintenanceRecord(row);
|
||||||
|
const receiptDocument = row.doc_id ? {
|
||||||
|
documentId: row.doc_id,
|
||||||
|
fileName: row.doc_file_name,
|
||||||
|
contentType: row.doc_content_type,
|
||||||
|
storageKey: row.doc_storage_key,
|
||||||
|
} : null;
|
||||||
|
return { record, receiptDocument };
|
||||||
|
}
|
||||||
|
|
||||||
async findRecordsByUserId(
|
async findRecordsByUserId(
|
||||||
userId: string,
|
userId: string,
|
||||||
filters?: { vehicleId?: string; category?: MaintenanceCategory }
|
filters?: { vehicleId?: string; category?: MaintenanceCategory }
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import type {
|
|||||||
MaintenanceScheduleResponse,
|
MaintenanceScheduleResponse,
|
||||||
MaintenanceCategory,
|
MaintenanceCategory,
|
||||||
ScheduleType,
|
ScheduleType,
|
||||||
MaintenanceCostStats
|
MaintenanceCostStats,
|
||||||
|
ReceiptDocumentMeta
|
||||||
} from './maintenance.types';
|
} from './maintenance.types';
|
||||||
import { validateSubtypes } from './maintenance.types';
|
import { validateSubtypes } from './maintenance.types';
|
||||||
import { MaintenanceRepository } from '../data/maintenance.repository';
|
import { MaintenanceRepository } from '../data/maintenance.repository';
|
||||||
@@ -40,6 +41,7 @@ export class MaintenanceService {
|
|||||||
cost: body.cost,
|
cost: body.cost,
|
||||||
shopName: body.shopName,
|
shopName: body.shopName,
|
||||||
notes: body.notes,
|
notes: body.notes,
|
||||||
|
receiptDocumentId: body.receiptDocumentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-link: Find and update matching 'time_since_last' schedules
|
// Auto-link: Find and update matching 'time_since_last' schedules
|
||||||
@@ -49,9 +51,9 @@ export class MaintenanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
|
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
|
||||||
const record = await this.repo.findRecordById(id, userId);
|
const result = await this.repo.findRecordByIdWithDocument(id, userId);
|
||||||
if (!record) return null;
|
if (!result) return null;
|
||||||
return this.toRecordResponse(record);
|
return this.toRecordResponse(result.record, result.receiptDocument);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecords(userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory }): Promise<MaintenanceRecordResponse[]> {
|
async getRecords(userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory }): Promise<MaintenanceRecordResponse[]> {
|
||||||
@@ -272,10 +274,11 @@ export class MaintenanceService {
|
|||||||
return { nextDueDate, nextDueMileage };
|
return { nextDueDate, nextDueMileage };
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse {
|
private toRecordResponse(record: MaintenanceRecord, receiptDocument?: ReceiptDocumentMeta | null): MaintenanceRecordResponse {
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
subtypeCount: record.subtypes.length,
|
subtypeCount: record.subtypes.length,
|
||||||
|
receiptDocument: receiptDocument ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface MaintenanceRecord {
|
|||||||
cost?: number;
|
cost?: number;
|
||||||
shopName?: string;
|
shopName?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
receiptDocumentId?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,7 @@ export const CreateMaintenanceRecordSchema = z.object({
|
|||||||
cost: z.number().positive().optional(),
|
cost: z.number().positive().optional(),
|
||||||
shopName: z.string().max(200).optional(),
|
shopName: z.string().max(200).optional(),
|
||||||
notes: z.string().max(10000).optional(),
|
notes: z.string().max(10000).optional(),
|
||||||
|
receiptDocumentId: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
|
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
|
||||||
|
|
||||||
@@ -157,9 +159,18 @@ export const UpdateScheduleSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||||
|
|
||||||
|
// Receipt document metadata returned on GET
|
||||||
|
export interface ReceiptDocumentMeta {
|
||||||
|
documentId: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
storageKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Response types
|
// Response types
|
||||||
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
||||||
subtypeCount: number;
|
subtypeCount: number;
|
||||||
|
receiptDocument?: ReceiptDocumentMeta | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TCO aggregation stats
|
// TCO aggregation stats
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add receipt_document_id FK to link maintenance records to scanned receipt documents
|
||||||
|
ALTER TABLE maintenance_records
|
||||||
|
ADD COLUMN receipt_document_id UUID REFERENCES documents(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Index for querying records by receipt document
|
||||||
|
CREATE INDEX idx_maintenance_records_receipt_document_id ON maintenance_records(receipt_document_id)
|
||||||
|
WHERE receipt_document_id IS NOT NULL;
|
||||||
@@ -342,6 +342,114 @@ export class OcrController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ocr/extract/maintenance-receipt
|
||||||
|
* Extract data from a maintenance receipt image using maintenance-specific OCR.
|
||||||
|
* Requires Pro tier (maintenance.receiptScan).
|
||||||
|
*/
|
||||||
|
async extractMaintenanceReceipt(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const userId = (request as any).user?.sub as string;
|
||||||
|
|
||||||
|
logger.info('Maintenance receipt extract requested', {
|
||||||
|
operation: 'ocr.controller.extractMaintenanceReceipt',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = await (request as any).file({ limits: { files: 1 } });
|
||||||
|
if (!file) {
|
||||||
|
logger.warn('No file provided for maintenance receipt extraction', {
|
||||||
|
operation: 'ocr.controller.extractMaintenanceReceipt.no_file',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'No file provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = file.mimetype as string;
|
||||||
|
if (!SUPPORTED_IMAGE_TYPES.has(contentType)) {
|
||||||
|
logger.warn('Unsupported file type for maintenance receipt extraction', {
|
||||||
|
operation: 'ocr.controller.extractMaintenanceReceipt.unsupported_type',
|
||||||
|
userId,
|
||||||
|
contentType,
|
||||||
|
fileName: file.filename,
|
||||||
|
});
|
||||||
|
return reply.code(415).send({
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of file.file) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const fileBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
if (fileBuffer.length === 0) {
|
||||||
|
logger.warn('Empty file provided for maintenance receipt extraction', {
|
||||||
|
operation: 'ocr.controller.extractMaintenanceReceipt.empty_file',
|
||||||
|
userId,
|
||||||
|
fileName: file.filename,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Empty file provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ocrService.extractMaintenanceReceipt(userId, {
|
||||||
|
fileBuffer,
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Maintenance receipt extract completed', {
|
||||||
|
operation: 'ocr.controller.extractMaintenanceReceipt.success',
|
||||||
|
userId,
|
||||||
|
success: result.success,
|
||||||
|
receiptType: result.receiptType,
|
||||||
|
processingTimeMs: result.processingTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.statusCode === 413) {
|
||||||
|
return reply.code(413).send({
|
||||||
|
error: 'Payload Too Large',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.statusCode === 415) {
|
||||||
|
return reply.code(415).send({
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.statusCode === 422) {
|
||||||
|
return reply.code(422).send({
|
||||||
|
error: 'Unprocessable Entity',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Maintenance receipt extract failed', {
|
||||||
|
operation: 'ocr.controller.extractMaintenanceReceipt.error',
|
||||||
|
userId,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Maintenance receipt extraction failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ocr/extract/manual
|
* POST /api/ocr/extract/manual
|
||||||
* Submit an async manual extraction job for PDF owner's manuals.
|
* Submit an async manual extraction job for PDF owner's manuals.
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export const ocrRoutes: FastifyPluginAsync = async (
|
|||||||
handler: ctrl.extractReceipt.bind(ctrl),
|
handler: ctrl.extractReceipt.bind(ctrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/ocr/extract/maintenance-receipt - Maintenance receipt OCR extraction (Pro tier required)
|
||||||
|
fastify.post('/ocr/extract/maintenance-receipt', {
|
||||||
|
preHandler: [requireAuth, requireTier('maintenance.receiptScan')],
|
||||||
|
handler: ctrl.extractMaintenanceReceipt.bind(ctrl),
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/ocr/extract/manual - Manual extraction (Pro tier required)
|
// POST /api/ocr/extract/manual - Manual extraction (Pro tier required)
|
||||||
fastify.post('/ocr/extract/manual', {
|
fastify.post('/ocr/extract/manual', {
|
||||||
preHandler: [requireAuth, fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
|
preHandler: [requireAuth, fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { logger } from '../../../core/logging/logger';
|
|||||||
import { ocrClient, JobNotFoundError } from '../external/ocr-client';
|
import { ocrClient, JobNotFoundError } from '../external/ocr-client';
|
||||||
import type {
|
import type {
|
||||||
JobResponse,
|
JobResponse,
|
||||||
|
MaintenanceReceiptExtractRequest,
|
||||||
ManualJobResponse,
|
ManualJobResponse,
|
||||||
ManualJobSubmitRequest,
|
ManualJobSubmitRequest,
|
||||||
OcrExtractRequest,
|
OcrExtractRequest,
|
||||||
@@ -221,6 +222,63 @@ export class OcrService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract data from a maintenance receipt image using maintenance-specific OCR.
|
||||||
|
*
|
||||||
|
* @param userId - User ID for logging
|
||||||
|
* @param request - Maintenance receipt extraction request
|
||||||
|
* @returns Receipt extraction result
|
||||||
|
*/
|
||||||
|
async extractMaintenanceReceipt(userId: string, request: MaintenanceReceiptExtractRequest): Promise<ReceiptExtractionResponse> {
|
||||||
|
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
|
||||||
|
const err: any = new Error(
|
||||||
|
`File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.`
|
||||||
|
);
|
||||||
|
err.statusCode = 413;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_IMAGE_TYPES.has(request.contentType)) {
|
||||||
|
const err: any = new Error(
|
||||||
|
`Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(', ')}`
|
||||||
|
);
|
||||||
|
err.statusCode = 415;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Maintenance receipt extract requested', {
|
||||||
|
operation: 'ocr.service.extractMaintenanceReceipt',
|
||||||
|
userId,
|
||||||
|
contentType: request.contentType,
|
||||||
|
fileSize: request.fileBuffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ocrClient.extractMaintenanceReceipt(
|
||||||
|
request.fileBuffer,
|
||||||
|
request.contentType
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Maintenance receipt extract completed', {
|
||||||
|
operation: 'ocr.service.extractMaintenanceReceipt.success',
|
||||||
|
userId,
|
||||||
|
success: result.success,
|
||||||
|
receiptType: result.receiptType,
|
||||||
|
fieldCount: Object.keys(result.extractedFields).length,
|
||||||
|
processingTimeMs: result.processingTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Maintenance receipt extract failed', {
|
||||||
|
operation: 'ocr.service.extractMaintenanceReceipt.error',
|
||||||
|
userId,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit an async OCR job for large files.
|
* Submit an async OCR job for large files.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export interface ReceiptExtractRequest {
|
|||||||
receiptType?: string;
|
receiptType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request for maintenance receipt extraction */
|
||||||
|
export interface MaintenanceReceiptExtractRequest {
|
||||||
|
fileBuffer: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Response from VIN-specific extraction */
|
/** Response from VIN-specific extraction */
|
||||||
export interface VinExtractionResponse {
|
export interface VinExtractionResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
51
backend/src/features/ocr/external/ocr-client.ts
vendored
51
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -177,6 +177,57 @@ export class OcrClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract data from a maintenance receipt image using maintenance-specific OCR.
|
||||||
|
*
|
||||||
|
* @param fileBuffer - Image file buffer
|
||||||
|
* @param contentType - MIME type of the file
|
||||||
|
* @returns Receipt extraction result (receiptType: "maintenance")
|
||||||
|
*/
|
||||||
|
async extractMaintenanceReceipt(
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
contentType: string
|
||||||
|
): Promise<ReceiptExtractionResponse> {
|
||||||
|
const formData = this.buildFormData(fileBuffer, contentType);
|
||||||
|
const url = `${this.baseUrl}/extract/maintenance-receipt`;
|
||||||
|
|
||||||
|
logger.info('OCR maintenance receipt extract request', {
|
||||||
|
operation: 'ocr.client.extractMaintenanceReceipt',
|
||||||
|
url,
|
||||||
|
contentType,
|
||||||
|
fileSize: fileBuffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('OCR maintenance receipt extract failed', {
|
||||||
|
operation: 'ocr.client.extractMaintenanceReceipt.error',
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
|
||||||
|
err.statusCode = response.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as ReceiptExtractionResponse;
|
||||||
|
|
||||||
|
logger.info('OCR maintenance receipt extract completed', {
|
||||||
|
operation: 'ocr.client.extractMaintenanceReceipt.success',
|
||||||
|
success: result.success,
|
||||||
|
receiptType: result.receiptType,
|
||||||
|
fieldCount: Object.keys(result.extractedFields).length,
|
||||||
|
processingTimeMs: result.processingTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit an async OCR job for large files.
|
* Submit an async OCR job for large files.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user