Compare commits
5 Commits
18acfd2226
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32974e3b85 | ||
|
|
ab20856cd6 | ||
|
|
956667086d | ||
|
|
9b62b2b457 | ||
|
|
b13d9a466c |
@@ -2,38 +2,39 @@ import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import oracledb
|
||||
from datetime import datetime, timedelta
|
||||
from .. import database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Diacritics to ASCII mapping (Romanian)
|
||||
_DIACRITICS = str.maketrans({
|
||||
'\u0103': 'a', # ă
|
||||
'\u00e2': 'a', # â
|
||||
'\u00ee': 'i', # î
|
||||
'\u0219': 's', # ș
|
||||
'\u021b': 't', # ț
|
||||
'\u0102': 'A', # Ă
|
||||
'\u00c2': 'A', # Â
|
||||
'\u00ce': 'I', # Î
|
||||
'\u0218': 'S', # Ș
|
||||
'\u021a': 'T', # Ț
|
||||
# Older Unicode variants
|
||||
'\u015f': 's', # ş (cedilla)
|
||||
'\u0163': 't', # ţ (cedilla)
|
||||
'\u015e': 'S', # Ş
|
||||
'\u0162': 'T', # Ţ
|
||||
# Stroke/ligature letters NFKD does not decompose (structural mod, not a
|
||||
# combining mark). Everything else — RO cedilla ş/ţ, RO comma-below ș/ț,
|
||||
# HU ő/ű, DE umlaut, CZ háček, FR accent, ES tilde — is handled
|
||||
# universally by unicodedata.normalize('NFKD') + Mn-category strip below.
|
||||
_NFKD_OVERRIDES = str.maketrans({
|
||||
'ß': 'ss', # ß
|
||||
'æ': 'ae', 'Æ': 'AE', # æ Æ
|
||||
'œ': 'oe', 'Œ': 'OE', # œ Œ
|
||||
'ł': 'l', 'Ł': 'L', # ł Ł (Polish)
|
||||
'đ': 'd', 'Đ': 'D', # đ Đ (Croatian)
|
||||
'ø': 'o', 'Ø': 'O', # ø Ø (Danish/Norwegian)
|
||||
})
|
||||
|
||||
|
||||
def clean_web_text(text: str) -> str:
|
||||
"""Port of VFP CleanWebText: unescape HTML entities + diacritics to ASCII."""
|
||||
"""Port of VFP CleanWebText: unescape HTML entities + strip diacritics to ASCII.
|
||||
|
||||
NFKD decomposition + combining-mark filter covers RO/HU/DE/CZ/PL/FR/ES in
|
||||
one pass; _NFKD_OVERRIDES handles stroke letters NFKD leaves alone.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
result = html.unescape(text)
|
||||
result = result.translate(_DIACRITICS)
|
||||
result = result.translate(_NFKD_OVERRIDES)
|
||||
decomposed = unicodedata.normalize('NFKD', result)
|
||||
result = ''.join(ch for ch in decomposed if not unicodedata.combining(ch))
|
||||
# Remove any remaining <br> tags
|
||||
for br in ('<br>', '<br/>', '<br />'):
|
||||
result = result.replace(br, ' ')
|
||||
|
||||
@@ -1253,6 +1253,19 @@ async def get_all_imported_orders() -> list:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def get_deleted_in_roa_order_numbers() -> set[str]:
|
||||
"""Return set of order_numbers marked DELETED_IN_ROA (sticky-excluded from auto-sync)."""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
f"SELECT order_number FROM orders WHERE status = '{OrderStatus.DELETED_IN_ROA.value}'"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return {r[0] for r in rows}
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def clear_order_invoice(order_number: str):
|
||||
"""Clear cached invoice data when invoice was deleted in ROA."""
|
||||
db = await get_sqlite()
|
||||
@@ -1275,10 +1288,13 @@ async def clear_order_invoice(order_number: str):
|
||||
|
||||
|
||||
async def mark_order_deleted_in_roa(order_number: str):
|
||||
"""Mark an order as deleted in ROA — clears id_comanda, invoice cache, and stale items."""
|
||||
"""Mark an order as deleted in ROA — clears id_comanda + invoice cache.
|
||||
|
||||
order_items are preserved so the detail view can still show what was
|
||||
originally ordered. On 'Reimporta', add_order_items replaces them.
|
||||
"""
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("DELETE FROM order_items WHERE order_number = ?", (order_number,))
|
||||
await db.execute(f"""
|
||||
UPDATE orders SET
|
||||
status = '{OrderStatus.DELETED_IN_ROA.value}',
|
||||
|
||||
@@ -48,7 +48,7 @@ def _addr_match(gomag_json, roa_json):
|
||||
r'ET|ETAJ|COM|COMUNA|SAT|MUN|MUNICIPIUL|JUD|JUDETUL|CARTIER|PARTER|SECTOR|SECTORUL|ORAS)(?:\b|(?=\d))'
|
||||
)
|
||||
def norm(s):
|
||||
s = (s or '').translate(import_service._DIACRITICS).upper()
|
||||
s = import_service.clean_web_text(s or '').upper()
|
||||
s = _ADDR_WORDS.sub('', s)
|
||||
return re.sub(r'[^A-Z0-9]', '', s)
|
||||
def _soundex(s):
|
||||
@@ -476,6 +476,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
|
||||
orders = active_orders
|
||||
|
||||
# ── Sticky exclusion: skip orders previously marked DELETED_IN_ROA ──
|
||||
deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||
if deleted_set:
|
||||
excluded_deleted = [o for o in orders if o.number in deleted_set]
|
||||
orders = [o for o in orders if o.number not in deleted_set]
|
||||
if excluded_deleted:
|
||||
_log_line(run_id,
|
||||
f"Excluse {len(excluded_deleted)} comenzi marcate DELETED_IN_ROA "
|
||||
f"(stergeri sticky — foloseste 'Reimporta' pentru override)")
|
||||
for o in excluded_deleted:
|
||||
_log_line(run_id, f"#{o.number} [{o.date or '?'}] → IGNORAT (DELETED_IN_ROA)")
|
||||
|
||||
if not orders:
|
||||
_log_line(run_id, "Nicio comanda activa dupa filtrare anulate.")
|
||||
await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0)
|
||||
|
||||
@@ -601,6 +601,142 @@ function _renderReceipt(items, order) {
|
||||
}
|
||||
|
||||
// ── Order Detail Modal (shared) ──────────────────
|
||||
function _configureDetailButtons(order, orderNumber, opts) {
|
||||
const status = (order.status || '').toUpperCase();
|
||||
const isInvoiced = !!(order.factura_numar);
|
||||
|
||||
const retryBtn = document.getElementById('detailRetryBtn');
|
||||
if (retryBtn) {
|
||||
const canRetry = [ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED, ORDER_STATUS.DELETED_IN_ROA].includes(status);
|
||||
retryBtn.style.display = canRetry ? '' : 'none';
|
||||
if (canRetry) {
|
||||
retryBtn.onclick = async () => {
|
||||
retryBtn.disabled = true;
|
||||
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
|
||||
retryBtn.className = 'btn btn-sm btn-success';
|
||||
if (opts.onStatusChange) opts.onStatusChange();
|
||||
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||
} else {
|
||||
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||
retryBtn.className = 'btn btn-sm btn-danger';
|
||||
setTimeout(() => {
|
||||
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
|
||||
retryBtn.className = 'btn btn-sm btn-outline-primary';
|
||||
retryBtn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
retryBtn.innerHTML = 'Eroare: ' + err.message;
|
||||
retryBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resyncBtn = document.getElementById('detailResyncBtn');
|
||||
if (resyncBtn) {
|
||||
const canResync = [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(status);
|
||||
resyncBtn.style.display = canResync ? '' : 'none';
|
||||
if (canResync) {
|
||||
if (isInvoiced) {
|
||||
resyncBtn.disabled = true;
|
||||
resyncBtn.style.opacity = '0.5';
|
||||
resyncBtn.style.pointerEvents = 'none';
|
||||
resyncBtn.title = 'Comanda facturata';
|
||||
} else {
|
||||
resyncBtn.disabled = false;
|
||||
resyncBtn.style.opacity = '';
|
||||
resyncBtn.style.pointerEvents = '';
|
||||
resyncBtn.title = '';
|
||||
resyncBtn.onclick = () => {
|
||||
inlineConfirmAction(resyncBtn, 'Confirmi resync?', async (btn) => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/resync`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i class="bi bi-check-circle"></i> Reimportat';
|
||||
btn.className = 'btn btn-sm btn-success';
|
||||
if (opts.onStatusChange) opts.onStatusChange();
|
||||
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||
} else {
|
||||
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||
btn.className = 'btn btn-sm btn-danger';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Resync';
|
||||
btn.className = 'btn btn-sm btn-outline-warning';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
btn.innerHTML = 'Eroare: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}, {
|
||||
defaultHtml: '<i class="bi bi-arrow-repeat"></i> Resync',
|
||||
loadingText: 'Resync...',
|
||||
confirmClass: 'btn-warning',
|
||||
defaultBtnClass: 'btn-outline-warning'
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('detailDeleteBtn');
|
||||
if (deleteBtn) {
|
||||
const canDelete = [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(status);
|
||||
deleteBtn.style.display = canDelete ? '' : 'none';
|
||||
if (canDelete) {
|
||||
if (isInvoiced) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.style.opacity = '0.5';
|
||||
deleteBtn.style.pointerEvents = 'none';
|
||||
deleteBtn.title = 'Comanda facturata';
|
||||
} else {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.style.opacity = '';
|
||||
deleteBtn.style.pointerEvents = '';
|
||||
deleteBtn.title = '';
|
||||
deleteBtn.onclick = () => {
|
||||
inlineConfirmAction(deleteBtn, 'Confirmi stergerea?', async (btn) => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/delete`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i class="bi bi-check-circle"></i> Sters';
|
||||
btn.className = 'btn btn-sm btn-danger';
|
||||
if (opts.onStatusChange) opts.onStatusChange();
|
||||
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||
} else {
|
||||
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||
btn.className = 'btn btn-sm btn-danger';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<i class="bi bi-trash"></i> Sterge din ROA';
|
||||
btn.className = 'btn btn-sm btn-outline-danger';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
btn.innerHTML = 'Eroare: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}, {
|
||||
defaultHtml: '<i class="bi bi-trash"></i> Sterge din ROA',
|
||||
loadingText: 'Stergere...',
|
||||
confirmClass: 'btn-danger',
|
||||
defaultBtnClass: 'btn-outline-danger'
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render and show the order detail modal.
|
||||
* @param {string} orderNumber
|
||||
@@ -717,9 +853,14 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
|
||||
// Configure footer action buttons BEFORE any early-return on items —
|
||||
// DELETED_IN_ROA orders have no items but must still expose the Reimporta button.
|
||||
_configureDetailButtons(order, orderNumber, opts);
|
||||
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -853,143 +994,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
||||
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
||||
_renderReceipt(items, order);
|
||||
|
||||
// Retry button (only for ERROR/SKIPPED orders)
|
||||
const retryBtn = document.getElementById('detailRetryBtn');
|
||||
if (retryBtn) {
|
||||
const canRetry = [ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED, ORDER_STATUS.DELETED_IN_ROA].includes((order.status || '').toUpperCase());
|
||||
retryBtn.style.display = canRetry ? '' : 'none';
|
||||
if (canRetry) {
|
||||
retryBtn.onclick = async () => {
|
||||
retryBtn.disabled = true;
|
||||
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
|
||||
retryBtn.className = 'btn btn-sm btn-success';
|
||||
if (opts.onStatusChange) opts.onStatusChange();
|
||||
// Refresh modal after short delay
|
||||
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||
} else {
|
||||
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||
retryBtn.className = 'btn btn-sm btn-danger';
|
||||
setTimeout(() => {
|
||||
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
|
||||
retryBtn.className = 'btn btn-sm btn-outline-primary';
|
||||
retryBtn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
retryBtn.innerHTML = 'Eroare: ' + err.message;
|
||||
retryBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Resync button (IMPORTED/ALREADY_IMPORTED only)
|
||||
const resyncBtn = document.getElementById('detailResyncBtn');
|
||||
if (resyncBtn) {
|
||||
const canResync = [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes((order.status || '').toUpperCase());
|
||||
resyncBtn.style.display = canResync ? '' : 'none';
|
||||
if (canResync) {
|
||||
const isInvoiced = !!(order.factura_numar);
|
||||
if (isInvoiced) {
|
||||
resyncBtn.disabled = true;
|
||||
resyncBtn.style.opacity = '0.5';
|
||||
resyncBtn.style.pointerEvents = 'none';
|
||||
resyncBtn.title = 'Comanda facturata';
|
||||
} else {
|
||||
resyncBtn.disabled = false;
|
||||
resyncBtn.style.opacity = '';
|
||||
resyncBtn.style.pointerEvents = '';
|
||||
resyncBtn.title = '';
|
||||
resyncBtn.onclick = () => {
|
||||
inlineConfirmAction(resyncBtn, 'Confirmi resync?', async (btn) => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/resync`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i class="bi bi-check-circle"></i> Reimportat';
|
||||
btn.className = 'btn btn-sm btn-success';
|
||||
if (opts.onStatusChange) opts.onStatusChange();
|
||||
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||
} else {
|
||||
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||
btn.className = 'btn btn-sm btn-danger';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Resync';
|
||||
btn.className = 'btn btn-sm btn-outline-warning';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
btn.innerHTML = 'Eroare: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}, {
|
||||
defaultHtml: '<i class="bi bi-arrow-repeat"></i> Resync',
|
||||
loadingText: 'Resync...',
|
||||
confirmClass: 'btn-warning',
|
||||
defaultBtnClass: 'btn-outline-warning'
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete button (IMPORTED/ALREADY_IMPORTED only)
|
||||
const deleteBtn = document.getElementById('detailDeleteBtn');
|
||||
if (deleteBtn) {
|
||||
const canDelete = [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes((order.status || '').toUpperCase());
|
||||
deleteBtn.style.display = canDelete ? '' : 'none';
|
||||
if (canDelete) {
|
||||
const isInvoiced = !!(order.factura_numar);
|
||||
if (isInvoiced) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.style.opacity = '0.5';
|
||||
deleteBtn.style.pointerEvents = 'none';
|
||||
deleteBtn.title = 'Comanda facturata';
|
||||
} else {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.style.opacity = '';
|
||||
deleteBtn.style.pointerEvents = '';
|
||||
deleteBtn.title = '';
|
||||
deleteBtn.onclick = () => {
|
||||
inlineConfirmAction(deleteBtn, 'Confirmi stergerea?', async (btn) => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/delete`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i class="bi bi-check-circle"></i> Sters';
|
||||
btn.className = 'btn btn-sm btn-danger';
|
||||
if (opts.onStatusChange) opts.onStatusChange();
|
||||
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||
} else {
|
||||
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||
btn.className = 'btn btn-sm btn-danger';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<i class="bi bi-trash"></i> Sterge din ROA';
|
||||
btn.className = 'btn btn-sm btn-outline-danger';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
btn.innerHTML = 'Eroare: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}, {
|
||||
defaultHtml: '<i class="bi bi-trash"></i> Sterge din ROA',
|
||||
loadingText: 'Stergere...',
|
||||
confirmClass: 'btn-danger',
|
||||
defaultBtnClass: 'btn-outline-danger'
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||
} catch (err) {
|
||||
document.getElementById('detailError').textContent = err.message;
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=47"></script>
|
||||
<script src="{{ rp }}/static/js/shared.js?v=49"></script>
|
||||
<script>
|
||||
// Dark mode toggle
|
||||
function toggleDarkMode() {
|
||||
|
||||
@@ -821,6 +821,49 @@ class TestFormatAddressForOracle:
|
||||
assert result == "JUD:Bacau;Zemes;Str Principala Modarzau Blocuri"
|
||||
|
||||
|
||||
class TestCleanWebTextDiacritics:
|
||||
"""clean_web_text strips diacritics across RO/HU/DE/CZ/PL via NFKD."""
|
||||
|
||||
def test_hungarian_acute(self):
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("BALÁZS LORÁNT") == "BALAZS LORANT"
|
||||
|
||||
def test_hungarian_double_acute(self):
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("Lőrincz Ödön") == "Lorincz Odon"
|
||||
assert clean_web_text("Erdős Pál") == "Erdos Pal"
|
||||
|
||||
def test_romanian_comma_below_modern(self):
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("Ștefan Țîrcă") == "Stefan Tirca"
|
||||
assert clean_web_text("ȘTEFAN ȚÎRCĂ") == "STEFAN TIRCA"
|
||||
|
||||
def test_romanian_cedilla_legacy_preserved(self):
|
||||
"""Cedilla ş/ţ/Ş/Ţ must still normalize to s/t/S/T (regression guard)."""
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("şcoala") == "scoala"
|
||||
assert clean_web_text("ţara") == "tara"
|
||||
assert clean_web_text("ŞTEFAN ŢARA") == "STEFAN TARA"
|
||||
assert clean_web_text("IAŞI") == "IASI"
|
||||
|
||||
def test_german_umlaut_and_eszett(self):
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("Müller Straße") == "Muller Strasse"
|
||||
|
||||
def test_czech_polish(self):
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("Dvořák") == "Dvorak"
|
||||
assert clean_web_text("Łódź") == "Lodz"
|
||||
|
||||
def test_html_entity_unescape(self):
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("Café") == "Cafe"
|
||||
|
||||
def test_empty_input(self):
|
||||
from app.services.import_service import clean_web_text
|
||||
assert clean_web_text("") == ""
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Group 11: TestRefreshOrderAddress
|
||||
# ===========================================================================
|
||||
|
||||
@@ -168,12 +168,15 @@ async def test_save_orders_batch_overwrite():
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# mark_order_deleted_in_roa — must purge items
|
||||
# mark_order_deleted_in_roa — preserves items so detail view stays useful
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_order_deleted_removes_items():
|
||||
"""Soft-delete must remove order_items (no ghost rows)."""
|
||||
async def test_mark_order_deleted_preserves_items():
|
||||
"""Soft-delete keeps order_items so the detail view shows what was ordered.
|
||||
|
||||
On 'Reimporta', add_order_items replaces them (DELETE+INSERT inside _safe_upsert_order_items).
|
||||
"""
|
||||
await _seed_order("ORD-DEL")
|
||||
await sqlite_service.add_order_items("ORD-DEL", [
|
||||
_item("SKU1", qty=5), _item("SKU2", qty=3),
|
||||
@@ -182,8 +185,10 @@ async def test_mark_order_deleted_removes_items():
|
||||
|
||||
await sqlite_service.mark_order_deleted_in_roa("ORD-DEL")
|
||||
|
||||
# Items purged
|
||||
assert await _items_for("ORD-DEL") == []
|
||||
# Items preserved — detail view can still display them alongside "Comanda stearsa din ROA"
|
||||
items = await _items_for("ORD-DEL")
|
||||
assert len(items) == 2
|
||||
assert {i["sku"] for i in items} == {"SKU1", "SKU2"}
|
||||
|
||||
# Orders row still present with DELETED_IN_ROA status (not hard-deleted)
|
||||
db = await sqlite_service.get_sqlite()
|
||||
|
||||
140
api/tests/test_sticky_deleted_filter.py
Normal file
140
api/tests/test_sticky_deleted_filter.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Sticky DELETED_IN_ROA Filter Tests
|
||||
===================================
|
||||
Unit tests for get_deleted_in_roa_order_numbers() helper and integration
|
||||
test for the sticky-exclusion filter applied in sync_service before
|
||||
order classification.
|
||||
|
||||
Run:
|
||||
cd api && python -m pytest tests/test_sticky_deleted_filter.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_tmpdir = tempfile.mkdtemp()
|
||||
os.environ.setdefault("FORCE_THIN_MODE", "true")
|
||||
os.environ.setdefault("SQLITE_DB_PATH", os.path.join(_tmpdir, "test_sticky_deleted.db"))
|
||||
os.environ.setdefault("ORACLE_DSN", "dummy")
|
||||
os.environ.setdefault("ORACLE_USER", "dummy")
|
||||
os.environ.setdefault("ORACLE_PASSWORD", "dummy")
|
||||
os.environ.setdefault("JSON_OUTPUT_DIR", _tmpdir)
|
||||
|
||||
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _api_dir not in sys.path:
|
||||
sys.path.insert(0, _api_dir)
|
||||
|
||||
from app import database
|
||||
from app.services import sqlite_service
|
||||
from app.constants import OrderStatus
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_orders():
|
||||
"""Ensure schema exists, clear orders table before each test."""
|
||||
database.init_sqlite()
|
||||
db = await database.get_sqlite()
|
||||
try:
|
||||
await db.execute("DELETE FROM orders")
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
yield
|
||||
|
||||
|
||||
async def _insert_order(order_number: str, status: str, id_comanda: int | None = None):
|
||||
db = await database.get_sqlite()
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO orders (order_number, order_date, customer_name, status, id_comanda)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(order_number, "2026-04-22", "Test Customer", status, id_comanda),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_set_when_no_orders():
|
||||
"""Helper unit: empty table → empty set."""
|
||||
result = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||
assert result == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_only_deleted_in_roa_status():
|
||||
"""Helper unit: filters only DELETED_IN_ROA, ignores other statuses."""
|
||||
await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100)
|
||||
await _insert_order("ORD-2", OrderStatus.DELETED_IN_ROA.value)
|
||||
await _insert_order("ORD-3", OrderStatus.CANCELLED.value)
|
||||
await _insert_order("ORD-4", OrderStatus.ERROR.value)
|
||||
await _insert_order("ORD-5", OrderStatus.DELETED_IN_ROA.value)
|
||||
await _insert_order("ORD-6", OrderStatus.SKIPPED.value)
|
||||
|
||||
result = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||
assert result == {"ORD-2", "ORD-5"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_order_deleted_then_helper_returns_it():
|
||||
"""Integration: mark_order_deleted_in_roa → helper picks it up."""
|
||||
await _insert_order("ORD-100", OrderStatus.IMPORTED.value, id_comanda=500)
|
||||
|
||||
before = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||
assert "ORD-100" not in before
|
||||
|
||||
await sqlite_service.mark_order_deleted_in_roa("ORD-100")
|
||||
|
||||
after = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||
assert "ORD-100" in after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_excludes_deleted_orders():
|
||||
"""Integration: simulates sync filter step.
|
||||
|
||||
Pre-mark ORD-2 as DELETED_IN_ROA, run the same filter logic from
|
||||
sync_service:478-489, assert ORD-2 is excluded.
|
||||
"""
|
||||
await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100)
|
||||
await _insert_order("ORD-2", OrderStatus.DELETED_IN_ROA.value)
|
||||
await _insert_order("ORD-3", OrderStatus.IMPORTED.value, id_comanda=300)
|
||||
|
||||
incoming = [
|
||||
type("O", (), {"number": "ORD-1", "date": "2026-04-22"})(),
|
||||
type("O", (), {"number": "ORD-2", "date": "2026-04-22"})(),
|
||||
type("O", (), {"number": "ORD-3", "date": "2026-04-22"})(),
|
||||
type("O", (), {"number": "ORD-NEW", "date": "2026-04-22"})(),
|
||||
]
|
||||
|
||||
deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||
excluded = [o for o in incoming if o.number in deleted_set]
|
||||
survivors = [o for o in incoming if o.number not in deleted_set]
|
||||
|
||||
assert {o.number for o in excluded} == {"ORD-2"}
|
||||
assert {o.number for o in survivors} == {"ORD-1", "ORD-3", "ORD-NEW"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_with_no_deleted_is_noop():
|
||||
"""Integration: deleted_set empty → all orders pass through."""
|
||||
await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100)
|
||||
|
||||
incoming = [
|
||||
type("O", (), {"number": "ORD-1", "date": "2026-04-22"})(),
|
||||
type("O", (), {"number": "ORD-NEW", "date": "2026-04-22"})(),
|
||||
]
|
||||
|
||||
deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||
survivors = [o for o in incoming if o.number not in deleted_set]
|
||||
|
||||
assert deleted_set == set()
|
||||
assert {o.number for o in survivors} == {"ORD-1", "ORD-NEW"}
|
||||
Reference in New Issue
Block a user