"""Reviewed hardware intake from invoice lines, product codes, or manual entry.""" import copy import hashlib import json from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Literal, Optional from uuid import UUID, uuid4 from fastapi import APIRouter, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field from psycopg2.extras import Json from app.core.database import atomic_transaction, execute_query router = APIRouter(prefix='/hardware-intake') def _one(sql, params=()): rows = execute_query(sql, params) return rows[0] if rows else None def _decimal(value): try: number = Decimal(str(value)) if number.is_finite(): return number except (InvalidOperation, ValueError, TypeError): pass raise HTTPException(422, 'Antal og beløb skal være gyldige tal') def _lines(raw_lines, use_line_numbers=False): result = [] seen = set() for index, line in enumerate(raw_lines or [], 1): key = str((line.get('line_number') or index) if use_line_numbers else (line.get('id') or line.get('line_number') or index)) if key in seen: raise HTTPException(422, 'Fakturaen har tvetydige linjenumre; kontrollér fakturagrundlaget') seen.add(key) qty = line.get('quantity') price = line.get('unit_price') result.append(dict(key=key, source_line_id=line.get('id'), description=str(line.get('description') or line.get('name') or ''), sku=line.get('sku') or line.get('item_number'), quantity=str(qty) if qty is not None else None, unit_price=str(price) if price is not None else None)) return result def _store_preview(kind, source_id, header, lines): payload = dict(header=header, lines=_lines(lines, use_line_numbers=kind == 'file' or str(header.get('allocation_source') or '').startswith('file:'))) preview_id = str(uuid4()) execute_query('''INSERT INTO hardware_intake_previews(id,source_kind,source_id,payload) VALUES(%s,%s,%s,%s)''', (preview_id, kind, source_id, Json(payload, dumps=lambda value: json.dumps(value, default=str)))) taken = execute_query("SELECT id,hardware_specs->>'intake_unit_key' AS key FROM hardware_assets WHERE starts_with(hardware_specs->>'intake_unit_key',%s)", (header.get('allocation_source', f'{kind}:{source_id}') + ':',)) or [] created_units = {} for row in taken: key = row['key'].rsplit(':', 2)[-2] created_units.setdefault(key, []).append(row['id']) return dict(preview_id=preview_id, source_kind=kind, source_id=source_id, created_units=created_units, **payload) def _invoice_preview(invoice_id): invoice = _one('''SELECT id,invoice_number,vendor_name,invoice_date,currency,file_path,status,extraction_id FROM supplier_invoices WHERE id=%s''', (invoice_id,)) if not invoice: raise HTTPException(404, 'Leverandørfakturaen findes ikke') if invoice['status'] in ('cancelled', 'rejected', 'credited'): raise HTTPException(409, 'Afviste, annullerede eller krediterede fakturaer kan ikke bruges til ny hardware') file_path = invoice.pop('file_path', None) invoice['document_available'] = bool(file_path) extraction_id = invoice.pop('extraction_id', None) linked_file = _one('SELECT file_id FROM extractions WHERE extraction_id=%s', (extraction_id,)) if extraction_id else None if not linked_file and file_path: linked_file = _one('SELECT file_id FROM incoming_files WHERE file_path=%s ORDER BY file_id LIMIT 1', (file_path,)) if linked_file: invoice['allocation_source'] = f"file:{linked_file['file_id']}" invoice['document_available'] = True lines = execute_query('SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id=%s ORDER BY line_number,id', (invoice_id,)) return _store_preview('invoice', invoice_id, invoice, lines) async def _file_preview(file_id): from app.services.ollama_service import ollama_service from app.services.invoice2data_service import get_invoice2data_service file = _one('SELECT file_id,filename,file_path FROM incoming_files WHERE file_id=%s', (file_id,)) if not file: raise HTTPException(404, 'Fakturafilen findes ikke') invoice = _one('''SELECT id FROM supplier_invoices WHERE file_path=%s OR id IN (SELECT supplier_invoice_id FROM supplier_invoice_attachments WHERE file_path=%s) OR extraction_id IN (SELECT extraction_id FROM extractions WHERE file_id=%s) ORDER BY id LIMIT 1''', (file['file_path'], file['file_path'], file_id)) if invoice: return _invoice_preview(invoice['id']) extraction = _one('SELECT * FROM extractions WHERE file_id=%s ORDER BY created_at DESC LIMIT 1', (file_id,)) payload = {} lines = [] if extraction: raw = extraction.get('llm_response_json') or {} payload = json.loads(raw) if isinstance(raw, str) else raw if not isinstance(payload, dict): payload = {} lines = execute_query('SELECT * FROM extraction_lines WHERE extraction_id=%s ORDER BY line_number', (extraction['extraction_id'],)) or payload.get('lines') or [] if not lines: path = Path(file['file_path']) if not path.is_file(): raise HTTPException(404, 'Fakturafilen mangler på disken') text = await ollama_service._extract_text_from_file(path) if not text.strip(): raise HTTPException(422, 'Kunne ikke læse fakturaen. Prøv en tydeligere fil eller manuel oprettelse.') payload = get_invoice2data_service().extract(text) or {} if not payload.get('lines'): payload = await ollama_service.extract_from_text(text) lines = payload.get('lines') or [] header = {key: payload.get(key) or (extraction or {}).get(key) for key in ('invoice_number', 'vendor_name', 'invoice_date', 'currency')} header['filename'] = file['filename'] header['document_available'] = True return _store_preview('file', file_id, header, lines) @router.get('/sources') async def list_sources(q: str = Query('', max_length=200)): term = '%' + q.strip() + '%' invoices = execute_query('''SELECT id,invoice_number,vendor_name,invoice_date FROM supplier_invoices WHERE status NOT IN ('cancelled','rejected','credited') AND (invoice_number ILIKE %s OR COALESCE(vendor_name,'') ILIKE %s) ORDER BY created_at DESC LIMIT 20''', (term, term)) or [] files = execute_query('''SELECT file_id,filename FROM incoming_files WHERE filename ILIKE %s ORDER BY uploaded_at DESC LIMIT 20''', (term,)) or [] return {'sources': [dict(kind='invoice', id=row['id'], label=f"{row.get('vendor_name') or 'Leverandør'} · {row['invoice_number']}") for row in invoices] + [dict(kind='file', id=row['file_id'], label=row['filename']) for row in files]} class SourceInput(BaseModel): kind: Literal['invoice', 'file'] id: int = Field(gt=0) @router.post('/preview') async def preview_source(source: SourceInput): return _invoice_preview(source.id) if source.kind == 'invoice' else await _file_preview(source.id) @router.post('/upload') async def upload_preview(file: UploadFile = File(...)): from app.billing.backend.supplier_invoices import upload_supplier_invoice suffix = Path(file.filename or '').suffix.lower() if suffix not in ('.pdf', '.png', '.jpg', '.jpeg'): raise HTTPException(422, 'Vælg en PDF, PNG eller JPG') uploaded = await upload_supplier_invoice(file) file_id = uploaded.get('file_id') if not file_id: raise HTTPException(422, 'Upload kunne ikke knyttes til en fakturafil') return await _file_preview(file_id) class IntakeInput(BaseModel): request_id: UUID mode: Literal['manual', 'barcode', 'invoice'] = 'manual' hardware: dict count: int = Field(default=1, ge=1, le=100) serial_numbers: list[str] = Field(default_factory=list, max_length=100) preview_id: Optional[UUID] = None line_key: Optional[str] = None code: Optional[str] = Field(default=None, max_length=200) product_id: Optional[int] = Field(default=None, gt=0) approved_unit_cost: Optional[Decimal] = Field(default=None, ge=0, allow_inf_nan=False) currency: str = Field(default='DKK', pattern='^[A-Z]{3}$') @router.post('/create') async def create_from_intake(request: IntakeInput): from app.modules.hardware.backend.router import create_hardware if not request.hardware.get('asset_type'): raise HTTPException(422, 'Vælg hardwaretype før oprettelse') if request.mode == 'barcode' and not request.code: raise HTTPException(422, 'Slå en stregkode op, og vælg en vare før oprettelse') if request.count > 1 and any(request.hardware.get(key) for key in ('internal_asset_id', 'customer_asset_id', 'anydesk_id', 'anydesk_link')): raise HTTPException(422, 'Ved flere enheder skal individuelle asset-ID og AnyDesk-ID udfyldes på hver hardwarepost efter oprettelsen') serials = [value.strip() for value in request.serial_numbers] if not serials: serials = [str(request.hardware.get('serial_number') or '').strip()] if request.count == 1 else [''] * request.count if len(serials) != request.count: raise HTTPException(422, 'Angiv ét serienummer pr. enhed, eller lad serienumrene være tomme') normalized_serials = [value.replace(' ', '').replace('-', '').upper() for value in serials if value] if len(set(normalized_serials)) != len(normalized_serials): raise HTTPException(409, 'Samme serienummer må ikke bruges på flere enheder') request_json = request.model_dump(mode='json') fingerprint = hashlib.sha256(json.dumps(request_json, sort_keys=True).encode()).hexdigest() with atomic_transaction(): execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-request:' + str(request.request_id),)) previous = execute_query('''SELECT id,hardware_specs FROM hardware_assets WHERE hardware_specs->>'intake_request_id'=%s ORDER BY id''', (str(request.request_id),)) or [] if previous: if any(row['hardware_specs'].get('intake_request_hash') != fingerprint for row in previous): raise HTTPException(409, 'Oprettelsen er allerede gennemført med andre data. Genindlæs formularen for en ny oprettelse.') return {'hardware_ids': [row['id'] for row in previous], 'already_created': True} source = dict(mode=request.mode, code=request.code) unit_keys = [f"manual:{request.request_id}:{index}" for index in range(request.count)] invoice_id = None if request.mode == 'invoice': preview = _one('''SELECT * FROM hardware_intake_previews WHERE id=%s AND created_at > NOW()-INTERVAL '24 hours' ''', (str(request.preview_id),)) if request.preview_id else None if not preview: raise HTTPException(409, 'Vælg og gennemgå fakturalinjen igen; forhåndsvisningen mangler eller er udløbet') line = next((line for line in preview['payload']['lines'] if line['key'] == request.line_key), None) if not line: raise HTTPException(422, 'Vælg en fakturalinje') quantity = _decimal(line['quantity']) if quantity <= 0 or quantity != int(quantity) or quantity > 10000: raise HTTPException(422, 'Fakturalinjen skal have et positivt helt antal fysiske enheder') if line['unit_price'] is not None and _decimal(line['unit_price']) < 0: raise HTTPException(422, 'Kreditlinjer kan ikke oprette ny hardware') key_prefix = preview['payload']['header'].get('allocation_source', f"{preview['source_kind']}:{preview['source_id']}") + f":{line['key']}:" execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-source:' + key_prefix,)) taken = execute_query("SELECT hardware_specs->>'intake_unit_key' AS key FROM hardware_assets WHERE starts_with(hardware_specs->>'intake_unit_key',%s)", (key_prefix,)) or [] occupied = {row['key'] for row in taken} unit_keys = [key_prefix + str(index) for index in range(1, int(quantity)+1) if key_prefix + str(index) not in occupied][:request.count] if len(unit_keys) != request.count: raise HTTPException(409, 'Der er allerede oprettet hardware for disse enheder. Vælg færre enheder eller gennemgå eksisterende hardware.') source = dict(mode='invoice', source_kind=preview['source_kind'], source_id=preview['source_id'], line=copy.deepcopy(line), header=copy.deepcopy(preview['payload']['header'])) if preview['source_kind'] == 'invoice': invoice_id = preview['source_id'] invoice = _one('SELECT id,status FROM supplier_invoices WHERE id=%s', (invoice_id,)) current_line = _one('SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id=%s AND id=%s', (invoice_id, line['source_line_id'])) current_snapshot = _lines([current_line], use_line_numbers=str(preview['payload']['header'].get('allocation_source') or '').startswith('file:'))[0] if current_line else None if current_snapshot and current_line.get('line_number') is None: current_snapshot['key'] = line['key'] if current_snapshot != line: raise HTTPException(409, 'Fakturalinjen er ændret. Gennemgå fakturaen igen før oprettelse.') if not invoice or invoice['status'] in ('cancelled', 'rejected', 'credited'): raise HTTPException(409, 'Fakturaen er ikke længere tilgængelig som oprettelsesgrundlag') elif not _one('SELECT file_id FROM incoming_files WHERE file_id=%s', (preview['source_id'],)): raise HTTPException(409, 'Fakturafilen findes ikke længere') source.update(approved_unit_cost=str(request.approved_unit_cost) if request.approved_unit_cost is not None else None, approved_currency=request.currency) if request.product_id: product = _one('SELECT id,name,ean,sku_internal FROM products WHERE id=%s AND deleted_at IS NULL', (request.product_id,)) if not product: raise HTTPException(409, 'Den valgte vare findes ikke længere') source['product'] = dict(product) for normalized_serial in sorted(normalized_serials): execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-serial:' + normalized_serial,)) created = [] for index, serial in enumerate(serials): if serial: duplicate = _one("""SELECT id FROM hardware_assets WHERE deleted_at IS NULL AND UPPER(REPLACE(REPLACE(COALESCE(serial_number,''),' ',''),'-',''))=%s LIMIT 1""", (serial.replace(' ', '').replace('-', '').upper(),)) if duplicate: raise HTTPException(409, f"Serienummeret findes allerede på hardware #{duplicate['id']}") hardware = copy.deepcopy(request.hardware) if hardware.get('hardware_specs') is not None and not isinstance(hardware['hardware_specs'], dict): raise HTTPException(422, 'Hardwarespecifikationer skal være et objekt') specs = dict(hardware.get('hardware_specs') or {}) specs.update(intake_request_id=str(request.request_id), intake_request_hash=fingerprint, intake_unit_key=unit_keys[index], acquisition=source) hardware.update(serial_number=serial or None, hardware_specs=specs) asset = await create_hardware(hardware) if invoice_id: execute_query('''INSERT INTO supplier_invoice_relations(supplier_invoice_id,relation_type,relation_id,note) VALUES(%s,'asset',%s,'Oprettet fra godkendt fakturalinje') ON CONFLICT DO NOTHING''', (invoice_id, asset['id'])) created.append(asset['id']) return {'hardware_ids': created, 'already_created': False} @router.get('/{hardware_id}/document') async def acquisition_document(hardware_id: int): asset = _one('SELECT hardware_specs FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,)) source = ((asset or {}).get('hardware_specs') or {}).get('acquisition') or {} if source.get('source_kind') == 'invoice': document = _one('''SELECT COALESCE(NULLIF(si.file_path,''),f.file_path) AS file_path FROM supplier_invoices si LEFT JOIN extractions e ON e.extraction_id=si.extraction_id LEFT JOIN incoming_files f ON f.file_id=e.file_id WHERE si.id=%s''', (source['source_id'],)) elif source.get('source_kind') == 'file': document = _one('SELECT file_path FROM incoming_files WHERE file_id=%s', (source['source_id'],)) else: document = None path = Path(document['file_path']) if document and document.get('file_path') else None if not path or not path.is_file(): raise HTTPException(404, 'Det oprindelige fakturabilag findes ikke') return FileResponse(path, filename=path.name)