feat(data-entry): Add unified receipt form with OCR confidence tracking
New unified receipt creation system with: - UnifiedReceiptForm component with inline OCR preview and confidence indicators - Compact upload zone with drag-drop and camera support - TVA and Payment fields with dynamic add/remove - Supplier dual-field with autocomplete and OCR hint - Receipt form sections with collapsible auxiliary data Backend OCR improvements: - Add confidence_tva and confidence_payment to extraction results - Update TVA extraction to return confidence scores - Include TVA (15%) and payment (10%) in overall_confidence calculation Also includes: - CSS design system rules documentation - Port check helper function for service scripts - Expanded design tokens documentation in CLAUDE.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
282
src/modules/data-entry/utils/receiptConversions.js
Normal file
282
src/modules/data-entry/utils/receiptConversions.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Conversion utilities for Receipt form data
|
||||
*
|
||||
* These functions convert between the API format (dynamic arrays) and
|
||||
* the UI format (fixed fields for TVA and payments).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default fixed TVA structure for UI
|
||||
*/
|
||||
export const getDefaultTva = () => ({
|
||||
A: { percent: 19, amount: 0 },
|
||||
B: { percent: 9, amount: 0 },
|
||||
C: { percent: 5, amount: 0 },
|
||||
D: { percent: 0, amount: 0 },
|
||||
E: { percent: null, amount: 0 },
|
||||
})
|
||||
|
||||
/**
|
||||
* Default fixed payments structure for UI
|
||||
*/
|
||||
export const getDefaultPayments = () => ({
|
||||
CARD: 0,
|
||||
NUMERAR: 0,
|
||||
ALTE: { amount: 0, type: null },
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert API tva_entries/tva_breakdown array → UI fixed fields
|
||||
*
|
||||
* @param {Array} entries - Array of {code, percent, amount}
|
||||
* @returns {Object} Fixed TVA fields {A, B, C, D, E}
|
||||
*/
|
||||
export const apiToUiTva = (entries) => {
|
||||
const ui = getDefaultTva()
|
||||
|
||||
if (!entries || !Array.isArray(entries)) {
|
||||
return ui
|
||||
}
|
||||
|
||||
entries.forEach(entry => {
|
||||
const code = entry.code?.toUpperCase()
|
||||
if (code && ui[code]) {
|
||||
ui[code] = {
|
||||
percent: entry.percent ?? ui[code].percent,
|
||||
amount: parseFloat(entry.amount) || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UI fixed TVA fields → API tva_breakdown array
|
||||
* Only includes entries with amount > 0
|
||||
*
|
||||
* @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E}
|
||||
* @returns {Array} Array of {code, percent, amount} or null if empty
|
||||
*/
|
||||
export const uiToApiTva = (tvaUi) => {
|
||||
if (!tvaUi) return null
|
||||
|
||||
const entries = Object.entries(tvaUi)
|
||||
.filter(([_, v]) => v.amount && v.amount > 0)
|
||||
.map(([code, v]) => ({
|
||||
code,
|
||||
percent: v.percent ?? 0,
|
||||
amount: parseFloat(v.amount) || 0
|
||||
}))
|
||||
|
||||
return entries.length > 0 ? entries : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total TVA from UI fixed fields
|
||||
*
|
||||
* @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E}
|
||||
* @returns {number} Total TVA amount
|
||||
*/
|
||||
export const calculateTvaTotal = (tvaUi) => {
|
||||
if (!tvaUi) return 0
|
||||
|
||||
return Object.values(tvaUi)
|
||||
.reduce((sum, v) => sum + (parseFloat(v.amount) || 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert API payment_methods array → UI fixed fields
|
||||
*
|
||||
* @param {Array} methods - Array of {method, amount}
|
||||
* @returns {Object} Fixed payment fields {CARD, NUMERAR, ALTE}
|
||||
*/
|
||||
export const apiToUiPayments = (methods) => {
|
||||
const ui = getDefaultPayments()
|
||||
|
||||
if (!methods || !Array.isArray(methods)) {
|
||||
return ui
|
||||
}
|
||||
|
||||
methods.forEach(pm => {
|
||||
const method = pm.method?.toUpperCase()
|
||||
const amount = parseFloat(pm.amount) || 0
|
||||
|
||||
if (method === 'CARD') {
|
||||
ui.CARD = amount
|
||||
} else if (method === 'NUMERAR' || method === 'CASH') {
|
||||
ui.NUMERAR = amount
|
||||
} else if (method) {
|
||||
// Other payment types go to ALTE
|
||||
ui.ALTE.amount += amount
|
||||
// Try to determine type from method name
|
||||
if (method.includes('TICH') || method.includes('MASA')) {
|
||||
ui.ALTE.type = 'tichete_masa'
|
||||
} else if (method.includes('VOUCHER')) {
|
||||
ui.ALTE.type = 'voucher'
|
||||
} else if (method.includes('CREDIT')) {
|
||||
ui.ALTE.type = 'credit_magazin'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UI fixed payment fields → API payment_methods array
|
||||
* Only includes entries with amount > 0
|
||||
*
|
||||
* @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE}
|
||||
* @returns {Array} Array of {method, amount} or null if empty
|
||||
*/
|
||||
export const uiToApiPayments = (paymentsUi) => {
|
||||
if (!paymentsUi) return null
|
||||
|
||||
const methods = []
|
||||
|
||||
if (paymentsUi.CARD && paymentsUi.CARD > 0) {
|
||||
methods.push({ method: 'CARD', amount: paymentsUi.CARD })
|
||||
}
|
||||
|
||||
if (paymentsUi.NUMERAR && paymentsUi.NUMERAR > 0) {
|
||||
methods.push({ method: 'NUMERAR', amount: paymentsUi.NUMERAR })
|
||||
}
|
||||
|
||||
if (paymentsUi.ALTE?.amount && paymentsUi.ALTE.amount > 0) {
|
||||
// Map type to method name
|
||||
let methodName = 'ALTE'
|
||||
if (paymentsUi.ALTE.type === 'tichete_masa') {
|
||||
methodName = 'TICHETE_MASA'
|
||||
} else if (paymentsUi.ALTE.type === 'voucher') {
|
||||
methodName = 'VOUCHER'
|
||||
} else if (paymentsUi.ALTE.type === 'credit_magazin') {
|
||||
methodName = 'CREDIT_MAGAZIN'
|
||||
}
|
||||
methods.push({ method: methodName, amount: paymentsUi.ALTE.amount })
|
||||
}
|
||||
|
||||
return methods.length > 0 ? methods : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total payments from UI fixed fields
|
||||
*
|
||||
* @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE}
|
||||
* @returns {number} Total payment amount
|
||||
*/
|
||||
export const calculatePaymentsTotal = (paymentsUi) => {
|
||||
if (!paymentsUi) return 0
|
||||
|
||||
return (
|
||||
(paymentsUi.CARD || 0) +
|
||||
(paymentsUi.NUMERAR || 0) +
|
||||
(paymentsUi.ALTE?.amount || 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default form state for unified receipt form
|
||||
*/
|
||||
export const getDefaultUnifiedFormState = () => ({
|
||||
receipt_type: 'bon_fiscal',
|
||||
receipt_date: new Date(),
|
||||
receipt_number: '',
|
||||
|
||||
// Supplier (DB validated)
|
||||
partner_name: null,
|
||||
cui: '',
|
||||
vendor_address: '',
|
||||
|
||||
// Total
|
||||
amount: null,
|
||||
|
||||
// TVA (5 fixed fields)
|
||||
tva: getDefaultTva(),
|
||||
|
||||
// Payments (3 fixed fields)
|
||||
payments: getDefaultPayments(),
|
||||
|
||||
// Auxiliary
|
||||
expense_type_code: null,
|
||||
description: '',
|
||||
|
||||
// Metadata
|
||||
ocr_raw_text: '',
|
||||
items_count: null,
|
||||
company_id: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert legacy form state to unified form state
|
||||
*
|
||||
* @param {Object} legacyForm - Old form format
|
||||
* @returns {Object} Unified form format
|
||||
*/
|
||||
export const legacyToUnifiedForm = (legacyForm) => {
|
||||
return {
|
||||
receipt_type: legacyForm.receipt_type || 'bon_fiscal',
|
||||
receipt_date: legacyForm.receipt_date instanceof Date
|
||||
? legacyForm.receipt_date
|
||||
: new Date(legacyForm.receipt_date),
|
||||
receipt_number: legacyForm.receipt_number || '',
|
||||
|
||||
partner_name: legacyForm.partner_name || null,
|
||||
cui: legacyForm.cui || '',
|
||||
vendor_address: legacyForm.vendor_address || '',
|
||||
|
||||
amount: parseFloat(legacyForm.amount) || null,
|
||||
|
||||
tva: apiToUiTva(legacyForm.tva_breakdown),
|
||||
payments: apiToUiPayments(legacyForm.payment_methods),
|
||||
|
||||
expense_type_code: legacyForm.expense_type_code || null,
|
||||
description: legacyForm.description || '',
|
||||
|
||||
ocr_raw_text: legacyForm.ocr_raw_text || '',
|
||||
items_count: legacyForm.items_count || null,
|
||||
company_id: legacyForm.company_id || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert unified form state to API payload
|
||||
*
|
||||
* @param {Object} unifiedForm - Unified form format
|
||||
* @returns {Object} API payload format
|
||||
*/
|
||||
export const unifiedFormToApiPayload = (unifiedForm) => {
|
||||
return {
|
||||
receipt_type: unifiedForm.receipt_type,
|
||||
direction: 'cheltuiala', // Always expense (no more income)
|
||||
receipt_date: unifiedForm.receipt_date instanceof Date
|
||||
? unifiedForm.receipt_date.toISOString().split('T')[0]
|
||||
: unifiedForm.receipt_date,
|
||||
receipt_number: unifiedForm.receipt_number || null,
|
||||
|
||||
partner_name: typeof unifiedForm.partner_name === 'string'
|
||||
? unifiedForm.partner_name
|
||||
: unifiedForm.partner_name?.name || null,
|
||||
cui: unifiedForm.cui || null,
|
||||
vendor_address: unifiedForm.vendor_address || null,
|
||||
|
||||
amount: unifiedForm.amount || 0,
|
||||
|
||||
tva_breakdown: uiToApiTva(unifiedForm.tva),
|
||||
tva_total: calculateTvaTotal(unifiedForm.tva) || null,
|
||||
payment_methods: uiToApiPayments(unifiedForm.payments),
|
||||
|
||||
expense_type_code: unifiedForm.expense_type_code || null,
|
||||
description: unifiedForm.description || null,
|
||||
|
||||
ocr_raw_text: unifiedForm.ocr_raw_text || null,
|
||||
items_count: unifiedForm.items_count || null,
|
||||
company_id: unifiedForm.company_id,
|
||||
|
||||
// Legacy fields (removed but kept for backwards compat)
|
||||
payment_mode: null,
|
||||
cash_register_id: null,
|
||||
cash_register_name: null,
|
||||
cash_register_account: null,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user