release: v2.8.9 preview missing products during export
This commit is contained in:
parent
87932f8e16
commit
b3059dced1
14
MDfile/RELEASE_NOTES_v2.8.9.md
Normal file
14
MDfile/RELEASE_NOTES_v2.8.9.md
Normal 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.
|
||||
@ -59,6 +59,20 @@ def _require_valid_product_numbers(product_numbers: set[str], valid_product_numb
|
||||
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:
|
||||
"""e-conomic export service for global ordre page."""
|
||||
|
||||
@ -115,6 +129,7 @@ class OrdreEconomicExportService:
|
||||
user_id: Optional[int] = None,
|
||||
document_key: Optional[str] = None,
|
||||
currency: str = 'DKK',
|
||||
create_missing_products: Optional[Dict[str, int]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
|
||||
connection = active_connection()
|
||||
@ -147,7 +162,9 @@ class OrdreEconomicExportService:
|
||||
product_map: Dict[int, str] = {}
|
||||
if product_ids:
|
||||
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,),
|
||||
) or []
|
||||
product_map = {
|
||||
@ -155,8 +172,12 @@ class OrdreEconomicExportService:
|
||||
for row in product_rows
|
||||
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]] = []
|
||||
creation_candidates: Dict[str, Dict[str, Any]] = {}
|
||||
for line in selected_lines:
|
||||
try:
|
||||
quantity = float(line.get("quantity") or 0)
|
||||
@ -189,6 +210,16 @@ class OrdreEconomicExportService:
|
||||
|
||||
if 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:
|
||||
line_payload["discountPercentage"] = discount
|
||||
@ -247,7 +278,50 @@ class OrdreEconomicExportService:
|
||||
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 {}
|
||||
resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout
|
||||
|
||||
@ -44,6 +44,7 @@ class OrdreLineInput(BaseModel):
|
||||
discount_percentage: float = Field(default=0, ge=0, le=100)
|
||||
unit: Optional[str] = None
|
||||
product_id: Optional[int] = None
|
||||
ean: Optional[str] = None
|
||||
selected: bool = True
|
||||
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
|
||||
|
||||
@ -56,6 +57,7 @@ class OrdreExportRequest(BaseModel):
|
||||
draft_id: Optional[int] = None
|
||||
force_export: bool = False
|
||||
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
|
||||
create_missing_products: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OrdreDraftUpsertRequest(BaseModel):
|
||||
@ -160,12 +162,14 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
try:
|
||||
user_id = _get_user_id_from_request(http_request)
|
||||
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_service import AuthService
|
||||
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'):
|
||||
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']
|
||||
previous_status = None
|
||||
export_idempotency_key = None
|
||||
@ -211,6 +215,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
user_id=user_id,
|
||||
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
|
||||
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")]
|
||||
|
||||
@ -294,6 +294,30 @@
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@ -306,6 +330,7 @@
|
||||
let syncEventsOffset = 0;
|
||||
let syncEventsTotal = 0;
|
||||
let detailToast = null;
|
||||
let pendingEconomicProductPreview = null;
|
||||
|
||||
function showToast(message, variant = 'dark') {
|
||||
const toastEl = document.getElementById('detailToast');
|
||||
@ -780,7 +805,41 @@
|
||||
} 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);
|
||||
if (!customerId) {
|
||||
showToast('Angiv kunde ID før eksport', 'warning');
|
||||
@ -805,12 +864,14 @@
|
||||
discount_percentage: Number(line.discount_percentage || 0),
|
||||
unit: line.unit || 'stk',
|
||||
product_id: line.product_id || null,
|
||||
ean: line.ean || null,
|
||||
selected: true,
|
||||
})),
|
||||
notes: document.getElementById('orderNotes').value || null,
|
||||
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
||||
draft_id: draftId,
|
||||
force_export: document.getElementById('forceExportToggle').checked,
|
||||
create_missing_products: createMissingProducts,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -821,6 +882,10 @@
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (data.detail && data.detail.code === 'economic_products_missing') {
|
||||
showEconomicProductPreview(data.detail);
|
||||
return;
|
||||
}
|
||||
throw new Error(data.detail || 'Eksport fejlede');
|
||||
}
|
||||
|
||||
|
||||
@ -66,3 +66,25 @@ def test_order_export_accepts_only_verified_products():
|
||||
from app.modules.orders.backend.economic_export import _require_valid_product_numbers
|
||||
|
||||
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},
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user