285 lines
17 KiB
Python
285 lines
17 KiB
Python
|
|
"""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)
|