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 supplier lookup
|
||||||
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
|
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
|
||||||
ELNET_TIMEOUT_SECONDS: int = 12
|
ELNET_TIMEOUT_SECONDS: int = 12
|
||||||
|
|
||||||
|
# API Gateway (Product catalog)
|
||||||
|
APIGW_BASE_URL: str = "https://apigateway.bmcnetworks.dk"
|
||||||
|
APIGATEWAY_URL: str = ""
|
||||||
|
APIGW_TOKEN: str = ""
|
||||||
|
APIGW_TIMEOUT_SECONDS: int = 12
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||||
|
|||||||
@ -1022,9 +1022,9 @@ async def create_sale_item(sag_id: int, data: dict):
|
|||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO sag_salgsvarer
|
INSERT INTO sag_salgsvarer
|
||||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref)
|
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
||||||
VALUES
|
VALUES
|
||||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
params = (
|
params = (
|
||||||
@ -1039,6 +1039,7 @@ async def create_sale_item(sag_id: int, data: dict):
|
|||||||
status,
|
status,
|
||||||
data.get("line_date"),
|
data.get("line_date"),
|
||||||
data.get("external_ref"),
|
data.get("external_ref"),
|
||||||
|
data.get("product_id"),
|
||||||
)
|
)
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
if result:
|
if result:
|
||||||
@ -1095,6 +1096,7 @@ async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
|||||||
"status",
|
"status",
|
||||||
"line_date",
|
"line_date",
|
||||||
"external_ref",
|
"external_ref",
|
||||||
|
"product_id",
|
||||||
]
|
]
|
||||||
|
|
||||||
set_clauses = []
|
set_clauses = []
|
||||||
|
|||||||
@ -555,6 +555,11 @@
|
|||||||
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription">
|
||||||
|
<i class="bi bi-repeat me-2"></i>Abonnement
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders">
|
<button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders">
|
||||||
<i class="bi bi-bell me-2"></i>Reminders
|
<i class="bi bi-bell me-2"></i>Reminders
|
||||||
@ -2170,6 +2175,204 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscription Tab -->
|
||||||
|
<div class="tab-pane fade" id="subscription" role="tabpanel" tabindex="0" data-module="subscription" data-has-content="unknown">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 text-primary"><i class="bi bi-repeat me-2"></i>Abonnement</h6>
|
||||||
|
<span id="subscriptionStatusBadge" class="badge bg-light text-dark">Ingen</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="subscriptionEmpty" class="text-center text-muted py-3">
|
||||||
|
<i class="bi bi-receipt-cutoff display-6 mb-3 d-block opacity-25"></i>
|
||||||
|
<p>Ingen abonnement oprettet endnu.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="subscriptionDetails" class="d-none">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Abonnement</label>
|
||||||
|
<div class="fw-semibold" id="subscriptionNumber">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Produkt</label>
|
||||||
|
<div class="fw-semibold" id="subscriptionProduct">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Interval</label>
|
||||||
|
<div class="fw-semibold" id="subscriptionInterval">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Pris</label>
|
||||||
|
<div class="fw-semibold" id="subscriptionPrice">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Startdato</label>
|
||||||
|
<div class="fw-semibold" id="subscriptionStartDate">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="small text-muted">Status</label>
|
||||||
|
<div class="fw-semibold" id="subscriptionStatusText">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th class="text-end">Antal</th>
|
||||||
|
<th class="text-end">Enhedspris</th>
|
||||||
|
<th class="text-end">Linjesum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="subscriptionItemsBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted">Ingen linjer</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end mb-3">
|
||||||
|
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="subscriptionCreateForm" class="row g-3 d-none">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Interval *</label>
|
||||||
|
<select class="form-select" id="subscriptionIntervalInput" required>
|
||||||
|
<option value="monthly" selected>Maaned</option>
|
||||||
|
<option value="quarterly">Kvartal</option>
|
||||||
|
<option value="yearly">Aar</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Faktura dag *</label>
|
||||||
|
<input type="number" class="form-control" id="subscriptionBillingDayInput" min="1" max="31" value="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Startdato *</label>
|
||||||
|
<input type="date" class="form-control" id="subscriptionStartDateInput" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Varelinjer *</label>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 220px;">Produkt</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th style="width: 120px;">Antal</th>
|
||||||
|
<th style="width: 140px;">Enhedspris</th>
|
||||||
|
<th style="width: 140px;">Linjesum</th>
|
||||||
|
<th style="width: 60px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="subscriptionLineItemsBody">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
||||||
|
<option value="">Vælg produkt</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
||||||
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addSubscriptionLine()">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Tilfoej linje
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openSubscriptionProductModal()">
|
||||||
|
<i class="bi bi-box me-1"></i>Opret produkt
|
||||||
|
</button>
|
||||||
|
<div class="fw-semibold">Total: <span id="subscriptionLinesTotal">0,00 kr</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Noter</label>
|
||||||
|
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createSubscription()">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Opret abonnement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscription Product Modal -->
|
||||||
|
<div class="modal fade" id="subscriptionProductModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-box"></i> Opret produkt</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="subscriptionProductForm">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Navn *</label>
|
||||||
|
<input type="text" class="form-control" id="subscriptionProductName" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<input type="text" class="form-control" id="subscriptionProductType" placeholder="subscription, service">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="subscriptionProductStatus">
|
||||||
|
<option value="active" selected>Aktiv</option>
|
||||||
|
<option value="inactive">Inaktiv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Salgspris</label>
|
||||||
|
<input type="number" class="form-control" id="subscriptionProductSalesPrice" step="0.01" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Faktureringsinterval</label>
|
||||||
|
<select class="form-select" id="subscriptionProductBillingPeriod">
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="monthly">Maaned</option>
|
||||||
|
<option value="quarterly">Kvartal</option>
|
||||||
|
<option value="yearly">Aar</option>
|
||||||
|
<option value="one_time">Engang</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Kort beskrivelse</label>
|
||||||
|
<input type="text" class="form-control" id="subscriptionProductDescription">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createSubscriptionProduct()">
|
||||||
|
<i class="bi bi-save me-1"></i>Gem produkt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reminders Tab -->
|
<!-- Reminders Tab -->
|
||||||
<div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown">
|
<div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
@ -4349,5 +4552,422 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const subscriptionCaseId = {{ case.id }};
|
||||||
|
let currentSubscription = null;
|
||||||
|
let subscriptionProducts = [];
|
||||||
|
let lastCreatedSubscriptionProductId = null;
|
||||||
|
|
||||||
|
function formatSubscriptionInterval(interval) {
|
||||||
|
const map = {
|
||||||
|
'monthly': 'Maaned',
|
||||||
|
'quarterly': 'Kvartal',
|
||||||
|
'yearly': 'Aar'
|
||||||
|
};
|
||||||
|
return map[interval] || interval || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubscriptionCurrency(amount) {
|
||||||
|
return new Intl.NumberFormat('da-DK', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'DKK',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubscriptionDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('da-DK');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubscriptionBadge(status) {
|
||||||
|
const badge = document.getElementById('subscriptionStatusBadge');
|
||||||
|
if (!badge) return;
|
||||||
|
const classes = {
|
||||||
|
'draft': 'bg-light text-dark',
|
||||||
|
'active': 'bg-success',
|
||||||
|
'paused': 'bg-warning',
|
||||||
|
'cancelled': 'bg-secondary'
|
||||||
|
};
|
||||||
|
const label = {
|
||||||
|
'draft': 'Kladde',
|
||||||
|
'active': 'Aktiv',
|
||||||
|
'paused': 'Pauset',
|
||||||
|
'cancelled': 'Opsagt'
|
||||||
|
};
|
||||||
|
badge.className = `badge ${classes[status] || 'bg-light text-dark'}`;
|
||||||
|
badge.textContent = label[status] || status || 'Ingen';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSubscriptionCreateForm() {
|
||||||
|
const empty = document.getElementById('subscriptionEmpty');
|
||||||
|
const form = document.getElementById('subscriptionCreateForm');
|
||||||
|
const details = document.getElementById('subscriptionDetails');
|
||||||
|
if (empty) empty.classList.remove('d-none');
|
||||||
|
if (form) form.classList.remove('d-none');
|
||||||
|
if (details) details.classList.add('d-none');
|
||||||
|
setSubscriptionBadge(null);
|
||||||
|
|
||||||
|
const startDateInput = document.getElementById('subscriptionStartDateInput');
|
||||||
|
if (startDateInput && !startDateInput.value) {
|
||||||
|
startDateInput.value = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.getElementById('subscriptionLineItemsBody');
|
||||||
|
if (body) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
||||||
|
<option value="">Vælg produkt</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
||||||
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
populateSubscriptionProductSelects();
|
||||||
|
updateSubscriptionLineTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSubscriptionProductSelects() {
|
||||||
|
const selects = document.querySelectorAll('.subscriptionProductSelect');
|
||||||
|
selects.forEach(select => {
|
||||||
|
const currentValue = select.value;
|
||||||
|
select.innerHTML = '<option value="">Vælg produkt</option>';
|
||||||
|
subscriptionProducts.forEach(product => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = product.id;
|
||||||
|
option.textContent = product.name;
|
||||||
|
option.dataset.salesPrice = product.sales_price ?? '';
|
||||||
|
option.dataset.description = product.short_description ?? '';
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
if (currentValue) {
|
||||||
|
select.value = currentValue;
|
||||||
|
} else if (lastCreatedSubscriptionProductId) {
|
||||||
|
select.value = String(lastCreatedSubscriptionProductId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lastCreatedSubscriptionProductId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySubscriptionProduct(select) {
|
||||||
|
const row = select.closest('tr');
|
||||||
|
if (!row) return;
|
||||||
|
const descriptionInput = row.querySelector('input[type="text"]');
|
||||||
|
const unitPriceInput = row.querySelectorAll('input[type="number"]')[1];
|
||||||
|
const selected = select.options[select.selectedIndex];
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
const description = selected.dataset.description || selected.textContent || '';
|
||||||
|
const salesPrice = selected.dataset.salesPrice;
|
||||||
|
|
||||||
|
if (descriptionInput && !descriptionInput.value.trim()) {
|
||||||
|
descriptionInput.value = description;
|
||||||
|
}
|
||||||
|
if (unitPriceInput && salesPrice !== '') {
|
||||||
|
unitPriceInput.value = salesPrice;
|
||||||
|
}
|
||||||
|
updateSubscriptionLineTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSubscriptionLine() {
|
||||||
|
const body = document.getElementById('subscriptionLineItemsBody');
|
||||||
|
if (!body) return;
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
||||||
|
<option value="">Vælg produkt</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
||||||
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
||||||
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
body.appendChild(row);
|
||||||
|
populateSubscriptionProductSelects();
|
||||||
|
updateSubscriptionLineTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSubscriptionLine(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
const body = document.getElementById('subscriptionLineItemsBody');
|
||||||
|
if (!row || !body) return;
|
||||||
|
if (body.children.length <= 1) {
|
||||||
|
row.querySelectorAll('input').forEach(input => {
|
||||||
|
input.value = input.type === 'number' ? 0 : '';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
updateSubscriptionLineTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubscriptionLineTotals() {
|
||||||
|
const body = document.getElementById('subscriptionLineItemsBody');
|
||||||
|
const totalEl = document.getElementById('subscriptionLinesTotal');
|
||||||
|
if (!body || !totalEl) return;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
Array.from(body.querySelectorAll('tr')).forEach(row => {
|
||||||
|
const inputs = row.querySelectorAll('input');
|
||||||
|
const description = inputs[0]?.value || '';
|
||||||
|
const qty = parseFloat(inputs[1]?.value || 0);
|
||||||
|
const unit = parseFloat(inputs[2]?.value || 0);
|
||||||
|
const lineTotal = (qty > 0 ? qty : 0) * (unit > 0 ? unit : 0);
|
||||||
|
total += lineTotal;
|
||||||
|
const lineTotalEl = row.querySelector('.subscriptionLineTotal');
|
||||||
|
if (lineTotalEl) {
|
||||||
|
lineTotalEl.textContent = formatSubscriptionCurrency(lineTotal);
|
||||||
|
}
|
||||||
|
if (!description && qty === 0 && unit === 0) {
|
||||||
|
if (lineTotalEl) {
|
||||||
|
lineTotalEl.textContent = formatSubscriptionCurrency(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalEl.textContent = formatSubscriptionCurrency(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSubscriptionLineItems() {
|
||||||
|
const body = document.getElementById('subscriptionLineItemsBody');
|
||||||
|
if (!body) return [];
|
||||||
|
const items = [];
|
||||||
|
Array.from(body.querySelectorAll('tr')).forEach(row => {
|
||||||
|
const productSelect = row.querySelector('.subscriptionProductSelect');
|
||||||
|
const inputs = row.querySelectorAll('input');
|
||||||
|
const description = (inputs[0]?.value || '').trim();
|
||||||
|
const quantity = parseFloat(inputs[1]?.value || 0);
|
||||||
|
const unitPrice = parseFloat(inputs[2]?.value || 0);
|
||||||
|
if (!description && quantity === 0 && unitPrice === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
product_id: productSelect && productSelect.value ? parseInt(productSelect.value, 10) : null,
|
||||||
|
description,
|
||||||
|
quantity,
|
||||||
|
unit_price: unitPrice
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSubscriptionProducts() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/products');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Kunne ikke hente produkter');
|
||||||
|
}
|
||||||
|
subscriptionProducts = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading products:', e);
|
||||||
|
subscriptionProducts = [];
|
||||||
|
}
|
||||||
|
populateSubscriptionProductSelects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubscriptionProductModal() {
|
||||||
|
const form = document.getElementById('subscriptionProductForm');
|
||||||
|
if (form) form.reset();
|
||||||
|
new bootstrap.Modal(document.getElementById('subscriptionProductModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSubscriptionProduct() {
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('subscriptionProductName').value.trim(),
|
||||||
|
type: document.getElementById('subscriptionProductType').value.trim() || null,
|
||||||
|
status: document.getElementById('subscriptionProductStatus').value,
|
||||||
|
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
|
||||||
|
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
|
||||||
|
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
alert('Navn er paakraevet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/products', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
alert(error.detail || 'Kunne ikke oprette produkt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await res.json();
|
||||||
|
lastCreatedSubscriptionProductId = product.id;
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('subscriptionProductModal')).hide();
|
||||||
|
await loadSubscriptionProducts();
|
||||||
|
updateSubscriptionLineTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubscription(subscription) {
|
||||||
|
currentSubscription = subscription;
|
||||||
|
const empty = document.getElementById('subscriptionEmpty');
|
||||||
|
const form = document.getElementById('subscriptionCreateForm');
|
||||||
|
const details = document.getElementById('subscriptionDetails');
|
||||||
|
if (empty) empty.classList.add('d-none');
|
||||||
|
if (form) form.classList.add('d-none');
|
||||||
|
if (details) details.classList.remove('d-none');
|
||||||
|
|
||||||
|
document.getElementById('subscriptionNumber').textContent = subscription.subscription_number || `#${subscription.id}`;
|
||||||
|
document.getElementById('subscriptionProduct').textContent = subscription.product_name || '-';
|
||||||
|
document.getElementById('subscriptionInterval').textContent = formatSubscriptionInterval(subscription.billing_interval);
|
||||||
|
document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price);
|
||||||
|
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
|
||||||
|
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
|
||||||
|
|
||||||
|
setSubscriptionBadge(subscription.status);
|
||||||
|
|
||||||
|
const itemsBody = document.getElementById('subscriptionItemsBody');
|
||||||
|
const itemsTotal = document.getElementById('subscriptionItemsTotal');
|
||||||
|
if (itemsBody) {
|
||||||
|
const items = subscription.line_items || [];
|
||||||
|
if (!items.length) {
|
||||||
|
itemsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen linjer</td></tr>';
|
||||||
|
} else {
|
||||||
|
itemsBody.innerHTML = items.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td>${item.product_name || '-'}</td>
|
||||||
|
<td>${item.description}</td>
|
||||||
|
<td class="text-end">${parseFloat(item.quantity).toFixed(2)}</td>
|
||||||
|
<td class="text-end">${formatSubscriptionCurrency(item.unit_price)}</td>
|
||||||
|
<td class="text-end">${formatSubscriptionCurrency(item.line_total)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (itemsTotal) {
|
||||||
|
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.getElementById('subscriptionActions');
|
||||||
|
if (!actions) return;
|
||||||
|
|
||||||
|
const buttons = [];
|
||||||
|
if (subscription.status === 'draft' || subscription.status === 'paused') {
|
||||||
|
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
|
||||||
|
}
|
||||||
|
if (subscription.status === 'active') {
|
||||||
|
buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`);
|
||||||
|
}
|
||||||
|
if (subscription.status !== 'cancelled') {
|
||||||
|
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
|
||||||
|
}
|
||||||
|
actions.innerHTML = buttons.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSubscriptionForCase() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
showSubscriptionCreateForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Kunne ikke hente abonnement');
|
||||||
|
}
|
||||||
|
const subscription = await res.json();
|
||||||
|
renderSubscription(subscription);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading subscription:', e);
|
||||||
|
showSubscriptionCreateForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSubscription() {
|
||||||
|
const billingInterval = document.getElementById('subscriptionIntervalInput').value;
|
||||||
|
const billingDay = parseInt(document.getElementById('subscriptionBillingDayInput').value, 10);
|
||||||
|
const startDate = document.getElementById('subscriptionStartDateInput').value;
|
||||||
|
const notes = document.getElementById('subscriptionNotesInput').value.trim();
|
||||||
|
|
||||||
|
const lineItems = collectSubscriptionLineItems();
|
||||||
|
|
||||||
|
if (!billingInterval || !billingDay || !startDate) {
|
||||||
|
alert('Udfyld venligst alle paakraevet felter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lineItems.length) {
|
||||||
|
alert('Du skal angive mindst en varelinje');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/subscriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sag_id: subscriptionCaseId,
|
||||||
|
billing_interval: billingInterval,
|
||||||
|
billing_day: billingDay,
|
||||||
|
start_date: startDate,
|
||||||
|
notes: notes || null,
|
||||||
|
line_items: lineItems
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || 'Fejl ved oprettelse');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await res.json();
|
||||||
|
renderSubscription(subscription);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSubscriptionStatus(status) {
|
||||||
|
if (!currentSubscription) return;
|
||||||
|
if (status === 'cancelled' && !confirm('Er du sikker paa, at abonnementet skal opsiges?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/subscriptions/${currentSubscription.id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || 'Kunne ikke opdatere status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await res.json();
|
||||||
|
renderSubscription(updated);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadSubscriptionProducts();
|
||||||
|
loadSubscriptionForCase();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
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="#">Ny Ticket</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
||||||
|
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -259,7 +260,7 @@
|
|||||||
<ul class="dropdown-menu mt-2">
|
<ul class="dropdown-menu mt-2">
|
||||||
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
|
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Ordre</a></li>
|
<li><a class="dropdown-item py-2" href="#">Ordre</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Produkter</a></li>
|
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
|||||||
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
|
# Override database URL to point to postgres service
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
|
||||||
- ENABLE_RELOAD=false
|
- ENABLE_RELOAD=false
|
||||||
|
- APIGW_TOKEN=${APIGW_TOKEN}
|
||||||
|
- APIGATEWAY_URL=${APIGATEWAY_URL}
|
||||||
|
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
|||||||
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.prepaid.backend import views as prepaid_views
|
||||||
from app.fixed_price.backend import router as fixed_price_api
|
from app.fixed_price.backend import router as fixed_price_api
|
||||||
from app.fixed_price.frontend import views as fixed_price_views
|
from app.fixed_price.frontend import views as fixed_price_views
|
||||||
|
from app.subscriptions.backend import router as subscriptions_api
|
||||||
|
from app.subscriptions.frontend import views as subscriptions_views
|
||||||
|
from app.products.backend import router as products_api
|
||||||
|
from app.products.frontend import views as products_views
|
||||||
from app.ticket.backend import router as ticket_api
|
from app.ticket.backend import router as ticket_api
|
||||||
from app.ticket.frontend import views as ticket_views
|
from app.ticket.frontend import views as ticket_views
|
||||||
from app.vendors.backend import router as vendors_api
|
from app.vendors.backend import router as vendors_api
|
||||||
@ -235,6 +239,8 @@ app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
|
|||||||
app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"])
|
app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"])
|
||||||
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
||||||
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
|
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
|
||||||
|
app.include_router(subscriptions_api.router, prefix="/api/v1", tags=["Subscriptions"])
|
||||||
|
app.include_router(products_api.router, prefix="/api/v1", tags=["Products"])
|
||||||
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
|
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
|
||||||
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
||||||
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
||||||
@ -263,6 +269,8 @@ app.include_router(dashboard_views.router, tags=["Frontend"])
|
|||||||
app.include_router(customers_views.router, tags=["Frontend"])
|
app.include_router(customers_views.router, tags=["Frontend"])
|
||||||
app.include_router(prepaid_views.router, tags=["Frontend"])
|
app.include_router(prepaid_views.router, tags=["Frontend"])
|
||||||
app.include_router(fixed_price_views.router, tags=["Frontend"])
|
app.include_router(fixed_price_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(subscriptions_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(products_views.router, tags=["Frontend"])
|
||||||
app.include_router(vendors_views.router, tags=["Frontend"])
|
app.include_router(vendors_views.router, tags=["Frontend"])
|
||||||
app.include_router(timetracking_views.router, tags=["Frontend"])
|
app.include_router(timetracking_views.router, tags=["Frontend"])
|
||||||
app.include_router(billing_views.router, tags=["Frontend"])
|
app.include_router(billing_views.router, tags=["Frontend"])
|
||||||
|
|||||||
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