feat: Implement supplier invoice case traceability and purchase line classification
This commit is contained in:
parent
ceb560e2f2
commit
13dc1736b4
@ -22,6 +22,121 @@ import re
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_PURCHASE_CASE_TYPE = "indkøb"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_procurement_customer_id() -> int:
|
||||||
|
"""Find customer used for internally-owned procurement cases."""
|
||||||
|
configured_id = getattr(settings, "PROCUREMENT_CASE_CUSTOMER_ID", None)
|
||||||
|
if configured_id:
|
||||||
|
row = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE id = %s AND is_active = true",
|
||||||
|
(configured_id,)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
bmc_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM customers
|
||||||
|
WHERE is_active = true
|
||||||
|
AND LOWER(name) LIKE %s
|
||||||
|
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
("%bmc%", "%bmc networks%")
|
||||||
|
)
|
||||||
|
if bmc_row:
|
||||||
|
return int(bmc_row["id"])
|
||||||
|
|
||||||
|
fallback = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1"
|
||||||
|
)
|
||||||
|
if fallback:
|
||||||
|
return int(fallback["id"])
|
||||||
|
|
||||||
|
raise ValueError("No active customer available for procurement case creation")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_case_for_supplier_invoice(
|
||||||
|
invoice_id: int,
|
||||||
|
invoice_number: str,
|
||||||
|
vendor_name: Optional[str],
|
||||||
|
total_amount,
|
||||||
|
currency: Optional[str],
|
||||||
|
file_id: Optional[int] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Create and link a procurement case if missing for a supplier invoice."""
|
||||||
|
existing = execute_query_single(
|
||||||
|
"SELECT sag_id FROM supplier_invoices WHERE id = %s",
|
||||||
|
(invoice_id,)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_sag_id = existing.get("sag_id")
|
||||||
|
if current_sag_id:
|
||||||
|
return int(current_sag_id)
|
||||||
|
|
||||||
|
customer_id = _resolve_procurement_customer_id()
|
||||||
|
vendor_label = (vendor_name or "Ukendt leverandør").strip()
|
||||||
|
currency_label = (currency or "DKK").strip()
|
||||||
|
amount_label = f"{Decimal(total_amount or 0):.2f}"
|
||||||
|
|
||||||
|
case_title = f"Leverandørfaktura {invoice_number} - {vendor_label}"
|
||||||
|
case_description = (
|
||||||
|
"Auto-oprettet fra leverandørfaktura\n"
|
||||||
|
f"Faktura: {invoice_number}\n"
|
||||||
|
f"Leverandør: {vendor_label}\n"
|
||||||
|
f"Beløb: {amount_label} {currency_label}\n"
|
||||||
|
f"Invoice ID: {invoice_id}\n"
|
||||||
|
f"Fil ID: {file_id or '-'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
case_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager (titel, beskrivelse, type, status, customer_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(case_title, case_description, _PURCHASE_CASE_TYPE, "åben", customer_id)
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sag_id = int(case_row["id"])
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"UPDATE supplier_invoices SET sag_id = %s WHERE id = %s",
|
||||||
|
(sag_id, invoice_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sag_id,
|
||||||
|
"Invoice Bot",
|
||||||
|
(
|
||||||
|
"🔗 Automatisk oprettet fra leverandørfaktura\n"
|
||||||
|
f"Faktura: {invoice_number}\n"
|
||||||
|
f"Leverandør: {vendor_label}\n"
|
||||||
|
f"Beløb: {amount_label} {currency_label}\n"
|
||||||
|
f"Invoice ID: {invoice_id}"
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as comment_error:
|
||||||
|
logger.warning("⚠️ Could not create case comment for supplier invoice %s: %s", invoice_id, comment_error)
|
||||||
|
|
||||||
|
logger.info("✅ Linked supplier invoice %s to SAG-%s", invoice_id, sag_id)
|
||||||
|
return sag_id
|
||||||
|
|
||||||
|
|
||||||
def _smart_extract_lines(text: str) -> List[Dict]:
|
def _smart_extract_lines(text: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
@ -957,7 +1072,7 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
if not extraction:
|
if not extraction:
|
||||||
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
||||||
|
|
||||||
extraction_data = extraction[0]
|
extraction_data = extraction
|
||||||
|
|
||||||
# Check if vendor is matched
|
# Check if vendor is matched
|
||||||
if not extraction_data['vendor_matched_id']:
|
if not extraction_data['vendor_matched_id']:
|
||||||
@ -975,7 +1090,7 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
|
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
|
||||||
|
|
||||||
# Get extraction lines
|
# Get extraction lines
|
||||||
lines = execute_query_single(
|
lines = execute_query(
|
||||||
"""SELECT * FROM extraction_lines
|
"""SELECT * FROM extraction_lines
|
||||||
WHERE extraction_id = %s
|
WHERE extraction_id = %s
|
||||||
ORDER BY line_number""",
|
ORDER BY line_number""",
|
||||||
@ -1037,6 +1152,15 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sag_id = _ensure_case_for_supplier_invoice(
|
||||||
|
invoice_id=invoice_id,
|
||||||
|
invoice_number=invoice_number,
|
||||||
|
vendor_name=extraction.get("vendor_name"),
|
||||||
|
total_amount=extraction_data.get("total_amount"),
|
||||||
|
currency=extraction_data.get("currency"),
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Create invoice lines
|
# Create invoice lines
|
||||||
if lines:
|
if lines:
|
||||||
for line in lines:
|
for line in lines:
|
||||||
@ -1067,6 +1191,7 @@ async def create_invoice_from_extraction(file_id: int):
|
|||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"invoice_id": invoice_id,
|
"invoice_id": invoice_id,
|
||||||
|
"sag_id": sag_id,
|
||||||
"invoice_number": invoice_number,
|
"invoice_number": invoice_number,
|
||||||
"vendor_name": extraction['vendor_name'],
|
"vendor_name": extraction['vendor_name'],
|
||||||
"total_amount": extraction['total_amount'],
|
"total_amount": extraction['total_amount'],
|
||||||
@ -3089,6 +3214,15 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_ensure_case_for_supplier_invoice(
|
||||||
|
invoice_id=invoice_id,
|
||||||
|
invoice_number=f"PENDING-{file_id}",
|
||||||
|
vendor_name=None,
|
||||||
|
total_amount=0,
|
||||||
|
currency="DKK",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"✅ Created minimal invoice {invoice_id} for file {file_id}, vendor {vendor_id}")
|
logger.info(f"✅ Created minimal invoice {invoice_id} for file {file_id}, vendor {vendor_id}")
|
||||||
return invoice_id
|
return invoice_id
|
||||||
|
|
||||||
|
|||||||
@ -2240,6 +2240,73 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True):
|
|||||||
# VAREKØB & SALG - CRUD (Case-linked sale items)
|
# VAREKØB & SALG - CRUD (Case-linked sale items)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
_PURCHASE_PURPOSE_VALUES = {
|
||||||
|
"salg",
|
||||||
|
"lager",
|
||||||
|
"asset",
|
||||||
|
"intern_brug",
|
||||||
|
"retur_reklamation",
|
||||||
|
"projekt_omkostning",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_purchase_purpose(value: Optional[object], item_type: str) -> Optional[str]:
|
||||||
|
if item_type != "purchase":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if normalized not in _PURCHASE_PURPOSE_VALUES:
|
||||||
|
allowed = ", ".join(sorted(_PURCHASE_PURPOSE_VALUES))
|
||||||
|
raise HTTPException(status_code=400, detail=f"purchase_purpose must be one of: {allowed}")
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_purchase_traceability(
|
||||||
|
supplier_invoice_id_value: Optional[object],
|
||||||
|
supplier_invoice_line_id_value: Optional[object],
|
||||||
|
) -> tuple[Optional[int], Optional[int]]:
|
||||||
|
supplier_invoice_id = _coerce_optional_int(supplier_invoice_id_value, "supplier_invoice_id")
|
||||||
|
supplier_invoice_line_id = _coerce_optional_int(supplier_invoice_line_id_value, "supplier_invoice_line_id")
|
||||||
|
|
||||||
|
if supplier_invoice_line_id is not None and supplier_invoice_id is None:
|
||||||
|
line_row = execute_query_single(
|
||||||
|
"SELECT supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s",
|
||||||
|
(supplier_invoice_line_id,)
|
||||||
|
)
|
||||||
|
if not line_row:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id")
|
||||||
|
supplier_invoice_id = int(line_row["supplier_invoice_id"])
|
||||||
|
|
||||||
|
if supplier_invoice_id is not None:
|
||||||
|
invoice_exists = execute_query_single(
|
||||||
|
"SELECT id FROM supplier_invoices WHERE id = %s",
|
||||||
|
(supplier_invoice_id,)
|
||||||
|
)
|
||||||
|
if not invoice_exists:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid supplier_invoice_id")
|
||||||
|
|
||||||
|
if supplier_invoice_line_id is not None:
|
||||||
|
line_exists = execute_query_single(
|
||||||
|
"SELECT id, supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s",
|
||||||
|
(supplier_invoice_line_id,)
|
||||||
|
)
|
||||||
|
if not line_exists:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id")
|
||||||
|
if supplier_invoice_id is not None and int(line_exists["supplier_invoice_id"]) != supplier_invoice_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="supplier_invoice_line_id does not belong to supplier_invoice_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
return supplier_invoice_id, supplier_invoice_line_id
|
||||||
|
|
||||||
@router.get("/sag/{sag_id}/sale-items")
|
@router.get("/sag/{sag_id}/sale-items")
|
||||||
async def list_sale_items(sag_id: int):
|
async def list_sale_items(sag_id: int):
|
||||||
"""List sale items for a case."""
|
"""List sale items for a case."""
|
||||||
@ -2290,27 +2357,68 @@ async def create_sale_item(sag_id: int, data: dict):
|
|||||||
if status not in ("draft", "confirmed", "cancelled"):
|
if status not in ("draft", "confirmed", "cancelled"):
|
||||||
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
||||||
|
|
||||||
query = """
|
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||||
INSERT INTO sag_salgsvarer
|
purchase_purpose = None
|
||||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
supplier_invoice_id = None
|
||||||
VALUES
|
supplier_invoice_line_id = None
|
||||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
if has_purchase_columns:
|
||||||
RETURNING *
|
purchase_purpose = _normalize_purchase_purpose(data.get("purchase_purpose"), item_type)
|
||||||
"""
|
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
|
||||||
params = (
|
data.get("supplier_invoice_id"),
|
||||||
sag_id,
|
data.get("supplier_invoice_line_id"),
|
||||||
item_type,
|
)
|
||||||
description,
|
if item_type != "purchase":
|
||||||
data.get("quantity"),
|
supplier_invoice_id = None
|
||||||
data.get("unit"),
|
supplier_invoice_line_id = None
|
||||||
data.get("unit_price"),
|
|
||||||
amount,
|
if has_purchase_columns:
|
||||||
data.get("currency", "DKK"),
|
query = """
|
||||||
status,
|
INSERT INTO sag_salgsvarer
|
||||||
data.get("line_date"),
|
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id,
|
||||||
data.get("external_ref"),
|
purchase_purpose, supplier_invoice_id, supplier_invoice_line_id)
|
||||||
data.get("product_id"),
|
VALUES
|
||||||
)
|
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
params = (
|
||||||
|
sag_id,
|
||||||
|
item_type,
|
||||||
|
description,
|
||||||
|
data.get("quantity"),
|
||||||
|
data.get("unit"),
|
||||||
|
data.get("unit_price"),
|
||||||
|
amount,
|
||||||
|
data.get("currency", "DKK"),
|
||||||
|
status,
|
||||||
|
data.get("line_date"),
|
||||||
|
data.get("external_ref"),
|
||||||
|
data.get("product_id"),
|
||||||
|
purchase_purpose,
|
||||||
|
supplier_invoice_id,
|
||||||
|
supplier_invoice_line_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
INSERT INTO sag_salgsvarer
|
||||||
|
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
params = (
|
||||||
|
sag_id,
|
||||||
|
item_type,
|
||||||
|
description,
|
||||||
|
data.get("quantity"),
|
||||||
|
data.get("unit"),
|
||||||
|
data.get("unit_price"),
|
||||||
|
amount,
|
||||||
|
data.get("currency", "DKK"),
|
||||||
|
status,
|
||||||
|
data.get("line_date"),
|
||||||
|
data.get("external_ref"),
|
||||||
|
data.get("product_id"),
|
||||||
|
)
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
if result:
|
if result:
|
||||||
logger.info("✅ Sale item created for case %s", sag_id)
|
logger.info("✅ Sale item created for case %s", sag_id)
|
||||||
@ -2349,7 +2457,7 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
|||||||
"""Update a sale item for a case."""
|
"""Update a sale item for a case."""
|
||||||
try:
|
try:
|
||||||
check = execute_query(
|
check = execute_query(
|
||||||
"SELECT id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
|
"SELECT id, type FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
|
||||||
(item_id, sag_id)
|
(item_id, sag_id)
|
||||||
)
|
)
|
||||||
if not check:
|
if not check:
|
||||||
@ -2367,10 +2475,18 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
|||||||
"line_date",
|
"line_date",
|
||||||
"external_ref",
|
"external_ref",
|
||||||
"product_id",
|
"product_id",
|
||||||
|
"purchase_purpose",
|
||||||
|
"supplier_invoice_id",
|
||||||
|
"supplier_invoice_line_id",
|
||||||
]
|
]
|
||||||
|
|
||||||
set_clauses = []
|
set_clauses = []
|
||||||
params = []
|
params = []
|
||||||
|
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||||
|
current_type = (check[0].get("type") or "sale").lower()
|
||||||
|
next_type = current_type
|
||||||
|
if "type" in updates:
|
||||||
|
next_type = (updates.get("type") or "").lower()
|
||||||
|
|
||||||
for field in allowed_fields:
|
for field in allowed_fields:
|
||||||
if field in updates:
|
if field in updates:
|
||||||
@ -2386,10 +2502,56 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
|||||||
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
||||||
set_clauses.append("status = %s")
|
set_clauses.append("status = %s")
|
||||||
params.append(value)
|
params.append(value)
|
||||||
|
elif field == "purchase_purpose":
|
||||||
|
if not has_purchase_columns:
|
||||||
|
continue
|
||||||
|
value = _normalize_purchase_purpose(updates.get(field), next_type)
|
||||||
|
set_clauses.append("purchase_purpose = %s")
|
||||||
|
params.append(value)
|
||||||
|
elif field in ("supplier_invoice_id", "supplier_invoice_line_id"):
|
||||||
|
# handled together below to keep consistency between IDs
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
set_clauses.append(f"{field} = %s")
|
set_clauses.append(f"{field} = %s")
|
||||||
params.append(updates[field])
|
params.append(updates[field])
|
||||||
|
|
||||||
|
if has_purchase_columns and (
|
||||||
|
"supplier_invoice_id" in updates
|
||||||
|
or "supplier_invoice_line_id" in updates
|
||||||
|
or next_type != current_type
|
||||||
|
):
|
||||||
|
raw_supplier_invoice_id = updates.get("supplier_invoice_id") if "supplier_invoice_id" in updates else None
|
||||||
|
raw_supplier_invoice_line_id = updates.get("supplier_invoice_line_id") if "supplier_invoice_line_id" in updates else None
|
||||||
|
|
||||||
|
if "supplier_invoice_id" not in updates or "supplier_invoice_line_id" not in updates:
|
||||||
|
current_refs = execute_query_single(
|
||||||
|
"SELECT supplier_invoice_id, supplier_invoice_line_id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s",
|
||||||
|
(item_id, sag_id)
|
||||||
|
) or {}
|
||||||
|
if "supplier_invoice_id" not in updates:
|
||||||
|
raw_supplier_invoice_id = current_refs.get("supplier_invoice_id")
|
||||||
|
if "supplier_invoice_line_id" not in updates:
|
||||||
|
raw_supplier_invoice_line_id = current_refs.get("supplier_invoice_line_id")
|
||||||
|
|
||||||
|
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
|
||||||
|
raw_supplier_invoice_id,
|
||||||
|
raw_supplier_invoice_line_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if next_type != "purchase":
|
||||||
|
supplier_invoice_id = None
|
||||||
|
supplier_invoice_line_id = None
|
||||||
|
|
||||||
|
set_clauses.append("supplier_invoice_id = %s")
|
||||||
|
params.append(supplier_invoice_id)
|
||||||
|
set_clauses.append("supplier_invoice_line_id = %s")
|
||||||
|
params.append(supplier_invoice_line_id)
|
||||||
|
|
||||||
|
if has_purchase_columns and next_type != "purchase":
|
||||||
|
if "purchase_purpose" not in updates:
|
||||||
|
set_clauses.append("purchase_purpose = %s")
|
||||||
|
params.append(None)
|
||||||
|
|
||||||
if not set_clauses:
|
if not set_clauses:
|
||||||
raise HTTPException(status_code=400, detail="No valid fields to update")
|
raise HTTPException(status_code=400, detail="No valid fields to update")
|
||||||
|
|
||||||
|
|||||||
@ -6024,6 +6024,7 @@
|
|||||||
<th>Enhed</th>
|
<th>Enhed</th>
|
||||||
<th>Enhedspris</th>
|
<th>Enhedspris</th>
|
||||||
<th>Linjesum</th>
|
<th>Linjesum</th>
|
||||||
|
<th>Formål</th>
|
||||||
<th>Kilde-sag</th>
|
<th>Kilde-sag</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th class="text-end pe-4">Handlinger</th>
|
<th class="text-end pe-4">Handlinger</th>
|
||||||
@ -6031,7 +6032,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="saleItemsPurchaseBody">
|
<tbody id="saleItemsPurchaseBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="text-center py-4 text-muted">Indlæser indkøbslinjer...</td>
|
<td colspan="10" class="text-center py-4 text-muted">Indlæser indkøbslinjer...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -7282,6 +7283,20 @@
|
|||||||
|
|
||||||
let saleItemsCache = [];
|
let saleItemsCache = [];
|
||||||
|
|
||||||
|
const purchasePurposeLabels = {
|
||||||
|
salg: 'Salg',
|
||||||
|
lager: 'Lager',
|
||||||
|
asset: 'Asset',
|
||||||
|
intern_brug: 'Intern brug',
|
||||||
|
retur_reklamation: 'Retur/reklamation',
|
||||||
|
projekt_omkostning: 'Projektomkostning'
|
||||||
|
};
|
||||||
|
|
||||||
|
function purchasePurposeLabel(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return purchasePurposeLabels[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVarekobSalg() {
|
async function loadVarekobSalg() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/sag/${salesCaseId}/varekob-salg?include_subcases=true`);
|
const res = await fetch(`/api/v1/sag/${salesCaseId}/varekob-salg?include_subcases=true`);
|
||||||
@ -7323,9 +7338,10 @@
|
|||||||
const salesItems = items.filter(item => (item.type || '').toLowerCase() !== 'purchase');
|
const salesItems = items.filter(item => (item.type || '').toLowerCase() !== 'purchase');
|
||||||
const purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase');
|
const purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase');
|
||||||
|
|
||||||
const renderRows = (list) => {
|
const renderRows = (list, isPurchase) => {
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
return '<tr><td colspan="9" class="text-center py-4 text-muted">Ingen linjer</td></tr>';
|
const columns = isPurchase ? 10 : 9;
|
||||||
|
return `<tr><td colspan="${columns}" class="text-center py-4 text-muted">Ingen linjer</td></tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.map(item => {
|
return list.map(item => {
|
||||||
@ -7334,6 +7350,9 @@
|
|||||||
const sourceBadge = isSubcase
|
const sourceBadge = isSubcase
|
||||||
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
|
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
|
||||||
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
|
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
|
||||||
|
const purposeCell = isPurchase
|
||||||
|
? `<td><span class="badge bg-info-subtle text-dark border">${purchasePurposeLabel(item.purchase_purpose)}</span></td>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ps-4">${item.line_date || '-'}</td>
|
<td class="ps-4">${item.line_date || '-'}</td>
|
||||||
@ -7342,6 +7361,7 @@
|
|||||||
<td>${item.unit || '-'}</td>
|
<td>${item.unit || '-'}</td>
|
||||||
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
||||||
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
||||||
|
${purposeCell}
|
||||||
<td>${item.source_sag_titel || '-'}${sourceBadge}</td>
|
<td>${item.source_sag_titel || '-'}${sourceBadge}</td>
|
||||||
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
||||||
<td class="text-end pe-4">
|
<td class="text-end pe-4">
|
||||||
@ -7355,8 +7375,8 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
salesBody.innerHTML = renderRows(salesItems);
|
salesBody.innerHTML = renderRows(salesItems, false);
|
||||||
purchaseBody.innerHTML = renderRows(purchaseItems);
|
purchaseBody.innerHTML = renderRows(purchaseItems, true);
|
||||||
|
|
||||||
const salesSum = salesItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
const salesSum = salesItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
const purchaseSum = purchaseItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
const purchaseSum = purchaseItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
@ -7400,10 +7420,30 @@
|
|||||||
document.getElementById('sale_amount').value = item?.amount ?? '';
|
document.getElementById('sale_amount').value = item?.amount ?? '';
|
||||||
document.getElementById('sale_currency').value = item?.currency || 'DKK';
|
document.getElementById('sale_currency').value = item?.currency || 'DKK';
|
||||||
document.getElementById('sale_external_ref').value = item?.external_ref || '';
|
document.getElementById('sale_external_ref').value = item?.external_ref || '';
|
||||||
|
document.getElementById('sale_purchase_purpose').value = item?.purchase_purpose || '';
|
||||||
|
document.getElementById('sale_supplier_invoice_id').value = item?.supplier_invoice_id || '';
|
||||||
|
document.getElementById('sale_supplier_invoice_line_id').value = item?.supplier_invoice_line_id || '';
|
||||||
|
|
||||||
|
togglePurchaseFields();
|
||||||
|
|
||||||
new bootstrap.Modal(document.getElementById('saleItemModal')).show();
|
new bootstrap.Modal(document.getElementById('saleItemModal')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePurchaseFields() {
|
||||||
|
const type = document.getElementById('sale_type').value;
|
||||||
|
const purchaseFields = document.getElementById('sale_purchase_fields');
|
||||||
|
if (!purchaseFields) return;
|
||||||
|
|
||||||
|
if (type === 'purchase') {
|
||||||
|
purchaseFields.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
purchaseFields.classList.add('d-none');
|
||||||
|
document.getElementById('sale_purchase_purpose').value = '';
|
||||||
|
document.getElementById('sale_supplier_invoice_id').value = '';
|
||||||
|
document.getElementById('sale_supplier_invoice_line_id').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openSaleItemModalById(itemId) {
|
function openSaleItemModalById(itemId) {
|
||||||
const item = saleItemsCache.find((entry) => entry.id === itemId);
|
const item = saleItemsCache.find((entry) => entry.id === itemId);
|
||||||
openSaleItemModal(item || null);
|
openSaleItemModal(item || null);
|
||||||
@ -7429,9 +7469,18 @@
|
|||||||
unit_price: document.getElementById('sale_unit_price').value || null,
|
unit_price: document.getElementById('sale_unit_price').value || null,
|
||||||
amount: document.getElementById('sale_amount').value,
|
amount: document.getElementById('sale_amount').value,
|
||||||
currency: document.getElementById('sale_currency').value || 'DKK',
|
currency: document.getElementById('sale_currency').value || 'DKK',
|
||||||
external_ref: document.getElementById('sale_external_ref').value || null
|
external_ref: document.getElementById('sale_external_ref').value || null,
|
||||||
|
purchase_purpose: document.getElementById('sale_purchase_purpose').value || null,
|
||||||
|
supplier_invoice_id: document.getElementById('sale_supplier_invoice_id').value || null,
|
||||||
|
supplier_invoice_line_id: document.getElementById('sale_supplier_invoice_line_id').value || null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (payload.type !== 'purchase') {
|
||||||
|
payload.purchase_purpose = null;
|
||||||
|
payload.supplier_invoice_id = null;
|
||||||
|
payload.supplier_invoice_line_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!payload.description || !payload.amount) {
|
if (!payload.description || !payload.amount) {
|
||||||
alert('Beskrivelse og linjesum er påkrævet.');
|
alert('Beskrivelse og linjesum er påkrævet.');
|
||||||
return;
|
return;
|
||||||
@ -7470,8 +7519,10 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const qtyInput = document.getElementById('sale_quantity');
|
const qtyInput = document.getElementById('sale_quantity');
|
||||||
const priceInput = document.getElementById('sale_unit_price');
|
const priceInput = document.getElementById('sale_unit_price');
|
||||||
|
const typeInput = document.getElementById('sale_type');
|
||||||
if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount);
|
if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount);
|
||||||
if (priceInput) priceInput.addEventListener('input', updateSaleAmount);
|
if (priceInput) priceInput.addEventListener('input', updateSaleAmount);
|
||||||
|
if (typeInput) typeInput.addEventListener('change', togglePurchaseFields);
|
||||||
loadVarekobSalg();
|
loadVarekobSalg();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -8769,6 +8820,30 @@
|
|||||||
<label class="form-label">Reference</label>
|
<label class="form-label">Reference</label>
|
||||||
<input type="text" class="form-control" id="sale_external_ref" placeholder="Valgfri reference">
|
<input type="text" class="form-control" id="sale_external_ref" placeholder="Valgfri reference">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 d-none" id="sale_purchase_fields">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Indkøbsformål</label>
|
||||||
|
<select class="form-select" id="sale_purchase_purpose">
|
||||||
|
<option value="">Vælg formål</option>
|
||||||
|
<option value="salg">Salg</option>
|
||||||
|
<option value="lager">Lager</option>
|
||||||
|
<option value="asset">Asset</option>
|
||||||
|
<option value="intern_brug">Intern brug</option>
|
||||||
|
<option value="retur_reklamation">Retur/reklamation</option>
|
||||||
|
<option value="projekt_omkostning">Projektomkostning</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Leverandørfaktura ID</label>
|
||||||
|
<input type="number" class="form-control" id="sale_supplier_invoice_id" min="1" step="1" placeholder="Fx 1254">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Fakturalinje ID</label>
|
||||||
|
<input type="number" class="form-control" id="sale_supplier_invoice_line_id" min="1" step="1" placeholder="Fx 8921">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
migrations/169_supplier_invoice_case_traceability.sql
Normal file
47
migrations/169_supplier_invoice_case_traceability.sql
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
-- Migration 169: Supplier invoice -> case traceability and purchase line classification
|
||||||
|
-- Created: 2026-04-12
|
||||||
|
|
||||||
|
-- Link supplier invoices to cases for procurement workflow
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_sag_id
|
||||||
|
ON supplier_invoices(sag_id);
|
||||||
|
|
||||||
|
-- Add explicit purchase line classification + invoice traceability on case purchase lines
|
||||||
|
ALTER TABLE sag_salgsvarer
|
||||||
|
ADD COLUMN IF NOT EXISTS purchase_purpose VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS supplier_invoice_id INTEGER REFERENCES supplier_invoices(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS supplier_invoice_line_id INTEGER REFERENCES supplier_invoice_lines(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_sag_salgsvarer_purchase_purpose'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sag_salgsvarer
|
||||||
|
ADD CONSTRAINT chk_sag_salgsvarer_purchase_purpose
|
||||||
|
CHECK (
|
||||||
|
purchase_purpose IS NULL
|
||||||
|
OR purchase_purpose IN (
|
||||||
|
'salg',
|
||||||
|
'lager',
|
||||||
|
'asset',
|
||||||
|
'intern_brug',
|
||||||
|
'retur_reklamation',
|
||||||
|
'projekt_omkostning'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_purchase_purpose
|
||||||
|
ON sag_salgsvarer(purchase_purpose);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_supplier_invoice_id
|
||||||
|
ON sag_salgsvarer(supplier_invoice_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_supplier_invoice_line_id
|
||||||
|
ON sag_salgsvarer(supplier_invoice_line_id);
|
||||||
Loading…
Reference in New Issue
Block a user