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:
parent
e4b9091a1b
commit
6320809f17
@ -24,6 +24,12 @@ class Settings(BaseSettings):
|
||||
# Elnet supplier lookup
|
||||
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
|
||||
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
|
||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||
|
||||
@ -1022,9 +1022,9 @@ async def create_sale_item(sag_id: int, data: dict):
|
||||
|
||||
query = """
|
||||
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
|
||||
(%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 = (
|
||||
@ -1039,6 +1039,7 @@ async def create_sale_item(sag_id: int, data: dict):
|
||||
status,
|
||||
data.get("line_date"),
|
||||
data.get("external_ref"),
|
||||
data.get("product_id"),
|
||||
)
|
||||
result = execute_query(query, params)
|
||||
if result:
|
||||
@ -1095,6 +1096,7 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
||||
"status",
|
||||
"line_date",
|
||||
"external_ref",
|
||||
"product_id",
|
||||
]
|
||||
|
||||
set_clauses = []
|
||||
|
||||
@ -555,6 +555,11 @@
|
||||
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
||||
</button>
|
||||
</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">
|
||||
<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
|
||||
@ -2170,6 +2175,204 @@
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown">
|
||||
<div class="row g-3">
|
||||
@ -4349,5 +4552,422 @@
|
||||
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
0
app/products/backend/__init__.py
Normal file
0
app/products/backend/__init__.py
Normal file
645
app/products/backend/router.py
Normal file
645
app/products/backend/router.py
Normal 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))
|
||||
0
app/products/frontend/__init__.py
Normal file
0
app/products/frontend/__init__.py
Normal file
366
app/products/frontend/detail.html
Normal file
366
app/products/frontend/detail.html
Normal 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">← 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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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 %}
|
||||
1058
app/products/frontend/list.html
Normal file
1058
app/products/frontend/list.html
Normal file
File diff suppressed because it is too large
Load Diff
24
app/products/frontend/views.py
Normal file
24
app/products/frontend/views.py
Normal 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
|
||||
})
|
||||
@ -248,6 +248,7 @@
|
||||
<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="/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><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
||||
</ul>
|
||||
@ -259,7 +260,7 @@
|
||||
<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="#">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><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>
|
||||
|
||||
0
app/subscriptions/backend/__init__.py
Normal file
0
app/subscriptions/backend/__init__.py
Normal file
308
app/subscriptions/backend/router.py
Normal file
308
app/subscriptions/backend/router.py
Normal 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))
|
||||
0
app/subscriptions/frontend/__init__.py
Normal file
0
app/subscriptions/frontend/__init__.py
Normal file
164
app/subscriptions/frontend/list.html
Normal file
164
app/subscriptions/frontend/list.html
Normal 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 %}
|
||||
19
app/subscriptions/frontend/views.py
Normal file
19
app/subscriptions/frontend/views.py
Normal 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
|
||||
})
|
||||
@ -50,6 +50,9 @@ services:
|
||||
# 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}
|
||||
- ENABLE_RELOAD=false
|
||||
- APIGW_TOKEN=${APIGW_TOKEN}
|
||||
- APIGATEWAY_URL=${APIGATEWAY_URL}
|
||||
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
|
||||
8
main.py
8
main.py
@ -39,6 +39,10 @@ from app.prepaid.backend import router as prepaid_api
|
||||
from app.prepaid.backend import views as prepaid_views
|
||||
from app.fixed_price.backend import router as fixed_price_api
|
||||
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.frontend import views as ticket_views
|
||||
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(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(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(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
||||
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(prepaid_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(timetracking_views.router, tags=["Frontend"])
|
||||
app.include_router(billing_views.router, tags=["Frontend"])
|
||||
|
||||
61
migrations/104_sag_subscriptions.sql
Normal file
61
migrations/104_sag_subscriptions.sql
Normal 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();
|
||||
25
migrations/105_sag_subscription_items.sql
Normal file
25
migrations/105_sag_subscription_items.sql
Normal 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();
|
||||
82
migrations/106_products.sql
Normal file
82
migrations/106_products.sql
Normal 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();
|
||||
7
migrations/106_sag_subscription_items_product.sql
Normal file
7
migrations/106_sag_subscription_items_product.sql
Normal 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);
|
||||
@ -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);
|
||||
7
migrations/108_add_product_id_to_sag_salgsvarer.sql
Normal file
7
migrations/108_add_product_id_to_sag_salgsvarer.sql
Normal 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);
|
||||
19
migrations/109_product_price_history.sql
Normal file
19
migrations/109_product_price_history.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user