feat: dodaj dodatne troškove s OCR sinkronizacijom
Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API + frontend modal) s izračunom ukupnog iznosa za isplatu. Upload računa sada odmah upisuje redak u dodatne troškove, a asinkroni OCR (Celery) naknadno pokušava popuniti broj računa i iznos iz slike/PDF-a. Uvedena je invalidacija PDF cache-a nakon promjena računa/dodatnih troškova kako bi generirani PDF uvijek prikazivao najnovije podatke. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -8,7 +8,14 @@ import logging
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import base64
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.db import transaction
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PdfReadError
|
||||
import pytesseract
|
||||
from pytesseract import TesseractNotFoundError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
@@ -223,6 +230,148 @@ def build_work_order_invoices_pdf_task(work_order_id):
|
||||
}
|
||||
|
||||
|
||||
def _parse_decimal(value):
|
||||
if value in (None, ''):
|
||||
return Decimal('0.00')
|
||||
try:
|
||||
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return Decimal('0.00')
|
||||
|
||||
|
||||
def _extract_invoice_text(invoice):
|
||||
if not invoice.image:
|
||||
return ''
|
||||
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
invoice.image.open('rb')
|
||||
try:
|
||||
reader = PdfReader(invoice.image)
|
||||
return '\n'.join((page.extract_text() or '') for page in reader.pages)
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
invoice.image.open('rb')
|
||||
try:
|
||||
with Image.open(invoice.image) as source:
|
||||
image = source.convert('RGB')
|
||||
return pytesseract.image_to_string(image, lang='hrv+eng')
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
|
||||
def _extract_invoice_number(text, fallback):
|
||||
if not text:
|
||||
return fallback
|
||||
patterns = [
|
||||
r'(?i)(?:broj\s*računa|račun\s*broj|invoice\s*no\.?)\s*[:#]?\s*([A-Z0-9\/\-\._]+)',
|
||||
r'(?i)(?:broj)\s*[:#]?\s*([A-Z0-9\/\-\._]{4,})',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
candidate = (match.group(1) or '').strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return fallback
|
||||
|
||||
|
||||
def _extract_total_amount(text):
|
||||
if not text:
|
||||
return Decimal('0.00')
|
||||
|
||||
def _line_candidates(line):
|
||||
number_pattern = r'(?<!\d)(\d{1,3}(?:[.\s]\d{3})*(?:,\d{2})|\d+(?:[.,]\d{2}))(?!\d)'
|
||||
return [_parse_decimal(match) for match in re.findall(number_pattern, line)]
|
||||
|
||||
preferred_lines = []
|
||||
for line in text.splitlines():
|
||||
lower = line.lower()
|
||||
if any(token in lower for token in ('ukupno', 'za platiti', 'iznos', 'total')):
|
||||
preferred_lines.append(line)
|
||||
|
||||
for line in preferred_lines:
|
||||
candidates = [value for value in _line_candidates(line) if value > Decimal('0.00')]
|
||||
if candidates:
|
||||
return max(candidates)
|
||||
|
||||
all_candidates = []
|
||||
for line in text.splitlines():
|
||||
all_candidates.extend([value for value in _line_candidates(line) if value > Decimal('0.00')])
|
||||
if all_candidates:
|
||||
return max(all_candidates)
|
||||
return Decimal('0.00')
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_work_order_invoice_ocr(invoice_id):
|
||||
from .models import WorkOrderInvoice, WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf
|
||||
|
||||
invoice = (
|
||||
WorkOrderInvoice.objects
|
||||
.select_related('work_order')
|
||||
.filter(pk=invoice_id, is_active=True)
|
||||
.first()
|
||||
)
|
||||
if invoice is None or invoice.work_order_id is None:
|
||||
return {"status": "skipped", "reason": "invoice-not-found"}
|
||||
|
||||
try:
|
||||
text = _extract_invoice_text(invoice)
|
||||
except (UnidentifiedImageError, OSError, TesseractNotFoundError, ValueError, RuntimeError, PdfReadError):
|
||||
text = ''
|
||||
|
||||
attachment_name = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else ''
|
||||
row = {
|
||||
'naziv': (invoice.naziv_racuna or '').strip() or attachment_name or f'Račun {invoice.pk}',
|
||||
'broj_racuna': _extract_invoice_number(text, fallback=str(invoice.pk)),
|
||||
'ukupan_iznos': f"{_extract_total_amount(text):.2f}",
|
||||
'prilog': attachment_name,
|
||||
'source_invoice_id': str(invoice.pk),
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
table, _ = WorkOrderAdditionalCostsTable.objects.select_for_update().get_or_create(
|
||||
work_order=invoice.work_order,
|
||||
defaults={'data': {'rows': []}},
|
||||
)
|
||||
existing_rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
|
||||
existing_row = next(
|
||||
(
|
||||
item for item in existing_rows
|
||||
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) == str(invoice.pk)
|
||||
),
|
||||
None,
|
||||
)
|
||||
normalized_rows = [
|
||||
item for item in existing_rows
|
||||
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) != str(invoice.pk)
|
||||
]
|
||||
if existing_row and row['ukupan_iznos'] == '0.00':
|
||||
existing_amount = str(existing_row.get('ukupan_iznos', '') or '').strip()
|
||||
if existing_amount:
|
||||
row['ukupan_iznos'] = existing_amount
|
||||
if existing_row and not row['broj_racuna']:
|
||||
row['broj_racuna'] = str(existing_row.get('broj_racuna', '') or '').strip()
|
||||
normalized_rows.append(row)
|
||||
total = sum((_parse_decimal(item.get('ukupan_iznos')) for item in normalized_rows if isinstance(item, dict)), Decimal('0.00'))
|
||||
table.data = {'rows': normalized_rows}
|
||||
table.total_for_payout = total.quantize(Decimal('0.01'))
|
||||
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
|
||||
GeneratedWorkOrderPdf.objects.filter(
|
||||
is_active=True,
|
||||
work_order=invoice.work_order,
|
||||
pdf_type__in=['work_order', 'invoices'],
|
||||
).update(
|
||||
is_active=False,
|
||||
status='failed',
|
||||
error_message='PDF cache invalidiran zbog OCR ažuriranja dodatnih troškova.',
|
||||
)
|
||||
|
||||
return {"status": "ok", "invoice_id": str(invoice.pk)}
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_expired_generated_pdfs_task():
|
||||
now = timezone.now()
|
||||
|
||||
Reference in New Issue
Block a user