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__)
|
||||
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]:
|
||||
"""
|
||||
@ -957,7 +1072,7 @@ async def create_invoice_from_extraction(file_id: int):
|
||||
if not extraction:
|
||||
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
|
||||
|
||||
extraction_data = extraction[0]
|
||||
extraction_data = extraction
|
||||
|
||||
# Check if vendor is matched
|
||||
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")
|
||||
|
||||
# Get extraction lines
|
||||
lines = execute_query_single(
|
||||
lines = execute_query(
|
||||
"""SELECT * FROM extraction_lines
|
||||
WHERE extraction_id = %s
|
||||
ORDER BY line_number""",
|
||||
@ -1036,6 +1151,15 @@ async def create_invoice_from_extraction(file_id: int):
|
||||
invoice_type
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
if lines:
|
||||
@ -1067,6 +1191,7 @@ async def create_invoice_from_extraction(file_id: int):
|
||||
return {
|
||||
"status": "success",
|
||||
"invoice_id": invoice_id,
|
||||
"sag_id": sag_id,
|
||||
"invoice_number": invoice_number,
|
||||
"vendor_name": extraction['vendor_name'],
|
||||
"total_amount": extraction['total_amount'],
|
||||
@ -3088,6 +3213,15 @@ async def create_invoice_from_file(file_id: int, vendor_id: int) -> int:
|
||||
f"Oprettet fra fil: {file_info['filename']} (file_id: {file_id})"
|
||||
)
|
||||
)
|
||||
|
||||
_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}")
|
||||
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)
|
||||
# ============================================================================
|
||||
|
||||
_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")
|
||||
async def list_sale_items(sag_id: int):
|
||||
"""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"):
|
||||
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
||||
|
||||
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"),
|
||||
)
|
||||
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||
purchase_purpose = None
|
||||
supplier_invoice_id = None
|
||||
supplier_invoice_line_id = None
|
||||
if has_purchase_columns:
|
||||
purchase_purpose = _normalize_purchase_purpose(data.get("purchase_purpose"), item_type)
|
||||
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
|
||||
data.get("supplier_invoice_id"),
|
||||
data.get("supplier_invoice_line_id"),
|
||||
)
|
||||
if item_type != "purchase":
|
||||
supplier_invoice_id = None
|
||||
supplier_invoice_line_id = None
|
||||
|
||||
if has_purchase_columns:
|
||||
query = """
|
||||
INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id,
|
||||
purchase_purpose, supplier_invoice_id, supplier_invoice_line_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)
|
||||
if result:
|
||||
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."""
|
||||
try:
|
||||
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)
|
||||
)
|
||||
if not check:
|
||||
@ -2367,10 +2475,18 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
||||
"line_date",
|
||||
"external_ref",
|
||||
"product_id",
|
||||
"purchase_purpose",
|
||||
"supplier_invoice_id",
|
||||
"supplier_invoice_line_id",
|
||||
]
|
||||
|
||||
set_clauses = []
|
||||
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:
|
||||
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")
|
||||
set_clauses.append("status = %s")
|
||||
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:
|
||||
set_clauses.append(f"{field} = %s")
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="No valid fields to update")
|
||||
|
||||
|
||||
@ -6024,6 +6024,7 @@
|
||||
<th>Enhed</th>
|
||||
<th>Enhedspris</th>
|
||||
<th>Linjesum</th>
|
||||
<th>Formål</th>
|
||||
<th>Kilde-sag</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end pe-4">Handlinger</th>
|
||||
@ -6031,7 +6032,7 @@
|
||||
</thead>
|
||||
<tbody id="saleItemsPurchaseBody">
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -7282,6 +7283,20 @@
|
||||
|
||||
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() {
|
||||
try {
|
||||
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 purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase');
|
||||
|
||||
const renderRows = (list) => {
|
||||
const renderRows = (list, isPurchase) => {
|
||||
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 => {
|
||||
@ -7334,6 +7350,9 @@
|
||||
const sourceBadge = isSubcase
|
||||
? `<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>`;
|
||||
const purposeCell = isPurchase
|
||||
? `<td><span class="badge bg-info-subtle text-dark border">${purchasePurposeLabel(item.purchase_purpose)}</span></td>`
|
||||
: '';
|
||||
return `
|
||||
<tr>
|
||||
<td class="ps-4">${item.line_date || '-'}</td>
|
||||
@ -7342,6 +7361,7 @@
|
||||
<td>${item.unit || '-'}</td>
|
||||
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
||||
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
||||
${purposeCell}
|
||||
<td>${item.source_sag_titel || '-'}${sourceBadge}</td>
|
||||
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
||||
<td class="text-end pe-4">
|
||||
@ -7355,8 +7375,8 @@
|
||||
}).join('');
|
||||
};
|
||||
|
||||
salesBody.innerHTML = renderRows(salesItems);
|
||||
purchaseBody.innerHTML = renderRows(purchaseItems);
|
||||
salesBody.innerHTML = renderRows(salesItems, false);
|
||||
purchaseBody.innerHTML = renderRows(purchaseItems, true);
|
||||
|
||||
const salesSum = salesItems.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_currency').value = item?.currency || 'DKK';
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
const item = saleItemsCache.find((entry) => entry.id === itemId);
|
||||
openSaleItemModal(item || null);
|
||||
@ -7429,9 +7469,18 @@
|
||||
unit_price: document.getElementById('sale_unit_price').value || null,
|
||||
amount: document.getElementById('sale_amount').value,
|
||||
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) {
|
||||
alert('Beskrivelse og linjesum er påkrævet.');
|
||||
return;
|
||||
@ -7470,8 +7519,10 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const qtyInput = document.getElementById('sale_quantity');
|
||||
const priceInput = document.getElementById('sale_unit_price');
|
||||
const typeInput = document.getElementById('sale_type');
|
||||
if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount);
|
||||
if (priceInput) priceInput.addEventListener('input', updateSaleAmount);
|
||||
if (typeInput) typeInput.addEventListener('change', togglePurchaseFields);
|
||||
loadVarekobSalg();
|
||||
});
|
||||
</script>
|
||||
@ -8769,6 +8820,30 @@
|
||||
<label class="form-label">Reference</label>
|
||||
<input type="text" class="form-control" id="sale_external_ref" placeholder="Valgfri reference">
|
||||
</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>
|
||||
</form>
|
||||
</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