release: v2.8.9 preview missing products during export

This commit is contained in:
Christian 2026-09-13 09:56:24 +02:00
parent 87932f8e16
commit b3059dced1
6 changed files with 185 additions and 5 deletions

View File

@ -0,0 +1,14 @@
# BMC Hub v2.8.9
## Produktoprettelse ved ordreeksport
- Hvis en ordrelinjes vare ikke findes i e-conomic, åbnes et forhåndsvindue før eksport.
- Vinduet viser varenummer, navn, salgspris, EAN/stregkode, beskrivelse og varegruppe for hver vare, der skal oprettes.
- Brugeren vælger eller kontrollerer varegruppen og godkender samlet med **Opret varer og eksportér**.
- Før godkendelsen udføres kun opslag. Efter godkendelsen oprettes de viste varer og derefter ordrekladden.
- Vareoprettelse kræver fortsat rettigheden `economic.catalog.manage`; ordreeksport kræver `economic.documents.export`.
## Verifikation
- Regressionstest dækker den præcise e-conomic-payload med pris, EAN, beskrivelse og varegruppe.
- Python- og JavaScript-syntakskontrol samt diff-kontrol består.

View File

@ -1 +1 @@
2.8.8 2.8.9

View File

@ -59,6 +59,20 @@ def _require_valid_product_numbers(product_numbers: set[str], valid_product_numb
return missing return missing
def _product_creation_payload(proposal: Dict[str, Any], group_number: int) -> Dict[str, Any]:
payload = {
"productNumber": proposal["product_number"],
"name": str(proposal["name"])[:300],
"description": str(proposal.get("description") or proposal["name"])[:2500],
"salesPrice": proposal["sales_price"],
"barred": False,
"productGroup": {"productGroupNumber": group_number},
}
if proposal.get("ean"):
payload["barCode"] = str(proposal["ean"])[:50]
return payload
class OrdreEconomicExportService: class OrdreEconomicExportService:
"""e-conomic export service for global ordre page.""" """e-conomic export service for global ordre page."""
@ -115,6 +129,7 @@ class OrdreEconomicExportService:
user_id: Optional[int] = None, user_id: Optional[int] = None,
document_key: Optional[str] = None, document_key: Optional[str] = None,
currency: str = 'DKK', currency: str = 'DKK',
create_missing_products: Optional[Dict[str, int]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
connection = active_connection() connection = active_connection()
@ -147,7 +162,9 @@ class OrdreEconomicExportService:
product_map: Dict[int, str] = {} product_map: Dict[int, str] = {}
if product_ids: if product_ids:
product_rows = execute_query( product_rows = execute_query(
"SELECT id, sku_internal FROM products WHERE id = ANY(%s)", """SELECT id, name, sku_internal, ean, short_description, long_description,
sales_price, economic_product_group_number
FROM products WHERE id = ANY(%s)""",
(product_ids,), (product_ids,),
) or [] ) or []
product_map = { product_map = {
@ -155,8 +172,12 @@ class OrdreEconomicExportService:
for row in product_rows for row in product_rows
if row.get("sku_internal") if row.get("sku_internal")
} }
product_details = {int(row["id"]): row for row in product_rows}
else:
product_details = {}
economic_lines: List[Dict[str, Any]] = [] economic_lines: List[Dict[str, Any]] = []
creation_candidates: Dict[str, Dict[str, Any]] = {}
for line in selected_lines: for line in selected_lines:
try: try:
quantity = float(line.get("quantity") or 0) quantity = float(line.get("quantity") or 0)
@ -189,6 +210,16 @@ class OrdreEconomicExportService:
if product_number: if product_number:
line_payload["product"] = {"productNumber": str(product_number)} line_payload["product"] = {"productNumber": str(product_number)}
local_product = product_details.get(int(product_id)) if product_id is not None and str(product_id).isdigit() else None
creation_candidates.setdefault(str(product_number), {
"product_number": str(product_number),
"product_id": int(product_id) if product_id is not None and str(product_id).isdigit() else None,
"name": (local_product or {}).get("name") or line_payload["description"],
"description": (local_product or {}).get("long_description") or (local_product or {}).get("short_description") or line_payload["description"],
"ean": (local_product or {}).get("ean") or line.get("ean"),
"sales_price": float((local_product or {}).get("sales_price") or unit_price),
"suggested_group_number": (local_product or {}).get("economic_product_group_number"),
})
if discount > 0: if discount > 0:
line_payload["discountPercentage"] = discount line_payload["discountPercentage"] = discount
@ -247,7 +278,50 @@ class OrdreEconomicExportService:
detail=_economic_error_message(product_response.status, product_text), detail=_economic_error_message(product_response.status, product_text),
) )
missing_product_numbers = _require_valid_product_numbers(product_numbers, valid_product_numbers) missing_product_numbers = sorted(product_numbers - valid_product_numbers)
if missing_product_numbers:
async with session.get(
f"{self.api_url}/product-groups?pagesize=1000",
headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30),
) as groups_response:
groups_text = await groups_response.text()
if groups_response.status != 200:
raise HTTPException(502, _economic_error_message(groups_response.status, groups_text))
groups_data = await groups_response.json(content_type=None)
groups = [
{"number": row.get("productGroupNumber"), "name": row.get("name") or ""}
for row in (groups_data.get("collection") or []) if row.get("productGroupNumber") is not None
]
approved_groups = create_missing_products or {}
if any(number not in approved_groups for number in missing_product_numbers):
raise HTTPException(status_code=409, detail={
"code": "economic_products_missing",
"message": "Godkend oprettelse af de manglende varer før ordren eksporteres.",
"products": [creation_candidates[number] for number in missing_product_numbers],
"product_groups": groups,
})
if self.read_only or self.dry_run:
raise HTTPException(409, "Safety mode blokerer oprettelse af varer i e-conomic")
available_groups = {int(group["number"]) for group in groups}
for number in missing_product_numbers:
group_number = int(approved_groups[number])
if group_number not in available_groups:
raise HTTPException(409, f"Varegruppe {group_number} findes ikke i e-conomic")
proposal = creation_candidates[number]
product_payload = _product_creation_payload(proposal, group_number)
assert_economic_write_allowed("POST", "/products")
async with session.post(
f"{self.api_url}/products", headers=self._headers(), json=product_payload,
timeout=aiohttp.ClientTimeout(total=30),
) as create_response:
create_text = await create_response.text()
if create_response.status not in (200, 201):
raise HTTPException(502, _economic_error_message(create_response.status, create_text))
created = await create_response.json(content_type=None)
if str(created.get("productNumber") or "") != number:
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
valid_product_numbers.add(number)
_require_valid_product_numbers(product_numbers, valid_product_numbers)
customer_layout = economic_customer.get("layout") or {} customer_layout = economic_customer.get("layout") or {}
resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout

View File

@ -44,6 +44,7 @@ class OrdreLineInput(BaseModel):
discount_percentage: float = Field(default=0, ge=0, le=100) discount_percentage: float = Field(default=0, ge=0, le=100)
unit: Optional[str] = None unit: Optional[str] = None
product_id: Optional[int] = None product_id: Optional[int] = None
ean: Optional[str] = None
selected: bool = True selected: bool = True
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$') currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
@ -56,6 +57,7 @@ class OrdreExportRequest(BaseModel):
draft_id: Optional[int] = None draft_id: Optional[int] = None
force_export: bool = False force_export: bool = False
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$') currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
create_missing_products: Dict[str, int] = Field(default_factory=dict)
class OrdreDraftUpsertRequest(BaseModel): class OrdreDraftUpsertRequest(BaseModel):
@ -160,12 +162,14 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
try: try:
user_id = _get_user_id_from_request(http_request) user_id = _get_user_id_from_request(http_request)
from app.products.backend.economic_documents import active_connection from app.products.backend.economic_documents import active_connection
if active_connection(): if active_connection() or request.create_missing_products:
from app.core.auth_dependencies import get_current_user, security from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request)) actor = await get_current_user(http_request, await security(http_request))
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.documents.export'): if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.documents.export'):
raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport') raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport')
if request.create_missing_products and not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.catalog.manage'):
raise HTTPException(403, 'Du mangler rettighed til at oprette varer i e-conomic')
user_id = actor['id'] user_id = actor['id']
previous_status = None previous_status = None
export_idempotency_key = None export_idempotency_key = None
@ -211,6 +215,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
user_id=user_id, user_id=user_id,
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None, document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
currency=request.currency, currency=request.currency,
create_missing_products=request.create_missing_products,
) )
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")] exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]

View File

@ -294,6 +294,30 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="economicProductPreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Varer oprettes før eksport</h5>
<div class="text-muted small">Kontrollér værdierne. Først efter godkendelse oprettes varerne og ordrekladden.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>Der skrives ikke noget til e-conomic, før du vælger <strong>Opret varer og eksportér</strong>.</div>
<div id="economicProductPreviewRows"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="confirmEconomicProductsBtn" onclick="confirmEconomicProductCreation()">
<i class="bi bi-box-arrow-up-right me-1"></i>Opret varer og eksportér
</button>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@ -306,6 +330,7 @@
let syncEventsOffset = 0; let syncEventsOffset = 0;
let syncEventsTotal = 0; let syncEventsTotal = 0;
let detailToast = null; let detailToast = null;
let pendingEconomicProductPreview = null;
function showToast(message, variant = 'dark') { function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast'); const toastEl = document.getElementById('detailToast');
@ -780,7 +805,41 @@
} catch (err) { showToast(err.message, 'danger'); } } catch (err) { showToast(err.message, 'danger'); }
} }
async function exportOrder() { function showEconomicProductPreview(detail) {
pendingEconomicProductPreview = detail;
const groups = detail.product_groups || [];
document.getElementById('economicProductPreviewRows').innerHTML = (detail.products || []).map((product, index) => {
const options = groups.map(group => {
const selected = Number(product.suggested_group_number) === Number(group.number) ? ' selected' : '';
return `<option value="${Number(group.number)}"${selected}>${escapeHtml(group.number)} · ${escapeHtml(group.name)}</option>`;
}).join('');
return `<div class="card mb-3" data-economic-product="${escapeHtml(product.product_number)}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Opret produkt ${escapeHtml(product.product_number)}</strong>
<span class="badge bg-success">${formatCurrency(product.sales_price)}</span>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-4"><div class="small text-muted">Navn</div><div>${escapeHtml(product.name || '-')}</div></div>
<div class="col-md-4"><div class="small text-muted">EAN / stregkode</div><div>${escapeHtml(product.ean || '-')}</div></div>
<div class="col-md-4"><label class="small text-muted" for="economicGroup${index}">Varegruppe</label><select id="economicGroup${index}" class="form-select form-select-sm" data-product-group>${options}</select></div>
<div class="col-12"><div class="small text-muted">Beskrivelse</div><div>${escapeHtml(product.description || '-')}</div></div>
</div></div>
</div>`;
}).join('');
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicProductPreviewModal')).show();
}
async function confirmEconomicProductCreation() {
if (!pendingEconomicProductPreview) return;
const approvals = {};
document.querySelectorAll('[data-economic-product]').forEach(card => {
approvals[card.dataset.economicProduct] = Number(card.querySelector('[data-product-group]').value);
});
bootstrap.Modal.getInstance(document.getElementById('economicProductPreviewModal'))?.hide();
await exportOrder(approvals);
}
async function exportOrder(createMissingProducts = {}) {
const customerId = Number(document.getElementById('customerId').value || 0); const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) { if (!customerId) {
showToast('Angiv kunde ID før eksport', 'warning'); showToast('Angiv kunde ID før eksport', 'warning');
@ -805,12 +864,14 @@
discount_percentage: Number(line.discount_percentage || 0), discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk', unit: line.unit || 'stk',
product_id: line.product_id || null, product_id: line.product_id || null,
ean: line.ean || null,
selected: true, selected: true,
})), })),
notes: document.getElementById('orderNotes').value || null, notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null, layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId, draft_id: draftId,
force_export: document.getElementById('forceExportToggle').checked, force_export: document.getElementById('forceExportToggle').checked,
create_missing_products: createMissingProducts,
}; };
try { try {
@ -821,6 +882,10 @@
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
if (data.detail && data.detail.code === 'economic_products_missing') {
showEconomicProductPreview(data.detail);
return;
}
throw new Error(data.detail || 'Eksport fejlede'); throw new Error(data.detail || 'Eksport fejlede');
} }

View File

@ -66,3 +66,25 @@ def test_order_export_accepts_only_verified_products():
from app.modules.orders.backend.economic_export import _require_valid_product_numbers from app.modules.orders.backend.economic_export import _require_valid_product_numbers
assert _require_valid_product_numbers({"1000"}, {"1000"}) == [] assert _require_valid_product_numbers({"1000"}, {"1000"}) == []
def test_product_creation_preview_values_become_economic_payload():
from app.modules.orders.backend.economic_export import _product_creation_payload
payload = _product_creation_payload({
"product_number": "W2211X",
"name": "HP 207X Cyan",
"description": "Original toner",
"ean": "0194850012345",
"sales_price": 899.95,
}, 2)
assert payload == {
"productNumber": "W2211X",
"name": "HP 207X Cyan",
"description": "Original toner",
"salesPrice": 899.95,
"barCode": "0194850012345",
"barred": False,
"productGroup": {"productGroupNumber": 2},
}