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
|
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
|
||||||
|
|||||||
@ -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")]
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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},
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user