Compare commits

5 Commits

Author SHA1 Message Date
Claude Agent
32974e3b85 fix(orders): preserve order_items on mark_order_deleted_in_roa
Detail view for DELETED_IN_ROA orders showed "Niciun articol" because
the soft-delete helper hard-deleted order_items. Now items stay in
SQLite so the detail page displays the original GoMag order alongside
"Comanda stearsa din ROA". On 'Reimporta', add_order_items already
replaces them via DELETE+INSERT inside _safe_upsert_order_items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:06:13 +00:00
Claude Agent
ab20856cd6 fix(sync): sticky exclusion for DELETED_IN_ROA orders
Orders deleted via "Sterge" button were re-imported on the next sync
because classify step only checked Oracle (sters=0), not SQLite status.
Adds a filter step after cancellation handling that drops orders
already marked DELETED_IN_ROA before validation. "Reimporta" remains
the explicit override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:57:32 +00:00
Claude Agent
956667086d fix(import): NFKD normalization for non-Romanian diacritics
Some checks failed
Tests / fast-tests (push) Has been cancelled
Tests / full-tests (push) Has been cancelled
clean_web_text used a hard-coded Romanian-only translation map, so Hungarian
(BALÁZS LORÁNT), German, Czech, Polish names passed through unchanged into
SQLite and Oracle ROA. Replace with unicodedata.normalize('NFKD') + combining
mark strip — covers RO/HU/DE/CZ/PL/FR/ES universally. Romanian cedilla legacy
forms (ş/ţ/Ş/Ţ) remain handled (NFKD decomposes to base + combining cedilla).
Stroke letters not decomposed by NFKD (ß, ł, đ, ø, æ, œ) covered via
_NFKD_OVERRIDES translation map.

sync_service._addr_match.norm migrated off the removed _DIACRITICS constant
to clean_web_text; address matching now also handles non-RO diacritics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:52:50 +00:00
Claude Agent
9b62b2b457 fix(ui): _configureDetailButtons trapped in unterminated JSDoc
Stray /** at shared.js:604 wrapped the function body in a block comment
that only closed at line 747, so _configureDetailButtons was never
defined. renderOrderDetailModal threw ReferenceError, leaving the order
detail modal stuck on "Se incarca...".

Removed the orphan /** opener; bumped shared.js cache-bust to v=49.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 07:40:02 +00:00
Claude Agent
b13d9a466c fix(ui): show Reimporta button on DELETED_IN_ROA orders
mark_order_deleted_in_roa wipes order_items, so renderOrderDetailModal
hit the items.length===0 early-return BEFORE configuring footer buttons —
leaving DELETED_IN_ROA orders with no way to retry from the UI.

Extract the 3 button configurators (Retry/Resync/Delete) into
_configureDetailButtons() and call it before the early-return. Also
fire onAfterRender on the empty-items path for consistency.

Cache-bust shared.js v47 → v48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 07:28:55 +00:00
8 changed files with 386 additions and 165 deletions

View File

@@ -2,38 +2,39 @@ import html
import json import json
import logging import logging
import re import re
import unicodedata
import oracledb import oracledb
from datetime import datetime, timedelta from datetime import datetime, timedelta
from .. import database from .. import database
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Diacritics to ASCII mapping (Romanian) # Stroke/ligature letters NFKD does not decompose (structural mod, not a
_DIACRITICS = str.maketrans({ # combining mark). Everything else — RO cedilla ş/ţ, RO comma-below ș/ț,
'\u0103': 'a', # ă # HU ő/ű, DE umlaut, CZ háček, FR accent, ES tilde — is handled
'\u00e2': 'a', # â # universally by unicodedata.normalize('NFKD') + Mn-category strip below.
'\u00ee': 'i', # î _NFKD_OVERRIDES = str.maketrans({
'\u0219': 's', # ș 'ß': 'ss', # ß
'\u021b': 't', # ț 'æ': 'ae', 'Æ': 'AE', # æ Æ
'\u0102': 'A', # Ă 'œ': 'oe', 'Œ': 'OE', # œ Œ
'\u00c2': 'A', # Â 'ł': 'l', 'Ł': 'L', # ł Ł (Polish)
'\u00ce': 'I', # Î 'đ': 'd', 'Đ': 'D', # đ Đ (Croatian)
'\u0218': 'S', # Ș 'ø': 'o', 'Ø': 'O', # ø Ø (Danish/Norwegian)
'\u021a': 'T', # Ț
# Older Unicode variants
'\u015f': 's', # ş (cedilla)
'\u0163': 't', # ţ (cedilla)
'\u015e': 'S', # Ş
'\u0162': 'T', # Ţ
}) })
def clean_web_text(text: str) -> str: 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: if not text:
return "" return ""
result = html.unescape(text) 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 # Remove any remaining <br> tags
for br in ('<br>', '<br/>', '<br />'): for br in ('<br>', '<br/>', '<br />'):
result = result.replace(br, ' ') result = result.replace(br, ' ')

View File

@@ -1253,6 +1253,19 @@ async def get_all_imported_orders() -> list:
await db.close() 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): async def clear_order_invoice(order_number: str):
"""Clear cached invoice data when invoice was deleted in ROA.""" """Clear cached invoice data when invoice was deleted in ROA."""
db = await get_sqlite() 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): 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() db = await get_sqlite()
try: try:
await db.execute("DELETE FROM order_items WHERE order_number = ?", (order_number,))
await db.execute(f""" await db.execute(f"""
UPDATE orders SET UPDATE orders SET
status = '{OrderStatus.DELETED_IN_ROA.value}', status = '{OrderStatus.DELETED_IN_ROA.value}',

View File

@@ -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))' r'ET|ETAJ|COM|COMUNA|SAT|MUN|MUNICIPIUL|JUD|JUDETUL|CARTIER|PARTER|SECTOR|SECTORUL|ORAS)(?:\b|(?=\d))'
) )
def norm(s): 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) s = _ADDR_WORDS.sub('', s)
return re.sub(r'[^A-Z0-9]', '', s) return re.sub(r'[^A-Z0-9]', '', s)
def _soundex(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 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: if not orders:
_log_line(run_id, "Nicio comanda activa dupa filtrare anulate.") _log_line(run_id, "Nicio comanda activa dupa filtrare anulate.")
await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0) await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0)

View File

@@ -601,6 +601,142 @@ function _renderReceipt(items, order) {
} }
// ── Order Detail Modal (shared) ────────────────── // ── 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. * Render and show the order detail modal.
* @param {string} orderNumber * @param {string} orderNumber
@@ -717,9 +853,14 @@ async function renderOrderDetailModal(orderNumber, opts) {
document.getElementById('detailError').style.display = ''; 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 || []; const items = data.items || [];
if (items.length === 0) { if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>'; 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; return;
} }
@@ -853,143 +994,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
document.getElementById('detailItemsBody').innerHTML = tableHtml; document.getElementById('detailItemsBody').innerHTML = tableHtml;
_renderReceipt(items, order); _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); if (opts.onAfterRender) opts.onAfterRender(order, items);
} catch (err) { } catch (err) {
document.getElementById('detailError').textContent = err.message; document.getElementById('detailError').textContent = err.message;

View File

@@ -169,7 +169,7 @@
<script>window.ROOT_PATH = "{{ rp }}";</script> <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="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> <script>
// Dark mode toggle // Dark mode toggle
function toggleDarkMode() { function toggleDarkMode() {

View File

@@ -821,6 +821,49 @@ class TestFormatAddressForOracle:
assert result == "JUD:Bacau;Zemes;Str Principala Modarzau Blocuri" 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&eacute;") == "Cafe"
def test_empty_input(self):
from app.services.import_service import clean_web_text
assert clean_web_text("") == ""
# =========================================================================== # ===========================================================================
# Group 11: TestRefreshOrderAddress # Group 11: TestRefreshOrderAddress
# =========================================================================== # ===========================================================================

View File

@@ -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 @pytest.mark.asyncio
async def test_mark_order_deleted_removes_items(): async def test_mark_order_deleted_preserves_items():
"""Soft-delete must remove order_items (no ghost rows).""" """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 _seed_order("ORD-DEL")
await sqlite_service.add_order_items("ORD-DEL", [ await sqlite_service.add_order_items("ORD-DEL", [
_item("SKU1", qty=5), _item("SKU2", qty=3), _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") await sqlite_service.mark_order_deleted_in_roa("ORD-DEL")
# Items purged # Items preserved — detail view can still display them alongside "Comanda stearsa din ROA"
assert await _items_for("ORD-DEL") == [] 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) # Orders row still present with DELETED_IN_ROA status (not hard-deleted)
db = await sqlite_service.get_sqlite() db = await sqlite_service.get_sqlite()

View 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"}