feat: Add subscriptions and products management

- Implemented frontend views for products and subscriptions using FastAPI and Jinja2 templates.
- Created API endpoints for managing subscriptions, including creation, listing, and status updates.
- Added HTML templates for displaying active subscriptions and their statistics.
- Established database migrations for sag_subscriptions, sag_subscription_items, and products, including necessary indexes and triggers for automatic subscription number generation.
- Introduced product price history tracking to monitor changes in product pricing.
This commit is contained in:
Christian 2026-02-08 12:42:19 +01:00
parent e4b9091a1b
commit 6320809f17
24 changed files with 3435 additions and 3 deletions

View File

@ -24,6 +24,12 @@ class Settings(BaseSettings):
# Elnet supplier lookup # Elnet supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api" ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
ELNET_TIMEOUT_SECONDS: int = 12 ELNET_TIMEOUT_SECONDS: int = 12
# API Gateway (Product catalog)
APIGW_BASE_URL: str = "https://apigateway.bmcnetworks.dk"
APIGATEWAY_URL: str = ""
APIGW_TOKEN: str = ""
APIGW_TIMEOUT_SECONDS: int = 12
# Security # Security
SECRET_KEY: str = "dev-secret-key-change-in-production" SECRET_KEY: str = "dev-secret-key-change-in-production"

View File

@ -1022,9 +1022,9 @@ async def create_sale_item(sag_id: int, data: dict):
query = """ query = """
INSERT INTO sag_salgsvarer INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref) (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
VALUES VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
params = ( params = (
@ -1039,6 +1039,7 @@ async def create_sale_item(sag_id: int, data: dict):
status, status,
data.get("line_date"), data.get("line_date"),
data.get("external_ref"), data.get("external_ref"),
data.get("product_id"),
) )
result = execute_query(query, params) result = execute_query(query, params)
if result: if result:
@ -1095,6 +1096,7 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
"status", "status",
"line_date", "line_date",
"external_ref", "external_ref",
"product_id",
] ]
set_clauses = [] set_clauses = []

View File

@ -555,6 +555,11 @@
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg <i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription">
<i class="bi bi-repeat me-2"></i>Abonnement
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders"> <button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders">
<i class="bi bi-bell me-2"></i>Reminders <i class="bi bi-bell me-2"></i>Reminders
@ -2170,6 +2175,204 @@
</div> </div>
</div> </div>
<!-- Subscription Tab -->
<div class="tab-pane fade" id="subscription" role="tabpanel" tabindex="0" data-module="subscription" data-has-content="unknown">
<div class="row g-3">
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-repeat me-2"></i>Abonnement</h6>
<span id="subscriptionStatusBadge" class="badge bg-light text-dark">Ingen</span>
</div>
<div class="card-body">
<div id="subscriptionEmpty" class="text-center text-muted py-3">
<i class="bi bi-receipt-cutoff display-6 mb-3 d-block opacity-25"></i>
<p>Ingen abonnement oprettet endnu.</p>
</div>
<div id="subscriptionDetails" class="d-none">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="small text-muted">Abonnement</label>
<div class="fw-semibold" id="subscriptionNumber">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Produkt</label>
<div class="fw-semibold" id="subscriptionProduct">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Interval</label>
<div class="fw-semibold" id="subscriptionInterval">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Pris</label>
<div class="fw-semibold" id="subscriptionPrice">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Startdato</label>
<div class="fw-semibold" id="subscriptionStartDate">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Status</label>
<div class="fw-semibold" id="subscriptionStatusText">-</div>
</div>
</div>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead class="bg-light">
<tr>
<th>Produkt</th>
<th>Beskrivelse</th>
<th class="text-end">Antal</th>
<th class="text-end">Enhedspris</th>
<th class="text-end">Linjesum</th>
</tr>
</thead>
<tbody id="subscriptionItemsBody">
<tr>
<td colspan="5" class="text-center text-muted">Ingen linjer</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-end mb-3">
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
</div>
<div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
</div>
<form id="subscriptionCreateForm" class="row g-3 d-none">
<div class="col-md-3">
<label class="form-label">Interval *</label>
<select class="form-select" id="subscriptionIntervalInput" required>
<option value="monthly" selected>Maaned</option>
<option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Faktura dag *</label>
<input type="number" class="form-control" id="subscriptionBillingDayInput" min="1" max="31" value="1" required>
</div>
<div class="col-md-6">
<label class="form-label">Startdato *</label>
<input type="date" class="form-control" id="subscriptionStartDateInput" required>
</div>
<div class="col-12">
<label class="form-label">Varelinjer *</label>
<div class="table-responsive">
<table class="table table-sm align-middle mb-2">
<thead>
<tr>
<th style="width: 220px;">Produkt</th>
<th>Beskrivelse</th>
<th style="width: 120px;">Antal</th>
<th style="width: 140px;">Enhedspris</th>
<th style="width: 140px;">Linjesum</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody id="subscriptionLineItemsBody">
<tr>
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addSubscriptionLine()">
<i class="bi bi-plus-lg me-1"></i>Tilfoej linje
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openSubscriptionProductModal()">
<i class="bi bi-box me-1"></i>Opret produkt
</button>
<div class="fw-semibold">Total: <span id="subscriptionLinesTotal">0,00 kr</span></div>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Noter</label>
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
</div>
<div class="col-12">
<button type="button" class="btn btn-primary" onclick="createSubscription()">
<i class="bi bi-plus-circle me-1"></i>Opret abonnement
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Subscription Product Modal -->
<div class="modal fade" id="subscriptionProductModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-box"></i> Opret produkt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="subscriptionProductForm">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="subscriptionProductName" required>
</div>
<div class="col-12">
<label class="form-label">Type</label>
<input type="text" class="form-control" id="subscriptionProductType" placeholder="subscription, service">
</div>
<div class="col-6">
<label class="form-label">Status</label>
<select class="form-select" id="subscriptionProductStatus">
<option value="active" selected>Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Salgspris</label>
<input type="number" class="form-control" id="subscriptionProductSalesPrice" step="0.01" min="0">
</div>
<div class="col-12">
<label class="form-label">Faktureringsinterval</label>
<select class="form-select" id="subscriptionProductBillingPeriod">
<option value="">-</option>
<option value="monthly">Maaned</option>
<option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option>
<option value="one_time">Engang</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Kort beskrivelse</label>
<input type="text" class="form-control" id="subscriptionProductDescription">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="createSubscriptionProduct()">
<i class="bi bi-save me-1"></i>Gem produkt
</button>
</div>
</div>
</div>
</div>
<!-- Reminders Tab --> <!-- Reminders Tab -->
<div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown"> <div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown">
<div class="row g-3"> <div class="row g-3">
@ -4349,5 +4552,422 @@
</script> </script>
<script>
const subscriptionCaseId = {{ case.id }};
let currentSubscription = null;
let subscriptionProducts = [];
let lastCreatedSubscriptionProductId = null;
function formatSubscriptionInterval(interval) {
const map = {
'monthly': 'Maaned',
'quarterly': 'Kvartal',
'yearly': 'Aar'
};
return map[interval] || interval || '-';
}
function formatSubscriptionCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount || 0);
}
function formatSubscriptionDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK');
}
function setSubscriptionBadge(status) {
const badge = document.getElementById('subscriptionStatusBadge');
if (!badge) return;
const classes = {
'draft': 'bg-light text-dark',
'active': 'bg-success',
'paused': 'bg-warning',
'cancelled': 'bg-secondary'
};
const label = {
'draft': 'Kladde',
'active': 'Aktiv',
'paused': 'Pauset',
'cancelled': 'Opsagt'
};
badge.className = `badge ${classes[status] || 'bg-light text-dark'}`;
badge.textContent = label[status] || status || 'Ingen';
}
function showSubscriptionCreateForm() {
const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails');
if (empty) empty.classList.remove('d-none');
if (form) form.classList.remove('d-none');
if (details) details.classList.add('d-none');
setSubscriptionBadge(null);
const startDateInput = document.getElementById('subscriptionStartDateInput');
if (startDateInput && !startDateInput.value) {
startDateInput.value = new Date().toISOString().split('T')[0];
}
const body = document.getElementById('subscriptionLineItemsBody');
if (body) {
body.innerHTML = `
<tr>
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
</tr>
`;
}
populateSubscriptionProductSelects();
updateSubscriptionLineTotals();
}
function populateSubscriptionProductSelects() {
const selects = document.querySelectorAll('.subscriptionProductSelect');
selects.forEach(select => {
const currentValue = select.value;
select.innerHTML = '<option value="">Vælg produkt</option>';
subscriptionProducts.forEach(product => {
const option = document.createElement('option');
option.value = product.id;
option.textContent = product.name;
option.dataset.salesPrice = product.sales_price ?? '';
option.dataset.description = product.short_description ?? '';
select.appendChild(option);
});
if (currentValue) {
select.value = currentValue;
} else if (lastCreatedSubscriptionProductId) {
select.value = String(lastCreatedSubscriptionProductId);
}
});
lastCreatedSubscriptionProductId = null;
}
function applySubscriptionProduct(select) {
const row = select.closest('tr');
if (!row) return;
const descriptionInput = row.querySelector('input[type="text"]');
const unitPriceInput = row.querySelectorAll('input[type="number"]')[1];
const selected = select.options[select.selectedIndex];
if (!selected) return;
const description = selected.dataset.description || selected.textContent || '';
const salesPrice = selected.dataset.salesPrice;
if (descriptionInput && !descriptionInput.value.trim()) {
descriptionInput.value = description;
}
if (unitPriceInput && salesPrice !== '') {
unitPriceInput.value = salesPrice;
}
updateSubscriptionLineTotals();
}
function addSubscriptionLine() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return;
const row = document.createElement('tr');
row.innerHTML = `
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
`;
body.appendChild(row);
populateSubscriptionProductSelects();
updateSubscriptionLineTotals();
}
function removeSubscriptionLine(button) {
const row = button.closest('tr');
const body = document.getElementById('subscriptionLineItemsBody');
if (!row || !body) return;
if (body.children.length <= 1) {
row.querySelectorAll('input').forEach(input => {
input.value = input.type === 'number' ? 0 : '';
});
} else {
row.remove();
}
updateSubscriptionLineTotals();
}
function updateSubscriptionLineTotals() {
const body = document.getElementById('subscriptionLineItemsBody');
const totalEl = document.getElementById('subscriptionLinesTotal');
if (!body || !totalEl) return;
let total = 0;
Array.from(body.querySelectorAll('tr')).forEach(row => {
const inputs = row.querySelectorAll('input');
const description = inputs[0]?.value || '';
const qty = parseFloat(inputs[1]?.value || 0);
const unit = parseFloat(inputs[2]?.value || 0);
const lineTotal = (qty > 0 ? qty : 0) * (unit > 0 ? unit : 0);
total += lineTotal;
const lineTotalEl = row.querySelector('.subscriptionLineTotal');
if (lineTotalEl) {
lineTotalEl.textContent = formatSubscriptionCurrency(lineTotal);
}
if (!description && qty === 0 && unit === 0) {
if (lineTotalEl) {
lineTotalEl.textContent = formatSubscriptionCurrency(0);
}
}
});
totalEl.textContent = formatSubscriptionCurrency(total);
}
function collectSubscriptionLineItems() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return [];
const items = [];
Array.from(body.querySelectorAll('tr')).forEach(row => {
const productSelect = row.querySelector('.subscriptionProductSelect');
const inputs = row.querySelectorAll('input');
const description = (inputs[0]?.value || '').trim();
const quantity = parseFloat(inputs[1]?.value || 0);
const unitPrice = parseFloat(inputs[2]?.value || 0);
if (!description && quantity === 0 && unitPrice === 0) {
return;
}
items.push({
product_id: productSelect && productSelect.value ? parseInt(productSelect.value, 10) : null,
description,
quantity,
unit_price: unitPrice
});
});
return items;
}
async function loadSubscriptionProducts() {
try {
const res = await fetch('/api/v1/products');
if (!res.ok) {
throw new Error('Kunne ikke hente produkter');
}
subscriptionProducts = await res.json();
} catch (e) {
console.error('Error loading products:', e);
subscriptionProducts = [];
}
populateSubscriptionProductSelects();
}
function openSubscriptionProductModal() {
const form = document.getElementById('subscriptionProductForm');
if (form) form.reset();
new bootstrap.Modal(document.getElementById('subscriptionProductModal')).show();
}
async function createSubscriptionProduct() {
const payload = {
name: document.getElementById('subscriptionProductName').value.trim(),
type: document.getElementById('subscriptionProductType').value.trim() || null,
status: document.getElementById('subscriptionProductStatus').value,
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null
};
if (!payload.name) {
alert('Navn er paakraevet');
return;
}
const res = await fetch('/api/v1/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const error = await res.json();
alert(error.detail || 'Kunne ikke oprette produkt');
return;
}
const product = await res.json();
lastCreatedSubscriptionProductId = product.id;
bootstrap.Modal.getInstance(document.getElementById('subscriptionProductModal')).hide();
await loadSubscriptionProducts();
updateSubscriptionLineTotals();
}
function renderSubscription(subscription) {
currentSubscription = subscription;
const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails');
if (empty) empty.classList.add('d-none');
if (form) form.classList.add('d-none');
if (details) details.classList.remove('d-none');
document.getElementById('subscriptionNumber').textContent = subscription.subscription_number || `#${subscription.id}`;
document.getElementById('subscriptionProduct').textContent = subscription.product_name || '-';
document.getElementById('subscriptionInterval').textContent = formatSubscriptionInterval(subscription.billing_interval);
document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price);
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
setSubscriptionBadge(subscription.status);
const itemsBody = document.getElementById('subscriptionItemsBody');
const itemsTotal = document.getElementById('subscriptionItemsTotal');
if (itemsBody) {
const items = subscription.line_items || [];
if (!items.length) {
itemsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen linjer</td></tr>';
} else {
itemsBody.innerHTML = items.map(item => `
<tr>
<td>${item.product_name || '-'}</td>
<td>${item.description}</td>
<td class="text-end">${parseFloat(item.quantity).toFixed(2)}</td>
<td class="text-end">${formatSubscriptionCurrency(item.unit_price)}</td>
<td class="text-end">${formatSubscriptionCurrency(item.line_total)}</td>
</tr>
`).join('');
}
}
if (itemsTotal) {
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
}
const actions = document.getElementById('subscriptionActions');
if (!actions) return;
const buttons = [];
if (subscription.status === 'draft' || subscription.status === 'paused') {
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
}
if (subscription.status === 'active') {
buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`);
}
if (subscription.status !== 'cancelled') {
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
}
actions.innerHTML = buttons.join(' ');
}
async function loadSubscriptionForCase() {
try {
const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`);
if (res.status === 404) {
showSubscriptionCreateForm();
return;
}
if (!res.ok) {
throw new Error('Kunne ikke hente abonnement');
}
const subscription = await res.json();
renderSubscription(subscription);
} catch (e) {
console.error('Error loading subscription:', e);
showSubscriptionCreateForm();
}
}
async function createSubscription() {
const billingInterval = document.getElementById('subscriptionIntervalInput').value;
const billingDay = parseInt(document.getElementById('subscriptionBillingDayInput').value, 10);
const startDate = document.getElementById('subscriptionStartDateInput').value;
const notes = document.getElementById('subscriptionNotesInput').value.trim();
const lineItems = collectSubscriptionLineItems();
if (!billingInterval || !billingDay || !startDate) {
alert('Udfyld venligst alle paakraevet felter');
return;
}
if (!lineItems.length) {
alert('Du skal angive mindst en varelinje');
return;
}
try {
const res = await fetch('/api/v1/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: subscriptionCaseId,
billing_interval: billingInterval,
billing_day: billingDay,
start_date: startDate,
notes: notes || null,
line_items: lineItems
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
const subscription = await res.json();
renderSubscription(subscription);
} catch (e) {
alert(e.message || e);
}
}
async function updateSubscriptionStatus(status) {
if (!currentSubscription) return;
if (status === 'cancelled' && !confirm('Er du sikker paa, at abonnementet skal opsiges?')) {
return;
}
try {
const res = await fetch(`/api/v1/subscriptions/${currentSubscription.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke opdatere status');
}
const updated = await res.json();
renderSubscription(updated);
} catch (e) {
alert(e.message || e);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadSubscriptionProducts();
loadSubscriptionForCase();
});
</script>
</div> </div>
{% endblock %} {% endblock %}

View File

View File

@ -0,0 +1,645 @@
"""
Products API
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Dict, Any, Optional, Tuple
from app.core.database import execute_query, execute_query_single
from app.core.config import settings
import logging
import os
import aiohttp
logger = logging.getLogger(__name__)
router = APIRouter()
def _apigw_headers() -> Dict[str, str]:
token = settings.APIGW_TOKEN or os.getenv("APIGW_TOKEN") or os.getenv("APIGATEWAY_TOKEN")
if not token:
raise HTTPException(status_code=400, detail="APIGW_TOKEN is not configured")
return {"Authorization": f"Bearer {token}"}
def _normalize_query(raw_query: str) -> Tuple[str, List[str]]:
normalized = " ".join(
"".join(ch.lower() if ch.isalnum() else " " for ch in raw_query).split()
)
tokens = [token for token in normalized.split() if len(token) > 1]
return normalized, tokens
def _score_apigw_product(product: Dict[str, Any], normalized_query: str, tokens: List[str]) -> int:
if not normalized_query and not tokens:
return 0
name = str(product.get("product_name") or product.get("name") or "")
sku = str(product.get("sku") or "")
manufacturer = str(product.get("manufacturer") or "")
category = str(product.get("category") or "")
supplier = str(product.get("supplier_name") or "")
haystack = " ".join(
"".join(ch.lower() if ch.isalnum() else " " for ch in value).split()
for value in (name, sku, manufacturer, category, supplier)
if value
)
score = 0
if normalized_query and normalized_query in haystack:
score += 100
if tokens:
if all(token in haystack for token in tokens):
score += 50
for token in tokens:
if token in name.lower():
score += 5
elif token in haystack:
score += 2
if sku and sku.lower() == normalized_query:
score += 120
return score
@router.get("/products/apigateway/search", response_model=Dict[str, Any])
async def search_apigw_products(
q: Optional[str] = Query(None),
supplier_code: Optional[str] = Query(None),
min_price: Optional[float] = Query(None),
max_price: Optional[float] = Query(None),
in_stock: Optional[bool] = Query(None),
category: Optional[str] = Query(None),
manufacturer: Optional[str] = Query(None),
sort: Optional[str] = Query(None),
page: Optional[int] = Query(None),
per_page: Optional[int] = Query(None),
):
"""Search products via API Gateway and return raw results."""
params: Dict[str, Any] = {}
if q:
params["q"] = q
if supplier_code:
params["supplier_code"] = supplier_code
if min_price is not None:
params["min_price"] = min_price
if max_price is not None:
params["max_price"] = max_price
if in_stock is not None:
params["in_stock"] = str(in_stock).lower()
if category:
params["category"] = category
if manufacturer:
params["manufacturer"] = manufacturer
if sort:
params["sort"] = sort
if page is not None:
params["page"] = page
if per_page is not None:
params["per_page"] = per_page
if not params:
raise HTTPException(status_code=400, detail="Provide at least one search parameter")
base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL
url = f"{base_url.rstrip('/')}/api/v1/products/search"
logger.info("🔍 APIGW product search: %s", params)
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=_apigw_headers(), params=params) as response:
if response.status >= 400:
detail = await response.text()
raise HTTPException(status_code=response.status, detail=detail)
data = await response.json()
if q and isinstance(data, dict) and isinstance(data.get("products"), list):
normalized_query, tokens = _normalize_query(q)
data["products"].sort(
key=lambda product: _score_apigw_product(product, normalized_query, tokens),
reverse=True,
)
return data
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error searching APIGW products: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/apigateway/import", response_model=Dict[str, Any])
async def import_apigw_product(payload: Dict[str, Any]):
"""Import a single APIGW product into local catalog."""
try:
product = payload.get("product") or payload
name = (product.get("product_name") or product.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="product_name is required")
supplier_code = product.get("supplier_code")
sku = product.get("sku")
sku_internal = f"{supplier_code}:{sku}" if supplier_code and sku else sku
if sku_internal:
existing = execute_query_single(
"SELECT * FROM products WHERE sku_internal = %s AND deleted_at IS NULL",
(sku_internal,)
)
if existing:
return existing
sales_price = product.get("price")
supplier_price = product.get("price")
insert_query = """
INSERT INTO products (
name,
short_description,
type,
status,
sku_internal,
ean,
manufacturer,
supplier_name,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
sales_price,
vat_rate,
billable
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING *
"""
params = (
name,
product.get("category"),
"hardware",
"active",
sku_internal,
product.get("ean"),
product.get("manufacturer"),
product.get("supplier_name"),
sku,
supplier_price,
product.get("currency") or "DKK",
product.get("stock_qty"),
sales_price,
25.00,
True,
)
result = execute_query(insert_query, params)
return result[0] if result else {}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error importing APIGW product: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products", response_model=List[Dict[str, Any]])
async def list_products(
status: Optional[str] = Query("active"),
q: Optional[str] = Query(None),
product_type: Optional[str] = Query(None, alias="type"),
manufacturer: Optional[str] = Query(None),
supplier_name: Optional[str] = Query(None),
sku: Optional[str] = Query(None),
ean: Optional[str] = Query(None),
billable: Optional[bool] = Query(None),
is_bundle: Optional[bool] = Query(None),
min_price: Optional[float] = Query(None),
max_price: Optional[float] = Query(None),
):
"""List products with optional search and filters."""
try:
conditions = ["deleted_at IS NULL"]
params = []
if status and status.lower() != "all":
conditions.append("status = %s")
params.append(status)
if q:
like = f"%{q.strip()}%"
conditions.append(
"(name ILIKE %s OR sku_internal ILIKE %s OR ean ILIKE %s OR manufacturer ILIKE %s OR supplier_name ILIKE %s)"
)
params.extend([like, like, like, like, like])
if product_type:
conditions.append("type = %s")
params.append(product_type)
if manufacturer:
conditions.append("manufacturer ILIKE %s")
params.append(f"%{manufacturer.strip()}%")
if supplier_name:
conditions.append("supplier_name ILIKE %s")
params.append(f"%{supplier_name.strip()}%")
if sku:
conditions.append("sku_internal ILIKE %s")
params.append(f"%{sku.strip()}%")
if ean:
conditions.append("ean ILIKE %s")
params.append(f"%{ean.strip()}%")
if billable is not None:
conditions.append("billable = %s")
params.append(billable)
if is_bundle is not None:
conditions.append("is_bundle = %s")
params.append(is_bundle)
if min_price is not None:
conditions.append("sales_price >= %s")
params.append(min_price)
if max_price is not None:
conditions.append("sales_price <= %s")
params.append(max_price)
where_clause = "WHERE " + " AND ".join(conditions)
query = f"""
SELECT
id,
uuid,
name,
short_description,
type,
status,
sku_internal,
ean,
manufacturer,
supplier_name,
supplier_price,
cost_price,
sales_price,
vat_rate,
billing_period,
auto_renew,
minimum_term_months,
is_bundle,
billable,
image_url
FROM products
{where_clause}
ORDER BY name ASC
"""
return execute_query(query, tuple(params)) or []
except Exception as e:
logger.error(f"❌ Error listing products: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products", response_model=Dict[str, Any])
async def create_product(payload: Dict[str, Any]):
"""Create a product."""
try:
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
query = """
INSERT INTO products (
name,
short_description,
long_description,
type,
status,
sku_internal,
ean,
er_number,
manufacturer,
manufacturer_sku,
supplier_id,
supplier_name,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
supplier_lead_time_days,
supplier_updated_at,
cost_price,
sales_price,
vat_rate,
price_model,
price_override_allowed,
billing_period,
billing_anchor_month,
auto_renew,
minimum_term_months,
subscription_group_id,
is_bundle,
parent_product_id,
bundle_pricing_model,
billable,
default_case_tag,
default_time_rate_id,
category_id,
subcategory_id,
tags,
attributes_json,
technical_spec_json,
ai_classified,
ai_confidence,
ai_category_suggestion,
ai_tags_suggestion,
ai_classified_at,
image_url,
datasheet_url,
manual_url,
created_by,
updated_by
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING *
"""
params = (
name,
payload.get("short_description"),
payload.get("long_description"),
payload.get("type"),
payload.get("status", "active"),
payload.get("sku_internal"),
payload.get("ean"),
payload.get("er_number"),
payload.get("manufacturer"),
payload.get("manufacturer_sku"),
payload.get("supplier_id"),
payload.get("supplier_name"),
payload.get("supplier_sku"),
payload.get("supplier_price"),
payload.get("supplier_currency", "DKK"),
payload.get("supplier_stock"),
payload.get("supplier_lead_time_days"),
payload.get("supplier_updated_at"),
payload.get("cost_price"),
payload.get("sales_price"),
payload.get("vat_rate", 25.00),
payload.get("price_model"),
payload.get("price_override_allowed", False),
payload.get("billing_period"),
payload.get("billing_anchor_month"),
payload.get("auto_renew", False),
payload.get("minimum_term_months"),
payload.get("subscription_group_id"),
payload.get("is_bundle", False),
payload.get("parent_product_id"),
payload.get("bundle_pricing_model"),
payload.get("billable", True),
payload.get("default_case_tag"),
payload.get("default_time_rate_id"),
payload.get("category_id"),
payload.get("subcategory_id"),
payload.get("tags"),
payload.get("attributes_json"),
payload.get("technical_spec_json"),
payload.get("ai_classified", False),
payload.get("ai_confidence"),
payload.get("ai_category_suggestion"),
payload.get("ai_tags_suggestion"),
payload.get("ai_classified_at"),
payload.get("image_url"),
payload.get("datasheet_url"),
payload.get("manual_url"),
payload.get("created_by"),
payload.get("updated_by"),
)
result = execute_query(query, params)
return result[0] if result else {}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating product: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}", response_model=Dict[str, Any])
async def get_product(product_id: int):
"""Get a single product."""
try:
query = "SELECT * FROM products WHERE id = %s AND deleted_at IS NULL"
product = execute_query_single(query, (product_id,))
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error loading product: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}/price-history", response_model=List[Dict[str, Any]])
async def list_product_price_history(product_id: int, limit: int = Query(100)):
"""List price history entries for a product."""
try:
query = """
SELECT
id,
product_id,
price_type,
old_price,
new_price,
note,
changed_by,
changed_at
FROM product_price_history
WHERE product_id = %s
ORDER BY changed_at DESC
LIMIT %s
"""
return execute_query(query, (product_id, limit)) or []
except Exception as e:
logger.error("❌ Error loading product price history: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/{product_id}/price", response_model=Dict[str, Any])
async def update_product_price(product_id: int, payload: Dict[str, Any]):
"""Update product sales price and record price history."""
try:
if "new_price" not in payload:
raise HTTPException(status_code=400, detail="new_price is required")
new_price = payload.get("new_price")
note = payload.get("note")
changed_by = payload.get("changed_by")
current = execute_query_single(
"SELECT sales_price FROM products WHERE id = %s AND deleted_at IS NULL",
(product_id,)
)
if not current:
raise HTTPException(status_code=404, detail="Product not found")
old_price = current.get("sales_price")
if old_price == new_price:
return {"status": "no_change", "sales_price": old_price}
update_query = """
UPDATE products
SET sales_price = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
updated = execute_query(update_query, (new_price, product_id))
history_query = """
INSERT INTO product_price_history (
product_id,
price_type,
old_price,
new_price,
note,
changed_by
) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
history = execute_query(
history_query,
(product_id, "sales_price", old_price, new_price, note, changed_by)
)
return {
"status": "updated",
"product": updated[0] if updated else {},
"history": history[0] if history else {}
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating product price: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/{product_id}/supplier", response_model=Dict[str, Any])
async def update_product_supplier(product_id: int, payload: Dict[str, Any]):
"""Update supplier info and optionally record supplier price history."""
try:
supplier_name = payload.get("supplier_name")
supplier_price = payload.get("supplier_price")
note = payload.get("note")
changed_by = payload.get("changed_by")
current = execute_query_single(
"SELECT supplier_name, supplier_price FROM products WHERE id = %s AND deleted_at IS NULL",
(product_id,)
)
if not current:
raise HTTPException(status_code=404, detail="Product not found")
update_query = """
UPDATE products
SET supplier_name = %s,
supplier_price = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
updated = execute_query(
update_query,
(
supplier_name if supplier_name is not None else current.get("supplier_name"),
supplier_price if supplier_price is not None else current.get("supplier_price"),
product_id,
)
)
history_entry = {}
if supplier_price is not None and current.get("supplier_price") != supplier_price:
history_query = """
INSERT INTO product_price_history (
product_id,
price_type,
old_price,
new_price,
note,
changed_by
) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
history = execute_query(
history_query,
(
product_id,
"supplier_price",
current.get("supplier_price"),
supplier_price,
note,
changed_by,
)
)
history_entry = history[0] if history else {}
return {
"status": "updated",
"product": updated[0] if updated else {},
"history": history_entry
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating supplier info: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}/sales-history", response_model=List[Dict[str, Any]])
async def list_product_sales_history(product_id: int, limit: int = Query(100)):
"""List historical sales for a product from cases and subscriptions."""
try:
query = """
SELECT
'case_sale' AS source,
ss.id AS reference_id,
ss.sag_id,
COALESCE(ss.line_date, ss.created_at)::date AS line_date,
ss.description,
ss.quantity,
ss.unit_price,
ss.amount AS total_amount,
ss.currency,
ss.status
FROM sag_salgsvarer ss
WHERE ss.product_id = %s AND ss.type = 'sale'
UNION ALL
SELECT
'subscription' AS source,
ssi.id AS reference_id,
ss.sag_id,
ssi.created_at::date AS line_date,
ssi.description,
ssi.quantity,
ssi.unit_price,
ssi.line_total AS total_amount,
'DKK' AS currency,
ss.status
FROM sag_subscription_items ssi
JOIN sag_subscriptions ss ON ss.id = ssi.subscription_id
WHERE ssi.product_id = %s
ORDER BY line_date DESC
LIMIT %s
"""
return execute_query(query, (product_id, product_id, limit)) or []
except Exception as e:
logger.error("❌ Error loading product sales history: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

View File

@ -0,0 +1,366 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Produkt - BMC Hub{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Serif:wght@400;500;600&display=swap">
<style>
:root {
--product-bg-1: #f5f7fb;
--product-bg-2: #e9f1f7;
--product-ink: #102a43;
--product-ink-muted: #486581;
--product-accent: #0f4c75;
--product-card: #ffffff;
--product-border: rgba(15, 76, 117, 0.12);
}
[data-bs-theme="dark"] {
--product-bg-1: #1b1f24;
--product-bg-2: #20252b;
--product-ink: #f1f5f9;
--product-ink-muted: #b6c2d1;
--product-card: #252b33;
--product-border: rgba(61, 139, 253, 0.18);
}
.product-page {
font-family: "Space Grotesk", sans-serif;
color: var(--product-ink);
background: radial-gradient(circle at 10% 10%, var(--product-bg-2), transparent 45%),
radial-gradient(circle at 90% 20%, rgba(15, 76, 117, 0.15), transparent 50%),
linear-gradient(160deg, var(--product-bg-1), var(--product-bg-2));
border-radius: 18px;
padding: 28px;
box-shadow: 0 20px 40px rgba(15, 76, 117, 0.08);
position: relative;
overflow: hidden;
}
.product-page h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 6px;
}
.product-muted {
color: var(--product-ink-muted);
}
.product-card {
background: var(--product-card);
border-radius: 16px;
border: 1px solid var(--product-border);
padding: 18px;
box-shadow: 0 16px 30px rgba(15, 76, 117, 0.08);
}
.product-table th {
background: rgba(15, 76, 117, 0.06);
}
.badge-soft {
background: rgba(15, 76, 117, 0.12);
color: var(--product-ink);
border-radius: 999px;
padding: 4px 10px;
font-size: 0.75rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="product-page">
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-4">
<div>
<a href="/products" class="text-decoration-none product-muted">&#8592; Tilbage til produkter</a>
<h1 id="productName">Produkt</h1>
<div class="product-muted" id="productMeta"></div>
</div>
<div class="text-end">
<div class="badge-soft" id="productStatus">-</div>
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
<div class="product-muted" id="productSku">-</div>
<div class="product-muted" id="productSupplierPrice">-</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-4">
<div class="product-card h-100">
<h5 class="mb-3">Opdater pris</h5>
<div class="mb-3">
<label class="form-label">Ny salgspris</label>
<input type="number" class="form-control" id="priceNewValue" step="0.01" min="0">
</div>
<div class="mb-3">
<label class="form-label">Note</label>
<input type="text" class="form-control" id="priceNote" placeholder="Aarsag til prisændring">
</div>
<div class="mb-3">
<label class="form-label">Opdateret af</label>
<input type="text" class="form-control" id="priceChangedBy" placeholder="Navn">
</div>
<button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button>
<div class="small mt-3" id="priceUpdateMessage"></div>
</div>
<div class="product-card mt-3">
<h5 class="mb-3">Opdater leverandoer</h5>
<div class="mb-3">
<label class="form-label">Leverandoer</label>
<input type="text" class="form-control" id="supplierName" placeholder="Leverandoer navn">
</div>
<div class="mb-3">
<label class="form-label">Leverandoer pris</label>
<input type="number" class="form-control" id="supplierPrice" step="0.01" min="0">
</div>
<div class="mb-3">
<label class="form-label">Note</label>
<input type="text" class="form-control" id="supplierNote" placeholder="Aarsag til ændring">
</div>
<div class="mb-3">
<label class="form-label">Opdateret af</label>
<input type="text" class="form-control" id="supplierChangedBy" placeholder="Navn">
</div>
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandoer</button>
<div class="small mt-3" id="supplierUpdateMessage"></div>
</div>
</div>
<div class="col-lg-8">
<div class="product-card mb-3">
<h5 class="mb-3">Pris historik</h5>
<div class="table-responsive">
<table class="table table-sm product-table mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Type</th>
<th>Gammel</th>
<th>Ny</th>
<th>Note</th>
<th>Af</th>
</tr>
</thead>
<tbody id="priceHistoryBody">
<tr><td colspan="6" class="text-center product-muted py-3">Indlaeser...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="product-card">
<h5 class="mb-3">Tidligere salg</h5>
<div class="table-responsive">
<table class="table table-sm product-table mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Kilde</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Pris</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody id="salesHistoryBody">
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const productId = {{ product_id }};
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount || 0);
}
function formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString('da-DK');
}
function setMessage(message, tone = 'text-muted') {
const el = document.getElementById('priceUpdateMessage');
if (!el) return;
el.className = `small ${tone}`;
el.textContent = message;
}
function setSupplierMessage(message, tone = 'text-muted') {
const el = document.getElementById('supplierUpdateMessage');
if (!el) return;
el.className = `small ${tone}`;
el.textContent = message;
}
async function loadProductDetail() {
try {
const res = await fetch(`/api/v1/products/${productId}`);
if (!res.ok) throw new Error('Kunne ikke hente produkt');
const product = await res.json();
document.getElementById('productName').textContent = product.name || 'Produkt';
document.getElementById('productMeta').textContent = [
product.manufacturer,
product.type,
product.supplier_name
].filter(Boolean).join(' • ');
document.getElementById('productStatus').textContent = product.status || '-';
document.getElementById('productPrice').textContent = product.sales_price != null ? formatCurrency(product.sales_price) : '-';
document.getElementById('productSku').textContent = product.sku_internal || '-';
document.getElementById('productSupplierPrice').textContent = product.supplier_price != null
? `Leverandoer: ${formatCurrency(product.supplier_price)}`
: 'Leverandoer pris: -';
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
document.getElementById('supplierName').value = product.supplier_name || '';
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
} catch (e) {
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
}
}
async function loadPriceHistory() {
const tbody = document.getElementById('priceHistoryBody');
try {
const res = await fetch(`/api/v1/products/${productId}/price-history`);
if (!res.ok) throw new Error('Kunne ikke hente pris historik');
const history = await res.json();
if (!history.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center product-muted py-3">Ingen historik</td></tr>';
return;
}
tbody.innerHTML = history.map(entry => `
<tr>
<td>${formatDate(entry.changed_at)}</td>
<td>${entry.price_type === 'supplier_price' ? 'Leverandoer' : 'Salgspris'}</td>
<td>${entry.old_price != null ? formatCurrency(entry.old_price) : '-'}</td>
<td>${entry.new_price != null ? formatCurrency(entry.new_price) : '-'}</td>
<td>${escapeHtml(entry.note || '-')}</td>
<td>${escapeHtml(entry.changed_by || '-')}</td>
</tr>
`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="6" class="text-center text-danger py-3">${escapeHtml(e.message || 'Fejl')}</td></tr>`;
}
}
async function loadSalesHistory() {
const tbody = document.getElementById('salesHistoryBody');
try {
const res = await fetch(`/api/v1/products/${productId}/sales-history`);
if (!res.ok) throw new Error('Kunne ikke hente salgs historik');
const history = await res.json();
if (!history.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center product-muted py-3">Ingen salg fundet</td></tr>';
return;
}
tbody.innerHTML = history.map(entry => `
<tr>
<td>${formatDate(entry.line_date)}</td>
<td>${entry.source === 'subscription' ? 'Abonnement' : 'Sag salg'}</td>
<td>${escapeHtml(entry.description || '-')}</td>
<td>${entry.quantity != null ? entry.quantity : '-'}</td>
<td>${entry.unit_price != null ? formatCurrency(entry.unit_price) : '-'}</td>
<td>${entry.total_amount != null ? formatCurrency(entry.total_amount) : '-'}</td>
<td>${escapeHtml(entry.status || '-')}</td>
</tr>
`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${escapeHtml(e.message || 'Fejl')}</td></tr>`;
}
}
async function submitPriceUpdate() {
const newPrice = document.getElementById('priceNewValue').value;
const note = document.getElementById('priceNote').value.trim();
const changedBy = document.getElementById('priceChangedBy').value.trim();
if (!newPrice) {
setMessage('Angiv ny pris', 'text-danger');
return;
}
try {
const res = await fetch(`/api/v1/products/${productId}/price`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
new_price: Number(newPrice),
note: note || null,
changed_by: changedBy || null
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Prisopdatering fejlede');
}
await res.json();
setMessage('Pris opdateret', 'text-success');
await loadProductDetail();
await loadPriceHistory();
} catch (e) {
setMessage(e.message || 'Fejl ved opdatering', 'text-danger');
}
}
async function submitSupplierUpdate() {
const supplierName = document.getElementById('supplierName').value.trim();
const supplierPriceValue = document.getElementById('supplierPrice').value;
const note = document.getElementById('supplierNote').value.trim();
const changedBy = document.getElementById('supplierChangedBy').value.trim();
try {
const res = await fetch(`/api/v1/products/${productId}/supplier`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
supplier_name: supplierName || null,
supplier_price: supplierPriceValue ? Number(supplierPriceValue) : null,
note: note || null,
changed_by: changedBy || null
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Leverandoer opdatering fejlede');
}
await res.json();
setSupplierMessage('Leverandoer opdateret', 'text-success');
await loadProductDetail();
await loadPriceHistory();
} catch (e) {
setSupplierMessage(e.message || 'Fejl ved opdatering', 'text-danger');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadProductDetail();
loadPriceHistory();
loadSalesHistory();
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
"""
Products Frontend Views
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/products", response_class=HTMLResponse)
async def products_list(request: Request):
return templates.TemplateResponse("products/frontend/list.html", {
"request": request
})
@router.get("/products/{product_id}", response_class=HTMLResponse)
async def product_detail(request: Request, product_id: int):
return templates.TemplateResponse("products/frontend/detail.html", {
"request": request,
"product_id": product_id
})

View File

@ -248,6 +248,7 @@
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li> <li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li> <li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li> <li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li> <li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul> </ul>
@ -259,7 +260,7 @@
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li> <li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
<li><a class="dropdown-item py-2" href="#">Ordre</a></li> <li><a class="dropdown-item py-2" href="#">Ordre</a></li>
<li><a class="dropdown-item py-2" href="#">Produkter</a></li> <li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li> <li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>

View File

View File

@ -0,0 +1,308 @@
"""
Subscriptions API
Sag-based subscriptions listing and stats
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Dict, Any
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_STATUSES = {"draft", "active", "paused", "cancelled"}
@router.get("/subscriptions/by-sag/{sag_id}", response_model=Dict[str, Any])
async def get_subscription_by_sag(sag_id: int):
"""Get latest subscription for a case."""
try:
query = """
SELECT
s.id,
s.subscription_number,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.status,
s.notes
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.sag_id = %s
ORDER BY s.id DESC
LIMIT 1
"""
subscription = execute_query_single(query, (sag_id,))
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
items = execute_query(
"""
SELECT
i.id,
i.line_no,
i.product_id,
p.name AS product_name,
i.description,
i.quantity,
i.unit_price,
i.line_total
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription["id"],)
)
subscription["line_items"] = items or []
return subscription
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error loading subscription by case: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/subscriptions", response_model=Dict[str, Any])
async def create_subscription(payload: Dict[str, Any]):
"""Create a new subscription tied to a case (status = draft)."""
try:
sag_id = payload.get("sag_id")
billing_interval = payload.get("billing_interval")
billing_day = payload.get("billing_day")
start_date = payload.get("start_date")
notes = payload.get("notes")
line_items = payload.get("line_items") or []
if not sag_id:
raise HTTPException(status_code=400, detail="sag_id is required")
if not billing_interval:
raise HTTPException(status_code=400, detail="billing_interval is required")
if billing_day is None:
raise HTTPException(status_code=400, detail="billing_day is required")
if not start_date:
raise HTTPException(status_code=400, detail="start_date is required")
if not line_items:
raise HTTPException(status_code=400, detail="line_items is required")
sag = execute_query_single(
"SELECT id, customer_id FROM sag_sager WHERE id = %s",
(sag_id,)
)
if not sag or not sag.get("customer_id"):
raise HTTPException(status_code=400, detail="Case must have a customer")
existing = execute_query_single(
"""
SELECT id FROM sag_subscriptions
WHERE sag_id = %s AND status != 'cancelled'
ORDER BY id DESC
LIMIT 1
""",
(sag_id,)
)
if existing:
raise HTTPException(status_code=400, detail="Subscription already exists for this case")
product_ids = [item.get("product_id") for item in line_items if item.get("product_id")]
product_map = {}
if product_ids:
rows = execute_query(
"SELECT id, name, sales_price FROM products WHERE id = ANY(%s)",
(product_ids,)
)
product_map = {row["id"]: row for row in (rows or [])}
cleaned_items = []
total_price = 0
for idx, item in enumerate(line_items, start=1):
product_id = item.get("product_id")
description = (item.get("description") or "").strip()
quantity = item.get("quantity")
unit_price = item.get("unit_price")
product = product_map.get(product_id)
if not description and product:
description = product.get("name") or ""
if unit_price is None and product and product.get("sales_price") is not None:
unit_price = product.get("sales_price")
if not description:
raise HTTPException(status_code=400, detail="line_items description is required")
if quantity is None or float(quantity) <= 0:
raise HTTPException(status_code=400, detail="line_items quantity must be > 0")
if unit_price is None or float(unit_price) < 0:
raise HTTPException(status_code=400, detail="line_items unit_price must be >= 0")
line_total = float(quantity) * float(unit_price)
total_price += line_total
cleaned_items.append({
"line_no": idx,
"product_id": product_id,
"description": description,
"quantity": quantity,
"unit_price": unit_price,
"line_total": line_total,
})
product_name = cleaned_items[0]["description"]
if len(cleaned_items) > 1:
product_name = f"{product_name} (+{len(cleaned_items) - 1})"
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
INSERT INTO sag_subscriptions (
sag_id,
customer_id,
product_name,
billing_interval,
billing_day,
price,
start_date,
status,
notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'draft', %s)
RETURNING *
""",
(
sag_id,
sag["customer_id"],
product_name,
billing_interval,
billing_day,
total_price,
start_date,
notes,
)
)
subscription = cursor.fetchone()
for item in cleaned_items:
cursor.execute(
"""
INSERT INTO sag_subscription_items (
subscription_id,
line_no,
product_id,
description,
quantity,
unit_price,
line_total
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
subscription["id"],
item["line_no"],
item["product_id"],
item["description"],
item["quantity"],
item["unit_price"],
item["line_total"],
)
)
conn.commit()
subscription["line_items"] = cleaned_items
return subscription
finally:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating subscription: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/subscriptions/{subscription_id}/status", response_model=Dict[str, Any])
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]):
"""Update subscription status."""
try:
status = payload.get("status")
if status not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Invalid status")
query = """
UPDATE sag_subscriptions
SET status = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (status, subscription_id))
if not result:
raise HTTPException(status_code=404, detail="Subscription not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating subscription status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions", response_model=List[Dict[str, Any]])
async def list_subscriptions(status: str = Query("active")):
"""List subscriptions by status (default: active)."""
try:
where_clause = "WHERE s.status = %s"
params = (status,)
query = f"""
SELECT
s.id,
s.subscription_number,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.status
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
{where_clause}
ORDER BY s.start_date DESC, s.id DESC
"""
return execute_query(query, params) or []
except Exception as e:
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any])
async def subscription_stats(status: str = Query("active")):
"""Summary stats for subscriptions by status (default: active)."""
try:
query = """
SELECT
COUNT(*) AS subscription_count,
COALESCE(SUM(price), 0) AS total_amount,
COALESCE(AVG(price), 0) AS avg_amount
FROM sag_subscriptions
WHERE status = %s
"""
result = execute_query(query, (status,))
return result[0] if result else {
"subscription_count": 0,
"total_amount": 0,
"avg_amount": 0
}
except Exception as e:
logger.error(f"❌ Error loading subscription stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

View File

@ -0,0 +1,164 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Abonnementer - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
<p class="text-muted">Alle solgte, aktive abonnementer</p>
</div>
</div>
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Aktive Abonnementer</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Total Pris (aktive)</p>
<h3 class="mb-0" id="totalAmount">-</h3>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Gns. Pris</p>
<h3 class="mb-0" id="avgAmount">-</h3>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">Aktive abonnementer</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th>Abonnement</th>
<th>Kunde</th>
<th>Sag</th>
<th>Produkt</th>
<th>Interval</th>
<th>Pris</th>
<th>Start</th>
<th>Status</th>
</tr>
</thead>
<tbody id="subscriptionsBody">
<tr>
<td colspan="8" class="text-center text-muted py-5">
<span class="spinner-border spinner-border-sm me-2"></span>Indlaeser...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
async function loadSubscriptions() {
try {
const stats = await fetch('/api/v1/subscriptions/stats/summary').then(r => r.json());
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
const subscriptions = await fetch('/api/v1/subscriptions').then(r => r.json());
renderSubscriptions(subscriptions);
} catch (e) {
console.error('Error loading subscriptions:', e);
document.getElementById('subscriptionsBody').innerHTML = `
<tr><td colspan="8" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlaesning</p>
</td></tr>
`;
}
}
function renderSubscriptions(subscriptions) {
const tbody = document.getElementById('subscriptionsBody');
if (!subscriptions || subscriptions.length === 0) {
tbody.innerHTML = `
<tr><td colspan="8" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen aktive abonnementer</p>
</td></tr>
`;
return;
}
tbody.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`;
return `
<tr>
<td><strong>${subNumber}</strong></td>
<td>${sub.customer_name || '-'}</td>
<td>${sagLink}</td>
<td>${sub.product_name || '-'}</td>
<td>${intervalLabel}${sub.billing_day ? ' (dag ' + sub.billing_day + ')' : ''}</td>
<td>${formatCurrency(sub.price || 0)}</td>
<td>${formatDate(sub.start_date)}</td>
<td>${statusBadge}</td>
</tr>
`;
}).join('');
}
function formatInterval(interval) {
const map = {
'monthly': 'Maaned',
'quarterly': 'Kvartal',
'yearly': 'Aar'
};
return map[interval] || interval || '-';
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'paused': '<span class="badge bg-warning">Pauset</span>',
'cancelled': '<span class="badge bg-secondary">Opsagt</span>',
'draft': '<span class="badge bg-light text-dark">Kladde</span>'
};
return badges[status] || status || '-';
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK');
}
document.addEventListener('DOMContentLoaded', loadSubscriptions);
</script>
{% endblock %}

View File

@ -0,0 +1,19 @@
"""
Subscriptions Frontend Views
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/subscriptions", response_class=HTMLResponse)
async def subscriptions_list(request: Request):
"""List all active subscriptions."""
return templates.TemplateResponse("subscriptions/frontend/list.html", {
"request": request
})

View File

@ -50,6 +50,9 @@ services:
# Override database URL to point to postgres service # Override database URL to point to postgres service
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub} - DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
- ENABLE_RELOAD=false - ENABLE_RELOAD=false
- APIGW_TOKEN=${APIGW_TOKEN}
- APIGATEWAY_URL=${APIGATEWAY_URL}
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]

View File

@ -39,6 +39,10 @@ from app.prepaid.backend import router as prepaid_api
from app.prepaid.backend import views as prepaid_views from app.prepaid.backend import views as prepaid_views
from app.fixed_price.backend import router as fixed_price_api from app.fixed_price.backend import router as fixed_price_api
from app.fixed_price.frontend import views as fixed_price_views from app.fixed_price.frontend import views as fixed_price_views
from app.subscriptions.backend import router as subscriptions_api
from app.subscriptions.frontend import views as subscriptions_views
from app.products.backend import router as products_api
from app.products.frontend import views as products_views
from app.ticket.backend import router as ticket_api from app.ticket.backend import router as ticket_api
from app.ticket.frontend import views as ticket_views from app.ticket.frontend import views as ticket_views
from app.vendors.backend import router as vendors_api from app.vendors.backend import router as vendors_api
@ -235,6 +239,8 @@ app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"]) app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"])
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"]) app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"]) app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
app.include_router(subscriptions_api.router, prefix="/api/v1", tags=["Subscriptions"])
app.include_router(products_api.router, prefix="/api/v1", tags=["Products"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"]) app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"]) app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"]) app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
@ -263,6 +269,8 @@ app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"]) app.include_router(customers_views.router, tags=["Frontend"])
app.include_router(prepaid_views.router, tags=["Frontend"]) app.include_router(prepaid_views.router, tags=["Frontend"])
app.include_router(fixed_price_views.router, tags=["Frontend"]) app.include_router(fixed_price_views.router, tags=["Frontend"])
app.include_router(subscriptions_views.router, tags=["Frontend"])
app.include_router(products_views.router, tags=["Frontend"])
app.include_router(vendors_views.router, tags=["Frontend"]) app.include_router(vendors_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"]) app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(billing_views.router, tags=["Frontend"]) app.include_router(billing_views.router, tags=["Frontend"])

View File

@ -0,0 +1,61 @@
-- Migration 104: Sag Subscriptions
-- Sag-based subscriptions (module on cases)
CREATE TABLE IF NOT EXISTS sag_subscriptions (
id SERIAL PRIMARY KEY,
subscription_number VARCHAR(50) UNIQUE NOT NULL,
-- Ownership
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
-- Product and billing
product_name VARCHAR(255),
billing_interval VARCHAR(20) NOT NULL DEFAULT 'monthly' CHECK (billing_interval IN ('monthly', 'quarterly', 'yearly')),
billing_day INTEGER NOT NULL DEFAULT 1 CHECK (billing_day BETWEEN 1 AND 31),
price DECIMAL(10,2) NOT NULL CHECK (price >= 0),
-- Lifecycle
start_date DATE NOT NULL,
end_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'cancelled')),
-- Metadata
notes TEXT,
created_by_user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_customer ON sag_subscriptions(customer_id);
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_sag ON sag_subscriptions(sag_id);
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_status ON sag_subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_dates ON sag_subscriptions(start_date, end_date);
-- Auto-generate subscription_number (SUB-YYYYMMDD-XXX)
CREATE OR REPLACE FUNCTION generate_sag_subscription_number()
RETURNS TRIGGER AS $$
DECLARE
new_number VARCHAR(50);
day_count INTEGER;
BEGIN
SELECT COUNT(*) INTO day_count
FROM sag_subscriptions
WHERE DATE(created_at) = CURRENT_DATE;
new_number := 'SUB-' || TO_CHAR(CURRENT_DATE, 'YYYYMMDD') || '-' || LPAD((day_count + 1)::TEXT, 3, '0');
NEW.subscription_number := new_number;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_generate_sag_subscription_number
BEFORE INSERT ON sag_subscriptions
FOR EACH ROW
EXECUTE FUNCTION generate_sag_subscription_number();
CREATE TRIGGER trigger_sag_subscriptions_updated_at
BEFORE UPDATE ON sag_subscriptions
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@ -0,0 +1,25 @@
-- Migration 105: Sag Subscription Items
-- Line items for sag-based subscriptions
CREATE TABLE IF NOT EXISTS sag_subscription_items (
id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES sag_subscriptions(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL DEFAULT 1,
description VARCHAR(255) NOT NULL,
quantity DECIMAL(10,2) NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(10,2) NOT NULL CHECK (unit_price >= 0),
line_total DECIMAL(12,2) NOT NULL CHECK (line_total >= 0),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_subscription
ON sag_subscription_items(subscription_id);
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_line_no
ON sag_subscription_items(subscription_id, line_no);
CREATE TRIGGER trigger_sag_subscription_items_updated_at
BEFORE UPDATE ON sag_subscription_items
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@ -0,0 +1,82 @@
-- Migration 106: Products
-- Master product catalog for subscriptions and sales
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
short_description TEXT,
long_description TEXT,
type VARCHAR(50),
status VARCHAR(20) DEFAULT 'active',
sku_internal VARCHAR(100),
ean VARCHAR(50),
er_number VARCHAR(50),
manufacturer VARCHAR(100),
manufacturer_sku VARCHAR(100),
supplier_id INTEGER,
supplier_name VARCHAR(255),
supplier_sku VARCHAR(100),
supplier_price DECIMAL(10,2),
supplier_currency VARCHAR(10) DEFAULT 'DKK',
supplier_stock INTEGER,
supplier_lead_time_days INTEGER,
supplier_updated_at TIMESTAMP,
cost_price DECIMAL(10,2),
sales_price DECIMAL(10,2),
vat_rate DECIMAL(5,2) DEFAULT 25.00,
price_model VARCHAR(50),
price_override_allowed BOOLEAN DEFAULT false,
billing_period VARCHAR(20),
billing_anchor_month INTEGER,
auto_renew BOOLEAN DEFAULT false,
minimum_term_months INTEGER,
subscription_group_id INTEGER,
is_bundle BOOLEAN DEFAULT false,
parent_product_id INTEGER,
bundle_pricing_model VARCHAR(50),
billable BOOLEAN DEFAULT true,
default_case_tag VARCHAR(100),
default_time_rate_id INTEGER,
category_id INTEGER,
subcategory_id INTEGER,
tags TEXT[],
attributes_json JSONB,
technical_spec_json JSONB,
ai_classified BOOLEAN DEFAULT false,
ai_confidence DECIMAL(5,2),
ai_category_suggestion VARCHAR(255),
ai_tags_suggestion TEXT[],
ai_classified_at TIMESTAMP,
image_url TEXT,
datasheet_url TEXT,
manual_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
created_by INTEGER,
updated_by INTEGER
);
CREATE INDEX IF NOT EXISTS idx_products_name ON products(name);
CREATE INDEX IF NOT EXISTS idx_products_status ON products(status);
CREATE INDEX IF NOT EXISTS idx_products_sku ON products(sku_internal);
CREATE INDEX IF NOT EXISTS idx_products_deleted_at ON products(deleted_at);
CREATE TRIGGER trigger_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@ -0,0 +1,7 @@
-- Migration 106: Add product_id to subscription items
ALTER TABLE sag_subscription_items
ADD COLUMN IF NOT EXISTS product_id INTEGER REFERENCES products(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_product
ON sag_subscription_items(product_id);

View File

@ -0,0 +1,7 @@
-- Migration 107: Add product_id to subscription line items
ALTER TABLE sag_subscription_items
ADD COLUMN IF NOT EXISTS product_id INTEGER REFERENCES products(id);
CREATE INDEX IF NOT EXISTS idx_sag_subscription_items_product
ON sag_subscription_items(product_id);

View File

@ -0,0 +1,7 @@
-- Migration 108: Add product_id to sag_salgsvarer
ALTER TABLE sag_salgsvarer
ADD COLUMN IF NOT EXISTS product_id INTEGER REFERENCES products(id);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_product
ON sag_salgsvarer(product_id);

View File

@ -0,0 +1,19 @@
-- Migration 109: Product price history
-- Track changes to product pricing
CREATE TABLE IF NOT EXISTS product_price_history (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
price_type VARCHAR(50) NOT NULL DEFAULT 'sales_price',
old_price DECIMAL(10,2),
new_price DECIMAL(10,2),
note TEXT,
changed_by VARCHAR(255),
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_product_price_history_product
ON product_price_history(product_id);
CREATE INDEX IF NOT EXISTS idx_product_price_history_changed_at
ON product_price_history(changed_at);