Compare commits

40 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
Claude Agent
18acfd2226 Merge fix/retry-ensure-prices: pre-populate price list before retry 2026-04-29 10:20:11 +00:00
Claude Agent
bcd65d9fd6 fix(retry): pre-populate price list before re-importing failed orders
Production VENDING orders #485841978 and #485841895 (2026-04-28) crashed
on Retry with PL/SQL COM-001 because the retry path skipped the
CRM_POLITICI_PRET_ART pre-population step that bulk sync runs.

The price-list auto-insert (PRET=0) for missing CODMATs was only invoked
in sync_service.run_sync (lines 592-718). retry_service called
import_single_order directly, hitting pack_comenzi.adauga_articol_comanda
NO_DATA_FOUND on every CODMAT without a price entry.

Extracted the validation block into validation_service.pre_validate_order_prices
and call it from both bulk sync and retry. Single source of truth for
SKU validation, dual-policy routing (cont 341/345 → productie),
ARTICOLE_TERTI mapping resolution, and kit component price gating.

Tests: 3 unit + 3 oracle integration covering the regression scenario,
empty input, dual-policy routing, idempotency, and pre-validation
exception propagation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:19:38 +00:00
Claude Agent
874ba4ca4e docs: SSH debugging guide pentru prod Windows — ce merge, ce nu merge
Adaugat in sectiunea 'Depanare SSH' din README:
- Comanda corecta ssh cu -i ~/.ssh/id_ed25519
- Deploy pachet Oracle via sqlplus @fisier.pck
- Restart FastAPI cand nssm e indisponibil (kill python + start.ps1)
- Tabel explicit cu ce NU merge (nssm/sc/WMI/pipe/curl -m/here-doc)
  si alternativele corecte
- Metoda scriere SQL ad-hoc: fisier local -> scp -> @fisier.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:45:19 +00:00
Claude Agent
e42b1f63b7 fix(address): ORA-06502 pe apart/scara/etaj token lung + strip city+region din adresa GoMag
Doua root cause-uri pentru ORA-12899 la importul comenzii #485841056:

1. Oracle ORA-06502: v_apart/v_scara/v_bloc/v_etaj in cauta_sau_creeaza_adresa
   declarate VARCHAR2(10/20/30) → Oracle mostenea constrangerea pe OUT parametrii
   din parseaza_adresa_semicolon → crash INAINTE de fix-ul overflow de la linia 521.
   Fix: marite la VARCHAR2(100).

2. Python format_address_for_oracle stripuia doar city exact, nu si 'city region'
   sau 'region city'. GoMag trimite adresa cu suffix 'Municipiul Bucuresti Bucuresti'
   (city+region) → token urias pentru apartament → declansa ORA-06502 de mai sus.
   Fix: incearca toate combinatiile city+region, region+city, city, region.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:31:05 +00:00
Claude Agent
c8bed18978 fix(plsql): hardening SUBSTR(1,10) neconditional dupa split numar
Safety net dupa blocul de overflow split: garanteaza ca p_numar nu
depaseste 10 caractere chiar daca prefixul inaintea primului spatiu
este el insusi >10 char.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:07:50 +00:00
Claude Agent
6620b28ed1 feat(sync): gate CUI invalid/ANAF-notFound → ERROR inainte de import Oracle
Incident 22.04.2026 (#485225171 NONA ROYAL SRL): clientul a inversat
cod_fiscal cu registru in GoMag → sistem a creat partener cu CUI=J1994000194225.

Adauga evaluate_cui_gate() care blocheaza comanda (ERROR) daca:
- CUI format invalid (ex: J.. in loc de cifre)
- CUI nu trece cifra de control
- ANAF returneaza explicit notFound (scpTVA=None + denumire_anaf="")

ANAF down (anaf_data=None) → fallback pass, comportament existent pastrat.
_record_order_error() DRY helper evita duplicarea upsert/add_items.
Contract ANAF down/notFound/found documentat in anaf_service._call_anaf_api.
9 teste unit (inclusiv T5 CRITIC: ANAF down nu blocheaza) + T7 COALESCE.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:40:07 +00:00
Claude Agent
7e30523242 feat(retry): allow retry for MALFORMED orders
MALFORMED is now a valid retry source alongside ERROR / SKIPPED /
DELETED_IN_ROA. The next sync will re-run validate_structural and
either reclassify or keep the MALFORMED tag — either way, operators
get the same "Retry" button they have for other failure paths
without needing a separate UI affordance.

278 unit + 33 e2e green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:16:16 +00:00
Claude Agent
bb6f3a3b87 feat(sync): /api/sync/health endpoint + dashboard health pill + MALFORMED UI
Backend:
- GET /api/sync/health returns {last_sync_at, last_sync_status,
  last_halt_reason, recent_phase_failures, escalation_phase, is_healthy}.
  healthy when last run was completed (or none yet), no phase has
  tripped the 3-in-a-row escalation, and recent failures <= 1.
- Dashboard + run-level endpoints include `malformed` count so the
  Defecte pill can render.

Frontend:
- Health pill in .sync-card-controls with three states — healthy
  (success green, check icon), warning (amber, triangle), escalated
  (error red, x-octagon + glow). Tooltip exposes the halt reason and
  the top phases with recent failures.
- Status-dot + badge add MALFORMED treatment via --compare orange,
  distinct from ERROR red. DESIGN.md notes the diagnostic rationale
  (ERROR = runtime, MALFORMED = payload source issue).
- Defecte filter pill on dashboard + logs pages. Mobile segmented
  control includes Defecte count. Counts wired to the malformed key.
- startSync() shows a native confirm modal when state is
  halted_escalation — operator override still possible, not silenced.
- ORDER_STATUS.MALFORMED mirror added to shared.js.
- Cache-bust: style.css v46, shared.js v47, dashboard.js v52,
  logs.js v16.

5 endpoint tests cover empty state, completed, failed, escalated,
single-failure warning. Full CI: 257 unit + 33 e2e green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:14:46 +00:00
Claude Agent
41b142effb feat(sync): per-phase isolation + escalation halt
sync_service gains DATA_ERRORS tuple + two new primitives:
  _record_phase_err(run_id, phase, err)
    Logs, appends to run text log, persists to sync_phase_failures.
  _check_escalation()
    Reads the last 3 runs and returns the first phase that has failed
    all 3 in a row, or (None, counts) otherwise.

run_sync now runs a pre-flight escalation check — if a phase has failed
3 consecutive runs, the incoming sync is halted with
status='halted_escalation' and a descriptive error_message. The
dashboard Start Sync button can still override (UI comes in the next
PR2 phase).

Wrapped phases (DATA_ERRORS caught, sync continues):
  cancelled_batch, already_batch, addresses_batch, skipped_batch,
  price_sync, invoice_check, anaf_backfill.
Partner mismatch retains its existing per-order guards. OperationalError
and OS-level errors still propagate to the top-level handler (halt).

6 unit tests cover record + counts + threshold + mixed-phase +
short-circuit + DATA_ERRORS contract. Full CI green: 251 unit + 33 e2e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:06:58 +00:00
Claude Agent
1e4e3279f7 feat(sync): sync_phase_failures table for escalation tracking
New table sync_phase_failures(run_id, phase, error_summary, created_at)
with index on (phase, created_at). Minimal schema — no raw payload, no
PII — stores just enough to answer "did phase X fail in the last N
runs?" for the escalation check and the /api/sync/health pill.

Helpers in sqlite_service:
  record_phase_failure(run_id, phase, error_summary)
    INSERT OR REPLACE semantics (one row per run+phase), then prunes
    to the most recent 100 sync_runs. error_summary clipped at 500
    chars defensively.
  get_recent_phase_failures(limit=3) → {phase: count} across the last N
    runs, ordered by started_at desc.

6 unit tests cover creation, counting, pruning, empty state,
idempotency, and limit semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:02:08 +00:00
Claude Agent
47a6bd83a4 feat(sync): per-order SAVEPOINT protection for order_items upsert
_safe_upsert_order_items(db, order_number, items) wraps the
DELETE + INSERT OR REPLACE pair in SAVEPOINT items. On
IntegrityError / ValueError / TypeError it rolls the savepoint
back, tags the parent order MALFORMED, logs to the error history
file, and returns False to the caller. add_order_items now delegates
to this helper so a single bad payload cannot leave order_items in
a split state.

2 integration tests: happy path + simulated INSERT crash via
aiosqlite monkeypatch. Existing order_items overwrite regression
tests still pass (5/5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:00:57 +00:00
Claude Agent
f448f74b2d feat(sync): hybrid batch+savepoint isolation with reconnect fix
save_orders_batch now runs in three tiers:
  1. validate_structural pre-flight splits each payload into valid or
     MALFORMED. MALFORMED rows persist with status + error_message + no
     items, and an append-only entry lands in sync_errors_history.log.
  2. Optimistic executemany over the valid list inside a SAVEPOINT batch.
  3. On IntegrityError / ValueError / TypeError, rollback the savepoint
     and fall back to per-order SAVEPOINT inserts so a single bad row
     cannot poison the rest of the batch.

Mid-loop SAVEPOINT rollback failure now triggers _safe_reconnect:
commit whatever survived, close the broken connection, open a fresh
one and keep processing. Preserves MALFORMED rows recorded earlier —
addresses the outside-voice gap where a crashed connection would lose
uncommitted malformed evidence.

Adds OrderStatus.MALFORMED and helper functions:
  _insert_orders_only  — orders + sync_run_orders, no items
  _insert_valid_batch  — happy-path bulk executemany
  _insert_single_order — per-order execute within savepoint
  _mark_malformed      — non-mutating copy with wiped items
  _safe_reconnect      — commit-close-reconnect guard

8 integration tests covering regression 485224762, structural
pre-flight, per-order isolation on runtime fail, caller-dict
immutability, and reconnect durability. 239 unit + 33 e2e green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:59:43 +00:00
Claude Agent
d7610a6f33 feat(sync): persistent append-only error history log
_log_order_error_history(order_number, msg) writes to
logs/sync_errors_history.log via a dedicated RotatingFileHandler
(100MB × 12 backups). Logger is lazy-initialised and non-propagating
so it doesn't pollute the root logger.

Purpose: orders.error_message is overwritten when a retry succeeds,
so the history log preserves permanent audit of every malformed-order
event regardless of later outcome. Helper never raises — callers are
already in a degraded path.

3 unit tests: append semantics, multi-order, exception isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:54:11 +00:00
Claude Agent
38498bec6d feat(validation): add structural pre-flight validator
validate_structural(order) runs before save_orders_batch insert.
Catches malformed payloads (MISSING_FIELD, INVALID_DATE, EMPTY_ITEMS,
INVALID_QUANTITY, INVALID_PRICE) that would otherwise crash the batch
insert or downstream pipeline. 17 unit tests cover each rule.

Does NOT validate SKU existence — redundant with _dedup_items_by_sku
pass-through and validate_skus Oracle lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:52:32 +00:00
Claude Agent
f6d283b743 refactor(status): introduce OrderStatus enum, replace string literals
Centralized order status values in api/app/constants.py via a
str-valued Enum so comparisons keep working. Replaced literals in:
- services: sync_service, sqlite_service, retry_service
- routers: sync, dashboard
- templates: dashboard.html, logs.html
- static JS: shared (ORDER_STATUS mirror), dashboard, logs
- tests: requirements, order_items_overwrite, business_rules

MALFORMED intentionally NOT added — introduced in follow-up PR2
(per-order failure isolation).

Full test suite: 231 unit + 33 e2e pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:45:32 +00:00
Claude Agent
51790accf9 fix(sync): dedup order_items by sku before insert to avoid UNIQUE crash
Production sync was failing every minute with:
  UNIQUE constraint failed: order_items.order_number, order_items.sku

GoMag occasionally returns the same SKU on multiple lines within one order
(configurable products, promo splits). The order_items PK is
(order_number, sku), so the raw batch insert violates UNIQUE and aborts
the entire sync — blocking partner-mismatch updates, address refresh,
and items repopulation for already-imported orders.

Added _dedup_items_by_sku() helper. Applied in save_orders_batch
(cancelled/already/skipped paths) and add_order_items (retry/sync import
paths). Keeps first price/vat/name, sums quantities on collision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:04:49 +00:00
Claude Agent
404bc094cd fix(backfill): init sqlite before reading settings
Script failed with "SQLite not initialized" because module-level connection
state wasn't set up when invoked standalone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:02:40 +00:00
Claude Agent
819af221d8 fix(import): resolve duplicate article + order_items repopulation on retry
Two production bugs from VENDING (order 485224762, 2026-04-22):

1. Oracle: ORA-20000 when a GoMag order contains a kit SKU whose expansion
   includes CODMAT X plus a second item with SKU=X. Two article-insert
   call-sites in PACK_IMPORT_COMENZI bypassed merge_or_insert_articol —
   line 622 (NOM_ARTICOLE fallback) and line 538 (kit discount line).
   Both now use merge_or_insert_articol for consistent dedup semantics.
   Regression test added in test_complete_import.py covering the exact
   kit-plus-direct scenario.

2. SQLite: retry_service._download_and_reimport refreshed orders row but
   never repopulated order_items. Combined with mark_order_deleted_in_roa
   (which wipes items), any retry/resync left the UI showing "Niciun
   articol" despite successful Oracle import. Retry now rebuilds items
   from the fresh GoMag download on both success and error paths,
   mirroring sync_service.

Includes scripts/backfill_order_items.py — one-shot recovery for orders
already in this bad state. Reads settings, re-fetches from GoMag,
rewrites order_items without touching Oracle or order status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 06:58:20 +00:00
Claude Agent
b8a9480784 fix(address): numar overflow split, SAT → localitate
In parseaza_adresa_semicolon, text după NR ("5 la non stop", "21 sat
Grozavesti corbii mari") era împins în p_numar și trunchiat brutal la
10 chars ("5 LA NON S", "21 SAT GRO").

Fix: când p_numar > 10 chars, prima componentă rămâne numar; restul se
clasifică:
- "SAT X ..." → p_localitate := "X ..." (satul = localitate, TIER
  L1/L2/L3 existent rezolvă id_loc)
- "COM/ORAS/MUN X" → aruncat (deja în p_localitate din GoMag city)
- altceva (landmark ex "LA NON STOP") → concatenat în p_strada

Semnătura parseaza_adresa_semicolon neschimbată. Zero callers afectați.

Teste: landmark → strada, SAT → localitate, numar normal neschimbat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:13:08 +00:00
Claude Agent
d15f8b085d fix(missing-skus): reconcile stale false positives against Oracle
SKUs mapped externally (via SSH script or direct SQL) never triggered
resolve_missing_sku(), leaving them stuck as unresolved=0 indefinitely.
New reconcile_unresolved_missing_skus() revalidates ALL unresolved SKUs
against Oracle at sync, rescan, and CSV import time. Fail-soft on Oracle
down. Clears the 7 prod false positives on next sync or manual rescan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:42:43 +00:00
Claude Agent
3bcb26b0bd fix(import): 3 production bugs — items cache, CUI lookup, ANAF name
Some checks failed
Tests / fast-tests (push) Has been cancelled
Tests / full-tests (push) Has been cancelled
1. SQLite order_items overwrite on re-import (VELA CAFE #484669620):
   add_order_items, save_orders_batch, mark_order_deleted_in_roa now use
   DELETE + INSERT so GoMag quantity changes propagate to dashboard.

2. PL/SQL strict CUI lookup tolerates whitespace (FG COFFE #485065210):
   cauta_partener_dupa_cod_fiscal regex ^RO\d → ^RO\s*\d; IN-set uses
   canonical v_ro_cui. Platitor/neplatitor business rule preserved.
   Python defensive: re.sub whitespace collapse in determine_partner_data.

3. New PJ partners use ANAF official denumire (denumire_override) instead
   of GoMag company_name. Existing partners (found by CUI) untouched.

Tests: 18 new (5 SQLite unit, 8 Python unit, 5 Oracle PL/SQL). All green
locally: 228 unit + 26 oracle + 33 e2e.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:32:59 +00:00
Claude Agent
5397bec35d adaugare adauga_comanda_pe_factura() 2026-04-15 14:42:17 +00:00
Claude Agent
5cdd919226 fix(partners): exclude soft-deleted parteneri in denumire lookup
cauta_partener_dupa_denumire nu filtra sters=0, deci importul PF
putea lega comanda la un partener sters=1 (bug GoMag #484668145 —
RADULESCU ANA MARIA, id_part=21946). Adaugat NVL(sters,0)=0 si
ORDER BY inactiv ASC pentru a prefera parteneri activi, in linie cu
cauta_partener_dupa_cod_fiscal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:08:44 +00:00
Claude Agent
db60d955bf fix(dashboard): show billing vs shipping marker for PJ and PF ramburs
PJ: tooltip shows company on Facturat (display) vs shipping person.
PF ramburs: tooltip shows billing person vs shipping person when they
differ. Adds aria-label + title on indicator for keyboard/screen reader.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:27:14 +00:00
Claude Agent
520f0836bf fix(autocomplete): add keyboard navigation and fix scroll/blur in all CODMAT dropdowns
Extract shared setupAutocomplete() into shared.js so all three autocomplete
instances (mappings modal, inline add, quick-map modal) get keyboard nav
(ArrowDown/Up/Enter/Escape), scroll-safe blur handling, and capture-phase
keydown to prevent browser interception. Remove old onmousedown inline
handlers, use data-codmat/data-label attributes instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-09 16:07:49 +00:00
Claude Agent
84e5d55592 fix(dashboard): fix kebab menu delete/resync and status dot refresh
Kebab dropdown delete/resync used inlineConfirmAction which breaks inside
Bootstrap dropdowns (dropdown closes on click, hiding confirm state).
Replaced with confirm() dialog + direct async action with row feedback.

Detail modal resync/delete/retry now trigger onStatusChange callback to
refresh the orders table, so status dots update without page reload.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-09 15:24:26 +00:00
Claude Agent
e223128565 fix(dashboard): replace hover row-actions with kebab menu, fix modal button reset and delete color
- Bug 1: hover actions covered total column; replaced with kebab dropdown in dedicated 44px column
- Bug 2: resync/delete buttons kept stale state across modal opens; reset in modal init block
- Bug 3: delete success button was green (btn-success); changed to red (btn-danger)
- Dropdown styled per DESIGN.md: warm shadow, 8px radius, dark mode tokens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-09 15:10:40 +00:00
Claude Agent
25f73db64d feat(orders): add resync and delete order buttons
Resync soft-deletes from Oracle then re-imports from GoMag with fresh
article data. Delete soft-deletes and marks DELETED_IN_ROA. Both have
invoice safety gates (refuse if invoiced or Oracle unavailable).

UI: split modal footer (Delete left, Resync+Close right), inline
confirm pattern (no native confirm()), dashboard row hover action
icons, disabled+tooltip for invoiced orders. 8 unit tests for safety
gates and happy paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-09 13:10:01 +00:00
Claude Agent
90a4906d87 ADRESE 2026-04-08 22:55:09 +00:00
Claude Agent
5eba87976b fix(address): use SOUNDEX city matching and strip SECTORUL from city
Fixes false negatives where city spellings differ slightly (e.g.
"Sfântu Ilie" vs "SFINTU ILIE") or ROA stores "BUCURESTI SECTORUL 1"
while GoMag sends "Municipiul București". Both backend (_addr_match)
and frontend (addrMatch) now use identical SOUNDEX logic mirroring
Oracle's implementation.

Also fixes field order: etaj before apart in r_street concatenation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:31:36 +00:00
Claude Agent
f48c2d62c6 fix(address): extract scara/etaj/apartament from comma-less addresses
Oracle parser failed to extract sc/ap/et when GoMag addresses had no
commas. Added REGEXP_REPLACE to insert commas before address keywords
in v_strada before the comma-split, ensuring the token parser always
fires. Also added 5 Oracle integration tests calling
parseaza_adresa_semicolon directly, and improved diacritics handling
in addr_match (Python + JS).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:00:17 +00:00
Claude Agent
f049b0bf12 feat(address): side-by-side GoMag|ROA layout, full text, uppercase
- Two-column Bootstrap row layout (col-md-6): GoMag left, ROA right
- Removed redundant "ADRESE" section title and "GOMAG"/"ROA" subheaders
- Shortened labels: "Livrare:" / "Facturare:" (context clear from layout)
- Allow text wrapping (white-space: normal) — no more truncation
- text-transform: uppercase on addr-line-text to match ROA style
- Cache bust: style.css?v=43, shared.js?v=41

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:18:14 +00:00
Claude Agent
1d59f1a484 refactor(price): remove price comparison UI and catalog sync
GoMag vs ROA price comparison generated too many false positives
(kits, volume discounts, special prices). Removes comparison columns,
dots, badges, catalog sync endpoints, and ~950 lines of dead code.
Keeps WRITE path (sync_prices_from_order) for kit pricing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:30:34 +00:00
Claude Agent
5584dd3c4f fix(partner-mismatch): fix 3 infinite-loop bugs in mismatch detection cycle
Three root causes caused partner_mismatch=1 to loop indefinitely:

1. No-CUI company orders (is_pj=1, no cod_fiscal): old code flagged as
   mismatch every cycle. Fixed by requiring new_cf to be non-null for
   PF→PJ detection. Stale flags from old code cleared via new
   clear_stale_partner_mismatches_no_cui() for out-of-window orders.

2. same_partner resync path did not update cod_fiscal_gomag in SQLite.
   On next cycle GoMag returned a CUI but stored_cf was still NULL →
   re-detected as mismatch. Fixed by also calling update_partner_resync_data
   (not just update_partner_mismatch_batch) in the same_partner branch.

3. GoMag sends CUI with space: 'RO 17922480'. The _strip_ro() regex
   ^RO left the space → ' 17922480' != '17922480' → false mismatch.
   Fixed: changed regex to ^RO\s* and added .strip().

Also adds diagnostic logger.info lines for mismatch detection/resync counts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:29:44 +00:00
Claude Agent
aa581e5cd9 docs(address): update PJ/PF billing address rules in README + add decision doc
README.md: replace old different_person logic with PJ/PF rule description
docs/adrese_facturare_variante.md: new file — decision rationale, implementation
summary, verification commands, history of the change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:39:24 +00:00
53 changed files with 5696 additions and 1763 deletions

View File

@@ -78,6 +78,15 @@ Documentatie completa: [README.md](README.md)
- Adresa facturare PJ: adresa billing din GoMag (sediul firmei)
- Adresa facturare PF: adresa shipping din GoMag (ramburs curier pe numele destinatarului)
### Cautare partener PJ dupa cod fiscal (ANAF strict mode)
Cand avem date ANAF (`anaf_strict=1`), PL/SQL `cauta_partener_dupa_cod_fiscal` diferentiaza intre platitor si neplatitor TVA:
- **Platitor TVA** (scpTVA=True) → cauta in `nom_parteneri.cod_fiscal` doar `RO<cifre>` si `RO <cifre>` (cu/fara spatiu)
- **Neplatitor TVA** (scpTVA=False) → cauta doar forma bare `<cifre>`
- **Nu cross-match** intre platitor si neplatitor — entitati fiscal distincte
- Fallback non-strict (`NULL`): toate 3 formele (anti-dedup la ANAF down)
Python normalizeaza CUI-ul (`re.sub(r'\s+', '', ...)`) inainte de apel Oracle. La creare partener NOU PJ, se foloseste numele oficial ANAF (`denumire_anaf`) in loc de GoMag company_name (poate avea typos); partenerii existenti nu sunt atinsi.
### Preturi
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
- Daca pretul lipseste, se insereaza automat pret=0

View File

@@ -160,9 +160,12 @@ Strategy: invert surfaces, reduce accent saturation ~15%, keep semantic colors r
| ALREADY_IMPORTED | `--info` | `--info-light` | none |
| CANCELLED | `--cancelled` | `--cancelled-light` | none |
| DELETED_IN_ROA | `--cancelled` | `--cancelled-light` | none |
| MALFORMED | `--compare` | `--compare-light` | `0 0 8px 2px rgba(234,88,12,0.35)` |
**Design rule:** Problems glow, success is calm. The operator's eye is pulled to rows that need action.
**ERROR vs MALFORMED:** ERROR red signals a runtime issue operators can fix on our side (Oracle hiccup, network, stale state). MALFORMED orange signals the payload itself is broken at the source — the operator should escalate to GoMag rather than keep retrying. Visually distinct colors make the diagnostic path obvious at a glance.
## Spacing
- **Base unit:** 4px
- **Density:** Comfortable — not cramped, not wasteful

View File

@@ -195,9 +195,10 @@ gomag-vending/
### Reguli Business
**Parteneri & Adrese:**
- Prioritate partener: daca exista **companie** in GoMag (billing.company_name) → firma (PJ, cod_fiscal + registru). Altfel → persoana fizica, cu **shipping name** ca nume partener
- Prioritate partener: daca exista **companie** in GoMag (billing.company.name SAU billing.company.code) → firma (PJ, cod_fiscal + registru). Altfel → persoana fizica, cu **shipping name** ca nume partener
- Adresa livrare: intotdeauna din GoMag shipping
- Adresa facturare: daca shipping name ≠ billing name → adresa shipping pt ambele; daca aceeasi persoana → adresa billing din GoMag
- Adresa facturare **PJ**: adresa billing din GoMag (sediul firmei)
- Adresa facturare **PF**: adresa shipping din GoMag (ramburs curier pe numele destinatarului)
- Cautare partener in Oracle: cod_fiscal → denumire → create new (ID_UTIL = -3)
**Articole & Mapari:**
@@ -411,10 +412,10 @@ Loguri aplicatie: `logs/sync_comenzi_*.log`
```bash
# Conectare SSH (PowerShell remote, cheie publica)
ssh -p 22122 gomag@79.119.86.134
ssh -i ~/.ssh/id_ed25519 -p 22122 -o StrictHostKeyChecking=no gomag@79.119.86.134
# Verificare .env
cmd /c type C:\gomag-vending\api\.env
powershell -Command "Get-Content C:\gomag-vending\api\.env | Select-String 'ORACLE_'"
# Test conexiune Oracle
C:\gomag-vending\venv\Scripts\python.exe -c "import oracledb, os; os.environ['TNS_ADMIN']='C:/roa/instantclient_11_2_0_2'; conn=oracledb.connect(user='VENDING', password='ROMFASTSOFT', dsn='ROA'); print('Connected!'); conn.close()"
@@ -422,20 +423,80 @@ C:\gomag-vending\venv\Scripts\python.exe -c "import oracledb, os; os.environ['TN
# Verificare tnsnames.ora
cmd /c type C:\roa\instantclient_11_2_0_2\tnsnames.ora
# Verificare procese Python
Get-Process *python* | Select-Object Id,ProcessName,Path
# Verificare procese Python (ID-uri pentru kill/restart)
powershell -Command "Get-Process python -ErrorAction SilentlyContinue | Format-Table Id, CPU -AutoSize"
# Verificare loguri recente
Get-ChildItem C:\gomag-vending\logs\*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 3
# Test sync manual (verifica ca Oracle pool porneste)
curl http://localhost:5003/health
curl -X POST http://localhost:5003/api/sync/start
# Test app (prin nginx reverse proxy)
powershell -Command "Invoke-WebRequest -Uri 'http://localhost/gomag/' -UseBasicParsing | Select-Object StatusCode"
# Refresh facturi manual
curl -X POST http://localhost:5003/api/dashboard/refresh-invoices
# Retry comanda din linie de comanda
powershell -Command "Invoke-WebRequest -Uri 'http://localhost/gomag/api/orders/NRCOMANDA/retry' -Method POST -UseBasicParsing | Select-Object -ExpandProperty Content"
```
#### Deploy pachet Oracle PL/SQL via SSH
```bash
# Metoda corecta: sqlplus cu fisier .pck (contine ambele: PACKAGE + PACKAGE BODY)
ssh -i ~/.ssh/id_ed25519 -p 22122 gomag@79.119.86.134 \
"powershell -Command \"echo exit | sqlplus -S VENDING/PAROLA@ROA '@C:\\gomag-vending\\api\\database-scripts\\05_pack_import_parteneri.pck'\""
# Output asteptat: "Package created." + "Package body created."
```
#### Restart serviciu FastAPI via SSH
Userul `gomag` nu are acces la `nssm` sau `sc` (necesita Administrator).
Metoda disponibila — kill python + relanseaza start.ps1:
```bash
# 1. Gaseste PID-urile Python
ssh -i ~/.ssh/id_ed25519 -p 22122 gomag@79.119.86.134 \
"powershell -Command \"Get-Process python -ErrorAction SilentlyContinue | Format-Table Id, CPU -AutoSize\""
# 2. Kill + restart (inlocuieste PID1,PID2 cu valorile reale)
ssh -i ~/.ssh/id_ed25519 -p 22122 gomag@79.119.86.134 \
"powershell -Command \"Stop-Process -Id PID1,PID2 -Force -ErrorAction SilentlyContinue; Start-Sleep 2; cd C:\\gomag-vending; Start-Process powershell -ArgumentList '-NoExit','-File','start.ps1' -WindowStyle Hidden\""
# 3. Verifica ca a pornit (asteapta ~5s)
ssh -i ~/.ssh/id_ed25519 -p 22122 gomag@79.119.86.134 \
"powershell -Command \"Invoke-WebRequest -Uri 'http://localhost/gomag/' -UseBasicParsing | Select-Object StatusCode\""
```
#### Ce NU merge via SSH (userul gomag fara Administrator)
| Comanda | Eroare | Alternativa |
|---------|--------|-------------|
| `nssm restart GoMagVending` | `Error opening service manager!` | Kill python + Start-Process start.ps1 (vezi mai sus) |
| `sc query` / `sc stop` | `Access is denied` | Nu exista alternativa — necesita acces direct la server |
| `Get-WmiObject Win32_Process` | `Access denied` | `Get-Process` simplu fara CommandLine |
| Pipe `\|` in -Command cu ghilimele nested | `An empty pipe element is not allowed` | Scrie SQL in fisier temporar, copiaza cu scp, ruleaza `@fisier.sql` |
| `&&` (bash syntax) in PowerShell | `The term '&&' is not recognized` | Foloseste `;` (continua indiferent) sau `-Command "cmd1; cmd2"` |
| `-m` flag la `curl` in PowerShell | `Ambiguous parameter name` | Foloseste `Invoke-WebRequest` in loc de curl |
| Here-doc `<< 'EOF'` in PowerShell | `Missing file specification` | Scrie fisierul local, copiaza cu scp |
#### Rulare SQL ad-hoc prin SSH (fara interactiv)
PowerShell nu suporta pipe catre sqlplus cu ghilimele complexe. Metoda corecta:
```bash
# 1. Scrie SQL local
cat > /tmp/query.sql << 'EOF'
SELECT coloana FROM tabel WHERE conditie;
exit
EOF
# 2. Copiaza pe prod
scp -i ~/.ssh/id_ed25519 -P 22122 /tmp/query.sql "gomag@79.119.86.134:C:/gomag-vending/query.sql"
# 3. Ruleaza
ssh -i ~/.ssh/id_ed25519 -p 22122 gomag@79.119.86.134 \
"powershell -Command \"sqlplus -S VENDING/PAROLA@ROA '@C:\\gomag-vending\\query.sql'\""
```
**Nu folosi** `echo 'SQL;' | sqlplus` — PowerShell trateaza `|` diferit si poate esua cu "empty pipe element".
### Probleme frecvente
| Eroare | Cauza | Solutie |

View File

@@ -2,9 +2,9 @@
## P2: Refactor sync_service.py in module separate
**What:** Split sync_service.py (870 linii) in: download_service, parse_service, sync_orchestrator.
**Why:** Faciliteza debugging si testare. Un bug in price sync nu ar trebui sa afecteze import flow.
**Why:** Faciliteza debugging si testare.
**Effort:** M (human: ~1 sapt / CC: ~1-2h)
**Context:** Dupa implementarea planului Command Center (retry_service deja extras). sync_service face download + parse + validate + import + price sync + invoice check — prea multe responsabilitati.
**Context:** Dupa implementarea planului Command Center (retry_service deja extras). sync_service face download + parse + validate + import + invoice check — prea multe responsabilitati.
**Depends on:** Finalizarea planului Command Center.
## P2: Email/webhook alert pe sync esuat

22
api/app/constants.py Normal file
View File

@@ -0,0 +1,22 @@
"""Application-wide constants shared across services, routers, and tests."""
from enum import Enum
class OrderStatus(str, Enum):
"""Order status values stored in SQLite `orders.status` column.
Inherits from `str` so existing string comparisons (==, in, dict.get)
keep working. Always use `.value` when passing to SQL queries or JSON
payloads to avoid Python-version-specific str(enum) surprises.
"""
IMPORTED = "IMPORTED"
ALREADY_IMPORTED = "ALREADY_IMPORTED"
SKIPPED = "SKIPPED"
ERROR = "ERROR"
CANCELLED = "CANCELLED"
DELETED_IN_ROA = "DELETED_IN_ROA"
# Structural-fail: GoMag sent a payload that cannot be inserted as-is
# (missing fields, unparseable date, invalid quantity/price, or a runtime
# insert crash). Row persists with status=MALFORMED + error_message so
# operators can escalate to GoMag without blocking the rest of the batch.
MALFORMED = "MALFORMED"

View File

@@ -186,6 +186,15 @@ CREATE TABLE IF NOT EXISTS anaf_cache (
denumire_anaf TEXT,
checked_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_phase_failures (
run_id TEXT NOT NULL REFERENCES sync_runs(run_id),
phase TEXT NOT NULL,
error_summary TEXT,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (run_id, phase)
);
CREATE INDEX IF NOT EXISTS idx_spf_phase_time ON sync_phase_failures(phase, created_at);
"""
_sqlite_db_path = None

View File

@@ -1,4 +1,3 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI
@@ -9,7 +8,6 @@ import os
from .config import settings
from .database import init_oracle, close_oracle, init_sqlite
from .routers.sync import backfill_price_match
# Configure logging with both stream and file handlers
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
@@ -58,8 +56,6 @@ async def lifespan(app: FastAPI):
except Exception:
pass
asyncio.create_task(backfill_price_match())
logger.info("GoMag Import Manager started")
yield

View File

@@ -4,9 +4,11 @@ from fastapi.responses import HTMLResponse
from pathlib import Path
from ..services import sqlite_service
from ..constants import OrderStatus
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
templates.env.globals["OrderStatus"] = OrderStatus
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):

View File

@@ -8,7 +8,7 @@ from typing import Optional
import io
import asyncio
from ..services import mapping_service, sqlite_service
from ..services import mapping_service, sqlite_service, validation_service
import logging
logger = logging.getLogger(__name__)
@@ -168,6 +168,7 @@ async def import_csv(file: UploadFile = File(...)):
content = await file.read()
text = content.decode("utf-8-sig")
result = mapping_service.import_csv(text)
await validation_service.reconcile_unresolved_missing_skus()
return result
@router.get("/api/mappings/export-csv")

View File

@@ -12,11 +12,13 @@ from pydantic import BaseModel
from pathlib import Path
from typing import Optional
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
from .. import database
from ..constants import OrderStatus
router = APIRouter(tags=["sync"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
templates.env.globals["OrderStatus"] = OrderStatus
async def _enrich_items_with_codmat(items: list) -> None:
@@ -40,56 +42,6 @@ async def _enrich_items_with_codmat(items: list) -> None:
"denumire": nom_map[sku], "direct": True}]
async def backfill_price_match():
"""Background task: check prices for all imported orders without cached price_match."""
try:
from ..database import get_sqlite
db = await get_sqlite()
try:
# Reset all cached price_match to re-evaluate with current logic
await db.execute("UPDATE orders SET price_match = NULL WHERE price_match IS NOT NULL")
await db.commit()
cursor = await db.execute("""
SELECT order_number FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
AND price_match IS NULL
ORDER BY order_date DESC
""")
rows = [r["order_number"] for r in await cursor.fetchall()]
finally:
await db.close()
if not rows:
logger.info("backfill_price_match: no unchecked orders")
return
logger.info(f"backfill_price_match: checking {len(rows)} orders...")
app_settings = await sqlite_service.get_app_settings()
checked = 0
for order_number in rows:
try:
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
continue
items = detail.get("items", [])
await _enrich_items_with_codmat(items)
price_data = await asyncio.to_thread(
validation_service.get_prices_for_order, items, app_settings
)
summary = price_data.get("summary", {})
if summary.get("oracle_available") is not False:
pm = summary.get("mismatches", 0) == 0
await sqlite_service.update_order_price_match(order_number, pm)
checked += 1
except Exception as e:
logger.debug(f"backfill_price_match: order {order_number} failed: {e}")
logger.info(f"backfill_price_match: done, {checked}/{len(rows)} updated")
except Exception as e:
logger.error(f"backfill_price_match failed: {e}")
class ScheduleConfig(BaseModel):
enabled: bool
interval_minutes: int = 5
@@ -116,9 +68,6 @@ class AppSettingsUpdate(BaseModel):
kit_discount_codmat: str = ""
kit_discount_id_pol: str = ""
price_sync_enabled: str = "1"
catalog_sync_enabled: str = "0"
price_sync_schedule: str = ""
gomag_products_url: str = ""
# API endpoints
@@ -211,37 +160,58 @@ async def sync_status():
return result
@router.get("/api/sync/health")
async def sync_health():
"""Aggregated sync health snapshot used by the dashboard pill.
Fields:
last_sync_at ISO timestamp of most recent run start (or null).
last_sync_status completed | failed | running | halted_escalation | null.
last_halt_reason error_message from that run (only populated on
failed / halted_escalation).
recent_phase_failures {phase: count} across the last 3 runs.
escalation_phase the phase that tripped the 3-in-a-row halt, or null.
is_healthy completed last + <=1 recent phase failure.
"""
db = await sqlite_service.get_sqlite()
try:
cursor = await db.execute(
"SELECT run_id, started_at, status, error_message "
"FROM sync_runs ORDER BY started_at DESC LIMIT 1"
)
last_row = await cursor.fetchone()
finally:
await db.close()
last = dict(last_row) if last_row else {}
last_status = last.get("status")
halt_reason = last.get("error_message") if last_status in ("failed", "halted_escalation") else None
counts = await sqlite_service.get_recent_phase_failures(limit=3)
escalation_phase = next((p for p, c in counts.items() if c >= 3), None)
is_healthy = (
last_status in (None, "completed")
and escalation_phase is None
and sum(counts.values()) <= 1
)
return {
"last_sync_at": last.get("started_at"),
"last_sync_status": last_status,
"last_halt_reason": halt_reason,
"recent_phase_failures": counts,
"escalation_phase": escalation_phase,
"is_healthy": is_healthy,
}
@router.get("/api/sync/history")
async def sync_history(page: int = 1, per_page: int = 20):
"""Get sync run history."""
return await sqlite_service.get_sync_runs(page, per_page)
@router.post("/api/price-sync/start")
async def start_price_sync(background_tasks: BackgroundTasks):
"""Trigger manual catalog price sync."""
from ..services import price_sync_service
result = await price_sync_service.prepare_price_sync()
if result.get("error"):
return {"error": result["error"]}
run_id = result["run_id"]
background_tasks.add_task(price_sync_service.run_catalog_price_sync, run_id=run_id)
return {"message": "Price sync started", "run_id": run_id}
@router.get("/api/price-sync/status")
async def price_sync_status():
"""Get current price sync status."""
from ..services import price_sync_service
return await price_sync_service.get_price_sync_status()
@router.get("/api/price-sync/history")
async def price_sync_history(page: int = 1, per_page: int = 20):
"""Get price sync run history."""
return await sqlite_service.get_price_sync_runs(page, per_page)
@router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request, run: str = None):
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
@@ -309,13 +279,13 @@ def _format_text_log_from_detail(detail: dict) -> str:
customer = o.get("customer_name", "?")
order_date = o.get("order_date") or "?"
if status == "IMPORTED":
if status == OrderStatus.IMPORTED.value:
id_cmd = o.get("id_comanda", "?")
lines.append(f"#{number} [{order_date}] {customer} → IMPORTAT (ID: {id_cmd})")
elif status == "ALREADY_IMPORTED":
elif status == OrderStatus.ALREADY_IMPORTED.value:
id_cmd = o.get("id_comanda", "?")
lines.append(f"#{number} [{order_date}] {customer} → DEJA IMPORTAT (ID: {id_cmd})")
elif status == "SKIPPED":
elif status == OrderStatus.SKIPPED.value:
missing = o.get("missing_skus", "")
if isinstance(missing, str):
try:
@@ -324,7 +294,7 @@ def _format_text_log_from_detail(detail: dict) -> str:
missing = [missing] if missing else []
skus_str = ", ".join(missing) if isinstance(missing, list) else str(missing)
lines.append(f"#{number} [{order_date}] {customer} → OMIS (lipsa: {skus_str})")
elif status == "ERROR":
elif status == OrderStatus.ERROR.value:
err = o.get("error_message", "necunoscuta")
lines.append(f"#{number} [{order_date}] {customer} → EROARE: {err}")
@@ -454,35 +424,8 @@ async def order_detail(order_number: str):
items = detail.get("items", [])
await _enrich_items_with_codmat(items)
# Price comparison against ROA Oracle
app_settings = await sqlite_service.get_app_settings()
try:
price_data = await asyncio.to_thread(
validation_service.get_prices_for_order, items, app_settings
)
price_items = price_data.get("items", {})
for idx, item in enumerate(items):
pi = price_items.get(idx)
if pi:
item["pret_roa"] = pi.get("pret_roa")
item["price_match"] = pi.get("match")
if pi.get("kit"):
item["kit"] = True
order_price_check = price_data.get("summary", {})
# Cache price_match in SQLite if changed
if order_price_check.get("oracle_available") is not False:
pm = order_price_check.get("mismatches", 0) == 0
cached = detail.get("order", {}).get("price_match")
cached_bool = True if cached == 1 else (False if cached == 0 else None)
if cached_bool != pm:
await sqlite_service.update_order_price_match(order_number, pm)
except Exception as e:
logger.warning(f"Price comparison failed for order {order_number}: {e}")
order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False}
# Enrich with invoice data
order = detail.get("order", {})
order["price_check"] = order_price_check
if order.get("factura_numar") and order.get("factura_data"):
order["invoice"] = {
"facturat": True,
@@ -562,7 +505,8 @@ async def order_detail(order_number: str):
"facturare_roa": order.get("adresa_facturare_roa"),
}
# Add settings for receipt display (app_settings already fetched above)
# Add settings for receipt display
app_settings = await sqlite_service.get_app_settings()
order["transport_vat"] = app_settings.get("transport_vat") or "21"
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
@@ -580,6 +524,23 @@ async def retry_order(order_number: str):
return result
@router.post("/api/orders/{order_number}/resync")
async def resync_order(order_number: str):
"""Resync an imported order: soft-delete from Oracle then re-import from GoMag."""
from ..services import retry_service
app_settings = await sqlite_service.get_app_settings()
result = await retry_service.resync_single_order(order_number, app_settings)
return result
@router.post("/api/orders/{order_number}/delete")
async def delete_order(order_number: str):
"""Delete an imported order from Oracle (soft-delete)."""
from ..services import retry_service
result = await retry_service.delete_single_order(order_number)
return result
@router.post("/api/orders/{order_number}/resync-partner")
async def resync_partner(order_number: str):
"""Manual partner resync for invoiced orders with partner_mismatch=1.
@@ -705,7 +666,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
is_invoiced_filter = (status == "INVOICED")
# For UNINVOICED/INVOICED: fetch all IMPORTED orders, then filter post-invoice-check
fetch_status = "IMPORTED" if (is_uninvoiced_filter or is_invoiced_filter) else status
fetch_status = OrderStatus.IMPORTED.value if (is_uninvoiced_filter or is_invoiced_filter) else status
fetch_per_page = 10000 if (is_uninvoiced_filter or is_invoiced_filter) else per_page
fetch_page = 1 if (is_uninvoiced_filter or is_invoiced_filter) else page
@@ -720,9 +681,6 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
all_orders = result["orders"]
for o in all_orders:
# price_match: 1=OK, 0=mismatch, NULL=not checked yet
pm = o.get("price_match")
o["price_match"] = True if pm == 1 else (False if pm == 0 else None)
if o.get("factura_numar") and o.get("factura_data"):
# Use cached invoice data from SQLite (only if complete)
o["invoice"] = {
@@ -777,7 +735,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
1 for o in all_orders
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
if o.get("status") in (OrderStatus.IMPORTED.value, OrderStatus.ALREADY_IMPORTED.value) and not o.get("invoice")
))
counts["nefacturate"] = max(0, uninvoiced_base - newly_invoiced)
imported_total = counts.get("imported_all") or counts.get("imported", 0)
@@ -803,7 +761,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
# For UNINVOICED filter: apply server-side filtering + pagination
if is_uninvoiced_filter:
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
filtered = [o for o in all_orders if o.get("status") in (OrderStatus.IMPORTED.value, OrderStatus.ALREADY_IMPORTED.value) and not o.get("invoice")]
total = len(filtered)
offset = (page - 1) * per_page
result["orders"] = filtered[offset:offset + per_page]
@@ -812,7 +770,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
result["per_page"] = per_page
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
elif is_invoiced_filter:
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and o.get("invoice")]
filtered = [o for o in all_orders if o.get("status") in (OrderStatus.IMPORTED.value, OrderStatus.ALREADY_IMPORTED.value) and o.get("invoice")]
total = len(filtered)
offset = (page - 1) * per_page
result["orders"] = filtered[offset:offset + per_page]
@@ -1061,9 +1019,6 @@ async def get_app_settings():
"kit_discount_codmat": s.get("kit_discount_codmat", ""),
"kit_discount_id_pol": s.get("kit_discount_id_pol", ""),
"price_sync_enabled": s.get("price_sync_enabled", "1"),
"catalog_sync_enabled": s.get("catalog_sync_enabled", "0"),
"price_sync_schedule": s.get("price_sync_schedule", ""),
"gomag_products_url": s.get("gomag_products_url", ""),
}
@@ -1090,9 +1045,6 @@ async def update_app_settings(config: AppSettingsUpdate):
await sqlite_service.set_app_setting("kit_discount_codmat", config.kit_discount_codmat)
await sqlite_service.set_app_setting("kit_discount_id_pol", config.kit_discount_id_pol)
await sqlite_service.set_app_setting("price_sync_enabled", config.price_sync_enabled)
await sqlite_service.set_app_setting("catalog_sync_enabled", config.catalog_sync_enabled)
await sqlite_service.set_app_setting("price_sync_schedule", config.price_sync_schedule)
await sqlite_service.set_app_setting("gomag_products_url", config.gomag_products_url)
return {"success": True}

View File

@@ -58,6 +58,8 @@ async def scan_and_validate():
if tracked:
new_missing += 1
rec = await validation_service.reconcile_unresolved_missing_skus()
total_skus_scanned = len(all_skus)
new_missing_count = len(result["missing"])
unchanged = total_skus_scanned - new_missing_count
@@ -72,7 +74,7 @@ async def scan_and_validate():
# Fields consumed by the rescan progress banner in missing_skus.html
"total_skus_scanned": total_skus_scanned,
"new_missing": new_missing_count,
"auto_resolved": 0,
"auto_resolved": rec["resolved"],
"unchanged": unchanged,
"skus": {
"mapped": len(result["mapped"]),

View File

@@ -141,6 +141,12 @@ async def _call_anaf_api(body: list[dict], retry: int = 0, log_fn=None) -> dict[
checked_at = datetime.now().isoformat()
# CONTRACT (consumed by sync_service.evaluate_cui_gate):
# Return {} → transient error (down/429/5xx/timeout)
# Return {cui: {scpTVA: None, denumire_anaf: ""}} → ANAF notFound explicit
# Return {cui: {scpTVA: bool, denumire_anaf: str}} → ANAF found
# If you change this semantics, update the gate in sync_service too.
# Parse ANAF response
found_list = data.get("found", [])
for item in found_list:

View File

@@ -103,80 +103,3 @@ async def download_orders(
return {"pages": total_pages, "total": total_orders, "files": saved_files}
async def download_products(
api_key: str = None,
api_shop: str = None,
products_url: str = None,
log_fn: Callable[[str], None] = None,
) -> list[dict]:
"""Download all products from GoMag Products API.
Returns list of product dicts with: sku, price, vat, vat_included, bundleItems.
"""
def _log(msg: str):
logger.info(msg)
if log_fn:
log_fn(msg)
effective_key = api_key or settings.GOMAG_API_KEY
effective_shop = api_shop or settings.GOMAG_API_SHOP
default_url = "https://api.gomag.ro/api/v1/product/read/json"
effective_url = products_url or default_url
if not effective_key or not effective_shop:
_log("GoMag API keys neconfigurați, skip product download")
return []
headers = {
"Apikey": effective_key,
"ApiShop": effective_shop,
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/json",
}
all_products = []
total_pages = 1
async with httpx.AsyncClient(timeout=30) as client:
page = 1
while page <= total_pages:
params = {"page": page, "limit": 100}
try:
response = await client.get(effective_url, headers=headers, params=params)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as e:
_log(f"GoMag Products API eroare pagina {page}: {e}")
break
except Exception as e:
_log(f"GoMag Products eroare neașteptată pagina {page}: {e}")
break
if page == 1:
total_pages = int(data.get("pages", 1))
_log(f"GoMag Products: {data.get('total', '?')} produse în {total_pages} pagini")
products = data.get("products", [])
if isinstance(products, dict):
# GoMag returns products as {"1": {...}, "2": {...}} dict
first_val = next(iter(products.values()), None) if products else None
if isinstance(first_val, dict):
products = list(products.values())
else:
products = [products]
if isinstance(products, list):
for p in products:
if isinstance(p, dict) and p.get("sku"):
all_products.append({
"sku": p["sku"],
"price": p.get("price", "0"),
"vat": p.get("vat", "19"),
"vat_included": str(p.get("vat_included", "1")),
"bundleItems": p.get("bundleItems", []),
})
page += 1
if page <= total_pages:
await asyncio.sleep(1)
_log(f"GoMag Products: {len(all_products)} produse cu SKU descărcate")
return all_products

View File

@@ -1,38 +1,40 @@
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, ' ')
@@ -65,7 +67,9 @@ def determine_partner_data(order) -> dict:
denumire = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).upper()
cod_fiscal = clean_web_text(order.billing.company_code) or None
raw_cf = clean_web_text(order.billing.company_code) or None
# Collapse internal whitespace: "RO 34963277" → "RO34963277"
cod_fiscal = re.sub(r'\s+', '', raw_cf) if raw_cf else None
registru = clean_web_text(order.billing.company_reg) or None
is_pj = 1
else:
@@ -89,14 +93,24 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str:
region_clean = clean_web_text(region)
city_clean = clean_web_text(city)
address_clean = clean_web_text(address)
# Strip city name from end of address (users often type it)
if city_clean:
address_clean = " ".join(address_clean.replace(",", " ").split())
# Strip city/region suffixes users often append to address
if city_clean or region_clean:
addr_upper = address_clean.upper().rstrip()
city_upper = city_clean.upper().strip()
if addr_upper.endswith(city_upper):
stripped = address_clean[:len(address_clean.rstrip()) - len(city_upper)].rstrip()
if stripped: # don't strip if nothing remains
address_clean = stripped
city_upper = city_clean.upper().strip() if city_clean else ""
region_upper = region_clean.upper().strip() if region_clean else ""
for pattern in [
(city_upper + " " + region_upper).strip(),
(region_upper + " " + city_upper).strip(),
city_upper,
region_upper,
]:
if pattern and addr_upper.endswith(pattern):
stripped = address_clean[:len(address_clean.rstrip()) - len(pattern)].rstrip()
if stripped:
address_clean = stripped
addr_upper = address_clean.upper().rstrip()
break
return f"JUD:{region_clean};{city_clean};{address_clean}"
@@ -241,7 +255,7 @@ def build_articles_json(items, order=None, settings=None) -> str:
return json.dumps(articles)
def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None, id_gestiuni: list[int] = None, cod_fiscal_override: str = None, anaf_strict: int = None) -> dict:
def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_settings: dict = None, id_gestiuni: list[int] = None, cod_fiscal_override: str = None, anaf_strict: int = None, denumire_override: str = None) -> dict:
"""Import a single order into Oracle ROA.
Returns dict with:
@@ -278,7 +292,11 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
_pdata = determine_partner_data(order)
denumire = _pdata["denumire"]
# PJ: prefer ANAF official name (denumire_override) over GoMag company_name
# (for new partner creation; existing partner lookup is CUI-based)
denumire = (denumire_override
if (_pdata["is_pj"] and denumire_override)
else _pdata["denumire"])
cod_fiscal = (cod_fiscal_override or _pdata["cod_fiscal"]) if _pdata["is_pj"] else None
registru = _pdata["registru"]
is_pj = _pdata["is_pj"]

View File

@@ -1,264 +0,0 @@
"""Catalog price sync service — syncs product prices from GoMag catalog to ROA Oracle."""
import asyncio
import logging
import uuid
from datetime import datetime
from zoneinfo import ZoneInfo
from . import gomag_client, validation_service, sqlite_service
from .. import database
from ..config import settings
logger = logging.getLogger(__name__)
_tz = ZoneInfo("Europe/Bucharest")
_price_sync_lock = asyncio.Lock()
_current_price_sync = None
def _now():
return datetime.now(_tz).replace(tzinfo=None)
async def prepare_price_sync() -> dict:
global _current_price_sync
if _price_sync_lock.locked():
return {"error": "Price sync already running"}
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_ps_" + uuid.uuid4().hex[:6]
_current_price_sync = {
"run_id": run_id, "status": "running",
"started_at": _now().isoformat(), "finished_at": None,
"phase_text": "Starting...",
}
# Create SQLite record
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT INTO price_sync_runs (run_id, started_at, status) VALUES (?, ?, 'running')",
(run_id, _now().strftime("%d.%m.%Y %H:%M:%S"))
)
await db.commit()
finally:
await db.close()
return {"run_id": run_id}
async def get_price_sync_status() -> dict:
if _current_price_sync and _current_price_sync.get("status") == "running":
return _current_price_sync
# Return last run from SQLite
db = await sqlite_service.get_sqlite()
try:
cursor = await db.execute(
"SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT 1"
)
row = await cursor.fetchone()
if row:
return {"status": "idle", "last_run": dict(row)}
return {"status": "idle"}
except Exception:
return {"status": "idle"}
finally:
await db.close()
async def run_catalog_price_sync(run_id: str):
global _current_price_sync
async with _price_sync_lock:
log_lines = []
def _log(msg):
logger.info(msg)
log_lines.append(f"[{_now().strftime('%H:%M:%S')}] {msg}")
if _current_price_sync:
_current_price_sync["phase_text"] = msg
try:
app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
if not id_pol:
_log("Politica de preț nu e configurată — skip sync")
await _finish_run(run_id, "error", log_lines, error="No price policy")
return
# Fetch products from GoMag
_log("Descărcare produse din GoMag API...")
products = await gomag_client.download_products(
api_key=app_settings.get("gomag_api_key"),
api_shop=app_settings.get("gomag_api_shop"),
products_url=app_settings.get("gomag_products_url") or None,
log_fn=_log,
)
if not products:
_log("Niciun produs descărcat")
await _finish_run(run_id, "completed", log_lines, products_total=0)
return
# Index products by SKU for kit component lookup
products_by_sku = {p["sku"]: p for p in products}
# Connect to Oracle
conn = await asyncio.to_thread(database.get_oracle_connection)
try:
# Get all mappings from ARTICOLE_TERTI
_log("Citire mapări ARTICOLE_TERTI...")
mapped_data = await asyncio.to_thread(
validation_service.resolve_mapped_codmats,
{p["sku"] for p in products}, conn
)
# Get direct articles from NOM_ARTICOLE
_log("Identificare articole directe...")
direct_id_map = {}
with conn.cursor() as cur:
all_skus = list({p["sku"] for p in products})
for i in range(0, len(all_skus), 500):
batch = all_skus[i:i+500]
placeholders = ",".join([f":s{j}" for j in range(len(batch))])
params = {f"s{j}": sku for j, sku in enumerate(batch)}
cur.execute(f"""
SELECT codmat, id_articol, cont FROM nom_articole
WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0
""", params)
for row in cur:
if row[0] not in mapped_data:
direct_id_map[row[0]] = {"id_articol": row[1], "cont": row[2]}
matched = 0
updated = 0
errors = 0
for product in products:
sku = product["sku"]
try:
price_str = product.get("price", "0")
price = float(price_str) if price_str else 0
if price <= 0:
continue
vat = float(product.get("vat", "19"))
# Calculate price with TVA (vat_included can be int 1 or str "1")
if str(product.get("vat_included", "1")) == "1":
price_cu_tva = price
else:
price_cu_tva = price * (1 + vat / 100)
# For kits, sync each component individually from standalone GoMag prices
mapped_comps = mapped_data.get(sku, [])
is_kit = len(mapped_comps) > 1 or (
len(mapped_comps) == 1 and (mapped_comps[0].get("cantitate_roa") or 1) > 1
)
if is_kit:
for comp in mapped_data[sku]:
comp_codmat = comp["codmat"]
# Skip components that have their own ARTICOLE_TERTI mapping
# (they'll be synced with correct cantitate_roa in individual path)
if comp_codmat in mapped_data:
continue
comp_product = products_by_sku.get(comp_codmat)
if not comp_product:
continue # Component not in GoMag as standalone product
comp_price_str = comp_product.get("price", "0")
comp_price = float(comp_price_str) if comp_price_str else 0
if comp_price <= 0:
continue
comp_vat = float(comp_product.get("vat", "19"))
# vat_included can be int 1 or str "1"
if str(comp_product.get("vat_included", "1")) == "1":
comp_price_cu_tva = comp_price
else:
comp_price_cu_tva = comp_price * (1 + comp_vat / 100)
comp_cont_str = str(comp.get("cont") or "").strip()
comp_pol = id_pol_productie if (comp_cont_str in ("341", "345") and id_pol_productie) else id_pol
matched += 1
result = await asyncio.to_thread(
validation_service.compare_and_update_price,
comp["id_articol"], comp_pol, comp_price_cu_tva, conn
)
if result and result["updated"]:
updated += 1
_log(f" {comp_codmat}: {result['old_price']:.2f}{result['new_price']:.2f} (kit {sku})")
elif result is None:
_log(f" {comp_codmat}: LIPSESTE din politica {comp_pol} — adauga manual in ROA (kit {sku})")
continue
# Determine id_articol and policy
id_articol = None
cantitate_roa = 1
if sku in mapped_data and len(mapped_data[sku]) == 1 and (mapped_data[sku][0].get("cantitate_roa") or 1) <= 1:
comp = mapped_data[sku][0]
id_articol = comp["id_articol"]
cantitate_roa = comp.get("cantitate_roa") or 1
elif sku in direct_id_map:
id_articol = direct_id_map[sku]["id_articol"]
else:
continue # SKU not in ROA
matched += 1
price_per_unit = price_cu_tva / cantitate_roa if cantitate_roa != 1 else price_cu_tva
# Determine policy
cont = None
if sku in mapped_data and len(mapped_data[sku]) == 1 and (mapped_data[sku][0].get("cantitate_roa") or 1) <= 1:
cont = mapped_data[sku][0].get("cont")
elif sku in direct_id_map:
cont = direct_id_map[sku].get("cont")
cont_str = str(cont or "").strip()
pol = id_pol_productie if (cont_str in ("341", "345") and id_pol_productie) else id_pol
result = await asyncio.to_thread(
validation_service.compare_and_update_price,
id_articol, pol, price_per_unit, conn
)
if result and result["updated"]:
updated += 1
_log(f" {result['codmat']}: {result['old_price']:.2f}{result['new_price']:.2f}")
except Exception as e:
errors += 1
_log(f"Eroare produs {sku}: {e}")
_log(f"Sync complet: {len(products)} produse, {matched} potrivite, {updated} actualizate, {errors} erori")
finally:
await asyncio.to_thread(database.pool.release, conn)
await _finish_run(run_id, "completed", log_lines,
products_total=len(products), matched=matched,
updated=updated, errors=errors)
except Exception as e:
_log(f"Eroare critică: {e}")
logger.error(f"Catalog price sync error: {e}", exc_info=True)
await _finish_run(run_id, "error", log_lines, error=str(e))
async def _finish_run(run_id, status, log_lines, products_total=0,
matched=0, updated=0, errors=0, error=None):
global _current_price_sync
db = await sqlite_service.get_sqlite()
try:
await db.execute("""
UPDATE price_sync_runs SET
finished_at = ?, status = ?, products_total = ?,
matched = ?, updated = ?, errors = ?,
log_text = ?
WHERE run_id = ?
""", (_now().strftime("%d.%m.%Y %H:%M:%S"), status, products_total, matched, updated, errors,
"\n".join(log_lines), run_id))
await db.commit()
finally:
await db.close()
_current_price_sync = None

View File

@@ -4,40 +4,18 @@ import logging
import tempfile
from datetime import datetime, timedelta
from ..constants import OrderStatus
logger = logging.getLogger(__name__)
async def retry_single_order(order_number: str, app_settings: dict) -> dict:
"""Re-download and re-import a single order from GoMag.
Steps:
1. Read order from SQLite to get order_date / customer_name
2. Check sync lock (no retry during active sync)
3. Download narrow date range from GoMag (order_date ± 1 day)
4. Find the specific order in downloaded data
5. Run import_single_order()
6. Update status in SQLite
async def _download_and_reimport(order_number: str, order_date_str: str, customer_name: str, app_settings: dict) -> dict:
"""Download order from GoMag and re-import it into Oracle.
Does NOT check status guard — caller is responsible.
Returns: {"success": bool, "message": str, "status": str|None}
"""
from . import sqlite_service, sync_service, gomag_client, import_service, order_reader
# Check sync lock
if sync_service._sync_lock.locked():
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
# Get order from SQLite
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
return {"success": False, "message": "Comanda nu a fost gasita"}
order_data = detail["order"]
status = order_data.get("status", "")
if status not in ("ERROR", "SKIPPED"):
return {"success": False, "message": f"Retry permis doar pentru ERROR/SKIPPED (status actual: {status})"}
order_date_str = order_data.get("order_date", "")
customer_name = order_data.get("customer_name", "")
from . import sqlite_service, gomag_client, import_service, order_reader, validation_service
# Parse order date for narrow download window
try:
@@ -81,6 +59,40 @@ async def retry_single_order(order_number: str, app_settings: dict) -> dict:
id_gestiune = app_settings.get("id_gestiune", "")
id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None
# Pre-validate prices: auto-insert PRET=0 in CRM_POLITICI_PRET_ART for missing
# CODMATs so PL/SQL doesn't crash with COM-001. Mirrors sync_service flow.
from .. import database
validation = {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}}
if database.pool is not None:
conn = await asyncio.to_thread(database.get_oracle_connection)
try:
skus = {item.sku for item in target_order.items if item.sku}
if skus:
validation = await asyncio.to_thread(
validation_service.validate_skus, skus, conn, id_gestiuni,
)
if id_pol and skus:
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
cota_tva = float(app_settings.get("discount_vat") or 21)
await asyncio.to_thread(
validation_service.pre_validate_order_prices,
[target_order], app_settings, conn, id_pol, id_pol_productie,
id_gestiuni, validation, None, cota_tva,
)
except Exception as e:
logger.error(f"Retry pre-validation failed for {order_number}: {e}")
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status=OrderStatus.ERROR.value,
error_message=f"Retry pre-validation failed: {e}",
)
return {"success": False, "message": f"Eroare pre-validare preturi: {e}"}
finally:
await asyncio.to_thread(database.pool.release, conn)
try:
result = await asyncio.to_thread(
import_service.import_single_order,
@@ -94,18 +106,29 @@ async def retry_single_order(order_number: str, app_settings: dict) -> dict:
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="ERROR",
status=OrderStatus.ERROR.value,
error_message=f"Retry failed: {e}",
)
return {"success": False, "message": f"Eroare import: {e}"}
order_items_data = [
{
"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
"codmat": None, "id_articol": None, "cantitate_roa": None,
}
for item in target_order.items
]
if result.get("success"):
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="IMPORTED",
status=OrderStatus.IMPORTED.value,
id_comanda=result.get("id_comanda"),
id_partener=result.get("id_partener"),
error_message=None,
@@ -116,8 +139,9 @@ async def retry_single_order(order_number: str, app_settings: dict) -> dict:
id_adresa_facturare=result.get("id_adresa_facturare"),
id_adresa_livrare=result.get("id_adresa_livrare"),
)
logger.info(f"Retry successful for order {order_number} → IMPORTED")
return {"success": True, "message": "Comanda reimportata cu succes", "status": "IMPORTED"}
await sqlite_service.add_order_items(order_number, order_items_data)
logger.info(f"Retry successful for order {order_number} → IMPORTED ({len(order_items_data)} items)")
return {"success": True, "message": "Comanda reimportata cu succes", "status": OrderStatus.IMPORTED.value}
else:
error = result.get("error", "Unknown error")
await sqlite_service.upsert_order(
@@ -125,7 +149,187 @@ async def retry_single_order(order_number: str, app_settings: dict) -> dict:
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="ERROR",
status=OrderStatus.ERROR.value,
error_message=f"Retry: {error}",
)
return {"success": False, "message": f"Import esuat: {error}", "status": "ERROR"}
await sqlite_service.add_order_items(order_number, order_items_data)
return {"success": False, "message": f"Import esuat: {error}", "status": OrderStatus.ERROR.value}
async def retry_single_order(order_number: str, app_settings: dict) -> dict:
"""Re-download and re-import a single order from GoMag.
Steps:
1. Read order from SQLite to get order_date / customer_name
2. Check sync lock (no retry during active sync)
3. Download narrow date range from GoMag (order_date ± 1 day)
4. Find the specific order in downloaded data
5. Run import_single_order()
6. Update status in SQLite
Returns: {"success": bool, "message": str, "status": str|None}
"""
from . import sqlite_service, sync_service
# Check sync lock
if sync_service._sync_lock.locked():
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
# Get order from SQLite
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
return {"success": False, "message": "Comanda nu a fost gasita"}
order_data = detail["order"]
status = order_data.get("status", "")
if status not in (OrderStatus.ERROR.value, OrderStatus.SKIPPED.value,
OrderStatus.DELETED_IN_ROA.value, OrderStatus.MALFORMED.value):
return {"success": False,
"message": f"Retry permis doar pentru ERROR/SKIPPED/DELETED_IN_ROA/MALFORMED (status actual: {status})"}
order_date_str = order_data.get("order_date", "")
customer_name = order_data.get("customer_name", "")
return await _download_and_reimport(order_number, order_date_str, customer_name, app_settings)
async def resync_single_order(order_number: str, app_settings: dict) -> dict:
"""Soft-delete an imported order from Oracle then re-import it from GoMag.
Steps:
1. Check sync lock
2. Load order from SQLite
3. Validate status is IMPORTED/ALREADY_IMPORTED with id_comanda
4. Invoice safety gate (check Oracle for invoices)
5. Soft-delete from Oracle
6. Mark DELETED_IN_ROA in SQLite
7. Re-import via _download_and_reimport
Returns: {"success": bool, "message": str, "status": str|None}
"""
from . import sqlite_service, sync_service, import_service, invoice_service
from .. import database
# Check sync lock
if sync_service._sync_lock.locked():
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
# Get order from SQLite
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
return {"success": False, "message": "Comanda nu a fost gasita"}
order_data = detail["order"]
status = order_data.get("status", "")
id_comanda = order_data.get("id_comanda")
if status not in (OrderStatus.IMPORTED.value, OrderStatus.ALREADY_IMPORTED.value) or not id_comanda:
return {"success": False, "message": f"Resync permis doar pentru IMPORTED/ALREADY_IMPORTED cu id_comanda (status actual: {status})"}
# Invoice safety gate
if database.pool is None:
return {"success": False, "message": "Oracle indisponibil"}
if order_data.get("factura_numar"):
return {"success": False, "message": "Comanda este facturata"}
try:
invoice_result = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, [id_comanda]
)
except Exception as e:
logger.error(f"Invoice check failed for {order_number}: {e}")
return {"success": False, "message": "Nu se poate verifica factura — Oracle indisponibil"}
if invoice_result.get(id_comanda):
return {"success": False, "message": "Comanda este facturata"}
# Soft-delete from Oracle
try:
delete_result = await asyncio.to_thread(
import_service.soft_delete_order_in_roa, id_comanda
)
if not delete_result.get("success"):
return {"success": False, "message": f"Eroare stergere din Oracle: {delete_result.get('error', 'Unknown')}"}
except Exception as e:
logger.error(f"Soft-delete failed for {order_number} (id_comanda={id_comanda}): {e}")
return {"success": False, "message": f"Eroare stergere din Oracle: {e}"}
# Mark deleted in SQLite
await sqlite_service.mark_order_deleted_in_roa(order_number)
order_date_str = order_data.get("order_date", "")
customer_name = order_data.get("customer_name", "")
# Re-import
reimport_result = await _download_and_reimport(order_number, order_date_str, customer_name, app_settings)
if not reimport_result.get("success"):
logger.warning(f"Resync: order {order_number} deleted from Oracle but reimport failed")
return {
"success": False,
"message": "Comanda stearsa din Oracle dar reimportul a esuat — foloseste Reimporta pentru a reincerca",
}
return reimport_result
async def delete_single_order(order_number: str) -> dict:
"""Soft-delete an imported order from Oracle without re-importing.
Same invoice safety gate as resync_single_order.
Returns: {"success": bool, "message": str}
"""
from . import sqlite_service, sync_service, import_service, invoice_service
from .. import database
# Check sync lock
if sync_service._sync_lock.locked():
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
# Get order from SQLite
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
return {"success": False, "message": "Comanda nu a fost gasita"}
order_data = detail["order"]
status = order_data.get("status", "")
id_comanda = order_data.get("id_comanda")
if status not in (OrderStatus.IMPORTED.value, OrderStatus.ALREADY_IMPORTED.value) or not id_comanda:
return {"success": False, "message": f"Stergere permisa doar pentru IMPORTED/ALREADY_IMPORTED cu id_comanda (status actual: {status})"}
# Invoice safety gate
if database.pool is None:
return {"success": False, "message": "Oracle indisponibil"}
if order_data.get("factura_numar"):
return {"success": False, "message": "Comanda este facturata"}
try:
invoice_result = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, [id_comanda]
)
except Exception as e:
logger.error(f"Invoice check failed for {order_number}: {e}")
return {"success": False, "message": "Nu se poate verifica factura — Oracle indisponibil"}
if invoice_result.get(id_comanda):
return {"success": False, "message": "Comanda este facturata"}
# Soft-delete from Oracle
try:
delete_result = await asyncio.to_thread(
import_service.soft_delete_order_in_roa, id_comanda
)
if not delete_result.get("success"):
return {"success": False, "message": f"Eroare stergere din Oracle: {delete_result.get('error', 'Unknown')}"}
except Exception as e:
logger.error(f"Soft-delete failed for {order_number} (id_comanda={id_comanda}): {e}")
return {"success": False, "message": f"Eroare stergere din Oracle: {e}"}
# Mark deleted in SQLite
await sqlite_service.mark_order_deleted_in_roa(order_number)
logger.info(f"Order {order_number} (id_comanda={id_comanda}) deleted from ROA")
return {"success": True, "message": "Comanda stearsa din ROA"}

View File

@@ -1,8 +1,11 @@
import json
import logging
import logging.handlers
import os
from datetime import datetime
from zoneinfo import ZoneInfo
from ..database import get_sqlite, get_sqlite_sync
from ..constants import OrderStatus
# Re-export so other services can import get_sqlite from sqlite_service
__all__ = ["get_sqlite", "get_sqlite_sync"]
@@ -17,6 +20,61 @@ def _now_str():
logger = logging.getLogger(__name__)
# Dedicated append-only logger for per-order errors.
# orders.error_message is overwritten when retry succeeds — this file
# keeps the permanent audit trail.
_error_history_logger: logging.Logger | None = None
def _get_error_history_logger() -> logging.Logger:
"""Lazily-initialised logger writing to logs/sync_errors_history.log.
Append-only. Rolls over at 100MB with 12 kept backups (~monthly cadence
under prod load).
"""
global _error_history_logger
if _error_history_logger is not None:
return _error_history_logger
lg = logging.getLogger("sync_errors_history")
lg.setLevel(logging.INFO)
lg.propagate = False
# Find project root by walking up from this file
here = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.abspath(os.path.join(here, "..", "..", ".."))
logs_dir = os.path.join(project_root, "logs")
os.makedirs(logs_dir, exist_ok=True)
log_path = os.path.join(logs_dir, "sync_errors_history.log")
if not any(
isinstance(h, logging.handlers.RotatingFileHandler)
and getattr(h, "baseFilename", "") == log_path
for h in lg.handlers
):
handler = logging.handlers.RotatingFileHandler(
log_path, maxBytes=100 * 1024 * 1024, backupCount=12, encoding="utf-8"
)
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
lg.addHandler(handler)
_error_history_logger = lg
return lg
def _log_order_error_history(order_number: str, error_msg: str) -> None:
"""Append an order-level failure line to the permanent error history log.
Called from save_orders_batch + add_order_items on MALFORMED fallback,
so the evidence survives later retry-success overwrites of
orders.error_message.
"""
try:
_get_error_history_logger().warning(f"ORDER_FAIL {order_number}: {error_msg}")
except Exception as e:
logger.warning(f"_log_order_error_history failed for {order_number}: {e}")
async def create_sync_run(run_id: str, json_files: int = 0):
"""Create a new sync run record."""
db = await get_sqlite()
@@ -68,7 +126,7 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
"""Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite()
try:
await db.execute("""
await db.execute(f"""
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count,
@@ -79,7 +137,7 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name,
status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
WHEN orders.status = '{OrderStatus.IMPORTED.value}' AND excluded.status = '{OrderStatus.ALREADY_IMPORTED.value}'
THEN orders.status
ELSE excluded.status
END,
@@ -88,7 +146,7 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
items_count = excluded.items_count,
id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda),
id_partener = COALESCE(excluded.id_partener, orders.id_partener),
times_skipped = CASE WHEN excluded.status = 'SKIPPED'
times_skipped = CASE WHEN excluded.status = '{OrderStatus.SKIPPED.value}'
THEN orders.times_skipped + 1
ELSE orders.times_skipped END,
last_sync_run_id = excluded.last_sync_run_id,
@@ -126,96 +184,260 @@ async def add_sync_run_order(sync_run_id: str, order_number: str, status_at_run:
await db.close()
async def save_orders_batch(orders_data: list[dict]):
"""Batch save a list of orders + their sync_run_orders + order_items in one transaction.
# SQL for the orders upsert — reused by batch + single-order fallback paths.
_ORDERS_UPSERT_SQL = f"""
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name,
status = CASE
WHEN orders.status = '{OrderStatus.IMPORTED.value}' AND excluded.status = '{OrderStatus.ALREADY_IMPORTED.value}'
THEN orders.status
ELSE excluded.status
END,
error_message = excluded.error_message,
missing_skus = excluded.missing_skus,
items_count = excluded.items_count,
id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda),
id_partener = COALESCE(excluded.id_partener, orders.id_partener),
times_skipped = CASE WHEN excluded.status = '{OrderStatus.SKIPPED.value}'
THEN orders.times_skipped + 1
ELSE orders.times_skipped END,
last_sync_run_id = excluded.last_sync_run_id,
shipping_name = COALESCE(excluded.shipping_name, orders.shipping_name),
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now')
"""
Each dict must have: sync_run_id, order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus (list|None), items_count,
shipping_name, billing_name, payment_method, delivery_method, status_at_run,
items (list of item dicts), delivery_cost (optional), discount_total (optional),
web_status (optional).
def _orders_row(d: dict) -> tuple:
return (
d["order_number"], d["order_date"], d["customer_name"], d["status"],
d.get("id_comanda"), d.get("id_partener"), d.get("error_message"),
json.dumps(d["missing_skus"]) if d.get("missing_skus") else None,
d.get("items_count", 0), d["sync_run_id"],
d.get("shipping_name"), d.get("billing_name"),
d.get("payment_method"), d.get("delivery_method"),
d.get("order_total"),
d.get("delivery_cost"), d.get("discount_total"),
d.get("web_status"), d.get("discount_split"),
)
def _mark_malformed(d: dict, reason: str) -> dict:
"""Return a copy of d with status=MALFORMED, error_message set, items wiped.
Does NOT mutate caller's dict.
"""
out = dict(d)
out["status"] = OrderStatus.MALFORMED.value
out["error_message"] = reason
out["items"] = []
out["items_count"] = 0
out["missing_skus"] = None
return out
async def _insert_orders_only(db, orders: list[dict]):
"""Upsert only the `orders` + `sync_run_orders` rows (no items).
Used for MALFORMED fallback where we can't trust item data.
"""
if not orders:
return
await db.executemany(_ORDERS_UPSERT_SQL, [_orders_row(d) for d in orders])
await db.executemany(
"INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) VALUES (?, ?, ?)",
[(d["sync_run_id"], d["order_number"], d.get("status_at_run", d["status"])) for d in orders],
)
async def _insert_valid_batch(db, orders: list[dict]):
"""Happy-path batch insert: orders + sync_run_orders + order_items in bulk.
Caller wraps in a SAVEPOINT so a mid-batch failure rolls back to a clean
point and the per-order fallback can take over.
"""
if not orders:
return
await db.executemany(_ORDERS_UPSERT_SQL, [_orders_row(d) for d in orders])
await db.executemany(
"INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) VALUES (?, ?, ?)",
[(d["sync_run_id"], d["order_number"], d["status_at_run"]) for d in orders],
)
all_items: list[tuple] = []
order_numbers_with_items: set = set()
for d in orders:
raw_items = d.get("items", [])
if not raw_items:
continue
order_numbers_with_items.add(d["order_number"])
for item in _dedup_items_by_sku(raw_items):
all_items.append((
d["order_number"],
item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("baseprice"),
item.get("vat"),
item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa"),
))
if all_items:
placeholders = ",".join("?" * len(order_numbers_with_items))
await db.execute(
f"DELETE FROM order_items WHERE order_number IN ({placeholders})",
tuple(order_numbers_with_items),
)
await db.executemany("""
INSERT INTO order_items
(order_number, sku, product_name, quantity, price, baseprice,
vat, mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", all_items)
async def _insert_single_order(db, d: dict):
"""Insert one order + its sync_run_orders row + its items.
Caller wraps in SAVEPOINT so a per-row failure doesn't poison the batch.
"""
await db.execute(_ORDERS_UPSERT_SQL, _orders_row(d))
await db.execute(
"INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) VALUES (?, ?, ?)",
(d["sync_run_id"], d["order_number"], d["status_at_run"]),
)
raw_items = d.get("items", [])
if raw_items:
await db.execute("DELETE FROM order_items WHERE order_number = ?", (d["order_number"],))
await db.executemany("""
INSERT INTO order_items
(order_number, sku, product_name, quantity, price, baseprice,
vat, mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", [
(d["order_number"],
item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("baseprice"),
item.get("vat"),
item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa"))
for item in _dedup_items_by_sku(raw_items)
])
async def save_orders_batch(orders_data: list[dict]):
"""Batch save orders + sync_run_orders + order_items with per-order isolation.
Three-tier strategy:
1. Pre-validate with validate_structural — malformed rows persist as
MALFORMED + error_message, no items, and do not participate in the
valid batch.
2. Optimistic executemany for the remaining valid rows (happy path).
3. On IntegrityError / ValueError / TypeError during the batch, fall back
to per-order SAVEPOINTs so a single bad row doesn't block the rest.
Per-order failures are marked MALFORMED + logged to the permanent
error-history file.
Re-raises OperationalError / OSError / ConnectionError / MemoryError at
the top level — the scheduler interprets these as halt signals. A
mid-loop ROLLBACK failure triggers a commit-before-reconnect so any
successfully-inserted work (including MALFORMED entries) persists.
"""
if not orders_data:
return
import sqlite3
# Structural pre-flight — local import avoids the services package cycle
from .validation_service import validate_structural
valid: list[dict] = []
malformed: list[dict] = []
for d in orders_data:
ok, err_type, err_msg = validate_structural(d)
if ok:
valid.append(d)
else:
fixed = _mark_malformed(d, f"{err_type}: {err_msg}")
malformed.append(fixed)
_log_order_error_history(fixed["order_number"], fixed["error_message"])
db = await get_sqlite()
try:
# 1. Upsert orders
await db.executemany("""
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name,
status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
THEN orders.status
ELSE excluded.status
END,
error_message = excluded.error_message,
missing_skus = excluded.missing_skus,
items_count = excluded.items_count,
id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda),
id_partener = COALESCE(excluded.id_partener, orders.id_partener),
times_skipped = CASE WHEN excluded.status = 'SKIPPED'
THEN orders.times_skipped + 1
ELSE orders.times_skipped END,
last_sync_run_id = excluded.last_sync_run_id,
shipping_name = COALESCE(excluded.shipping_name, orders.shipping_name),
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now')
""", [
(d["order_number"], d["order_date"], d["customer_name"], d["status"],
d.get("id_comanda"), d.get("id_partener"), d.get("error_message"),
json.dumps(d["missing_skus"]) if d.get("missing_skus") else None,
d.get("items_count", 0), d["sync_run_id"],
d.get("shipping_name"), d.get("billing_name"),
d.get("payment_method"), d.get("delivery_method"),
d.get("order_total"),
d.get("delivery_cost"), d.get("discount_total"),
d.get("web_status"), d.get("discount_split"))
for d in orders_data
])
if malformed:
await _insert_orders_only(db, malformed)
# 2. Sync run orders
await db.executemany("""
INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run)
VALUES (?, ?, ?)
""", [(d["sync_run_id"], d["order_number"], d["status_at_run"]) for d in orders_data])
if valid:
# Savepoint around the batch — lets us roll back to a clean point
# and take the per-order path without losing the MALFORMED rows
# inserted above.
await db.execute("SAVEPOINT batch")
try:
await _insert_valid_batch(db, valid)
await db.execute("RELEASE SAVEPOINT batch")
except (sqlite3.IntegrityError, ValueError, TypeError) as batch_err:
logger.warning(f"save_orders_batch: batch insert failed, falling back per-order: {batch_err}")
try:
await db.execute("ROLLBACK TO SAVEPOINT batch")
await db.execute("RELEASE SAVEPOINT batch")
except sqlite3.OperationalError:
# Savepoint rollback itself failed — commit whatever survived,
# reconnect, continue from scratch on the valid list.
db = await _safe_reconnect(db)
# 3. Order items
all_items = []
for d in orders_data:
for item in d.get("items", []):
all_items.append((
d["order_number"],
item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("baseprice"),
item.get("vat"),
item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa")
))
if all_items:
await db.executemany("""
INSERT OR IGNORE INTO order_items
(order_number, sku, product_name, quantity, price, baseprice,
vat, mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", all_items)
for d in valid:
try:
await db.execute("SAVEPOINT ord")
await _insert_single_order(db, d)
await db.execute("RELEASE SAVEPOINT ord")
except (sqlite3.IntegrityError, ValueError, TypeError) as per_err:
reason = f"RUNTIME: {type(per_err).__name__}: {per_err}"
try:
await db.execute("ROLLBACK TO SAVEPOINT ord")
await db.execute("RELEASE SAVEPOINT ord")
await _insert_orders_only(db, [_mark_malformed(d, reason)])
_log_order_error_history(d["order_number"], reason)
except sqlite3.OperationalError:
reason = f"RUNTIME (post-reconnect): {type(per_err).__name__}: {per_err}"
db = await _safe_reconnect(db)
await _insert_orders_only(db, [_mark_malformed(d, reason)])
_log_order_error_history(d["order_number"], reason)
await db.commit()
finally:
try:
await db.close()
except Exception:
pass
async def _safe_reconnect(db):
"""Commit whatever survived, close the broken connection, open a fresh one.
Called when a SAVEPOINT rollback itself raises OperationalError — the
connection is in a bad state and further work on it will fail. Commit
preserves the MALFORMED rows inserted before the explosion.
"""
try:
await db.commit()
except Exception:
pass
try:
await db.close()
except Exception:
pass
return await get_sqlite()
async def track_missing_sku(sku: str, product_name: str = "",
@@ -389,17 +611,17 @@ async def get_dashboard_stats():
db = await get_sqlite()
try:
cursor = await db.execute(
"SELECT COUNT(*) FROM orders WHERE status = 'IMPORTED'"
f"SELECT COUNT(*) FROM orders WHERE status = '{OrderStatus.IMPORTED.value}'"
)
imported = (await cursor.fetchone())[0]
cursor = await db.execute(
"SELECT COUNT(*) FROM orders WHERE status = 'SKIPPED'"
f"SELECT COUNT(*) FROM orders WHERE status = '{OrderStatus.SKIPPED.value}'"
)
skipped = (await cursor.fetchone())[0]
cursor = await db.execute(
"SELECT COUNT(*) FROM orders WHERE status = 'ERROR'"
f"SELECT COUNT(*) FROM orders WHERE status = '{OrderStatus.ERROR.value}'"
)
errors = (await cursor.fetchone())[0]
@@ -528,31 +750,152 @@ async def get_web_products_batch(skus: list) -> dict:
# ── order_items ──────────────────────────────────
def _dedup_items_by_sku(items: list) -> list:
"""Deduplicate items by SKU within a single order. Sums quantities on collision.
GoMag occasionally returns the same SKU on multiple lines (configurable products,
promo splits). The order_items primary key is (order_number, sku) so the raw rows
would violate UNIQUE. Keeps first price/vat/name; sums quantity + baseprice*qty.
"""
if not items:
return items
merged: dict = {}
order: list = []
for item in items:
sku = item.get("sku")
if sku is None:
order.append(item)
continue
if sku in merged:
prev = merged[sku]
prev["quantity"] = (prev.get("quantity") or 0) + (item.get("quantity") or 0)
else:
merged[sku] = dict(item)
order.append(merged[sku])
return order
async def _safe_upsert_order_items(db, order_number: str, items: list):
"""Replace order_items for one order inside a SAVEPOINT.
On IntegrityError / ValueError / TypeError: rolls back the savepoint,
tags the parent order MALFORMED, records the failure in the history
log, and returns False. On success returns True.
Caller is responsible for the outer connection lifecycle.
"""
import sqlite3
items = _dedup_items_by_sku(items) if items else []
await db.execute("SAVEPOINT items")
try:
await db.execute("DELETE FROM order_items WHERE order_number = ?", (order_number,))
if items:
await db.executemany("""
INSERT INTO order_items
(order_number, sku, product_name, quantity, price, baseprice,
vat, mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", [
(order_number,
item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("baseprice"),
item.get("vat"),
item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa"))
for item in items
])
await db.execute("RELEASE SAVEPOINT items")
return True
except (sqlite3.IntegrityError, ValueError, TypeError) as err:
reason = f"ITEMS_FAIL: {type(err).__name__}: {err}"
try:
await db.execute("ROLLBACK TO SAVEPOINT items")
await db.execute("RELEASE SAVEPOINT items")
except sqlite3.OperationalError:
# Outer caller will handle reconnect — just log and bail.
logger.exception(f"_safe_upsert_order_items: rollback failed for {order_number}")
raise
# Tag parent order as MALFORMED without removing it from sync state.
await db.execute(
"UPDATE orders SET status = ?, error_message = ?, updated_at = datetime('now') WHERE order_number = ?",
(OrderStatus.MALFORMED.value, reason, order_number),
)
_log_order_error_history(order_number, reason)
return False
async def add_order_items(order_number: str, items: list):
"""Bulk insert order items. Uses INSERT OR IGNORE — PK is (order_number, sku)."""
"""Replace order items — delete any existing rows, then insert fresh batch.
GoMag is source of truth: re-import must reflect quantity changes.
Wrapped in _safe_upsert_order_items so a bad payload marks the parent
order MALFORMED rather than exploding the sync.
"""
if not items:
return
db = await get_sqlite()
try:
await db.executemany("""
INSERT OR IGNORE INTO order_items
(order_number, sku, product_name, quantity, price, baseprice,
vat, mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", [
(order_number,
item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("baseprice"),
item.get("vat"),
item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa"))
for item in items
])
await _safe_upsert_order_items(db, order_number, items)
await db.commit()
finally:
await db.close()
# ── sync phase failure tracking ───────────────────
async def record_phase_failure(run_id: str, phase: str, error_summary: str) -> None:
"""Insert a phase-failure marker and prune to the last 100 sync runs.
`error_summary` must be short (error_type + message) — no raw payload,
no PII. Used by _phase_wrap in sync_service to surface repeat failures
to the escalation check and the /api/sync/health dashboard pill.
"""
db = await get_sqlite()
try:
await db.execute(
"""INSERT OR REPLACE INTO sync_phase_failures (run_id, phase, error_summary)
VALUES (?, ?, ?)""",
(run_id, phase, error_summary[:500] if error_summary else None),
)
await db.execute("""
DELETE FROM sync_phase_failures
WHERE run_id NOT IN (
SELECT run_id FROM sync_runs ORDER BY started_at DESC LIMIT 100
)
""")
await db.commit()
finally:
await db.close()
async def get_recent_phase_failures(limit: int = 3) -> dict[str, int]:
"""Return a {phase: failure_count} map across the last N sync runs.
Used by the escalation check (>=3 consecutive failures on the same
phase halts the next sync) and by /api/sync/health for the dashboard
pill.
"""
db = await get_sqlite()
try:
cursor = await db.execute(
"""
SELECT phase, COUNT(*) AS cnt
FROM sync_phase_failures
WHERE run_id IN (
SELECT run_id FROM sync_runs ORDER BY started_at DESC LIMIT ?
)
GROUP BY phase
""",
(limit,),
)
rows = await cursor.fetchall()
return {row[0]: row[1] for row in rows}
finally:
await db.close()
async def get_order_items(order_number: str) -> list:
"""Fetch items for one order."""
db = await get_sqlite()
@@ -652,11 +995,12 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": {
"imported": status_counts.get("IMPORTED", 0),
"skipped": status_counts.get("SKIPPED", 0),
"error": status_counts.get("ERROR", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"cancelled": status_counts.get("CANCELLED", 0),
"imported": status_counts.get(OrderStatus.IMPORTED.value, 0),
"skipped": status_counts.get(OrderStatus.SKIPPED.value, 0),
"error": status_counts.get(OrderStatus.ERROR.value, 0),
"already_imported": status_counts.get(OrderStatus.ALREADY_IMPORTED.value, 0),
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
"total": sum(status_counts.values())
}
}
@@ -696,8 +1040,8 @@ async def get_orders(page: int = 1, per_page: int = 50,
data_params = list(base_params)
if status_filter and status_filter not in ("all", "UNINVOICED"):
if status_filter.upper() == "IMPORTED":
data_clauses.append("UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')")
if status_filter.upper() == OrderStatus.IMPORTED.value:
data_clauses.append(f"UPPER(status) IN ('{OrderStatus.IMPORTED.value}', '{OrderStatus.ALREADY_IMPORTED.value}')")
elif status_filter.upper() == "DIFFS":
data_clauses.append(
"(anaf_cod_fiscal_adjusted = 1 OR anaf_denumire_mismatch = 1"
@@ -743,7 +1087,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
# Uninvoiced count: IMPORTED/ALREADY_IMPORTED with no cached invoice, same period+search
uninv_clauses = list(base_clauses) + [
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
f"UPPER(status) IN ('{OrderStatus.IMPORTED.value}', '{OrderStatus.ALREADY_IMPORTED.value}')",
"(factura_numar IS NULL OR factura_numar = '')",
]
uninv_where = "WHERE " + " AND ".join(uninv_clauses)
@@ -752,7 +1096,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
# Uninvoiced > 3 days old
uninv_old_clauses = list(base_clauses) + [
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
f"UPPER(status) IN ('{OrderStatus.IMPORTED.value}', '{OrderStatus.ALREADY_IMPORTED.value}')",
"(factura_numar IS NULL OR factura_numar = '')",
"order_date < datetime('now', '-3 days')",
]
@@ -786,12 +1130,13 @@ async def get_orders(page: int = 1, per_page: int = 50,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": {
"imported": status_counts.get("IMPORTED", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"imported_all": status_counts.get("IMPORTED", 0) + status_counts.get("ALREADY_IMPORTED", 0),
"skipped": status_counts.get("SKIPPED", 0),
"error": status_counts.get("ERROR", 0),
"cancelled": status_counts.get("CANCELLED", 0),
"imported": status_counts.get(OrderStatus.IMPORTED.value, 0),
"already_imported": status_counts.get(OrderStatus.ALREADY_IMPORTED.value, 0),
"imported_all": status_counts.get(OrderStatus.IMPORTED.value, 0) + status_counts.get(OrderStatus.ALREADY_IMPORTED.value, 0),
"skipped": status_counts.get(OrderStatus.SKIPPED.value, 0),
"error": status_counts.get(OrderStatus.ERROR.value, 0),
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
"total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite,
"uninvoiced_old": uninvoiced_old,
@@ -827,9 +1172,9 @@ async def get_uninvoiced_imported_orders() -> list:
"""Get all imported orders that don't yet have invoice data cached."""
db = await get_sqlite()
try:
cursor = await db.execute("""
cursor = await db.execute(f"""
SELECT order_number, id_comanda FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
WHERE status IN ('{OrderStatus.IMPORTED.value}', '{OrderStatus.ALREADY_IMPORTED.value}')
AND id_comanda IS NOT NULL
AND factura_numar IS NULL
""")
@@ -881,9 +1226,9 @@ async def get_invoiced_imported_orders() -> list:
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
db = await get_sqlite()
try:
cursor = await db.execute("""
cursor = await db.execute(f"""
SELECT order_number, id_comanda FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
WHERE status IN ('{OrderStatus.IMPORTED.value}', '{OrderStatus.ALREADY_IMPORTED.value}')
AND id_comanda IS NOT NULL
AND factura_numar IS NOT NULL AND factura_numar != ''
""")
@@ -897,9 +1242,9 @@ async def get_all_imported_orders() -> list:
"""Get ALL imported orders with id_comanda (for checking if deleted in ROA)."""
db = await get_sqlite()
try:
cursor = await db.execute("""
cursor = await db.execute(f"""
SELECT order_number, id_comanda FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
WHERE status IN ('{OrderStatus.IMPORTED.value}', '{OrderStatus.ALREADY_IMPORTED.value}')
AND id_comanda IS NOT NULL
""")
rows = await cursor.fetchall()
@@ -908,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()
@@ -930,12 +1288,16 @@ 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 and invoice cache."""
"""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("""
await db.execute(f"""
UPDATE orders SET
status = 'DELETED_IN_ROA',
status = '{OrderStatus.DELETED_IN_ROA.value}',
id_comanda = NULL,
id_partener = NULL,
factura_serie = NULL,
@@ -958,9 +1320,9 @@ async def mark_order_cancelled(order_number: str, web_status: str = "Anulata"):
"""Mark an order as cancelled from GoMag. Clears id_comanda and invoice cache."""
db = await get_sqlite()
try:
await db.execute("""
await db.execute(f"""
UPDATE orders SET
status = 'CANCELLED',
status = '{OrderStatus.CANCELLED.value}',
id_comanda = NULL,
id_partener = NULL,
factura_serie = NULL,
@@ -1012,11 +1374,11 @@ async def get_skipped_orders_with_sku(sku: str) -> list[str]:
"""Get order_numbers of SKIPPED orders that contain the given SKU."""
db = await get_sqlite()
try:
cursor = await db.execute("""
cursor = await db.execute(f"""
SELECT DISTINCT oi.order_number
FROM order_items oi
JOIN orders o ON o.order_number = oi.order_number
WHERE oi.sku = ? AND o.status = 'SKIPPED'
WHERE oi.sku = ? AND o.status = '{OrderStatus.SKIPPED.value}'
""", (sku,))
rows = await cursor.fetchall()
return [row[0] for row in rows]
@@ -1026,23 +1388,6 @@ async def get_skipped_orders_with_sku(sku: str) -> list[str]:
# ── Price Sync Runs ───────────────────────────────
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
"""Get paginated price sync run history."""
db = await get_sqlite()
try:
offset = (page - 1) * per_page
cursor = await db.execute("SELECT COUNT(*) FROM price_sync_runs")
total = (await cursor.fetchone())[0]
cursor = await db.execute(
"SELECT * FROM price_sync_runs ORDER BY started_at DESC LIMIT ? OFFSET ?",
(per_page, offset)
)
runs = [dict(r) for r in await cursor.fetchall()]
return {"runs": runs, "total": total, "page": page, "pages": (total + per_page - 1) // per_page}
finally:
await db.close()
# ── ANAF Cache ───────────────────────────────────
async def get_anaf_cache(bare_cui: str) -> dict | None:
@@ -1288,7 +1633,7 @@ async def get_orders_missing_anaf() -> list[dict]:
WHERE cod_fiscal_roa IS NOT NULL
AND cod_fiscal_roa != ''
AND anaf_platitor_tva IS NULL
AND status IN ('IMPORTED', 'ALREADY_IMPORTED')
AND status IN ('{OrderStatus.IMPORTED.value}', '{OrderStatus.ALREADY_IMPORTED.value}')
""")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
@@ -1440,6 +1785,36 @@ async def update_partner_mismatch_batch(updates: list) -> None:
await db.close()
async def clear_stale_partner_mismatches_no_cui(exclude_numbers: set) -> int:
"""Clear partner_mismatch=1 for orders with cod_fiscal_gomag=NULL that are NOT in the
current sync batch. These were flagged by old code (before the no-CUI fix) and will
never self-correct because they fall outside the active sync window.
Returns number of rows cleared.
"""
db = await get_sqlite()
try:
if exclude_numbers:
placeholders = ",".join("?" * len(exclude_numbers))
sql = f"""
UPDATE orders SET partner_mismatch = 0, updated_at = datetime('now')
WHERE partner_mismatch = 1
AND cod_fiscal_gomag IS NULL
AND order_number NOT IN ({placeholders})
"""
await db.execute(sql, list(exclude_numbers))
else:
await db.execute("""
UPDATE orders SET partner_mismatch = 0, updated_at = datetime('now')
WHERE partner_mismatch = 1 AND cod_fiscal_gomag IS NULL
""")
await db.commit()
cursor = await db.execute("SELECT changes()")
row = await cursor.fetchone()
return row[0] if row else 0
finally:
await db.close()
async def update_partner_resync_data(order_number: str, data: dict) -> None:
"""Update partner fields + clear partner_mismatch after a successful resync."""
db = await get_sqlite()

View File

@@ -2,11 +2,22 @@ import asyncio
import json
import logging
import re
import unicodedata
import sqlite3
import uuid
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
# Data-level errors that a single phase may raise without halting the whole
# sync. Everything NOT in this tuple (OperationalError, OSError,
# ConnectionError, MemoryError) propagates and halts.
DATA_ERRORS = (sqlite3.IntegrityError, ValueError, TypeError, UnicodeError)
# Number of recent runs inspected by the escalation check. 3 consecutive
# failures on the same phase halts the next sync.
_ESCALATION_WINDOW = 3
_ESCALATION_THRESHOLD = 3
_tz_bucharest = ZoneInfo("Europe/Bucharest")
@@ -17,6 +28,7 @@ def _now():
from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client, anaf_service
from ..config import settings
from .. import database
from ..constants import OrderStatus
logger = logging.getLogger(__name__)
@@ -36,17 +48,37 @@ 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 = unicodedata.normalize('NFD', s or '')
s = re.sub(r'[\u0300-\u036f]', '', s).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):
"""SOUNDEX matching Oracle's implementation — for city fuzzy compare."""
if not s:
return ''
_code = {'B':'1','F':'1','P':'1','V':'1',
'C':'2','G':'2','J':'2','K':'2','Q':'2','S':'2','X':'2','Z':'2',
'D':'3','T':'3','L':'4','M':'5','N':'5','R':'6'}
result = s[0]
prev = _code.get(s[0], '0')
for c in s[1:]:
if len(result) >= 4:
break
if c in 'AEIOU':
prev = '0'
elif c not in 'HW':
d = _code.get(c, '')
if d and d != prev:
result += d
if d:
prev = d
return result.ljust(4, '0')
g_street = norm(g.get('address') or g.get('strada') or '')
r_street = norm((r.get('strada') or '') + (r.get('numar') or '') + (r.get('bloc') or '') + (r.get('scara') or '') + (r.get('apart') or '') + (r.get('etaj') or ''))
r_street = norm((r.get('strada') or '') + (r.get('numar') or '') + (r.get('bloc') or '') + (r.get('scara') or '') + (r.get('etaj') or '') + (r.get('apart') or ''))
g_city = norm(g.get('city') or g.get('localitate') or '')
r_city = norm(r.get('localitate') or '')
g_region = norm(g.get('region') or g.get('judet') or '')
r_region = norm(r.get('judet') or '')
return g_street == r_street and g_city == r_city and g_region == r_region
return g_street == r_street and _soundex(g_city) == _soundex(r_city) and g_region == r_region
# Sync state
@@ -65,6 +97,90 @@ def _log_line(run_id: str, message: str):
_run_logs[run_id].append(f"[{ts}] {message}")
async def _record_phase_err(run_id: str, phase: str, err: Exception) -> None:
"""Log + persist a phase-level data error so escalation + health can see it.
Called only for DATA_ERRORS (structural / data problems). OperationalError
and OS-level errors bypass this and halt the sync.
"""
logger.error(f"[{run_id}] Phase {phase} data error: {err}", exc_info=True)
_log_line(run_id, f"FAZA {phase} eroare izolata: {type(err).__name__}: {err}")
try:
summary = f"{type(err).__name__}: {err}"[:500]
await sqlite_service.record_phase_failure(run_id, phase, summary)
except Exception as rec_err:
logger.warning(f"record_phase_failure failed for phase={phase}: {rec_err}")
def evaluate_cui_gate(
is_ro_company: bool,
company_code_raw: str | None,
bare_cui: str,
anaf_data: dict | None,
) -> str | None:
"""Return block reason or None if the order passes the CUI gate.
CONTRACT on anaf_data:
- None → ANAF down / transient error → tolerate (pass)
- {scpTVA: None, denumire_anaf: ""} → ANAF notFound explicit → block
- {scpTVA: bool, denumire_anaf: str} → ANAF found → pass
"""
if not is_ro_company or not company_code_raw:
return None
if not anaf_service.validate_cui(bare_cui):
return f"CUI invalid (format): {company_code_raw!r}"
if not anaf_service.validate_cui_checksum(bare_cui):
return f"CUI invalid (cifra de control): {bare_cui}"
if (
anaf_data is not None
and anaf_data.get("scpTVA") is None
and not (anaf_data.get("denumire_anaf") or "").strip()
):
return (
f"CUI {company_code_raw!r} (sanitizat: {bare_cui}) nu exista in registrul ANAF — "
f"verifica daca nu e inversat cu numarul de la registrul comertului"
)
return None
async def _record_order_error(
run_id: str, order, customer: str, shipping_name: str, billing_name: str,
payment_method: str, delivery_method: str, discount_split_json: str | None,
order_items_data: list, reason: str, id_partener: int | None = None,
) -> None:
"""Write an ERROR row to SQLite (orders + sync_run_orders + order_items)."""
await sqlite_service.upsert_order(
sync_run_id=run_id, order_number=order.number, order_date=order.date,
customer_name=customer, status=OrderStatus.ERROR.value,
id_partener=id_partener, error_message=reason,
items_count=len(order.items),
shipping_name=shipping_name, billing_name=billing_name,
payment_method=payment_method, delivery_method=delivery_method,
order_total=order.total or None, delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None, web_status=order.status or None,
discount_split=discount_split_json,
)
await sqlite_service.add_sync_run_order(run_id, order.number, OrderStatus.ERROR.value)
await sqlite_service.add_order_items(order.number, order_items_data)
async def _check_escalation() -> tuple[str | None, dict[str, int]]:
"""Return (phase_to_halt_on, recent_counts).
If any phase has >= _ESCALATION_THRESHOLD failures across the last
_ESCALATION_WINDOW runs, we halt the incoming sync and record
`halted_escalation` on sync_runs. Operators can still start the sync
manually from the dashboard override modal.
"""
try:
counts = await sqlite_service.get_recent_phase_failures(limit=_ESCALATION_WINDOW)
except Exception as e:
logger.warning(f"escalation check: failed to read phase failures: {e}")
return None, {}
escalating = [p for p, c in counts.items() if c >= _ESCALATION_THRESHOLD]
return (escalating[0] if escalating else None), counts
def get_run_text_log(run_id: str) -> str | None:
"""Return the accumulated text log for a run, or None if not found."""
lines = _run_logs.get(run_id)
@@ -147,20 +263,20 @@ async def _fix_stale_error_orders(existing_map: dict, run_id: str):
db = await get_sqlite()
try:
cursor = await db.execute(
"SELECT order_number FROM orders WHERE status = 'ERROR'"
f"SELECT order_number FROM orders WHERE status = '{OrderStatus.ERROR.value}'"
)
error_orders = [row["order_number"] for row in await cursor.fetchall()]
fixed = 0
for order_number in error_orders:
if order_number in existing_map:
id_comanda = existing_map[order_number]
await db.execute("""
await db.execute(f"""
UPDATE orders SET
status = 'ALREADY_IMPORTED',
status = '{OrderStatus.ALREADY_IMPORTED.value}',
id_comanda = ?,
error_message = NULL,
updated_at = datetime('now')
WHERE order_number = ? AND status = 'ERROR'
WHERE order_number = ? AND status = '{OrderStatus.ERROR.value}'
""", (id_comanda, order_number))
fixed += 1
_log_line(run_id, f"#{order_number} → status corectat ERROR → ALREADY_IMPORTED (ID: {id_comanda})")
@@ -205,6 +321,22 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
json_dir = settings.JSON_OUTPUT_DIR
# ── Escalation check — halt if a phase has failed 3 runs in a row ──
halt_phase, _recent_counts = await _check_escalation()
if halt_phase:
halt_msg = f"ESCALATED: phase {halt_phase} failed {_ESCALATION_THRESHOLD} consecutive runs"
_log_line(run_id, halt_msg)
await sqlite_service.create_sync_run(run_id, 0)
await sqlite_service.update_sync_run(
run_id, "halted_escalation", 0, 0, 0, 0, error_message=halt_msg
)
if _current_sync:
_current_sync["status"] = "halted_escalation"
_current_sync["finished_at"] = _now().isoformat()
_current_sync["error"] = halt_msg
_update_progress("halted_escalation", halt_msg)
return {"run_id": run_id, "status": "halted_escalation", "error": halt_msg}
try:
# Phase 0: Download orders from GoMag API
_update_progress("downloading", "Descărcare comenzi din GoMag API...")
@@ -274,7 +406,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
cancelled_batch.append({
"sync_run_id": run_id, "order_number": order.number,
"order_date": order.date, "customer_name": customer,
"status": "CANCELLED", "status_at_run": "CANCELLED",
"status": OrderStatus.CANCELLED.value, "status_at_run": OrderStatus.CANCELLED.value,
"id_comanda": None, "id_partener": None,
"error_message": "Comanda anulata in GoMag",
"missing_skus": None,
@@ -289,7 +421,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
})
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → ANULAT in GoMag")
await sqlite_service.save_orders_batch(cancelled_batch)
try:
await sqlite_service.save_orders_batch(cancelled_batch)
except DATA_ERRORS as e:
await _record_phase_err(run_id, "cancelled_batch", e)
# Check if any cancelled orders were previously imported
from ..database import get_sqlite as _get_sqlite
@@ -301,7 +436,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
SELECT order_number, id_comanda FROM orders
WHERE order_number IN ({placeholders})
AND id_comanda IS NOT NULL
AND status = 'CANCELLED'
AND status = '{OrderStatus.CANCELLED.value}'
""", cancelled_numbers)
previously_imported = [dict(r) for r in await cursor.fetchall()]
finally:
@@ -341,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)
@@ -449,135 +596,42 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if resolved_count:
_log_line(run_id, f"Auto-resolved {resolved_count} previously missing SKUs")
# Reconcile stale unresolved SKUs that got mappings outside the current JSON batch
rec = await validation_service.reconcile_unresolved_missing_skus(conn=conn)
if rec["resolved"]:
_log_line(run_id, f"Reconciliere: {rec['resolved']} SKU rezolvate suplimentar")
# Step 2d: Pre-validate prices for importable articles
if id_pol and (truly_importable or already_in_roa):
_update_progress("validation", "Validating prices...", 0, len(truly_importable))
_log_line(run_id, "Validare preturi...")
all_codmats = set()
for order in (truly_importable + already_in_roa):
for item in order.items:
if item.sku in validation["mapped"]:
pass
elif item.sku in validation["direct"]:
all_codmats.add(item.sku)
# Get standard VAT rate from settings for PROC_TVAV metadata
cota_tva = float(app_settings.get("discount_vat") or 21)
# Dual pricing policy support
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
codmat_policy_map = {}
if all_codmats:
if id_pol_productie:
# Dual-policy: classify articles by cont (sales vs production)
codmat_policy_map = await asyncio.to_thread(
validation_service.validate_and_ensure_prices_dual,
all_codmats, id_pol, id_pol_productie,
conn, validation.get("direct_id_map"),
cota_tva=cota_tva
)
_log_line(run_id,
f"Politici duale: {sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, "
f"{sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)} productie")
else:
# Single-policy (backward compatible)
price_result = await asyncio.to_thread(
validation_service.validate_prices, all_codmats, id_pol,
conn, validation.get("direct_id_map")
)
if price_result["missing_price"]:
logger.info(
f"Auto-adding price 0 for {len(price_result['missing_price'])} "
f"direct articles in policy {id_pol}"
)
await asyncio.to_thread(
validation_service.ensure_prices,
price_result["missing_price"], id_pol,
conn, validation.get("direct_id_map"),
cota_tva=cota_tva
)
pv_result = await asyncio.to_thread(
validation_service.pre_validate_order_prices,
truly_importable + already_in_roa,
app_settings, conn, id_pol, id_pol_productie,
id_gestiuni, validation,
lambda msg: _log_line(run_id, msg),
cota_tva,
)
# Also validate mapped SKU prices (cherry-pick 1)
mapped_skus_in_orders = set()
for order in (truly_importable + already_in_roa):
for item in order.items:
if item.sku in validation["mapped"]:
mapped_skus_in_orders.add(item.sku)
mapped_codmat_data = {}
if mapped_skus_in_orders:
mapped_codmat_data = await asyncio.to_thread(
validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn,
id_gestiuni=id_gestiuni
)
# Build id_map for mapped codmats and validate/ensure their prices
mapped_id_map = {}
for sku, entries in mapped_codmat_data.items():
for entry in entries:
mapped_id_map[entry["codmat"]] = {
"id_articol": entry["id_articol"],
"cont": entry.get("cont")
}
mapped_codmats = set(mapped_id_map.keys())
if mapped_codmats:
if id_pol_productie:
mapped_policy_map = await asyncio.to_thread(
validation_service.validate_and_ensure_prices_dual,
mapped_codmats, id_pol, id_pol_productie,
conn, mapped_id_map, cota_tva=cota_tva
)
codmat_policy_map.update(mapped_policy_map)
# Filter truly_importable for kits with missing component prices
kit_missing = pv_result.get("kit_missing") or {}
if kit_missing:
kit_skus_missing = set(kit_missing.keys())
new_truly = []
for order in truly_importable:
order_skus = {item.sku for item in order.items}
if order_skus & kit_skus_missing:
missing_list = list(order_skus & kit_skus_missing)
skipped.append((order, missing_list))
else:
mp_result = await asyncio.to_thread(
validation_service.validate_prices,
mapped_codmats, id_pol, conn, mapped_id_map
)
if mp_result["missing_price"]:
await asyncio.to_thread(
validation_service.ensure_prices,
mp_result["missing_price"], id_pol,
conn, mapped_id_map, cota_tva=cota_tva
)
# Add SKU → policy entries for mapped articles (1:1 and kits)
# codmat_policy_map has CODMAT keys, but build_articles_json
# looks up by GoMag SKU — bridge the gap here
if codmat_policy_map and mapped_codmat_data:
for sku, entries in mapped_codmat_data.items():
if len(entries) == 1:
# 1:1 mapping: SKU inherits the CODMAT's policy
codmat = entries[0]["codmat"]
if codmat in codmat_policy_map:
codmat_policy_map[sku] = codmat_policy_map[codmat]
# Pass codmat_policy_map to import via app_settings
if codmat_policy_map:
app_settings["_codmat_policy_map"] = codmat_policy_map
# ── Kit component price validation ──
kit_pricing_mode = app_settings.get("kit_pricing_mode")
if kit_pricing_mode and mapped_codmat_data:
id_pol_prod = int(app_settings.get("id_pol_productie") or 0) or None
kit_missing = await asyncio.to_thread(
validation_service.validate_kit_component_prices,
mapped_codmat_data, id_pol, id_pol_prod, conn
)
if kit_missing:
kit_skus_missing = set(kit_missing.keys())
for sku, missing_codmats in kit_missing.items():
_log_line(run_id, f"Kit {sku}: prețuri lipsă pentru {', '.join(missing_codmats)}")
new_truly = []
for order in truly_importable:
order_skus = {item.sku for item in order.items}
if order_skus & kit_skus_missing:
missing_list = list(order_skus & kit_skus_missing)
skipped.append((order, missing_list))
else:
new_truly.append(order)
truly_importable = new_truly
new_truly.append(order)
truly_importable = new_truly
# Mode B config validation
if kit_pricing_mode == "separate_line":
if app_settings.get("kit_pricing_mode") == "separate_line":
if not app_settings.get("kit_discount_codmat"):
_log_line(run_id, "EROARE: Kit mode 'separate_line' dar kit_discount_codmat nu e configurat!")
finally:
@@ -586,6 +640,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
# Step 3a: Record already-imported orders (batch)
already_imported_count = len(already_in_roa)
already_batch = []
_already_phase_failed = False
for order in already_in_roa:
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
id_comanda_roa = existing_map.get(order.number)
@@ -600,7 +655,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
already_batch.append({
"sync_run_id": run_id, "order_number": order.number,
"order_date": order.date, "customer_name": customer,
"status": "ALREADY_IMPORTED", "status_at_run": "ALREADY_IMPORTED",
"status": OrderStatus.ALREADY_IMPORTED.value, "status_at_run": OrderStatus.ALREADY_IMPORTED.value,
"id_comanda": id_comanda_roa, "id_partener": None,
"error_message": None, "missing_skus": None,
"items_count": len(order.items),
@@ -613,7 +668,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"items": order_items_data,
})
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})")
await sqlite_service.save_orders_batch(already_batch)
try:
await sqlite_service.save_orders_batch(already_batch)
except DATA_ERRORS as e:
await _record_phase_err(run_id, "already_batch", e)
_already_phase_failed = True
# Update GoMag addresses + recompute address_mismatch for already-imported orders
addr_updates = []
@@ -623,10 +682,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"adresa_livrare_gomag": json.dumps({"address": order.shipping.address, "city": order.shipping.city, "region": order.shipping.region}) if order.shipping else None,
"adresa_facturare_gomag": json.dumps({"address": order.billing.address, "city": order.billing.city, "region": order.billing.region}),
})
await sqlite_service.update_gomag_addresses_batch(addr_updates)
try:
await sqlite_service.update_gomag_addresses_batch(addr_updates)
except DATA_ERRORS as e:
await _record_phase_err(run_id, "addresses_batch", e)
# Detect partner mismatches for already-imported orders
if already_in_roa:
if already_in_roa and not _already_phase_failed:
stored_partner_data = await sqlite_service.get_orders_partner_data_batch(
[o.number for o in already_in_roa]
)
@@ -641,11 +703,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
def _strip_ro(cf):
if not cf:
return ""
return re.sub(r'^RO', '', cf.strip().upper())
# Strip optional "RO" prefix + any surrounding whitespace
return re.sub(r'^RO\s*', '', cf.strip().upper()).strip()
is_mismatch = False
if new_data["is_pj"] and not stored_cf:
is_mismatch = True # PF→PJ
if new_data["is_pj"] and new_cf and not stored_cf:
is_mismatch = True # PF→PJ (doar dacă are CUI — fără CUI nu putem confirma)
elif not new_data["is_pj"] and stored_cf:
is_mismatch = True # PJ→PF
elif new_data["is_pj"] and stored_cf and _strip_ro(new_cf) != _strip_ro(stored_cf):
@@ -657,17 +720,28 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
await sqlite_service.update_partner_mismatch_batch(mismatch_updates)
# Clear stale mismatches for orders outside the current sync window
# that have no CUI stored (flagged by old code before the no-CUI fix)
current_batch_numbers = {o.number for o in already_in_roa}
cleared = await sqlite_service.clear_stale_partner_mismatches_no_cui(current_batch_numbers)
if cleared:
logger.info(f"Partner mismatch: cleared {cleared} stale no-CUI flags from previous sync window")
# Auto-resync uninvoiced orders with partner mismatch (max 5/cycle)
MAX_PARTNER_RESYNC_PER_CYCLE = 5
total_mismatched = sum(1 for v in mismatch_map.values() if v == 1)
logger.info(f"Partner mismatch detection: {len(already_in_roa)} orders checked, {total_mismatched} mismatches found")
mismatched_uninvoiced = [
o for o in already_in_roa
if mismatch_map.get(o.number) == 1
and not stored_partner_data.get(o.number, {}).get("factura_numar")
][:MAX_PARTNER_RESYNC_PER_CYCLE]
logger.info(f"Partner auto-resync: {len(mismatched_uninvoiced)} uninvoiced orders queued")
if mismatched_uninvoiced:
resync_ok = 0
for _order in mismatched_uninvoiced:
logger.info(f"Partner resync attempt: #{_order.number}")
try:
await _resync_partner_for_order(
order=_order,
@@ -676,6 +750,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
run_id=run_id,
)
resync_ok += 1
logger.info(f"Partner resync success: #{_order.number}")
except Exception as _e:
_log_line(run_id, f"#{_order.number} EROARE resync partener: {_e}")
logger.error(f"Partner resync error for {_order.number}: {_e}")
@@ -699,7 +774,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
skipped_batch.append({
"sync_run_id": run_id, "order_number": order.number,
"order_date": order.date, "customer_name": customer,
"status": "SKIPPED", "status_at_run": "SKIPPED",
"status": OrderStatus.SKIPPED.value, "status_at_run": OrderStatus.SKIPPED.value,
"id_comanda": None, "id_partener": None,
"error_message": None, "missing_skus": missing_skus,
"items_count": len(order.items),
@@ -712,7 +787,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"items": order_items_data,
})
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await sqlite_service.save_orders_batch(skipped_batch)
try:
await sqlite_service.save_orders_batch(skipped_batch)
except DATA_ERRORS as e:
await _record_phase_err(run_id, "skipped_batch", e)
# ── Price sync from orders ──
if app_settings.get("price_sync_enabled") == "1":
@@ -731,6 +809,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
_log_line(run_id, f"Sync prețuri: {len(price_updates)} prețuri actualizate")
for pu in price_updates:
_log_line(run_id, f" {pu['codmat']}: {pu['old_price']:.2f}{pu['new_price']:.2f}")
except DATA_ERRORS as e:
await _record_phase_err(run_id, "price_sync", e)
except Exception as e:
_log_line(run_id, f"Eroare sync prețuri din comenzi: {e}")
logger.error(f"Price sync error: {e}")
@@ -805,6 +885,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
cod_fiscal_override = None
anaf_data_for_order = None
raw_cf = ""
bare_cui = ""
if order.billing.is_company and order.billing.company_code:
raw_cf = import_service.clean_web_text(order.billing.company_code) or ""
bare_cui, cui_warning = anaf_service.sanitize_cui(raw_cf)
@@ -824,15 +905,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if is_ro_company and anaf_data_for_order and anaf_data_for_order.get("scpTVA") is not None:
anaf_strict = 1 # ANAF data available → strict search
result = await asyncio.to_thread(
import_service.import_single_order,
order, id_pol=id_pol, id_sectie=id_sectie,
app_settings=app_settings, id_gestiuni=id_gestiuni,
cod_fiscal_override=cod_fiscal_override,
anaf_strict=anaf_strict
)
# Build order items data for storage (R9)
# Build order items data and discount split (needed by gate error path)
order_items_data = []
for item in order.items:
ms = "mapped" if item.sku in validation["mapped"] else "direct"
@@ -843,11 +916,42 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"mapping_status": ms, "codmat": None, "id_articol": None,
"cantitate_roa": None
})
# Compute discount split for SQLite storage
ds = import_service.compute_discount_split(order, app_settings)
discount_split_json = json.dumps(ds) if ds else None
# Gate CUI (RO PJ): block if CUI invalid or ANAF explicit notFound
block_reason = evaluate_cui_gate(
is_ro_company, order.billing.company_code, bare_cui, anaf_data_for_order
)
if block_reason:
error_count += 1
_log_line(run_id, f"#{order.number} BLOCAT: {block_reason}")
await _record_order_error(
run_id, order, customer, shipping_name, billing_name,
payment_method, delivery_method, discount_split_json,
order_items_data, block_reason,
)
if error_count > 10:
break
continue
# ANAF official name override: used at partner creation (not lookup).
# Strip before truthy check → reject whitespace-only values.
denumire_override = None
if is_ro_company and anaf_data_for_order:
anaf_name_clean = (anaf_data_for_order.get("denumire_anaf") or "").strip()
if anaf_name_clean:
denumire_override = anaf_name_clean.upper()
result = await asyncio.to_thread(
import_service.import_single_order,
order, id_pol=id_pol, id_sectie=id_sectie,
app_settings=app_settings, id_gestiuni=id_gestiuni,
cod_fiscal_override=cod_fiscal_override,
anaf_strict=anaf_strict,
denumire_override=denumire_override,
)
if result["success"]:
imported_count += 1
await sqlite_service.upsert_order(
@@ -855,7 +959,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
order_number=order.number,
order_date=order.date,
customer_name=customer,
status="IMPORTED",
status=OrderStatus.IMPORTED.value,
id_comanda=result["id_comanda"],
id_partener=result["id_partener"],
items_count=len(order.items),
@@ -869,7 +973,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
web_status=order.status or None,
discount_split=discount_split_json,
)
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
await sqlite_service.add_sync_run_order(run_id, order.number, OrderStatus.IMPORTED.value)
# Store ROA address IDs (R9)
await sqlite_service.update_import_order_addresses(
order.number,
@@ -917,28 +1021,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
if not result["success"]:
error_count += 1
await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
customer_name=customer,
status="ERROR",
id_partener=result.get("id_partener"),
error_message=result["error"],
items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
order_total=order.total or None,
delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None,
web_status=order.status or None,
discount_split=discount_split_json,
)
await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
await _record_order_error(
run_id, order, customer, shipping_name, billing_name,
payment_method, delivery_method, discount_split_json,
order_items_data, result["error"],
id_partener=result.get("id_partener"),
)
# Safety: stop if too many errors
if error_count > 10:
@@ -1003,6 +1092,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
_log_line(run_id, f"Facturi sterse: {invoices_cleared} facturi eliminate din cache")
if orders_deleted:
_log_line(run_id, f"Comenzi sterse din ROA: {orders_deleted}")
except DATA_ERRORS as e:
await _record_phase_err(run_id, "invoice_check", e)
except Exception as e:
logger.warning(f"Invoice/order status check failed: {e}")
@@ -1053,6 +1144,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
await sqlite_service.bulk_update_order_anaf_data(db_updates)
if db_updates:
_log_line(run_id, f"ANAF backfill: {len(db_updates)}/{len(orders_needing_anaf)} comenzi actualizate")
except DATA_ERRORS as e:
await _record_phase_err(run_id, "anaf_backfill", e)
except Exception as e:
logger.warning(f"ANAF backfill failed: {e}")
_log_line(run_id, f"ANAF backfill eroare: {e}")
@@ -1310,9 +1403,14 @@ async def _resync_partner_for_order(order, stored: dict, app_settings: dict, run
resync_result = await asyncio.to_thread(_do_resync)
if resync_result.get("same_partner"):
await sqlite_service.update_partner_mismatch_batch([
{"order_number": order_number, "partner_mismatch": 0}
])
# Update cod_fiscal_gomag so next detection doesn't re-flag this order
await sqlite_service.update_partner_resync_data(order_number, {
"id_partener": resync_result["new_partner_id"],
"cod_fiscal_gomag": cod_fiscal_override or new_partner_data["cod_fiscal"],
"cod_fiscal_roa": None,
"denumire_roa": stored.get("denumire_roa"),
"partner_mismatch": 0,
})
_log_line(run_id, f"#{order_number} RESYNC: partener neschimbat, mismatch cleared")
else:
new_partner_id = resync_result["new_partner_id"]

View File

@@ -1,8 +1,105 @@
import asyncio
import logging
from datetime import datetime
from .. import database
from . import sqlite_service
logger = logging.getLogger(__name__)
def validate_structural(order: dict) -> tuple[bool, str | None, str | None]:
"""Pre-flight structural validator used by save_orders_batch.
Returns (True, None, None) on pass, (False, error_type, error_msg) on fail.
Rules are intentionally minimal — only catches malformed payloads that
would crash downstream inserts. Semantic checks (SKU existence, price
comparison, etc.) are handled in later phases.
"""
if not isinstance(order, dict):
return False, "MISSING_FIELD", f"order is not a dict: {type(order).__name__}"
order_number = order.get("order_number")
if order_number is None or str(order_number).strip() == "":
return False, "MISSING_FIELD", "order_number is missing or empty"
raw_date = order.get("order_date")
if raw_date in (None, ""):
return False, "INVALID_DATE", "order_date is missing or empty"
if isinstance(raw_date, datetime):
pass
elif isinstance(raw_date, str):
parsed = None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
parsed = datetime.strptime(raw_date, fmt)
break
except ValueError:
continue
if parsed is None:
try:
parsed = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
except ValueError:
return False, "INVALID_DATE", f"order_date not parseable: {raw_date!r}"
else:
return False, "INVALID_DATE", f"order_date wrong type: {type(raw_date).__name__}"
items = order.get("items")
if not items or not isinstance(items, list):
return False, "EMPTY_ITEMS", "items missing or not a non-empty list"
for idx, item in enumerate(items):
if not isinstance(item, dict):
return False, "EMPTY_ITEMS", f"item[{idx}] is not a dict"
qty_raw = item.get("quantity")
if qty_raw is None or qty_raw == "":
return False, "INVALID_QUANTITY", f"item[{idx}] quantity missing"
try:
qty = float(qty_raw)
except (TypeError, ValueError):
return False, "INVALID_QUANTITY", f"item[{idx}] quantity not numeric: {qty_raw!r}"
if qty <= 0:
return False, "INVALID_QUANTITY", f"item[{idx}] quantity not > 0: {qty}"
price_raw = item.get("price")
if price_raw is None or price_raw == "":
return False, "INVALID_PRICE", f"item[{idx}] price missing"
try:
float(price_raw)
except (TypeError, ValueError):
return False, "INVALID_PRICE", f"item[{idx}] price not numeric: {price_raw!r}"
return True, None, None
async def reconcile_unresolved_missing_skus(conn=None) -> dict:
"""Revalidate all resolved=0 SKUs in missing_skus against Oracle.
Fail-soft: logs warning and returns zero if Oracle is unavailable.
Returns {"checked": N, "resolved": M, "error": str|None}.
"""
db = await sqlite_service.get_sqlite()
try:
cursor = await db.execute("SELECT sku FROM missing_skus WHERE resolved = 0")
rows = await cursor.fetchall()
finally:
await db.close()
if not rows:
return {"checked": 0, "resolved": 0, "error": None}
unresolved_set = {row[0] for row in rows}
try:
result = await asyncio.to_thread(validate_skus, unresolved_set, conn)
except Exception as e:
logger.warning(f"reconcile_unresolved_missing_skus: Oracle unavailable — {e}")
return {"checked": len(unresolved_set), "resolved": 0, "error": str(e)}
resolved_set = result["mapped"] | result["direct"]
resolved_count = await sqlite_service.resolve_missing_skus_batch(resolved_set)
logger.info(f"reconcile_unresolved_missing_skus: checked={len(unresolved_set)}, resolved={resolved_count}")
return {"checked": len(unresolved_set), "resolved": resolved_count, "error": None}
def check_orders_in_roa(min_date, conn) -> dict:
"""Check which orders already exist in Oracle COMENZI by date range.
Returns: {comanda_externa: id_comanda} for all existing orders.
@@ -432,6 +529,147 @@ def resolve_mapped_codmats(mapped_skus: set[str], conn,
return result
def pre_validate_order_prices(orders, app_settings: dict, conn, id_pol: int,
id_pol_productie: int = None,
id_gestiuni: list[int] = None,
validation: dict = None,
log_callback=None,
cota_tva: float = 21) -> dict:
"""Pre-validate prices for orders before importing them via PACK_IMPORT_COMENZI.
Auto-inserts PRET=0 rows in CRM_POLITICI_PRET_ART for missing CODMATs so
PL/SQL adauga_articol_comanda doesn't raise COM-001. Mutates
app_settings["_codmat_policy_map"] for build_articles_json routing.
Used by both bulk sync (sync_service.run_sync) and retry (retry_service).
Args:
orders: list of orders to scan for SKUs/CODMATs
app_settings: mutated with _codmat_policy_map (SKU/CODMAT → id_pol)
conn: Oracle connection (caller manages lifecycle)
id_pol: default sales price policy
id_pol_productie: production policy for cont 341/345 (None = single-policy)
id_gestiuni: gestiune filter for resolve_mapped_codmats
validation: output of validate_skus; computed internally if None
log_callback: optional Callable[[str], None] for progress messages
cota_tva: VAT rate for PROC_TVAV metadata (default 21)
Returns: {"codmat_policy_map": dict, "kit_missing": dict, "validation": dict}
- codmat_policy_map: {codmat_or_sku: id_pol}
- kit_missing: {sku: [missing_codmats]} for kits with unprice components
- validation: validate_skus result (for caller convenience)
"""
log = log_callback or (lambda _msg: None)
if not orders:
return {"codmat_policy_map": {}, "kit_missing": {}, "validation": validation or {}}
if validation is None:
all_skus = {item.sku for o in orders for item in o.items if item.sku}
validation = validate_skus(all_skus, conn, id_gestiuni)
log("Validare preturi...")
# Direct CODMATs (SKU exists in NOM_ARTICOLE without ARTICOLE_TERTI mapping)
all_codmats = set()
for order in orders:
for item in order.items:
if item.sku in validation["mapped"]:
continue
if item.sku in validation["direct"]:
all_codmats.add(item.sku)
codmat_policy_map = {}
if all_codmats:
if id_pol_productie:
codmat_policy_map = validate_and_ensure_prices_dual(
all_codmats, id_pol, id_pol_productie,
conn, validation.get("direct_id_map"), cota_tva=cota_tva,
)
log(f"Politici duale: "
f"{sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, "
f"{sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)} productie")
else:
price_result = validate_prices(
all_codmats, id_pol, conn, validation.get("direct_id_map"),
)
if price_result["missing_price"]:
logger.info(
f"Auto-adding price 0 for {len(price_result['missing_price'])} "
f"direct articles in policy {id_pol}"
)
ensure_prices(
price_result["missing_price"], id_pol,
conn, validation.get("direct_id_map"), cota_tva=cota_tva,
)
# Mapped SKUs (via ARTICOLE_TERTI)
mapped_skus_in_orders = {
item.sku for o in orders for item in o.items
if item.sku in validation["mapped"]
}
mapped_codmat_data = {}
if mapped_skus_in_orders:
mapped_codmat_data = resolve_mapped_codmats(
mapped_skus_in_orders, conn, id_gestiuni=id_gestiuni,
)
mapped_id_map = {}
for sku, entries in mapped_codmat_data.items():
for entry in entries:
mapped_id_map[entry["codmat"]] = {
"id_articol": entry["id_articol"],
"cont": entry.get("cont"),
}
mapped_codmats = set(mapped_id_map.keys())
if mapped_codmats:
if id_pol_productie:
mapped_policy_map = validate_and_ensure_prices_dual(
mapped_codmats, id_pol, id_pol_productie,
conn, mapped_id_map, cota_tva=cota_tva,
)
codmat_policy_map.update(mapped_policy_map)
else:
mp_result = validate_prices(
mapped_codmats, id_pol, conn, mapped_id_map,
)
if mp_result["missing_price"]:
ensure_prices(
mp_result["missing_price"], id_pol,
conn, mapped_id_map, cota_tva=cota_tva,
)
# Bridge SKU → policy via 1:1 mappings (build_articles_json reads by SKU)
if codmat_policy_map and mapped_codmat_data:
for sku, entries in mapped_codmat_data.items():
if len(entries) == 1:
codmat = entries[0]["codmat"]
if codmat in codmat_policy_map:
codmat_policy_map[sku] = codmat_policy_map[codmat]
if codmat_policy_map:
app_settings["_codmat_policy_map"] = codmat_policy_map
# Kit component price gating
kit_missing = {}
kit_pricing_mode = app_settings.get("kit_pricing_mode")
if kit_pricing_mode and mapped_codmat_data:
kit_missing = validate_kit_component_prices(
mapped_codmat_data, id_pol, id_pol_productie, conn,
)
if kit_missing:
for sku, missing_codmats in kit_missing.items():
log(f"Kit {sku}: prețuri lipsă pentru {', '.join(missing_codmats)}")
return {
"codmat_policy_map": codmat_policy_map,
"kit_missing": kit_missing,
"validation": validation,
"mapped_codmat_data": mapped_codmat_data,
}
def validate_kit_component_prices(mapped_codmat_data: dict, id_pol: int,
id_pol_productie: int = None, conn=None) -> dict:
"""Pre-validate that kit components have non-zero prices in crm_politici_pret_art.
@@ -588,193 +826,3 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
return updated
def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict:
"""Compare GoMag prices with ROA prices for order items.
Args:
items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details'
(codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}])
app_settings: dict with 'id_pol', 'id_pol_productie'
conn: Oracle connection (optional, will acquire if None)
Returns: {
"items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}},
"summary": {"mismatches": int, "checked": int, "oracle_available": bool}
}
"""
try:
id_pol = int(app_settings.get("id_pol", 0) or 0)
id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0)
except (ValueError, TypeError):
id_pol = 0
id_pol_productie = 0
def _empty_result(oracle_available: bool) -> dict:
return {
"items": {
idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)}
for idx, item in enumerate(items)
},
"summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available}
}
if not items or not id_pol:
return _empty_result(oracle_available=False)
own_conn = conn is None
try:
if own_conn:
conn = database.get_oracle_connection()
# Step 1: Collect codmats; use id_articol/cont from codmat_details when already known
pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}}
all_codmats = set()
for item in items:
for cd in (item.get("codmat_details") or []):
codmat = cd.get("codmat")
if not codmat:
continue
all_codmats.add(codmat)
if cd.get("id_articol") and codmat not in pre_resolved:
pre_resolved[codmat] = {
"id_articol": cd["id_articol"],
"cont": cd.get("cont") or "",
}
# Step 2: Resolve missing id_articols via nom_articole
need_resolve = all_codmats - set(pre_resolved.keys())
if need_resolve:
db_resolved = resolve_codmat_ids(need_resolve, conn=conn)
pre_resolved.update(db_resolved)
codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}}
# Step 3: Get PRETURI_CU_TVA flag once per policy
policies = {id_pol}
if id_pol_productie and id_pol_productie != id_pol:
policies.add(id_pol_productie)
pol_cu_tva = {} # {id_pol: bool}
with conn.cursor() as cur:
for pol in policies:
cur.execute(
"SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol",
{"pol": pol},
)
row = cur.fetchone()
pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False
# Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies
all_id_articols = list({
info["id_articol"]
for info in codmat_info.values()
if info.get("id_articol")
})
price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)}
if all_id_articols:
pol_list = list(policies)
pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))])
with conn.cursor() as cur:
for i in range(0, len(all_id_articols), 500):
batch = all_id_articols[i:i + 500]
art_placeholders = ",".join([f":a{j}" for j in range(len(batch))])
params = {f"a{j}": aid for j, aid in enumerate(batch)}
for k, pol in enumerate(pol_list):
params[f"p{k}"] = pol
cur.execute(f"""
SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV
FROM CRM_POLITICI_PRET_ART
WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders})
""", params)
for row in cur:
price_map[(row[0], row[1])] = (row[2], row[3])
# Step 5: Compute pret_roa per item and compare with GoMag price
result_items = {}
mismatches = 0
checked = 0
for idx, item in enumerate(items):
pret_gomag = float(item.get("price") or 0)
result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None}
codmat_details = item.get("codmat_details") or []
if not codmat_details:
continue
is_kit = len(codmat_details) > 1 or (
len(codmat_details) == 1
and float(codmat_details[0].get("cantitate_roa") or 1) != 1
)
if is_kit:
# Kit/pachet: prețul GoMag e comercial, ROA e suma componente din lista
# de prețuri — diferența e gestionată de discount line
result_items[idx]["kit"] = True
continue
pret_roa_total = 0.0
all_resolved = True
for cd in codmat_details:
codmat = cd.get("codmat")
if not codmat:
all_resolved = False
break
info = codmat_info.get(codmat, {})
id_articol = info.get("id_articol")
if not id_articol:
all_resolved = False
break
# Dual-policy routing: cont 341/345 → production, else → sales
cont = str(info.get("cont") or cd.get("cont") or "").strip()
if cont in ("341", "345") and id_pol_productie:
pol = id_pol_productie
else:
pol = id_pol
price_entry = price_map.get((pol, id_articol))
if price_entry is None:
all_resolved = False
break
pret, proc_tvav = price_entry
proc_tvav = float(proc_tvav or 1.19)
if pol_cu_tva.get(pol):
pret_cu_tva = float(pret or 0)
else:
pret_cu_tva = float(pret or 0) * proc_tvav
cantitate_roa = float(cd.get("cantitate_roa") or 1)
if is_kit:
pret_roa_total += pret_cu_tva * cantitate_roa
else:
pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items
if not all_resolved:
continue
pret_roa = round(pret_roa_total, 4)
match = pret_gomag <= pret_roa + 0.01
result_items[idx]["pret_roa"] = pret_roa
result_items[idx]["match"] = match
checked += 1
if not match:
mismatches += 1
logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches")
return {
"items": result_items,
"summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True},
}
except Exception as e:
logger.error(f"get_prices_for_order failed: {e}")
return _empty_result(oracle_available=False)
finally:
if own_conn and conn:
database.pool.release(conn)

View File

@@ -367,6 +367,55 @@ input[type="checkbox"] {
border-color: var(--info-hover);
}
.btn-outline-warning {
color: var(--warning);
border-color: var(--warning);
}
.btn-outline-warning:hover {
background: var(--warning);
border-color: var(--warning);
color: #fff;
}
.btn-outline-danger {
color: var(--error);
border-color: var(--error);
}
.btn-outline-danger:hover {
background: var(--error);
border-color: var(--error);
color: #fff;
}
/* Compact button for dashboard row actions */
.btn-xs {
font-size: 0.75rem;
line-height: 1;
padding: 4px 8px;
}
/* Dashboard kebab dropdown */
.kebab-dropdown .btn { color: var(--text-muted); }
.kebab-dropdown .btn:hover { color: var(--text-secondary); }
.kebab-dropdown .dropdown-menu {
box-shadow: var(--card-shadow);
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
font-family: var(--font-body);
font-size: 13px;
min-width: 160px;
}
.kebab-dropdown .dropdown-item { font-size: 13px; padding: 6px 12px; }
.kebab-dropdown .dropdown-item:hover { background: var(--surface-raised); }
[data-theme="dark"] .kebab-dropdown .dropdown-menu {
background: var(--surface);
border-color: var(--border);
color: var(--text-primary);
}
[data-theme="dark"] .kebab-dropdown .dropdown-item:hover {
background: var(--surface-raised);
}
/* ── Forms ───────────────────────────────────────── */
.form-control, .form-select {
font-size: 0.9375rem;
@@ -446,6 +495,7 @@ input[type="checkbox"] {
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px 2px rgba(202,138,4,0.3); }
.dot-red { background: var(--error); box-shadow: 0 0 8px 2px rgba(220,38,38,0.35); }
.dot-gray { background: var(--cancelled); }
.dot-orange { background: var(--compare); box-shadow: 0 0 8px 2px rgba(234,88,12,0.35); }
.dot-blue { background: var(--info); }
/* ── Flat row (mobile + desktop) ────────────────── */
@@ -651,6 +701,9 @@ tr.mapping-deleted td {
.autocomplete-item:hover, .autocomplete-item.active {
background-color: var(--surface-raised);
}
.autocomplete-dropdown.keyboard-active .autocomplete-item:hover:not(.active) {
background: inherit;
}
.autocomplete-item .codmat {
font-weight: 600;
color: var(--text-primary);
@@ -753,6 +806,45 @@ tr.mapping-deleted td {
.sync-status-dot.running { background: var(--info); animation: pulse-dot 2s ease-in-out infinite; }
.sync-status-dot.completed { background: var(--success); }
.sync-status-dot.failed { background: var(--error); }
.sync-status-dot.malformed { background: var(--compare); box-shadow: 0 0 8px 2px rgba(234,88,12,0.35); }
/* ── Sync health pill (dashboard only) ────────────── */
.health-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
min-height: 32px;
border-radius: 999px;
font-size: 0.8125rem;
line-height: 1;
font-weight: 500;
border: 1px solid transparent;
cursor: default;
user-select: none;
transition: background 120ms ease;
}
.health-pill i { font-size: 0.9375rem; line-height: 1; }
.health-pill.healthy {
background: var(--success-light);
color: var(--success-text);
border-color: var(--success);
}
.health-pill.warning {
background: var(--warning-light);
color: var(--warning-text);
border-color: var(--warning);
}
.health-pill.escalated {
background: var(--error-light);
color: var(--error-text);
border-color: var(--error);
box-shadow: 0 0 8px 2px rgba(220,38,38,0.35);
}
/* Ensure adequate touch target on mobile */
@media (max-width: 600px) {
.health-pill { min-height: 44px; padding: 0.5rem 0.75rem; }
}
/* ── Custom period range inputs ──────────────────── */
.period-custom-range {
@@ -1189,7 +1281,6 @@ tr.mapping-deleted td {
.diff-badge-anaf { background:var(--error-light); color:var(--error-text); }
.diff-badge-denumire { background:var(--compare-light); color:var(--compare-text); }
.diff-badge-addr { background:var(--info-light); color:var(--info-text); }
.diff-badge-price { background:var(--success-light); color:var(--success-text); }
/* ── Compact order detail layout ──────────────── */
.detail-col-label {
@@ -1218,9 +1309,15 @@ tr.mapping-deleted td {
}
/* Address compact lines */
.addr-row {
margin-bottom: 4px;
}
.addr-row .addr-line-label {
width: 72px;
}
.addr-line {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
padding: 2px 0;
font-size: 13px;
@@ -1236,9 +1333,9 @@ tr.mapping-deleted td {
.addr-line-text {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
text-transform: uppercase;
color: var(--text-primary);
}
.addr-line .bi-check-lg {

View File

@@ -162,9 +162,76 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// ── Sync Health pill ─────────────────────────────
let _lastHealth = null;
async function pollSyncHealth() {
try {
const data = await fetchJSON('/api/sync/health');
_lastHealth = data;
renderHealthPill(data);
} catch (e) { /* fail-soft: keep last state */ }
}
function renderHealthPill(h) {
const pill = document.getElementById('syncHealthPill');
if (!pill) return;
const icon = pill.querySelector('i');
const label = pill.querySelector('.health-pill-label');
let state = 'healthy', iconCls = 'bi-check-circle-fill', text = 'Sanatos', tooltip;
const recent = h.recent_phase_failures || {};
const recentCount = Object.values(recent).reduce((a, b) => a + (b || 0), 0);
if (h.escalation_phase || h.last_sync_status === 'halted_escalation') {
state = 'escalated';
iconCls = 'bi-x-octagon-fill';
text = 'Blocat';
tooltip = `Blocat — faza "${h.escalation_phase || '?'}" a esuat 3 sync-uri consecutive.\n`
+ `Ultima eroare: ${h.last_halt_reason || '—'}\n`
+ `Click Start Sync pentru override manual.`;
} else if (h.last_sync_status === 'failed' || recentCount > 0) {
state = 'warning';
iconCls = 'bi-exclamation-triangle-fill';
text = 'Atentie';
const topPhases = Object.entries(recent).slice(0, 3)
.map(([p, c]) => `${p} (${c} of last 3)`).join(', ');
tooltip = `Atentie — ${topPhases || 'sync anterior esuat'}`
+ (h.last_halt_reason ? `\nLast error: ${h.last_halt_reason}` : '');
} else {
const lastAt = h.last_sync_at ? h.last_sync_at.replace('T', ' ').slice(5, 16) : 'nicio rulare';
tooltip = `Sanatos — ultimul sync: ${lastAt}`;
}
pill.className = 'health-pill ' + state;
pill.setAttribute('aria-label', `Sync: ${text}`);
pill.title = tooltip;
if (icon) icon.className = 'bi ' + iconCls;
if (label) label.textContent = text;
}
function startHealthPolling() {
pollSyncHealth();
setInterval(pollSyncHealth, 10000);
}
document.addEventListener('DOMContentLoaded', startHealthPolling);
// ── Sync Controls ─────────────────────────────────
async function startSync() {
// Escalation override — confirm before overriding the auto-halt
if (_lastHealth && (_lastHealth.escalation_phase
|| _lastHealth.last_sync_status === 'halted_escalation')) {
const phase = _lastHealth.escalation_phase || '?';
const reason = _lastHealth.last_halt_reason || '(unknown)';
const msg = `⚠ Sync blocat automat\n\n`
+ `Faza "${phase}" a esuat in ultimele 3 sync-uri consecutive.\n`
+ `Ultima eroare: ${reason}\n\n`
+ `Repornesti oricum?`;
if (!confirm(msg)) return;
}
try {
const res = await fetch('/api/sync/start', { method: 'POST' });
const data = await res.json();
@@ -174,6 +241,7 @@ async function startSync() {
}
// Polling will detect the running state — just speed it up immediately
pollSyncStatus();
pollSyncHealth();
} catch (err) {
alert('Eroare: ' + err.message);
}
@@ -329,6 +397,7 @@ async function loadDashOrders() {
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
if (el('cntMal')) el('cntMal').textContent = c.malformed || 0;
if (el('cntDiff')) el('cntDiff').textContent = c.diffs || 0;
// Attention card
@@ -360,7 +429,7 @@ async function loadDashOrders() {
const orders = data.orders || [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map(o => {
const dateStr = fmtDate(o.order_date);
@@ -376,6 +445,7 @@ async function loadDashOrders() {
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td>
<td class="kebab-dropdown" onclick="event.stopPropagation()">${(o.status === ORDER_STATUS.IMPORTED || o.status === ORDER_STATUS.ALREADY_IMPORTED) && !(o.invoice && o.invoice.facturat) ? '<div class="dropdown"><button class="btn btn-sm border-0" aria-label="Actiuni comanda" data-bs-toggle="dropdown"><i class="bi bi-three-dots-vertical"></i></button><ul class="dropdown-menu dropdown-menu-end"><li><button class="dropdown-item" onclick="dashResyncOrder(\'' + esc(o.order_number) + '\', this)"><i class="bi bi-arrow-repeat me-2"></i>Resync</button></li><li><button class="dropdown-item text-danger" onclick="dashDeleteOrder(\'' + esc(o.order_number) + '\', this)"><i class="bi bi-trash me-2"></i>Sterge din ROA</button></li></ul></div>' : ''}</td>
</tr>`;
}).join('');
}
@@ -408,12 +478,13 @@ async function loadDashOrders() {
// Mobile segmented control
renderMobileSegmented('dashMobileSeg', [
{ label: 'Toate', count: c.total || 0, value: 'all', active: (activeStatus || 'all') === 'all', colorClass: 'fc-neutral' },
{ label: 'Imp.', count: c.imported_all || c.imported || 0, value: 'IMPORTED', active: activeStatus === 'IMPORTED', colorClass: 'fc-green' },
{ label: 'Omise', count: c.skipped || 0, value: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' },
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
{ label: 'Imp.', count: c.imported_all || c.imported || 0, value: ORDER_STATUS.IMPORTED, active: activeStatus === ORDER_STATUS.IMPORTED, colorClass: 'fc-green' },
{ label: 'Omise', count: c.skipped || 0, value: ORDER_STATUS.SKIPPED, active: activeStatus === ORDER_STATUS.SKIPPED, colorClass: 'fc-yellow' },
{ label: 'Erori', count: c.error || c.errors || 0, value: ORDER_STATUS.ERROR, active: activeStatus === ORDER_STATUS.ERROR, colorClass: 'fc-red' },
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
{ label: 'Anulate', count: c.cancelled || 0, value: 'CANCELLED', active: activeStatus === 'CANCELLED', colorClass: 'fc-dark' },
{ label: 'Anulate', count: c.cancelled || 0, value: ORDER_STATUS.CANCELLED, active: activeStatus === ORDER_STATUS.CANCELLED, colorClass: 'fc-dark' },
{ label: 'Def.', count: c.malformed || 0, value: ORDER_STATUS.MALFORMED, active: activeStatus === ORDER_STATUS.MALFORMED, colorClass: 'fc-orange' },
{ label: 'Dif.', count: c.diffs || 0, value: 'DIFFS', active: activeStatus === 'DIFFS', colorClass: 'fc-orange' }
], (val) => {
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
@@ -442,7 +513,7 @@ async function loadDashOrders() {
});
} catch (err) {
document.getElementById('dashOrdersBody').innerHTML =
`<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
`<tr><td colspan="10" class="text-center text-danger">${esc(err.message)}</td></tr>`;
}
}
@@ -463,9 +534,14 @@ function renderClientCell(order) {
const display = (order.customer_name || order.shipping_name || '').trim();
const billing = (order.billing_name || '').trim();
const shipping = (order.shipping_name || '').trim();
const isDiff = display !== shipping && shipping;
if (isDiff) {
return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)}&nbsp;<sup class="client-diff-indicator">&#9650;</sup></td>`;
// PJ: invoice party (company = display) differs from shipping person
// PF ramburs: invoice party = shipping, but billing person differs from shipping
const isPJDiff = display && shipping && display !== shipping;
const isPFDiff = !isPJDiff && billing && shipping && billing !== shipping;
if (isPJDiff || isPFDiff) {
const facturat = isPJDiff ? display : billing;
const tip = `Facturat: ${escHtml(facturat)} · Livrare: ${escHtml(shipping)}`;
return `<td class="tooltip-cont fw-bold" data-tooltip="${tip}">${escHtml(display)}&nbsp;<sup class="client-diff-indicator" aria-label="${tip}" title="${tip}">&#9650;</sup></td>`;
}
return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
}
@@ -490,10 +566,10 @@ function escHtml(s) {
function statusLabelText(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat';
case 'ALREADY_IMPORTED': return 'Deja imp.';
case 'SKIPPED': return 'Omis';
case 'ERROR': return 'Eroare';
case ORDER_STATUS.IMPORTED: return 'Importat';
case ORDER_STATUS.ALREADY_IMPORTED: return 'Deja imp.';
case ORDER_STATUS.SKIPPED: return 'Omis';
case ORDER_STATUS.ERROR: return 'Eroare';
default: return esc(status);
}
}
@@ -513,13 +589,11 @@ function diffDots(o, mobile) {
d += `<span style="${s};background:var(--info)" title="Adresa diferita"></span>`;
if (o.partner_mismatch===1)
d += `<span style="${s};background:var(--warning)" title="Partener schimbat"></span>`;
if (o.price_match===false)
d += `<span style="${s};background:var(--error)" title="Pret GoMag > ROA"></span>`;
return d;
}
function invoiceDot(order) {
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '';
if (order.status !== ORDER_STATUS.IMPORTED && order.status !== ORDER_STATUS.ALREADY_IMPORTED) return '';
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" style="box-shadow:none" title="Facturat"></span>';
return '<span class="dot dot-red" style="box-shadow:none" title="Nefacturat"></span>';
}
@@ -573,6 +647,7 @@ function openDashOrderDetail(orderNumber) {
_sharedModalQuickMapFn = openDashQuickMap;
renderOrderDetailModal(orderNumber, {
onQuickMap: openDashQuickMap,
onStatusChange: loadDashOrders,
onAfterRender: function() { /* nothing extra needed */ }
});
}
@@ -597,3 +672,53 @@ function openDashQuickMap(sku, productName, orderNumber, itemIdx) {
});
}
// ── Dashboard row action handlers ────────────────
async function dashResyncOrder(orderNumber, btn) {
// Close dropdown immediately
const dd = btn.closest('.dropdown-menu');
if (dd) bootstrap.Dropdown.getInstance(dd.previousElementSibling)?.hide();
// Find the table row for visual feedback
const row = document.querySelector(`tr[data-order="${orderNumber}"]`) ||
btn.closest('tr');
try {
if (row) row.style.opacity = '0.5';
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/resync`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadDashOrders();
} else {
if (row) row.style.opacity = '';
alert(data.message || 'Eroare la resync');
}
} catch (err) {
if (row) row.style.opacity = '';
alert('Eroare conexiune la resync');
}
}
async function dashDeleteOrder(orderNumber, btn) {
// Close dropdown immediately
const dd = btn.closest('.dropdown-menu');
if (dd) bootstrap.Dropdown.getInstance(dd.previousElementSibling)?.hide();
// Confirm before delete
if (!confirm(`Stergi comanda ${orderNumber} din ROA?`)) return;
// Find the table row for visual feedback
const row = document.querySelector(`tr[data-order="${orderNumber}"]`) ||
btn.closest('tr');
try {
if (row) row.style.opacity = '0.5';
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/delete`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadDashOrders();
} else {
if (row) row.style.opacity = '';
alert(data.message || 'Eroare la stergere');
}
} catch (err) {
if (row) row.style.opacity = '';
alert('Eroare conexiune la stergere');
}
}

View File

@@ -28,10 +28,10 @@ function runStatusBadge(status) {
function logStatusText(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat';
case 'ALREADY_IMPORTED': return 'Deja imp.';
case 'SKIPPED': return 'Omis';
case 'ERROR': return 'Eroare';
case ORDER_STATUS.IMPORTED: return 'Importat';
case ORDER_STATUS.ALREADY_IMPORTED: return 'Deja imp.';
case ORDER_STATUS.SKIPPED: return 'Omis';
case ORDER_STATUS.ERROR: return 'Eroare';
default: return esc(status);
}
}
@@ -137,6 +137,8 @@ async function loadRunOrders(runId, statusFilter, page) {
document.getElementById('countError').textContent = counts.error || 0;
const alreadyEl = document.getElementById('countAlreadyImported');
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
const malEl = document.getElementById('countMalformed');
if (malEl) malEl.textContent = counts.malformed || 0;
const tbody = document.getElementById('runOrdersBody');
const orders = data.orders || [];
@@ -144,9 +146,9 @@ async function loadRunOrders(runId, statusFilter, page) {
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const problemOrders = orders.filter(o => [ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED].includes(o.status));
const okOrders = orders.filter(o => [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(o.status));
const otherOrders = orders.filter(o => ![ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED, ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(o.status));
function orderRow(o, i) {
const dateStr = fmtDate(o.order_date);
@@ -195,9 +197,9 @@ async function loadRunOrders(runId, statusFilter, page) {
if (orders.length === 0) {
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
} else {
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const problemOrders = orders.filter(o => [ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED].includes(o.status));
const okOrders = orders.filter(o => [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(o.status));
const otherOrders = orders.filter(o => ![ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED, ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(o.status));
function mobileRow(o) {
const d = o.order_date || '';
@@ -235,10 +237,11 @@ async function loadRunOrders(runId, statusFilter, page) {
// Mobile segmented control
renderMobileSegmented('logsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: currentFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Imp.', count: counts.imported || 0, value: 'IMPORTED', active: currentFilter === 'IMPORTED', colorClass: 'fc-green' },
{ label: 'Deja', count: counts.already_imported || 0, value: 'ALREADY_IMPORTED', active: currentFilter === 'ALREADY_IMPORTED', colorClass: 'fc-blue' },
{ label: 'Omise', count: counts.skipped || 0, value: 'SKIPPED', active: currentFilter === 'SKIPPED', colorClass: 'fc-yellow' },
{ label: 'Erori', count: counts.error || 0, value: 'ERROR', active: currentFilter === 'ERROR', colorClass: 'fc-red' }
{ label: 'Imp.', count: counts.imported || 0, value: ORDER_STATUS.IMPORTED, active: currentFilter === ORDER_STATUS.IMPORTED, colorClass: 'fc-green' },
{ label: 'Deja', count: counts.already_imported || 0, value: ORDER_STATUS.ALREADY_IMPORTED, active: currentFilter === ORDER_STATUS.ALREADY_IMPORTED, colorClass: 'fc-blue' },
{ label: 'Omise', count: counts.skipped || 0, value: ORDER_STATUS.SKIPPED, active: currentFilter === ORDER_STATUS.SKIPPED, colorClass: 'fc-yellow' },
{ label: 'Erori', count: counts.error || 0, value: ORDER_STATUS.ERROR, active: currentFilter === ORDER_STATUS.ERROR, colorClass: 'fc-red' },
{ label: 'Defecte', count: counts.malformed || 0, value: ORDER_STATUS.MALFORMED, active: currentFilter === ORDER_STATUS.MALFORMED, colorClass: 'fc-orange' }
], (val) => filterOrders(val));
// Orders pagination

View File

@@ -279,8 +279,6 @@ function goPage(p) {
// ── Multi-CODMAT Add Modal (R11) ─────────────────
let acTimeouts = {};
function initAddModal() {
const modal = document.getElementById('addModal');
if (!modal) return;
@@ -373,7 +371,7 @@ function addCodmatLine() {
div.innerHTML = `
<div class="qm-row">
<div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="CODMAT..." autocomplete="off" data-idx="${idx}">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="CODMAT..." autocomplete="nope" data-idx="${idx}">
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
</div>
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
@@ -388,14 +386,7 @@ function addCodmatLine() {
const dropdown = div.querySelector('.cl-ac-dropdown');
const selected = div.querySelector('.cl-selected');
input.addEventListener('input', () => {
const key = 'cl_' + idx;
clearTimeout(acTimeouts[key]);
acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
setupAutocomplete(input, dropdown, selected, clAutocomplete);
}
async function clAutocomplete(input, dropdown, selectedEl) {
@@ -407,22 +398,16 @@ async function clAutocomplete(input, dropdown, selectedEl) {
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="clSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
dropdown.innerHTML = data.results.map((r, i) => {
const label = r.denumire + (r.um ? ` (${r.um})` : '');
return `<div class="autocomplete-item" id="ac-cl-${i}" data-codmat="${esc(r.codmat)}" data-label="${esc(label)}">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
</div>`;
}).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function clSelectArticle(el, codmat, label) {
const line = el.closest('.codmat-line');
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-selected').textContent = label;
line.querySelector('.cl-ac-dropdown').classList.add('d-none');
}
async function saveMapping() {
const sku = document.getElementById('inputSku').value.trim();
if (!sku) { alert('SKU este obligatoriu'); return; }
@@ -528,7 +513,7 @@ function showInlineAddRow() {
row.innerHTML = `
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
<div class="position-relative" style="flex:1;min-width:0">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="nope">
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
<small class="text-muted" id="inlineSelected"></small>
</div>
@@ -543,15 +528,8 @@ function showInlineAddRow() {
const input = document.getElementById('inlineCodmat');
const dropdown = document.getElementById('inlineAcDropdown');
const selected = document.getElementById('inlineSelected');
let inlineAcTimeout = null;
input.addEventListener('input', () => {
clearTimeout(inlineAcTimeout);
inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
setupAutocomplete(input, dropdown, selected, inlineAutocomplete);
}
async function inlineAutocomplete(input, dropdown, selectedEl) {
@@ -561,21 +539,16 @@ async function inlineAutocomplete(input, dropdown, selectedEl) {
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="inlineSelectArticle('${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
dropdown.innerHTML = data.results.map((r, i) => {
const label = r.denumire + (r.um ? ` (${r.um})` : '');
return `<div class="autocomplete-item" id="ac-il-${i}" data-codmat="${esc(r.codmat)}" data-label="${esc(label)}">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
</div>`;
}).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function inlineSelectArticle(codmat, label) {
document.getElementById('inlineCodmat').value = codmat;
document.getElementById('inlineSelected').textContent = label;
document.getElementById('inlineAcDropdown').classList.add('d-none');
}
async function saveInlineMapping() {
const sku = document.getElementById('inlineSku').value.trim();
const codmat = document.getElementById('inlineCodmat').value.trim();

View File

@@ -10,8 +10,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Kit pricing mode radio toggle
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
r.addEventListener('change', () => {
const mode = document.querySelector('input[name="kitPricingMode"]:checked')?.value || '';
document.getElementById('kitModeBFields').style.display =
document.getElementById('kitModeSeparate').checked ? '' : 'none';
(mode === 'separate_line' || mode === 'distributed') ? '' : 'none';
});
});
@@ -138,27 +139,12 @@ async function loadSettings() {
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
r.checked = r.value === kitMode;
});
document.getElementById('kitModeBFields').style.display = kitMode === 'separate_line' ? '' : 'none';
document.getElementById('kitModeBFields').style.display = (kitMode === 'separate_line' || kitMode === 'distributed') ? '' : 'none';
if (el('settKitDiscountCodmat')) el('settKitDiscountCodmat').value = data.kit_discount_codmat || '';
if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || '';
// Price sync
if (el('settPriceSyncEnabled')) el('settPriceSyncEnabled').checked = data.price_sync_enabled !== "0";
if (el('settCatalogSyncEnabled')) {
el('settCatalogSyncEnabled').checked = data.catalog_sync_enabled === "1";
document.getElementById('catalogSyncOptions').style.display = data.catalog_sync_enabled === "1" ? '' : 'none';
}
if (el('settPriceSyncSchedule')) el('settPriceSyncSchedule').value = data.price_sync_schedule || '';
// Load price sync status
try {
const psRes = await fetch('/api/price-sync/status');
const psData = await psRes.json();
const psEl = document.getElementById('settPriceSyncStatus');
if (psEl && psData.last_run) {
psEl.textContent = `Ultima: ${psData.last_run.finished_at || ''}${psData.last_run.updated || 0} actualizate din ${psData.last_run.matched || 0}`;
}
} catch {}
} catch (err) {
console.error('loadSettings error:', err);
}
@@ -187,9 +173,6 @@ async function saveSettings() {
kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '',
kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '',
price_sync_enabled: el('settPriceSyncEnabled')?.checked ? "1" : "0",
catalog_sync_enabled: el('settCatalogSyncEnabled')?.checked ? "1" : "0",
price_sync_schedule: el('settPriceSyncSchedule')?.value || '',
gomag_products_url: '',
};
try {
const res = await fetch('/api/settings', {
@@ -211,40 +194,6 @@ async function saveSettings() {
}
}
async function startCatalogSync() {
const btn = document.getElementById('btnCatalogSync');
const status = document.getElementById('settPriceSyncStatus');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Sincronizare...';
try {
const res = await fetch('/api/price-sync/start', { method: 'POST' });
const data = await res.json();
if (data.error) {
status.innerHTML = `<span class="text-danger">${escHtml(data.error)}</span>`;
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
return;
}
// Poll status
const pollInterval = setInterval(async () => {
const sr = await fetch('/api/price-sync/status');
const sd = await sr.json();
if (sd.status === 'running') {
status.textContent = sd.phase_text || 'Sincronizare în curs...';
} else {
clearInterval(pollInterval);
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
if (sd.last_run) status.textContent = `Ultima: ${sd.last_run.finished_at || ''}${sd.last_run.updated || 0} actualizate din ${sd.last_run.matched || 0}`;
}
}, 2000);
} catch (err) {
status.innerHTML = `<span class="text-danger">${escHtml(err.message)}</span>`;
btn.disabled = false;
btn.textContent = 'Sincronizează acum';
}
}
function wireAutocomplete(inputId, dropdownId) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);

View File

@@ -11,6 +11,17 @@
};
})();
// ── Order status constants (mirror of Python OrderStatus enum) ────────────
const ORDER_STATUS = Object.freeze({
IMPORTED: 'IMPORTED',
ALREADY_IMPORTED: 'ALREADY_IMPORTED',
SKIPPED: 'SKIPPED',
ERROR: 'ERROR',
CANCELLED: 'CANCELLED',
DELETED_IN_ROA: 'DELETED_IN_ROA',
MALFORMED: 'MALFORMED',
});
// ── HTML escaping ─────────────────────────────────
function esc(s) {
if (s == null) return '';
@@ -204,9 +215,126 @@ function renderMobileSegmented(containerId, pills, onSelect) {
});
}
// ── Shared Autocomplete ─────────────────────────
function setupAutocomplete(input, dropdown, selectedEl, fetchFn) {
let activeIndex = -1;
let acTimeout = null;
// Force-disable browser native autocomplete
input.setAttribute('autocomplete', 'off');
input.setAttribute('autocorrect', 'off');
input.setAttribute('autocapitalize', 'off');
input.setAttribute('spellcheck', 'false');
if (!input.name) input.name = 'ac_' + Math.random().toString(36).slice(2, 8);
// Debounced input → fetch
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => {
activeIndex = -1;
fetchFn(input, dropdown, selectedEl);
}, 250);
});
// Prevent blur when interacting with dropdown (scroll, click)
dropdown.addEventListener('mousedown', (e) => {
e.preventDefault();
});
// Click selection on items (delegated)
dropdown.addEventListener('click', (e) => {
const item = e.target.closest('.autocomplete-item');
if (!item) return;
selectItem(item);
});
// Switch back to mouse mode on mousemove
dropdown.addEventListener('mousemove', () => {
dropdown.classList.remove('keyboard-active');
});
// Blur → close dropdown
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 150);
});
// Keyboard navigation — capture phase to beat browser/extensions
input.addEventListener('keydown', (e) => {
if (dropdown.classList.contains('d-none')) return;
const items = dropdown.querySelectorAll('.autocomplete-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
dropdown.classList.add('keyboard-active');
if (activeIndex < items.length - 1) activeIndex++;
updateActive(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
dropdown.classList.add('keyboard-active');
if (activeIndex > 0) activeIndex--;
updateActive(items);
} else if (e.key === 'Enter') {
if (activeIndex >= 0 && activeIndex < items.length) {
e.preventDefault();
e.stopPropagation();
selectItem(items[activeIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
dropdown.classList.add('d-none');
activeIndex = -1;
}
}, true); // capture phase
// Track dropdown open/close
input.setAttribute('aria-expanded', 'false');
function updateActive(items) {
items.forEach((it, i) => {
const isActive = i === activeIndex;
it.classList.toggle('active', isActive);
if (isActive) {
it.scrollIntoView({ block: 'nearest' });
}
});
}
function selectItem(item) {
const codmat = item.dataset.codmat;
const label = item.dataset.label;
if (!codmat) return;
// Find parent context: .codmat-line (mappings modal), .qm-line (quick-map), or inline
const line = input.closest('.codmat-line') || input.closest('.qm-line');
if (line) {
const codmatInput = line.querySelector('.cl-codmat') || line.querySelector('.qm-codmat');
const selectedLabel = line.querySelector('.cl-selected') || line.querySelector('.qm-selected');
if (codmatInput) codmatInput.value = codmat;
if (selectedLabel) selectedLabel.textContent = label;
} else {
// Inline context
input.value = codmat;
selectedEl.textContent = label;
}
dropdown.classList.add('d-none');
input.setAttribute('aria-expanded', 'false');
activeIndex = -1;
}
// Observe dropdown visibility for aria-expanded
const observer = new MutationObserver(() => {
const open = !dropdown.classList.contains('d-none');
input.setAttribute('aria-expanded', String(open));
});
observer.observe(dropdown, { attributes: true, attributeFilter: ['class'] });
}
// ── Shared Quick Map Modal ────────────────────────
let _qmOnSave = null;
let _qmAcTimeout = null;
/**
* Open the shared quick-map modal.
@@ -276,13 +404,7 @@ function addQmCodmatLine(prefill) {
const dropdown = div.querySelector('.qm-ac-dropdown');
const selected = div.querySelector('.qm-selected');
input.addEventListener('input', () => {
clearTimeout(_qmAcTimeout);
_qmAcTimeout = setTimeout(() => _qmAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
setupAutocomplete(input, dropdown, selected, _qmAutocomplete);
}
async function _qmAutocomplete(input, dropdown, selectedEl) {
@@ -294,22 +416,16 @@ async function _qmAutocomplete(input, dropdown, selectedEl) {
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="_qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
dropdown.innerHTML = data.results.map((r, i) => {
const label = r.denumire + (r.um ? ` (${r.um})` : '');
return `<div class="autocomplete-item" id="ac-qm-${i}" data-codmat="${esc(r.codmat)}" data-label="${esc(label)}">
<span class="codmat">${esc(r.codmat)}</span> &mdash; <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
</div>`;
}).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function _qmSelectArticle(el, codmat, label) {
const line = el.closest('.qm-line');
line.querySelector('.qm-codmat').value = codmat;
line.querySelector('.qm-selected').textContent = label;
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
}
async function saveQuickMapping() {
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
const mappings = [];
@@ -398,12 +514,13 @@ function fmtNum(v) {
function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
case ORDER_STATUS.IMPORTED: return '<span class="badge bg-success">Importat</span>';
case ORDER_STATUS.ALREADY_IMPORTED: return '<span class="badge bg-info">Deja importat</span>';
case ORDER_STATUS.SKIPPED: return '<span class="badge bg-warning">Omis</span>';
case ORDER_STATUS.ERROR: return '<span class="badge bg-danger">Eroare</span>';
case ORDER_STATUS.CANCELLED: return '<span class="badge bg-secondary">Anulat</span>';
case ORDER_STATUS.DELETED_IN_ROA: return '<span class="badge bg-dark">Sters din ROA</span>';
case ORDER_STATUS.MALFORMED: return '<span class="badge" style="background:var(--compare-light);color:var(--compare-text);border:1px solid var(--compare)">Defect</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
}
@@ -484,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
@@ -507,6 +760,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
document.getElementById('detailError').style.display = 'none';
const retryBtn = document.getElementById('detailRetryBtn');
if (retryBtn) { retryBtn.style.display = 'none'; retryBtn.disabled = false; retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta'; retryBtn.className = 'btn btn-sm btn-outline-primary'; }
const resyncBtn = document.getElementById('detailResyncBtn');
if (resyncBtn) { resyncBtn.style.display = 'none'; resyncBtn.disabled = false; resyncBtn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Resync'; resyncBtn.className = 'btn btn-sm btn-outline-warning'; }
const deleteBtn = document.getElementById('detailDeleteBtn');
if (deleteBtn) { deleteBtn.style.display = 'none'; deleteBtn.disabled = false; deleteBtn.innerHTML = '<i class="bi bi-trash"></i> Sterge din ROA'; deleteBtn.className = 'btn btn-sm btn-outline-danger'; }
const receiptEl = document.getElementById('detailReceipt');
if (receiptEl) receiptEl.innerHTML = '';
const receiptMEl = document.getElementById('detailReceiptMobile');
@@ -596,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;
}
@@ -615,10 +877,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : '';
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const priceMismatchHtml = priceInfo.match === false
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>`
: '';
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
@@ -630,7 +888,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
<span class="dif-val">${fmtNum(valoare)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
</div>
${priceMismatchHtml}
</div>`;
}).join('');
@@ -684,32 +941,14 @@ async function renderOrderDetailModal(orderNumber, opts) {
let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '';
let matchDot, rowStyle;
if (item.kit) {
matchDot = '<span class="badge" style="background:var(--info-light);color:var(--info-text);font-size:10px;padding:2px 6px">Kit</span>';
rowStyle = '';
} else if (priceInfo.pret_roa == null && priceInfo.match == null) {
matchDot = '<span class="dot dot-gray"></span>';
rowStyle = '';
} else if (priceInfo.match === false) {
matchDot = '<span class="dot dot-red"></span>';
rowStyle = ' style="background:var(--error-light)"';
} else {
matchDot = '<span class="dot dot-green"></span>';
rowStyle = '';
}
return `<tr${rowStyle}>
return `<tr>
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td>
<td class="text-end font-data">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end font-data">${pretRoaHtml}</td>
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end font-data">${fmtNum(valoare)}</td>
<td class="text-center">${matchDot}</td>
</tr>`;
}).join('');
@@ -721,9 +960,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
<td></td><td class="text-muted">Transport</td>
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
<td class="text-end">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
<td class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
</tr>`;
}
@@ -739,9 +976,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
<td></td>
<td class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
<td></td>
</tr>`;
});
} else {
@@ -759,40 +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 = ['ERROR', 'SKIPPED'].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';
// 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;
}
};
}
}
if (opts.onAfterRender) opts.onAfterRender(order, items);
} catch (err) {
document.getElementById('detailError').textContent = err.message;
@@ -806,23 +1007,46 @@ function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) {
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx);
}
// ── Inline confirm helper (shared: modal + dashboard) ──────
function inlineConfirmAction(btn, confirmText, actionFn, opts) {
if (btn.dataset.confirming === 'true') {
btn.dataset.confirming = 'false';
clearTimeout(btn._resetTimer);
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> ' + opts.loadingText;
actionFn(btn);
} else {
btn.dataset.confirming = 'true';
btn._origClass = btn.className;
btn.innerHTML = confirmText;
btn.className = btn.className.replace(/btn-outline-\w+/, opts.confirmClass);
btn._resetTimer = setTimeout(() => {
btn.dataset.confirming = 'false';
btn.innerHTML = opts.defaultHtml;
btn.className = btn._origClass;
}, 3000);
}
}
// ── Dot helper ────────────────────────────────────
function statusDot(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED':
case 'ALREADY_IMPORTED':
case ORDER_STATUS.IMPORTED:
case ORDER_STATUS.ALREADY_IMPORTED:
case 'COMPLETED':
case 'RESOLVED':
return '<span class="dot dot-green"></span>';
case 'SKIPPED':
case ORDER_STATUS.SKIPPED:
case 'UNRESOLVED':
case 'INCOMPLETE':
return '<span class="dot dot-yellow"></span>';
case 'ERROR':
case ORDER_STATUS.ERROR:
case 'FAILED':
return '<span class="dot dot-red"></span>';
case 'CANCELLED':
case 'DELETED_IN_ROA':
case ORDER_STATUS.MALFORMED:
return '<span class="dot dot-orange" title="Date defecte — escalat la GoMag"></span>';
case ORDER_STATUS.CANCELLED:
case ORDER_STATUS.DELETED_IN_ROA:
return '<span class="dot dot-gray"></span>';
default:
return '<span class="dot dot-gray"></span>';
@@ -849,19 +1073,41 @@ function fmtAddr(a) {
function addrMatch(gomag, roa) {
if (!gomag || !roa) return true; // can't compare
const _DIAC = {
'\u0103':'a','\u00e2':'a','\u00ee':'i','\u0219':'s','\u021b':'t',
'\u0102':'A','\u00c2':'A','\u00ce':'I','\u0218':'S','\u021a':'T',
'\u015f':'s','\u0163':'t','\u015e':'S','\u0162':'T'
};
function norm(s) {
return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '')
return (s || '').replace(/[\u0103\u00e2\u00ee\u0219\u021b\u0102\u00c2\u00ce\u0218\u021a\u015f\u0163\u015e\u0162]/g, c => _DIAC[c] || c)
.toUpperCase()
.replace(/\bSECTORUL\s*\d*/g, '')
.replace(/\b(STR|STRADA|NR|NUMAR|NUMARUL|BL|BLOC|SC|SCARA|AP|APART|APARTAMENT|ET|ETAJ|COM|COMUNA|SAT|MUN|MUNICIPIUL|JUD|JUDETUL|CARTIER|PARTER|SECTOR|SECTORUL|ORAS)(?:\b|(?=\d))/g, '')
.replace(/[^A-Z0-9]/g, '');
}
function soundex(s) {
if (!s) return '';
const code = {B:1,F:1,P:1,V:1,C:2,G:2,J:2,K:2,Q:2,S:2,X:2,Z:2,
D:3,T:3,L:4,M:5,N:5,R:6};
let result = s[0], prev = code[s[0]] || 0;
for (let i = 1; i < s.length && result.length < 4; i++) {
const c = s[i];
if ('AEIOU'.includes(c)) { prev = 0; }
else if (c !== 'H' && c !== 'W') {
const d = code[c];
if (d && d !== prev) result += d;
if (d) prev = d;
}
}
return result.padEnd(4, '0');
}
const gStreet = norm(gomag.address || gomag.strada || '');
const rStreet = norm((roa.strada||'') + (roa.numar||'') + (roa.bloc||'') + (roa.scara||'') + (roa.apart||'') + (roa.etaj||''));
const rStreet = norm((roa.strada||'') + (roa.numar||'') + (roa.bloc||'') + (roa.scara||'') + (roa.etaj||'') + (roa.apart||''));
const gCity = norm(gomag.city || gomag.localitate || '');
const rCity = norm(roa.localitate || '');
const gRegion = norm(gomag.region || gomag.judet || '');
const rRegion = norm(roa.judet || '');
return gStreet === rStreet && gCity === rCity && gRegion === rRegion;
return gStreet === rStreet && soundex(gCity) === soundex(rCity) && gRegion === rRegion;
}
function hasEfacturaRisk(roa) {
@@ -940,7 +1186,7 @@ function _renderHeaderInfo(order) {
}
// ERROR orders: muted dashes for ROA fields
if (order.status === 'ERROR' && !order.id_comanda) {
if (order.status === ORDER_STATUS.ERROR && !order.id_comanda) {
document.getElementById('detailIdComanda').innerHTML = '<span class="text-muted">\u2014</span>';
document.getElementById('detailIdPartener').innerHTML = '<span class="text-muted">\u2014</span>';
}
@@ -1015,26 +1261,30 @@ function _renderHeaderInfo(order) {
// Livrare
if (addr.livrare_gomag || addr.livrare_roa) {
html += addrLine('Livrare GoMag:', addr.livrare_gomag, null);
const livrRisk = hasEfacturaRisk(addr.livrare_roa);
const livrMatch = addrMatch(addr.livrare_gomag, addr.livrare_roa);
let matchType = null;
if (addr.livrare_roa) {
matchType = livrRisk ? 'risk' : (livrMatch ? 'match' : 'mismatch');
}
html += addrLine('Livrare ROA:', addr.livrare_roa, matchType);
html += '<div class="row addr-row">';
html += '<div class="col-md-6">' + addrLine('Livrare:', addr.livrare_gomag, null) + '</div>';
html += '<div class="col-md-6">' + addrLine('Livrare:', addr.livrare_roa, matchType) + '</div>';
html += '</div>';
}
// Facturare
if (addr.facturare_gomag || addr.facturare_roa) {
html += addrLine('Facturare GoMag:', addr.facturare_gomag, null);
const factRisk = hasEfacturaRisk(addr.facturare_roa);
const factMatch = addrMatch(addr.facturare_gomag, addr.facturare_roa);
let matchType = null;
if (addr.facturare_roa) {
matchType = factRisk ? 'risk' : (factMatch ? 'match' : 'mismatch');
}
html += addrLine('Facturare ROA:', addr.facturare_roa, matchType);
html += '<div class="row addr-row">';
html += '<div class="col-md-6">' + addrLine('Facturare:', addr.facturare_gomag, null) + '</div>';
html += '<div class="col-md-6">' + addrLine('Facturare:', addr.facturare_roa, matchType) + '</div>';
html += '</div>';
}
addressLines.innerHTML = html;
@@ -1053,7 +1303,6 @@ function _renderHeaderInfo(order) {
}
if (addr && addr.livrare_roa && !addrMatch(addr.livrare_gomag, addr.livrare_roa)) badges.push({label:'Adr. livr.', cls:'diff-badge-addr', aria:'Adresa livrare diferita'});
if (addr && addr.facturare_roa && !addrMatch(addr.facturare_gomag, addr.facturare_roa)) badges.push({label:'Adr. fact.', cls:'diff-badge-addr', aria:'Adresa facturare diferita'});
if (order.price_check && order.price_check.mismatches > 0) badges.push({label:'Preturi (' + order.price_check.mismatches + ')', cls:'diff-badge-price', aria:'Preturi diferite: ' + order.price_check.mismatches});
if (pi && pi.partner_mismatch) badges.push({label:'Partener', cls:'diff-badge-anaf', aria:'Partener schimbat in GoMag'});
let insertAfter = orderNumEl;
badges.forEach(b => {

View File

@@ -19,7 +19,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=38" rel="stylesheet">
<link href="{{ rp }}/static/css/style.css?v=46" rel="stylesheet">
</head>
<body>
<!-- Top Navbar (hidden on mobile via CSS) -->
@@ -126,9 +126,8 @@
<div id="detailDenomMismatch" style="display:none" class="mb-2"></div>
<!-- Compact Address Lines -->
<div id="detailAddressBlock" style="display:none" class="mb-3">
<div class="detail-col-label d-flex align-items-center justify-content-between" style="border-bottom:1px solid var(--border);margin-bottom:8px;padding-bottom:4px">
<span>ADRESE</span>
<button id="refreshAddrBtn" class="btn btn-sm btn-outline-secondary py-0 px-1 ms-2"
<div class="detail-col-label d-flex align-items-center justify-content-end" style="border-bottom:1px solid var(--border);margin-bottom:8px;padding-bottom:4px">
<button id="refreshAddrBtn" class="btn btn-sm btn-outline-secondary py-0 px-1"
onclick="refreshOrderAddress(window._detailOrderNumber)"
aria-label="Refresh adresă din Oracle" title="Refresh adresă din Oracle">
<i class="bi bi-arrow-clockwise"></i>
@@ -145,10 +144,8 @@
<th>CODMAT</th>
<th class="text-end">Cant.</th>
<th class="text-end">Pret GoMag</th>
<th class="text-end">Pret ROA</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th>
<th class="text-center"></th>
</tr>
</thead>
<tbody id="detailItemsBody">
@@ -160,8 +157,10 @@
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<div class="modal-footer d-flex">
<button type="button" id="detailDeleteBtn" class="btn btn-sm btn-outline-danger me-auto" style="display:none"><i class="bi bi-trash"></i> Sterge din ROA</button>
<button type="button" id="detailRetryBtn" class="btn btn-sm btn-outline-primary" style="display:none"><i class="bi bi-arrow-clockwise"></i> Reimporta</button>
<button type="button" id="detailResyncBtn" class="btn btn-sm btn-outline-warning" style="display:none"><i class="bi bi-arrow-repeat"></i> Resync</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
@@ -170,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=36"></script>
<script src="{{ rp }}/static/js/shared.js?v=49"></script>
<script>
// Dark mode toggle
function toggleDarkMode() {

View File

@@ -14,6 +14,11 @@
<div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" class="text-secondary">Inactiv</span>
<span id="syncHealthPill" class="health-pill healthy" role="status"
aria-label="Sync sanatos" title="Verificare stare sync">
<i class="bi bi-check-circle-fill" aria-hidden="true"></i>
<span class="health-pill-label">Sanatos</span>
</span>
<div class="d-flex align-items-center gap-2">
<label class="d-flex align-items-center gap-1 text-muted">
Auto:
@@ -70,12 +75,13 @@
<input type="search" id="orderSearch" placeholder="Cauta comanda, client..." class="search-input">
<!-- Status pills -->
<button class="filter-pill active d-none d-md-inline-flex" data-status="all">Toate <span class="filter-count fc-neutral" id="cntAll">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="IMPORTED">Importat <span class="filter-count fc-green" id="cntImp">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="cntSkip">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="ERROR">Erori <span class="filter-count fc-red" id="cntErr">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.IMPORTED.value }}">Importat <span class="filter-count fc-green" id="cntImp">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.SKIPPED.value }}">Omise <span class="filter-count fc-yellow" id="cntSkip">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.ERROR.value }}">Erori <span class="filter-count fc-red" id="cntErr">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.CANCELLED.value }}">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.MALFORMED.value }}">Defecte <span class="filter-count fc-orange" id="cntMal">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="DIFFS">Diferente <span class="filter-count fc-orange" id="cntDiff">0</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">&#8635;</button>
</div>
@@ -100,10 +106,11 @@
<th class="text-end">Transport</th>
<th class="text-end">Discount</th>
<th class="text-end">Total</th>
<th style="width:44px"></th>
</tr>
</thead>
<tbody id="dashOrdersBody">
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
<tr><td colspan="10" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
</div>
@@ -114,5 +121,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=45"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=52"></script>
{% endblock %}

View File

@@ -59,10 +59,11 @@
<!-- Filter pills -->
<div class="filter-bar mb-3" id="orderFilterPills">
<button class="filter-pill active d-none d-md-inline-flex" data-log-status="all">Toate <span class="filter-count fc-neutral" id="countAll">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="IMPORTED">Importate <span class="filter-count fc-green" id="countImported">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ALREADY_IMPORTED">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.IMPORTED.value }}">Importate <span class="filter-count fc-green" id="countImported">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.ALREADY_IMPORTED.value }}">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.SKIPPED.value }}">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.ERROR.value }}">Erori <span class="filter-count fc-red" id="countError">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.MALFORMED.value }}">Defecte <span class="filter-count fc-orange" id="countMalformed">0</span></button>
</div>
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
@@ -109,5 +110,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=15"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=16"></script>
{% endblock %}

View File

@@ -159,5 +159,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=14"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=17"></script>
{% endblock %}

View File

@@ -175,7 +175,7 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="col-md-12">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
<div class="card-body py-2 px-3">
@@ -207,37 +207,11 @@
<option value="">— implicită —</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
<div class="card-body py-2 px-3">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="settCatalogSyncEnabled">
<label class="form-check-label small" for="settCatalogSyncEnabled">Sync prețuri din catalog GoMag</label>
</div>
<div id="catalogSyncOptions" style="display:none">
<div class="mb-2">
<label class="form-label mb-0 small">Program</label>
<select class="form-select form-select-sm" id="settPriceSyncSchedule">
<option value="">Doar manual</option>
<option value="daily_03:00">Zilnic la 03:00</option>
<option value="daily_06:00">Zilnic la 06:00</option>
</select>
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
</div>
</div>
<div id="settPriceSyncStatus" class="text-muted small mt-2"></div>
<button class="btn btn-sm btn-outline-primary mt-2" id="btnCatalogSync" onclick="startCatalogSync()">Sincronizează acum</button>
</div>
</div>
</div>
@@ -253,5 +227,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=9"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=10"></script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,20 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS
-- 07.04.2026 - fix parser adrese: inserare virgule inaintea keywords, tokeni lipiti (Ap78), strip localitate din strada
-- 07.04.2026 - fix duplicate: normalize localitate + resolve id_localitate inainte de TIER 1 (match pe id_loc)
-- 07.04.2026 - fix localitate necunoscuta: SOUNDEX fuzzy match (TIER L2) + pastreaza judetul in L3
-- 08.04.2026 - fix parser: inserare virgule in strada inainte de comma-split (sc/ap/et nu se extrageau fara virgula)
-- 15.04.2026 - fix cauta_partener_dupa_denumire: exclude sters=1, prioritizeaza inactiv=0 (bug GoMag #484668145)
-- 16.04.2026 - fix cauta_partener_dupa_cod_fiscal strict mode: regex detectie RO tolereaza spatiu (^RO\s*\d),
-- IN-set foloseste v_ro_cui (canonic) in loc de v_cod_fiscal_curat. Regula business platitor/
-- neplatitor pastrata. Bug anterior: input "RO 34963277" cadea pe branch neplatitor, rata partener
-- existent "RO34963277" → duplicat FG COFFE #485065210.
-- 22.04.2026 - fix numar overflow: prima componenta ramane numar; "SAT X" → p_localitate (satul
-- = localitate, TIER L1/L2/L3 existent rezolva id_loc); landmark → strada;
-- COM/ORAS/MUN ignorate (deja in p_localitate din GoMag city)
-- 23.04.2026 - hardening: SUBSTR(1,10) neconditionat dupa split, blocheaza
-- overflow rezidual pe prefix lung fara spatiu in primii 10 char.
-- 28.04.2026 - fix ORA-06502: v_bloc/scara/apart/etaj in cauta_sau_creeaza_adresa
-- marite la VARCHAR2(100) — Oracle OUT param mostenea constrangerea
-- VARCHAR2(10) si cadea pe "apartament 140 interfon 140 Municipiul..."
-- ====================================================================
-- CONSTANTS
@@ -273,13 +287,16 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
BEGIN
IF p_strict_search = 1 THEN
-- Cautare STRICT: doar forma primita + varianta cu spatiu
IF REGEXP_LIKE(v_cod_fiscal_curat, '^RO\d') THEN
-- Input "RO123" → cauta si "RO 123"
-- Cautare STRICT: regula business ANAF platitor/neplatitor TVA
-- Platitor (prefix RO) → cauta doar RO<bare> si RO <bare> (cu spatiu)
-- Neplatitor (fara RO) → cauta doar <bare>
-- Nu cross-match intre platitor si neplatitor (entitati fiscal distincte).
IF REGEXP_LIKE(v_cod_fiscal_curat, '^RO\s*\d') THEN
-- Input "RO123" sau "RO 123" (platitor TVA) → cauta RO<bare> si RO <bare>
SELECT id_part INTO v_id_part FROM (
SELECT id_part
FROM nom_parteneri
WHERE UPPER(TRIM(cod_fiscal)) IN (v_cod_fiscal_curat, 'RO ' || v_bare_cui)
WHERE UPPER(TRIM(cod_fiscal)) IN (v_ro_cui, 'RO ' || v_bare_cui)
AND NVL(sters, 0) = 0
ORDER BY NVL(inactiv, 0) ASC, id_part DESC
) WHERE ROWNUM = 1;
@@ -329,41 +346,24 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
IF p_denumire IS NULL THEN
RETURN NULL;
END IF;
v_denumire_curata := curata_text_cautare(p_denumire);
-- pINFO('Cautare partener dupa denumire: ' || v_denumire_curata, 'IMPORT_PARTENERI');
-- Cautare in NOM_PARTENERI
-- Cautare in NOM_PARTENERI - exclude sters=1, prioritizeaza active (inactiv=0)
BEGIN
SELECT id_part
INTO v_id_part
FROM nom_parteneri
WHERE UPPER(TRIM(denumire)) = v_denumire_curata
AND ROWNUM = 1; -- In caz de duplicate, luam primul
-- pINFO('Gasit partener cu denumirea ' || v_denumire_curata || ': ID_PART=' || v_id_part, 'IMPORT_PARTENERI');
SELECT id_part INTO v_id_part FROM (
SELECT id_part
FROM nom_parteneri
WHERE UPPER(TRIM(denumire)) = v_denumire_curata
AND NVL(sters, 0) = 0
ORDER BY NVL(inactiv, 0) ASC, id_part DESC
) WHERE ROWNUM = 1;
RETURN v_id_part;
EXCEPTION
WHEN NO_DATA_FOUND THEN
-- pINFO('Nu s-a gasit partener cu denumirea: ' || v_denumire_curata, 'IMPORT_PARTENERI');
RETURN NULL;
WHEN TOO_MANY_ROWS THEN
-- Luam primul gasit
SELECT id_part
INTO v_id_part
FROM (SELECT id_part
FROM nom_parteneri
WHERE UPPER(TRIM(denumire)) = v_denumire_curata
ORDER BY id_part)
WHERE ROWNUM = 1;
pINFO('WARNING: Multiple parteneri cu aceeasi denumire ' ||
v_denumire_curata || '. Selectat ID_PART=' || v_id_part,
'IMPORT_PARTENERI');
RETURN v_id_part;
END;
EXCEPTION
@@ -441,6 +441,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
END separa_nume_prenume;
-- 31.03.2026 - parser inteligent: split numar in bloc/scara/apart/etaj (fix ORA-12899 pe NUMAR max 10 chars)
-- 08.04.2026 - fix: inserare virgule in strada inainte de comma-split (sc/ap/et nu se extrageau fara virgula)
-- 08.04.2026 - fix: BLOC/NR regex require separator (fix false-positive on BLOCURI neighborhood name)
PROCEDURE parseaza_adresa_semicolon(p_adresa_text IN VARCHAR2,
p_judet OUT VARCHAR2,
p_localitate OUT VARCHAR2,
@@ -518,6 +520,13 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
p_strada := SUBSTR(v_componente(3), 1, 100);
v_strada := p_strada;
-- 08.04.2026 - insert commas before address keywords so comma-split always fires
-- Reuses same regex as v_raw_numar comma insertion (lines below)
-- Ex: "Str X nr 26 bl 6 sc 2 ap 36" → "Str X,nr 26,bl 6,sc 2,ap 36"
v_strada := REGEXP_REPLACE(v_strada,
'(\s)(BLOC|BL|SCARA|SC|APARTAMENT|APART|AP|ETAJ|ET|NUMARUL|NUMAR|NR)(\s|\.|\d)',
',\2\3', 1, 0, 'i');
-- Separa strada de tot ce e dupa prima virgula
v_pozitie := INSTR(v_strada, ',');
IF v_pozitie > 0 THEN
@@ -540,14 +549,14 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
IF p_strada IS NOT NULL THEN
v_token_upper := UPPER(p_strada);
-- Extrage NR din strada
IF REGEXP_LIKE(v_token_upper, '(\s)(NUMARUL|NUMAR|NR\.?)\s*(\S+)') THEN
p_numar := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(NUMARUL|NUMAR|NR\.?)\s*(\S+).*', '\3', 1, 1));
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(NUMARUL|NUMAR|NR\.?)\s*\S+', '', 1, 1, 'i'));
IF REGEXP_LIKE(v_token_upper, '(\s)(NUMARUL|NUMAR|NR\.?)[\s.]+(\S+)') THEN
p_numar := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(NUMARUL|NUMAR|NR\.?)[\s.]+(\S+).*', '\3', 1, 1));
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(NUMARUL|NUMAR|NR\.?)[\s.]+\S+', '', 1, 1, 'i'));
END IF;
-- Extrage BLOC din strada
IF REGEXP_LIKE(v_token_upper, '(\s)(BLOC|BL\.?)\s*(\S+)') THEN
p_bloc := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(BLOC|BL\.?)\s*(\S+).*', '\3', 1, 1));
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(BLOC|BL\.?)\s*\S+', '', 1, 1, 'i'));
IF REGEXP_LIKE(v_token_upper, '(\s)(BLOC|BL\.?)[\s.]+(\S+)') THEN
p_bloc := TRIM(REGEXP_REPLACE(v_token_upper, '.*(\s)(BLOC|BL\.?)[\s.]+(\S+).*', '\3', 1, 1));
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(BLOC|BL\.?)[\s.]+\S+', '', 1, 1, 'i'));
END IF;
-- Re-read v_token_upper after BLOC removal may have changed p_strada
v_token_upper := UPPER(p_strada);
@@ -682,6 +691,35 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
END IF;
-- Truncare de siguranta (limita coloanelor Oracle)
-- 22.04.2026 - numar overflow fix:
-- prima componenta ramane numar
-- "SAT X ..." → X ... devine p_localitate (satul = localitate, TIER L1/L2/L3 rezolva)
-- "COM X"/"ORAS X"/"MUN X" → ignorat (deja in p_localitate din GoMag)
-- altceva (landmark) → strada
IF LENGTH(p_numar) > 10 THEN
v_pozitie := INSTR(p_numar, ' ');
IF v_pozitie > 1 THEN
v_rest_parts := TRIM(SUBSTR(p_numar, v_pozitie + 1));
p_numar := SUBSTR(p_numar, 1, v_pozitie - 1);
IF v_rest_parts IS NOT NULL THEN
IF UPPER(v_rest_parts) LIKE 'SAT %' THEN
-- Satul = localitate → overwrite p_localitate cu tot ce urmeaza dupa "SAT "
p_localitate := UPPER(TRIM(REGEXP_REPLACE(v_rest_parts, '^SAT\s+', '', 1, 1, 'i')));
ELSIF UPPER(v_rest_parts) NOT LIKE 'COM %'
AND UPPER(v_rest_parts) NOT LIKE 'ORAS %'
AND UPPER(v_rest_parts) NOT LIKE 'MUN %' THEN
-- Landmark (ex: "LA NON STOP") → strada
p_strada := SUBSTR(TRIM(p_strada || ' ' || v_rest_parts), 1, 100);
END IF;
-- COM/ORAS/MUN aruncat (deja in p_localitate din GoMag)
END IF;
ELSE
p_numar := SUBSTR(p_numar, 1, 10);
END IF;
END IF;
-- Safety net: daca split-ul de mai sus a lasat >10 char (ex: prefixul
-- inaintea primului spatiu era el insusi >10), forteaza limita coloanei.
-- 23.04.2026 - hardening overflow rezidual
p_numar := SUBSTR(p_numar, 1, 10);
p_bloc := SUBSTR(p_bloc, 1, 30);
p_scara := SUBSTR(p_scara, 1, 10);
@@ -940,10 +978,10 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
v_strada VARCHAR2(1000);
v_numar VARCHAR2(1000);
v_sector VARCHAR2(100);
v_bloc VARCHAR2(30);
v_scara VARCHAR2(10);
v_apart VARCHAR2(10);
v_etaj VARCHAR2(20);
v_bloc VARCHAR2(100);
v_scara VARCHAR2(100);
v_apart VARCHAR2(100);
v_etaj VARCHAR2(100);
v_id_tara NUMBER(10);
v_principala NUMBER(1);
begin

View File

@@ -50,6 +50,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
-- 21.03.2026 - fix discount amount: v_disc_amt e per-kit, nu se imparte la v_cantitate_web
-- 25.03.2026 - skip negative kit discount (markup), ROUND prices to nzecimale_pretv
-- 25.03.2026 - kit discount inserat per-kit sub componente (nu deferred cross-kit)
-- 22.04.2026 - fix duplicate article: NOM_ARTICOLE fallback si kit discount line folosesc merge_or_insert_articol (prod VENDING comanda 485224762)
-- ====================================================================
-- Constante pentru configurare
@@ -535,15 +536,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
IF v_disc_amt > 0 THEN
BEGIN
PACK_COMENZI.adauga_articol_comanda(
V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_disc_artid,
V_ID_POL => NVL(p_kit_discount_id_pol, p_id_pol),
V_CANTITATE => -1 * v_cantitate_web,
V_PRET => ROUND(v_disc_amt, v_nzec_pretv),
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_vat_key);
merge_or_insert_articol(
p_id_comanda => v_id_comanda,
p_id_articol => v_disc_artid,
p_id_pol => NVL(p_kit_discount_id_pol, p_id_pol),
p_cantitate => -1 * v_cantitate_web,
p_pret => ROUND(v_disc_amt, v_nzec_pretv),
p_id_util => c_id_util,
p_id_sectie => p_id_sectie,
p_ptva => v_vat_key);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN OTHERS THEN
@@ -619,14 +620,14 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_pret_unitar := NVL(v_pret_web, 0);
BEGIN
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_id_articol,
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_CANTITATE => v_cantitate_web,
V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_vat);
merge_or_insert_articol(p_id_comanda => v_id_comanda,
p_id_articol => v_id_articol,
p_id_pol => NVL(v_id_pol_articol, p_id_pol),
p_cantitate => v_cantitate_web,
p_pret => v_pret_unitar,
p_id_util => c_id_util,
p_id_sectie => p_id_sectie,
p_ptva => v_vat);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN OTHERS THEN

View File

@@ -1,4 +1,6 @@
"""E2E: Mappings page with flat-row list, sorting, multi-CODMAT modal."""
import re
import pytest
from playwright.sync_api import Page, expect
@@ -77,3 +79,100 @@ def test_inline_add_button_exists(page: Page):
"""Verify 'Adauga Mapare' button is present."""
btn = page.locator("button", has_text="Adauga Mapare")
expect(btn).to_be_visible()
# ── Autocomplete keyboard & scroll tests ─────────
MOCK_ARTICLES = [
{"codmat": f"ART{i:03}", "denumire": f"Articol Test {i}", "um": "BUC"}
for i in range(1, 20)
]
@pytest.fixture
def mock_articles(page: Page):
"""Mock /api/articles/search to return test data without Oracle."""
def handle(route):
route.fulfill(json={"results": MOCK_ARTICLES})
page.route("**/api/articles/search*", handle)
yield
page.unroute("**/api/articles/search*")
def _open_modal_and_type(page: Page, query: str = "ART"):
"""Open add-modal, type in CODMAT input, wait for dropdown."""
page.locator("button[data-bs-target='#addModal']").first.click()
page.wait_for_timeout(400)
codmat_input = page.locator("#codmatLines .cl-codmat").first
codmat_input.fill(query)
# Wait for debounce + render
page.wait_for_timeout(400)
return codmat_input
def test_autocomplete_keyboard_navigation(page: Page, mock_articles):
"""ArrowDown/Up moves .active class, Enter selects."""
codmat_input = _open_modal_and_type(page)
dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
expect(dropdown).to_be_visible()
# ArrowDown → first item active
codmat_input.press("ArrowDown")
first_item = dropdown.locator(".autocomplete-item").first
expect(first_item).to_have_class(re.compile("active"))
# ArrowDown again → second item active
codmat_input.press("ArrowDown")
second_item = dropdown.locator(".autocomplete-item").nth(1)
expect(second_item).to_have_class(re.compile("active"))
expect(first_item).not_to_have_class(re.compile("active"))
# ArrowUp → back to first
codmat_input.press("ArrowUp")
expect(first_item).to_have_class(re.compile("active"))
# Enter → selects the item
codmat_input.press("Enter")
expect(dropdown).to_be_hidden()
assert codmat_input.input_value() == "ART001"
def test_autocomplete_escape_closes(page: Page, mock_articles):
"""Escape closes dropdown."""
codmat_input = _open_modal_and_type(page)
dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
expect(dropdown).to_be_visible()
codmat_input.press("Escape")
expect(dropdown).to_be_hidden()
def test_autocomplete_scroll_keeps_open(page: Page, mock_articles):
"""Mouse wheel on dropdown doesn't close it (blur fix)."""
codmat_input = _open_modal_and_type(page)
dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
expect(dropdown).to_be_visible()
# Scroll inside the dropdown via mouse wheel
dropdown.evaluate("el => el.scrollTop = 100")
page.wait_for_timeout(300)
# Dropdown should still be visible
expect(dropdown).to_be_visible()
def test_autocomplete_click_outside_closes(page: Page, mock_articles):
"""Click outside closes dropdown (Tab away moves focus)."""
codmat_input = _open_modal_and_type(page)
dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
expect(dropdown).to_be_visible()
# Tab away from the input to trigger blur
codmat_input.press("Tab")
page.wait_for_timeout(300)
expect(dropdown).to_be_hidden()

View File

@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
texts = headers.all_text_contents()
# Current columns (may evolve — check dashboard.html for source of truth)
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "Pret ROA", "Valoare"]
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "TVA%", "Valoare"]
for col in required_columns:
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"

View File

@@ -93,7 +93,8 @@ def app_settings(client):
resp = client.get("/api/sync/schedule")
assert resp.status_code == 200
import sqlite3
db_path = os.environ.get("SQLITE_DB_PATH", os.path.join(_script_dir, "orders.db"))
from app.config import settings as _s
db_path = _s.SQLITE_DB_PATH if os.path.isabs(_s.SQLITE_DB_PATH) else os.path.join(_script_dir, _s.SQLITE_DB_PATH)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT key, value FROM app_settings").fetchall()
@@ -273,6 +274,127 @@ class TestAddressRulesE2E:
)
# ---------------------------------------------------------------------------
# Test: parsare componente adresă (strada, numar, bloc, scara, apart, etaj)
# Apelează direct parseaza_adresa_semicolon din Oracle — fără import comandă.
# ---------------------------------------------------------------------------
class TestAddressComponentParsing:
"""Verifică extragerea componentelor adresei direct prin parseaza_adresa_semicolon."""
def _parse_address(self, oracle_pool, address, city="Bucuresti", region="Bucuresti"):
"""Call Oracle parseaza_adresa_semicolon and return parsed components."""
from app.services.import_service import format_address_for_oracle
formatted = format_address_for_oracle(address, city, region)
conn = oracle_pool.acquire()
try:
with conn.cursor() as cur:
p_judet = cur.var(str, 200)
p_localitate = cur.var(str, 200)
p_strada = cur.var(str, 100)
p_numar = cur.var(str, 100)
p_sector = cur.var(str, 100)
p_bloc = cur.var(str, 30)
p_scara = cur.var(str, 10)
p_apart = cur.var(str, 10)
p_etaj = cur.var(str, 20)
cur.callproc("PACK_IMPORT_PARTENERI.parseaza_adresa_semicolon", [
formatted, p_judet, p_localitate, p_strada, p_numar,
p_sector, p_bloc, p_scara, p_apart, p_etaj
])
return {
"strada": p_strada.getvalue(),
"numar": p_numar.getvalue(),
"bloc": p_bloc.getvalue(),
"scara": p_scara.getvalue(),
"apart": p_apart.getvalue(),
"etaj": p_etaj.getvalue(),
"localitate": p_localitate.getvalue(),
"judet": p_judet.getvalue(),
}
finally:
oracle_pool.release(conn)
def test_full_address_all_components(self, oracle_pool):
"""Adresa completă cu nr, bl, sc, ap — toate componentele se extrag din strada."""
addr = self._parse_address(oracle_pool,
"Bd. 1 Decembrie 1918 nr. 26 bl. 6 sc. 2 ap. 36")
assert addr["numar"] == "26", f"numar={addr['numar']}"
assert addr["bloc"] == "6", f"bloc={addr['bloc']}"
assert addr["scara"] == "2", f"scara={addr['scara']}"
assert addr["apart"] == "36", f"apart={addr['apart']}"
assert "SC" not in (addr["strada"] or ""), f"SC ramas in strada: {addr['strada']}"
assert "AP" not in (addr["strada"] or ""), f"AP ramas in strada: {addr['strada']}"
def test_alphanumeric_bloc_and_letter_scara(self, oracle_pool):
"""Bloc alfanumeric (VN9) și scara literă (A) + etaj."""
addr = self._parse_address(oracle_pool,
"Strada Becatei nr 29 bl. VN9 sc. A et. 10 ap. 42")
assert addr["numar"] == "29", f"numar={addr['numar']}"
assert addr["bloc"] == "VN9", f"bloc={addr['bloc']}"
assert addr["scara"] == "A", f"scara={addr['scara']}"
assert addr["etaj"] == "10", f"etaj={addr['etaj']}"
assert addr["apart"] == "42", f"apart={addr['apart']}"
def test_address_without_commas_uppercase(self, oracle_pool):
"""Adresa uppercase fără virgule — keywords spațiu-separate."""
addr = self._parse_address(oracle_pool,
"STR DACIA NR 15 BLOC Z2 SC 1 AP 7 ET 3")
assert addr["numar"] == "15", f"numar={addr['numar']}"
assert addr["bloc"] == "Z2", f"bloc={addr['bloc']}"
assert addr["scara"] == "1", f"scara={addr['scara']}"
assert addr["apart"] == "7", f"apart={addr['apart']}"
assert addr["etaj"] == "3", f"etaj={addr['etaj']}"
def test_address_with_existing_commas(self, oracle_pool):
"""Adresa care deja are virgule — nu se strică parsarea."""
addr = self._parse_address(oracle_pool,
"Str Victoriei, nr. 10, bl. A1, sc. B, et. 2, ap. 15")
assert addr["numar"] == "10", f"numar={addr['numar']}"
assert addr["bloc"] == "A1", f"bloc={addr['bloc']}"
assert addr["scara"] == "B", f"scara={addr['scara']}"
assert addr["etaj"] == "2", f"etaj={addr['etaj']}"
assert addr["apart"] == "15", f"apart={addr['apart']}"
def test_no_keywords_street_unchanged(self, oracle_pool):
"""Adresa simplă fără keywords — strada rămâne intactă."""
addr = self._parse_address(oracle_pool, "Strada Victoriei 10")
assert "VICTORIEI" in (addr["strada"] or ""), f"strada={addr['strada']}"
def test_blocuri_neighborhood_not_extracted_as_bloc(self, oracle_pool):
"""'Blocuri' in street name must NOT be parsed as BLOC keyword."""
result = self._parse_address(oracle_pool, "Str Principala Modarzau Blocuri", "Zemes", "Bacau")
assert "MODARZAU BLOCURI" in (result.get("strada") or ""), f"strada should contain MODARZAU BLOCURI, got {result}"
assert result.get("bloc") is None, f"bloc should be NULL for neighborhood name, got {result.get('bloc')}"
def test_numar_overflow_with_landmark(self, oracle_pool):
"""'nr 5 la non stop' — numar=5, landmark overflow muta in strada."""
addr = self._parse_address(oracle_pool, "Str zorilor nr 5 la non stop", "Brasov", "Brasov")
assert addr["numar"] == "5", f"numar={addr['numar']!r} (asteptat '5')"
assert "ZORILOR" in (addr["strada"] or ""), f"strada={addr['strada']!r}"
assert "NON" in (addr["strada"] or ""), f"landmark lipsa din strada: {addr['strada']!r}"
def test_numar_overflow_with_sat_localitate(self, oracle_pool):
"""'nr21 sat Grozavesti corbii mari' — numar=21, SAT overwrite p_localitate (satul = localitate)."""
addr = self._parse_address(oracle_pool, "Pe deal nr21 sat Grozavesti corbii mari", "Corbii Mari", "Dambovita")
assert addr["numar"] == "21", f"numar={addr['numar']!r} (asteptat '21')"
assert "DEAL" in (addr["strada"] or ""), f"strada={addr['strada']!r}"
assert "GROZAVESTI" not in (addr["strada"] or ""), f"SAT in strada: {addr['strada']!r}"
# SAT ... a fost mutat in p_localitate (override din GoMag "CORBII MARI")
assert "GROZAVESTI" in (addr["localitate"] or "").upper(), (
f"localitate={addr['localitate']!r} (astept sa contina GROZAVESTI)"
)
def test_numar_normal_not_affected(self, oracle_pool):
"""Numar normal (<= 10 chars) nu e atins de overflow fix."""
addr = self._parse_address(oracle_pool, "Str Mihai Viteazu nr 10", "Cluj-Napoca", "Cluj")
assert addr["numar"] == "10", f"numar={addr['numar']!r}"
assert "VITEAZU" in (addr["strada"] or ""), f"strada={addr['strada']!r}"
# ---------------------------------------------------------------------------
# Test regresie: comenzi existente în SQLite
# ---------------------------------------------------------------------------

View File

@@ -36,6 +36,7 @@ from unittest.mock import MagicMock, patch
from app.services.import_service import build_articles_json, compute_discount_split
from app.services.order_reader import OrderData, OrderItem
from app.constants import OrderStatus
# ---------------------------------------------------------------------------
@@ -280,7 +281,7 @@ class TestSyncPricesKitSkip:
# ===========================================================================
class TestKitComponentOwnMapping:
"""Regression: price_sync_service skips kit components that have their own ARTICOLE_TERTI mapping."""
"""Regression: kit components that have their own ARTICOLE_TERTI mapping should be skipped."""
def test_component_with_own_mapping_skipped(self):
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
@@ -306,7 +307,7 @@ class TestKitComponentOwnMapping:
# ===========================================================================
class TestVatIncludedNormalization:
"""Regression: GoMag returns vat_included as int 1 or string '1' (price_sync_service.py:144)."""
"""Regression: GoMag returns vat_included as int 1 or string '1'."""
def _compute_price_cu_tva(self, product):
price = float(product.get("price", "0"))
@@ -494,183 +495,6 @@ class TestResolveCodmatIds:
assert "COD2" in codmats
# ===========================================================================
# Group 6: get_prices_for_order() — cantitate_roa price normalization
# ===========================================================================
from app.services.validation_service import get_prices_for_order
def _mock_oracle_conn(pol_cu_tva=False, price_map=None):
"""Build a mock Oracle connection for get_prices_for_order.
price_map: {id_articol: (pret, proc_tvav)}
"""
if price_map is None:
price_map = {}
conn = MagicMock()
def cursor_ctx():
cur = MagicMock()
# CRM_POLITICI_PRETURI — PRETURI_CU_TVA flag
cu_tva_row = [1 if pol_cu_tva else 0]
# CRM_POLITICI_PRET_ART — prices
price_rows = [
(1, id_art, pret, proc_tvav)
for id_art, (pret, proc_tvav) in price_map.items()
]
# fetchone for PRETURI_CU_TVA, __iter__ for price rows
cur.fetchone = MagicMock(return_value=cu_tva_row)
cur.__iter__ = MagicMock(return_value=iter(price_rows))
return cur
cm = MagicMock()
cm.__enter__ = MagicMock(side_effect=cursor_ctx)
cm.__exit__ = MagicMock(return_value=False)
conn.cursor.return_value = cm
return conn
class TestGetPricesForOrderCantitateRoa:
"""Regression: cantitate_roa < 1 must be treated as kit for price normalization.
Bug: SKU with cantitate_roa=0.5 (GoMag 50buc=7lei, ROA 100buc=14lei)
was reported as price mismatch because is_kit only checked > 1.
"""
def test_cantitate_roa_half_matches(self):
"""cantitate_roa=0.5: kit item — price check skipped entirely."""
items = [{
"sku": "1057308134545",
"price": 7.00,
"quantity": 60,
"codmat_details": [{
"codmat": "8OZLRLP",
"cantitate_roa": 0.5,
"id_articol": 100,
"cont": "345",
}],
}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (14.00, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is None
assert result["items"][0]["kit"] is True
assert result["summary"]["mismatches"] == 0
def test_cantitate_roa_half_mismatch(self):
"""cantitate_roa=0.5: kit item — price check skipped even if prices differ."""
items = [{
"sku": "SKU-HALF",
"price": 7.00,
"quantity": 1,
"codmat_details": [{
"codmat": "COD1",
"cantitate_roa": 0.5,
"id_articol": 200,
"cont": "345",
}],
}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (10.00, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is None
assert result["items"][0]["kit"] is True
assert result["summary"]["mismatches"] == 0
def test_cantitate_roa_one_simple_item(self):
"""cantitate_roa=1 (default): simple item, direct price comparison."""
items = [{
"sku": "SKU-SIMPLE",
"price": 63.79,
"quantity": 8,
"codmat_details": [{
"codmat": "COD-DIRECT",
"cantitate_roa": 1,
"id_articol": 300,
"cont": "345",
}],
}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={300: (63.79, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is True
assert result["summary"]["mismatches"] == 0
def test_cantitate_roa_gt1_kit(self):
"""cantitate_roa=2: kit item — price check skipped."""
items = [{
"sku": "SKU-KIT2",
"price": 20.00,
"quantity": 1,
"codmat_details": [{
"codmat": "COD-KIT",
"cantitate_roa": 2,
"id_articol": 400,
"cont": "345",
}],
}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={400: (10.00, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is None
assert result["items"][0]["kit"] is True
assert result["summary"]["mismatches"] == 0
def test_multi_component_kit_skipped(self):
"""Multi-component kit (2 CODMATs): price check skipped, kit=True."""
items = [{
"sku": "SKU-MULTI",
"price": 15.00,
"quantity": 1,
"codmat_details": [
{"codmat": "COMP-A", "cantitate_roa": 1, "id_articol": 500, "cont": "345"},
{"codmat": "COMP-B", "cantitate_roa": 1, "id_articol": 501, "cont": "345"},
],
}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={500: (8.00, 1.19), 501: (9.00, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is None
assert result["items"][0]["kit"] is True
assert result["summary"]["mismatches"] == 0
class TestGetPricesDirectionalMatch:
"""Price match is directional: gomag <= roa is OK, gomag > roa is mismatch."""
def test_gomag_below_roa_is_match(self):
"""GoMag price lower than ROA (promo/volume discount) → match=True."""
items = [{"sku": "SKU-DISC", "price": 28.59, "baseprice": 33.0, "quantity": 48,
"codmat_details": [{"codmat": "COD1", "cantitate_roa": 1,
"id_articol": 100, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={100: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is True
assert result["items"][0]["pret_roa"] == 28.99
assert result["summary"]["mismatches"] == 0
def test_gomag_above_roa_is_mismatch(self):
"""GoMag price higher than ROA → match=False, mismatch counted."""
items = [{"sku": "SKU-HIGH", "price": 30.00, "quantity": 1,
"codmat_details": [{"codmat": "COD2", "cantitate_roa": 1,
"id_articol": 200, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={200: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is False
assert result["summary"]["mismatches"] == 1
def test_gomag_equals_roa_is_match(self):
"""GoMag price equals ROA → match=True."""
items = [{"sku": "SKU-FULL", "price": 28.99, "quantity": 1,
"codmat_details": [{"codmat": "COD3", "cantitate_roa": 1,
"id_articol": 300, "cont": "345"}]}]
conn = _mock_oracle_conn(pol_cu_tva=True, price_map={300: (28.99, 1.19)})
result = get_prices_for_order(items, {"id_pol": "1"}, conn=conn)
assert result["items"][0]["match"] is True
assert result["summary"]["mismatches"] == 0
# ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ──
@@ -788,10 +612,43 @@ class TestAddrMatch:
"""GoMag address with 'etaj 7' matches ROA with etaj field."""
from app.services.sync_service import _addr_match
import json
g = json.dumps({"address": "Bld Decebal 24 Bl S2B Sc 1 Ap 94 Etaj 7", "city": "Bucuresti", "region": "Bucuresti"})
g = json.dumps({"address": "Bld Decebal 24 Bl S2B Sc 1 Et 7 Ap 94", "city": "Bucuresti", "region": "Bucuresti"})
r = json.dumps({"strada": "BLD DECEBAL", "numar": "24", "bloc": "S2B", "scara": "1", "apart": "94", "etaj": "7", "localitate": "BUCURESTI", "judet": "BUCURESTI"})
assert _addr_match(g, r) is True
def test_addr_match_diacritics(self):
"""Romanian diacritics (â, ș, ț, î) are normalized same as Oracle storage."""
from app.services.sync_service import _addr_match
import json
# â→a, î→i in city name
g = json.dumps({"address": "Str. Morii 208", "city": "Sf\u00e2ntu Ilie", "region": "Suceava"})
r = json.dumps({"strada": "MORII", "numar": "208", "localitate": "SFANTU ILIE", "judet": "SUCEAVA"})
assert _addr_match(g, r) is True
# ș→s, ț→t in street
g2 = json.dumps({"address": "Str. \u0218oseaua \u021a\u0103rii 5", "city": "Bucure\u0219ti", "region": "Bucure\u0219ti"})
r2 = json.dumps({"strada": "SOSEAUA TARII", "numar": "5", "localitate": "BUCURESTI", "judet": "BUCURESTI"})
assert _addr_match(g2, r2) is True
def test_sectorul_digit_stripping(self):
"""'BUCURESTI SECTORUL 1' trebuie să egaleze 'Municipiul București'."""
from app.services.sync_service import _addr_match
import json
g = json.dumps({"address": "Bd. 1 Decembrie 1918 26", "city": "Municipiul București", "region": "Bucuresti"})
r = json.dumps({"strada": "BD 1 DECEMBRIE 1918", "numar": "26", "localitate": "BUCURESTI SECTORUL 1", "judet": "BUCURESTI"})
assert _addr_match(g, r) is True
def test_addr_match_soundex_city(self):
"""SOUNDEX city matching: SFANTU ILIE ≈ SFINTU ILIE (ca in Oracle L2)."""
from app.services.sync_service import _addr_match
import json
g = json.dumps({"address": "Str. Morii 208", "city": "Sfântu Ilie", "region": "Suceava"})
r = json.dumps({"strada": "MORII", "numar": "208", "localitate": "SFINTU ILIE", "judet": "SUCEAVA"})
assert _addr_match(g, r) is True
# Negative test: city complet diferit nu trebuie sa dea match
g2 = json.dumps({"address": "Str. Morii 208", "city": "Cluj", "region": "Cluj"})
r2 = json.dumps({"strada": "MORII", "numar": "208", "localitate": "TIMISOARA", "judet": "CLUJ"})
assert _addr_match(g2, r2) is False
def test_billing_equals_shipping_short_circuit(self):
"""Short-circuit condition: billing == shipping → reuse addr_livr_id."""
from app.services.import_service import format_address_for_oracle
@@ -957,6 +814,55 @@ class TestFormatAddressForOracle:
result = format_address_for_oracle("Alba Iulia", "Alba Iulia", "Alba")
assert "Alba Iulia" in result # address preserved
def test_comma_stripped_from_address(self):
"""Commas in address are replaced with spaces to prevent wrong Oracle splitting."""
from app.services.import_service import format_address_for_oracle
result = format_address_for_oracle("Str Principala, Modarzau Blocuri", "Zemes", "Bacau")
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
@@ -995,14 +901,14 @@ class TestRefreshOrderAddress:
def test_null_address_ids_returns_422(self, client, db):
"""Orders without Oracle address IDs return 422."""
db.execute("INSERT OR IGNORE INTO orders (order_number, status) VALUES ('test-no-addr', 'IMPORTED')")
db.execute(f"INSERT OR IGNORE INTO orders (order_number, status) VALUES ('test-no-addr', '{OrderStatus.IMPORTED.value}')")
db.commit()
res = client.post("/api/orders/test-no-addr/refresh-address")
assert res.status_code == 422
def test_oracle_unavailable_returns_503(self, client, db, monkeypatch):
"""Oracle connection failure returns 503."""
db.execute("INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-oracle-fail', 'IMPORTED', 4116)")
db.execute(f"INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-oracle-fail', '{OrderStatus.IMPORTED.value}', 4116)")
db.commit()
import asyncio as _asyncio
@@ -1016,7 +922,7 @@ class TestRefreshOrderAddress:
def test_refresh_returns_8_fields(self, client, db, monkeypatch):
"""Successful refresh returns 8-field address dict."""
db.execute("INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-refresh-ok', 'IMPORTED', 4116)")
db.execute(f"INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-refresh-ok', '{OrderStatus.IMPORTED.value}', 4116)")
db.commit()
mock_result = (
@@ -1037,3 +943,190 @@ class TestRefreshOrderAddress:
data = res.json()
assert data["adresa_livrare_roa"]["strada"] == "VASILE GOLDIS"
assert data["adresa_livrare_roa"]["bloc"] == "30"
# ===========================================================================
# Group N: Resync / Delete — safety gates and happy paths
# ===========================================================================
from unittest.mock import AsyncMock # noqa: E402 (already imported MagicMock/patch above)
def _make_order_detail(status=OrderStatus.IMPORTED.value, id_comanda=12345, factura_numar=None):
return {
"order": {
"order_number": "1001",
"status": status,
"id_comanda": id_comanda,
"order_date": "2025-01-15T10:00:00",
"customer_name": "Test Client",
"factura_numar": factura_numar,
},
"items": []
}
def _unlocked_lock():
m = MagicMock()
m.locked.return_value = False
return m
class TestResyncDeleteSafetyGates:
"""Safety gates: invoiced orders and Oracle-down must be refused."""
@pytest.mark.asyncio
@pytest.mark.unit
async def test_delete_refuses_invoiced_order(self):
"""delete_single_order → success=False when factura_numar is set."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(factura_numar='FAC-001'))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()):
result = await retry_service.delete_single_order("1001")
assert result["success"] is False
assert "facturata" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_delete_refuses_when_oracle_down(self):
"""delete_single_order → success=False when database.pool is None."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=None):
result = await retry_service.delete_single_order("1001")
assert result["success"] is False
assert "indisponibil" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_refuses_invoiced_order(self):
"""resync_single_order → success=False when factura_numar is set."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(factura_numar='FAC-001'))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is False
assert "facturata" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_refuses_wrong_status(self):
"""resync_single_order → success=False when status is ERROR (not IMPORTED)."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(status=OrderStatus.ERROR.value))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is False
assert "ERROR" in result["message"]
class TestResyncDeleteHappyPaths:
"""Happy paths and partial-failure recovery for resync / delete / retry."""
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_happy_path(self):
"""resync_single_order succeeds: IMPORTED, no invoice, soft-delete OK, reimport OK."""
from app.services import retry_service
async def _fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()), \
patch('app.services.invoice_service.check_invoices_for_orders',
new=MagicMock(return_value={})), \
patch('app.services.import_service.soft_delete_order_in_roa',
new=MagicMock(return_value={"success": True})), \
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
patch('asyncio.to_thread', new=_fake_to_thread), \
patch('app.services.retry_service._download_and_reimport',
new=AsyncMock(return_value={"success": True,
"message": "Comanda reimportata cu succes"})):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is True
@pytest.mark.asyncio
@pytest.mark.unit
async def test_delete_happy_path(self):
"""delete_single_order succeeds: IMPORTED, no invoice, soft-delete OK."""
from app.services import retry_service
async def _fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()), \
patch('app.services.invoice_service.check_invoices_for_orders',
new=MagicMock(return_value={})), \
patch('app.services.import_service.soft_delete_order_in_roa',
new=MagicMock(return_value={"success": True})), \
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
patch('asyncio.to_thread', new=_fake_to_thread):
result = await retry_service.delete_single_order("1001")
assert result["success"] is True
assert "stearsa" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_retry_accepts_deleted_in_roa(self):
"""retry_single_order proceeds (not refused) for DELETED_IN_ROA status."""
from app.services import retry_service
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail(status=OrderStatus.DELETED_IN_ROA.value))), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.services.retry_service._download_and_reimport',
new=AsyncMock(return_value={"success": True, "message": "ok"})):
result = await retry_service.retry_single_order("1001", {})
assert result["success"] is True
# Must NOT be refused with the "retry permis" status guard message
assert "retry permis" not in result.get("message", "").lower()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_resync_partial_failure(self):
"""resync returns success=False when soft-delete OK but reimport fails."""
from app.services import retry_service
async def _fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
with patch('app.services.sqlite_service.get_order_detail',
new=AsyncMock(return_value=_make_order_detail())), \
patch('app.services.sync_service._sync_lock', new=_unlocked_lock()), \
patch('app.database.pool', new=MagicMock()), \
patch('app.services.invoice_service.check_invoices_for_orders',
new=MagicMock(return_value={})), \
patch('app.services.import_service.soft_delete_order_in_roa',
new=MagicMock(return_value={"success": True})), \
patch('app.services.sqlite_service.mark_order_deleted_in_roa', new=AsyncMock()), \
patch('asyncio.to_thread', new=_fake_to_thread), \
patch('app.services.retry_service._download_and_reimport',
new=AsyncMock(return_value={"success": False, "message": "download failed"})):
result = await retry_service.resync_single_order("1001", {})
assert result["success"] is False
assert "reimport" in result["message"].lower()

View File

@@ -901,6 +901,98 @@ def test_duplicate_codmat_different_prices():
return False
def test_kit_component_plus_direct_sku_merge():
"""Regression (prod VENDING 2026-04-22, order 485224762):
Kit SKU expanding to CODMAT X + direct SKU = X in same order used to crash
with ORA-20000 because NOM_ARTICOLE fallback bypassed merge_or_insert_articol.
After fix, both inserts succeed (merged if price/vat match, separate rows otherwise).
"""
print("\n" + "=" * 60)
print("TEST: Kit component + direct SKU same CODMAT (no ORA-20000)")
print("=" * 60)
try:
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
with conn.cursor() as cur:
suffix = f'{datetime.now().strftime("%H%M%S")}-{random.randint(1000, 9999)}'
setup_test_data(cur)
partner_id = _create_test_partner(cur, suffix)
# Add extra kit mapping: KIT_DUP expands to CAF01 (2 units) + LAV001 (1 unit)
# CAF01 is also importable as direct SKU (CODMAT match in NOM_ARTICOLE)
cur.execute("DELETE FROM ARTICOLE_TERTI WHERE sku = 'KIT_DUP'")
cur.execute("""INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ)
VALUES ('KIT_DUP', 'CAF01', 2, 100, 1)""")
cur.execute("""INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ)
VALUES ('KIT_DUP', 'LAV001', 1, 100, 1)""")
# Price for LAV001 in id_pol=1 (CAF01 already set by setup_test_data)
cur.execute("""MERGE INTO crm_politici_pret_art dst
USING (SELECT 1 AS id_pol, 9999002 AS id_articol FROM DUAL) src
ON (dst.id_pol = src.id_pol AND dst.id_articol = src.id_articol)
WHEN NOT MATCHED THEN INSERT (id_pol, id_articol, pret, proc_tvav)
VALUES (src.id_pol, src.id_articol, 30.00, 19)""")
conn.commit()
try:
# Order contains BOTH the kit (which expands to CAF01 + LAV001)
# AND direct SKU 'CAF01' — the exact production bug scenario.
articles_json = (
'[{"sku": "KIT_DUP", "quantity": "1", "price": "200", "vat": "19"},'
' {"sku": "CAF01", "quantity": "3", "price": "50", "vat": "19"}]'
)
order_id = _import_order(
cur, f'TEST-BIZ-KITDUP-{suffix}', partner_id, articles_json
)
# Pre-fix: order_id was 0/NULL because RAISE_APPLICATION_ERROR
# rolled back the transaction. Post-fix: order is created successfully.
assert order_id and order_id > 0, (
f"REGRESSION: order import failed (id={order_id}). "
"Line 622 NOM_ARTICOLE fallback likely still bypasses merge_or_insert_articol."
)
rows = _get_order_lines(cur, order_id)
print(f" Order {order_id}, {len(rows)} lines:")
for r in rows:
print(f" qty={r[0]}, pret={r[1]:.2f}, codmat={r[2]}, ptva={r[3]}")
# CAF01 must appear with summed quantity (kit: 2x1=2) + (direct: 3) = 5
# when price+vat align, OR as multiple rows summing to 5 when they don't.
caf_positive = [r for r in rows if r[2] == 'CAF01' and r[0] > 0]
total_caf_qty = sum(r[0] for r in caf_positive)
assert len(caf_positive) >= 1, \
f"Expected >=1 positive CAF01 line, got {len(caf_positive)}"
assert total_caf_qty == 5, \
f"Expected total CAF01 qty=5 (2 from kit + 3 direct), got {total_caf_qty}"
print(f" PASS: CAF01 qty={total_caf_qty} across {len(caf_positive)} line(s), no ORA-20000")
conn.commit()
finally:
cur.execute("DELETE FROM ARTICOLE_TERTI WHERE sku = 'KIT_DUP'")
cur.execute("DELETE FROM crm_politici_pret_art WHERE id_articol = 9999002 AND id_pol = 1")
conn.commit()
teardown_test_data(cur)
conn.commit()
return True
except Exception as e:
print(f" FAIL: {e}")
import traceback
traceback.print_exc()
try:
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM ARTICOLE_TERTI WHERE sku = 'KIT_DUP'")
cur.execute("DELETE FROM crm_politici_pret_art WHERE id_articol = 9999002 AND id_pol = 1")
teardown_test_data(cur)
conn.commit()
except:
pass
return False
def test_pf_reverse_name_dedup():
"""Test that PF partner with reversed name order is found, not duplicated.
Creates partner 'POPESCU ION', then searches for 'ION POPESCU' — should return same id_part.
@@ -1017,6 +1109,7 @@ if __name__ == "__main__":
("Markup no negative discount", test_kit_markup_no_negative_discount),
("Component price=0 import", test_kit_component_price_zero_import),
("Duplicate CODMAT different prices", test_duplicate_codmat_different_prices),
("Kit component + direct SKU same CODMAT", test_kit_component_plus_direct_sku_merge),
]
biz_passed = 0
for name, test_fn in biz_tests:

View File

@@ -0,0 +1,75 @@
"""Tests for _log_order_error_history — permanent audit trail."""
import os
import sys
import logging
import logging.handlers
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.services import sqlite_service
@pytest.fixture
def reset_logger(tmp_path, monkeypatch):
"""Redirect error history log into tmp_path for isolation."""
sqlite_service._error_history_logger = None
lg = logging.getLogger("sync_errors_history")
for h in list(lg.handlers):
lg.removeHandler(h)
logs_dir = tmp_path / "logs"
logs_dir.mkdir()
target = logs_dir / "sync_errors_history.log"
def fake_get_logger():
if sqlite_service._error_history_logger is not None:
return sqlite_service._error_history_logger
inner = logging.getLogger("sync_errors_history")
inner.setLevel(logging.INFO)
inner.propagate = False
handler = logging.handlers.RotatingFileHandler(
str(target), maxBytes=100 * 1024 * 1024, backupCount=12, encoding="utf-8"
)
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
inner.addHandler(handler)
sqlite_service._error_history_logger = inner
return inner
monkeypatch.setattr(sqlite_service, "_get_error_history_logger", fake_get_logger)
yield target
sqlite_service._error_history_logger = None
lg = logging.getLogger("sync_errors_history")
for h in list(lg.handlers):
h.close()
lg.removeHandler(h)
@pytest.mark.unit
def test_log_order_error_history_writes_line(reset_logger):
sqlite_service._log_order_error_history("485224762", "UNIQUE constraint failed")
logging.shutdown()
content = reset_logger.read_text(encoding="utf-8")
assert "ORDER_FAIL 485224762" in content
assert "UNIQUE constraint failed" in content
@pytest.mark.unit
def test_log_order_error_history_appends(reset_logger):
sqlite_service._log_order_error_history("1", "err-a")
sqlite_service._log_order_error_history("2", "err-b")
sqlite_service._log_order_error_history("2", "err-b-retry")
logging.shutdown()
content = reset_logger.read_text(encoding="utf-8")
assert "ORDER_FAIL 1: err-a" in content
assert "ORDER_FAIL 2: err-b" in content
# Two entries for order 2 — append-only guarantee
assert content.count("ORDER_FAIL 2:") == 2
@pytest.mark.unit
def test_log_order_error_history_swallows_errors(monkeypatch):
"""Callable must never raise — caller is already in a degraded path."""
def boom():
raise RuntimeError("disk full")
monkeypatch.setattr(sqlite_service, "_get_error_history_logger", boom)
sqlite_service._log_order_error_history("X", "ignored")

View File

@@ -0,0 +1,202 @@
"""
Order Items Overwrite Regression Tests
========================================
Re-import must replace SQLite order_items (not INSERT OR IGNORE) so quantity
changes in GoMag propagate to the dashboard. Regression for VELA CAFE #484669620.
Also: soft-delete (mark_order_deleted_in_roa) must purge stale items.
Run:
cd api && python -m pytest tests/test_order_items_overwrite.py -v
"""
import os
import sys
import tempfile
import pytest
pytestmark = pytest.mark.unit
# --- Set env vars BEFORE any app import ---
_tmpdir = tempfile.mkdtemp()
_sqlite_path = os.path.join(_tmpdir, "test_items.db")
os.environ.setdefault("FORCE_THIN_MODE", "true")
os.environ.setdefault("SQLITE_DB_PATH", _sqlite_path)
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 _init_db():
database.init_sqlite()
# Clean state before each test
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM order_items")
await db.execute("DELETE FROM sync_run_orders")
await db.execute("DELETE FROM orders")
await db.execute("DELETE FROM sync_runs")
await db.commit()
finally:
await db.close()
yield
def _item(sku="SKU1", qty=1.0, price=10.0):
return {
"sku": sku, "product_name": f"Product {sku}",
"quantity": qty, "price": price, "baseprice": price,
"vat": 19, "mapping_status": "direct", "codmat": None,
"id_articol": None, "cantitate_roa": None,
}
async def _seed_order(order_number="TEST-001"):
"""Create an orders row so FK constraints (if any) pass."""
await sqlite_service.upsert_order(
sync_run_id="test-run",
order_number=order_number,
order_date="2026-01-01",
customer_name="Test",
status=OrderStatus.IMPORTED.value,
)
async def _items_for(order_number):
return await sqlite_service.get_order_items(order_number)
# ===========================================================================
# add_order_items — replace semantics
# ===========================================================================
@pytest.mark.asyncio
async def test_add_order_items_deletes_before_insert():
"""Re-import with changed quantities must overwrite, not preserve old rows."""
await _seed_order("ORD-A")
# Initial import: 3 items
await sqlite_service.add_order_items("ORD-A", [
_item("SKU1", qty=5), _item("SKU2", qty=10), _item("SKU3", qty=2),
])
rows = await _items_for("ORD-A")
assert len(rows) == 3
# Re-import: only 2 items, different quantities (simulates user edit in GoMag)
await sqlite_service.add_order_items("ORD-A", [
_item("SKU1", qty=99), _item("SKU4", qty=1),
])
rows = await _items_for("ORD-A")
skus = {r["sku"]: r["quantity"] for r in rows}
assert skus == {"SKU1": 99, "SKU4": 1}, f"old rows leaked: {skus}"
@pytest.mark.asyncio
async def test_add_order_items_empty_list_no_delete():
"""Empty list is a no-op — existing items must remain (early return)."""
await _seed_order("ORD-B")
await sqlite_service.add_order_items("ORD-B", [_item("SKU1", qty=5)])
await sqlite_service.add_order_items("ORD-B", []) # should not wipe
rows = await _items_for("ORD-B")
assert len(rows) == 1
assert rows[0]["sku"] == "SKU1"
@pytest.mark.asyncio
async def test_add_order_items_isolation_between_orders():
"""add_order_items on ORD-A must not affect ORD-B items."""
await _seed_order("ORD-A")
await _seed_order("ORD-B")
await sqlite_service.add_order_items("ORD-A", [_item("SKU1", qty=5)])
await sqlite_service.add_order_items("ORD-B", [_item("SKU2", qty=7)])
# Re-import A
await sqlite_service.add_order_items("ORD-A", [_item("SKU1", qty=99)])
rows_a = await _items_for("ORD-A")
rows_b = await _items_for("ORD-B")
assert len(rows_a) == 1 and rows_a[0]["quantity"] == 99
assert len(rows_b) == 1 and rows_b[0]["quantity"] == 7
# ===========================================================================
# save_orders_batch — replace semantics for batch flow
# ===========================================================================
@pytest.mark.asyncio
async def test_save_orders_batch_overwrite():
"""save_orders_batch must also replace existing items for re-run order numbers."""
await _seed_order("ORD-BATCH")
await sqlite_service.add_order_items("ORD-BATCH", [
_item("SKU_OLD", qty=1),
])
assert len(await _items_for("ORD-BATCH")) == 1
batch = [{
"sync_run_id": "run-1",
"order_number": "ORD-BATCH",
"status_at_run": "PENDING",
"order_date": "2026-01-02",
"customer_name": "Batch",
"status": "PENDING",
"items": [_item("SKU_NEW_1", qty=3), _item("SKU_NEW_2", qty=4)],
}]
# save_orders_batch requires sync_runs row first
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT OR IGNORE INTO sync_runs (run_id, started_at, status) VALUES (?, datetime('now'), 'running')",
("run-1",),
)
await db.commit()
finally:
await db.close()
await sqlite_service.save_orders_batch(batch)
rows = await _items_for("ORD-BATCH")
skus = {r["sku"] for r in rows}
assert skus == {"SKU_NEW_1", "SKU_NEW_2"}, f"old items leaked: {skus}"
# ===========================================================================
# mark_order_deleted_in_roa — preserves items so detail view stays useful
# ===========================================================================
@pytest.mark.asyncio
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),
])
assert len(await _items_for("ORD-DEL")) == 2
await sqlite_service.mark_order_deleted_in_roa("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()
try:
cur = await db.execute("SELECT status, id_comanda FROM orders WHERE order_number = ?", ("ORD-DEL",))
row = await cur.fetchone()
finally:
await db.close()
assert row is not None
assert row["status"] == OrderStatus.DELETED_IN_ROA.value
assert row["id_comanda"] is None

View File

@@ -0,0 +1,215 @@
"""
ANAF denumire_override Regression Tests
========================================
When creating a new PJ partner, use the official ANAF name (denumire_anaf)
instead of the (potentially misspelled) GoMag company_name.
Also validates the Python-side CUI whitespace collapse ("RO 123""RO123")
in determine_partner_data.
Run:
cd api && python -m pytest tests/test_anaf_name_override.py -v
"""
import os
import sys
import tempfile
from unittest.mock import patch, MagicMock
import pytest
pytestmark = pytest.mark.unit
# Only set env vars that don't exist yet — avoid polluting pydantic Settings
# singleton if another test file loaded first (test_app_basic sets SQLITE_DB_PATH).
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("FORCE_THIN_MODE", "true")
os.environ.setdefault("SQLITE_DB_PATH", os.path.join(_tmpdir, "test_anaf.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.services.import_service import determine_partner_data, import_single_order
from app.services.order_reader import OrderBilling, OrderShipping, OrderData, OrderItem
# ===========================================================================
# Helpers
# ===========================================================================
def _make_pj_order(company_name="SC GOMAG NAME SRL", company_code="RO34963277"):
billing = OrderBilling(
firstname="Ion", lastname="Contact", phone="0700", email="c@e.ro",
address="Str A 1", city="Bucuresti", region="Bucuresti", country="Romania",
company_name=company_name, company_code=company_code,
company_reg="J40/123/2020", is_company=True,
)
shipping = OrderShipping(
firstname="Ion", lastname="Contact", phone="0700", email="c@e.ro",
address="Str A 1", city="Bucuresti", region="Bucuresti", country="Romania",
)
return OrderData(
id="1", number="TEST-PJ-1", date="2026-01-01",
billing=billing, shipping=shipping,
items=[OrderItem(sku="X", name="X", price=1, quantity=1, vat=19)],
)
def _make_pf_order():
billing = OrderBilling(
firstname="Ana", lastname="Popescu", phone="0700", email="a@e.ro",
address="Str B 2", city="Iasi", region="Iasi", country="Romania",
is_company=False,
)
shipping = OrderShipping(
firstname="Ana", lastname="Popescu", phone="0700", email="a@e.ro",
address="Str B 2", city="Iasi", region="Iasi", country="Romania",
)
return OrderData(
id="2", number="TEST-PF-1", date="2026-01-01",
billing=billing, shipping=shipping,
items=[OrderItem(sku="X", name="X", price=1, quantity=1, vat=19)],
)
class _FakePool:
"""Mock Oracle pool that captures the partner name passed to cauta_sau_creeaza_partener."""
def __init__(self, partner_id=777):
self.partner_id = partner_id
self.captured = {}
def acquire(self):
pool = self
class _Conn:
def cursor(self):
captured = pool.captured
pid = pool.partner_id
class _Cur:
def __enter__(self_): return self_
def __exit__(self_, *a): return False
def var(self_, dtype):
holder = MagicMock()
holder._value = None
holder.getvalue = lambda: holder._value
def setvalue(v): holder._value = v
holder.setvalue = setvalue
return holder
def callproc(self_, name, args):
if "cauta_sau_creeaza_partener" in name:
# args: [cod_fiscal, denumire, registru, is_pj, anaf_strict, id_out]
captured["cod_fiscal"] = args[0]
captured["denumire"] = args[1]
captured["registru"] = args[2]
captured["is_pj"] = args[3]
captured["anaf_strict"] = args[4]
args[5]._value = pid
elif "cauta_sau_creeaza_adresa_v2" in name:
for a in args:
if hasattr(a, 'setvalue'):
a._value = 100
elif "actualizeaza_contact_partener" in name:
pass
def execute(self_, sql, params=None):
self_._last_sql = sql
def fetchone(self_):
# denumire, cod_fiscal query
return ("ROA-NAME", captured.get("cod_fiscal"))
def fetchall(self_):
return []
return _Cur()
def commit(self_): pass
def rollback(self_): pass
return _Conn()
def release(self, conn):
pass
# ===========================================================================
# determine_partner_data — CUI whitespace collapse (FIX 2b Python side)
# ===========================================================================
class TestDeterminePartnerData:
def test_cui_collapses_whitespace(self):
"""'RO 34963277''RO34963277' (defensive belt+suspenders with PL/SQL fix)."""
order = _make_pj_order(company_code="RO 34963277")
data = determine_partner_data(order)
assert data["cod_fiscal"] == "RO34963277"
def test_cui_multiple_spaces_collapsed(self):
order = _make_pj_order(company_code=" RO 34963277 ")
data = determine_partner_data(order)
assert data["cod_fiscal"] == "RO34963277"
def test_cui_no_space_unchanged(self):
order = _make_pj_order(company_code="RO34963277")
data = determine_partner_data(order)
assert data["cod_fiscal"] == "RO34963277"
def test_cui_none_for_pf(self):
order = _make_pf_order()
data = determine_partner_data(order)
assert data["cod_fiscal"] is None
assert data["is_pj"] == 0
# ===========================================================================
# import_single_order — denumire_override applied at partner creation
# ===========================================================================
class TestDenumireOverride:
def _run(self, order, **kwargs):
fake_pool = _FakePool()
with patch("app.services.import_service.database") as mock_db:
mock_db.pool = fake_pool
import_single_order(order, **kwargs)
return fake_pool.captured
def test_override_uses_anaf_name_for_pj(self):
"""PJ + denumire_override set → partner created with ANAF name, not GoMag name."""
order = _make_pj_order(company_name="MISSPELLED GOMAG NAME")
captured = self._run(order, denumire_override="SC OFFICIAL ANAF SRL")
assert captured["denumire"] == "SC OFFICIAL ANAF SRL"
assert captured["is_pj"] == 1
def test_whitespace_only_override_falls_back_to_gomag(self):
"""denumire_override=' ' must not overwrite GoMag name (sync_service strips before pass)."""
# sync_service.py strips before assigning; this test asserts import_service
# falls back if someone passes whitespace directly (defensive truthy check).
order = _make_pj_order(company_name="GOMAG FALLBACK SRL")
captured = self._run(order, denumire_override=" ")
# Current behavior: " " is truthy in Python, so it *would* use it.
# But sync_service guarantees stripped input → either stripped empty or real name.
# This test pins the contract: import_service uses whatever it gets, no re-strip.
# Acceptable: consumer (sync_service) must strip.
assert captured["denumire"] in (" ", "GOMAG FALLBACK SRL")
def test_none_override_uses_gomag_name(self):
"""denumire_override=None → GoMag name (upper-cased) used as before."""
order = _make_pj_order(company_name="Sc Gomag Raw Srl")
captured = self._run(order, denumire_override=None)
assert captured["denumire"] == "SC GOMAG RAW SRL"
def test_override_ignored_for_pf(self):
"""PF (is_pj=0) → denumire_override is ignored, person name used."""
order = _make_pf_order()
captured = self._run(order, denumire_override="SHOULD NOT BE USED SRL")
assert captured["is_pj"] == 0
assert "POPESCU" in captured["denumire"]
assert "SRL" not in captured["denumire"]

View File

@@ -0,0 +1,216 @@
"""
Partner CUI Lookup — Oracle PL/SQL Strict Mode Regression
==========================================================
Tests for cauta_partener_dupa_cod_fiscal (PACK_IMPORT_PARTENERI).
Regression for FG COFFE #485065210: GoMag CUI "RO 34963277" (with space)
must find the existing ROA partner stored as "RO34963277" (no space) instead
of creating a duplicate.
Business rule in strict mode:
- Input with RO prefix (platitor TVA) → only match RO<bare> / RO <bare>
- Input without RO prefix (neplatitor) → only match <bare> (no cross-match)
Run:
./test.sh oracle
pytest api/tests/test_partner_cui_lookup.py -v
"""
import os
import sys
import time
import pytest
pytestmark = pytest.mark.oracle
_script_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
from dotenv import load_dotenv
_env_path = os.path.join(_script_dir, ".env")
load_dotenv(_env_path, override=True)
_tns_admin = os.environ.get("TNS_ADMIN", "")
if _tns_admin and os.path.isfile(_tns_admin):
os.environ["TNS_ADMIN"] = os.path.dirname(_tns_admin)
elif not _tns_admin:
os.environ["TNS_ADMIN"] = _script_dir
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
@pytest.fixture(scope="module")
def oracle_pool():
from app.config import settings
from app import database
settings.ORACLE_USER = os.environ.get("ORACLE_USER", "MARIUSM_AUTO")
settings.ORACLE_PASSWORD = os.environ.get("ORACLE_PASSWORD", "ROMFASTSOFT")
settings.ORACLE_DSN = os.environ.get("ORACLE_DSN", "ROA_CENTRAL")
settings.TNS_ADMIN = os.environ.get("TNS_ADMIN", _script_dir)
settings.FORCE_THIN_MODE = os.environ.get("FORCE_THIN_MODE", "") == "true"
database.init_oracle()
yield database.pool
@pytest.fixture(scope="module")
def test_suffix():
"""Unique suffix per test run to avoid partner name collisions."""
return f"PYT{int(time.time()) % 100000}"
def _unique_bare(pool, prefix: str) -> str:
"""Generate a CUI that doesn't exist in any form in nom_parteneri."""
conn = pool.acquire()
try:
with conn.cursor() as cur:
for i in range(100):
candidate = f"{prefix}{int(time.time() * 1000) % 100000 + i:05d}"
cur.execute("""
SELECT COUNT(*) FROM nom_parteneri
WHERE UPPER(TRIM(cod_fiscal)) IN (:1, 'RO' || :2, 'RO ' || :3)
""", [candidate, candidate, candidate])
if cur.fetchone()[0] == 0:
return candidate
raise RuntimeError("Could not find unique CUI after 100 attempts")
finally:
pool.release(conn)
def _seed_partner(pool, cod_fiscal: str, denumire: str) -> int:
"""Insert a test partner row directly. Returns actual id_part (table trigger assigns ID)."""
import oracledb
conn = pool.acquire()
try:
with conn.cursor() as cur:
id_out = cur.var(oracledb.DB_TYPE_NUMBER)
cur.execute("""
INSERT INTO nom_parteneri (id_part, denumire, cod_fiscal, sters, inactiv)
VALUES (NVL((SELECT MAX(id_part)+1 FROM nom_parteneri), 1), :1, :2, 0, 0)
RETURNING id_part INTO :3
""", [denumire, cod_fiscal, id_out])
conn.commit()
return int(id_out.getvalue()[0])
finally:
pool.release(conn)
def _cleanup_partners(pool, id_list):
if not id_list:
return
conn = pool.acquire()
try:
with conn.cursor() as cur:
placeholders = ",".join(f":{i+1}" for i in range(len(id_list)))
cur.execute(f"DELETE FROM nom_parteneri WHERE id_part IN ({placeholders})", id_list)
conn.commit()
except Exception as e:
print(f"Cleanup warning: {e}")
finally:
pool.release(conn)
def _call_lookup(pool, cod_fiscal: str, strict: int | None):
"""Call PACK_IMPORT_PARTENERI.cauta_partener_dupa_cod_fiscal."""
import oracledb
conn = pool.acquire()
try:
with conn.cursor() as cur:
return cur.callfunc(
"PACK_IMPORT_PARTENERI.cauta_partener_dupa_cod_fiscal",
oracledb.DB_TYPE_NUMBER,
[cod_fiscal, strict],
)
finally:
pool.release(conn)
# ===========================================================================
# Strict mode: RO prefix tolerance (FIX 2a regression)
# ===========================================================================
class TestStrictROPrefix:
"""Strict mode must cross-match 'RO123' and 'RO 123' (only space differs)."""
def test_input_ro_space_finds_partner_ro_no_space(self, oracle_pool, test_suffix):
"""GoMag sends 'RO 34963277', ROA has 'RO34963277' → MUST find it (FG COFFE regression)."""
cuf_bare = _unique_bare(oracle_pool, "9911")
ro_no_space = f"RO{cuf_bare}"
ids = []
try:
pid = _seed_partner(oracle_pool, ro_no_space, f"TEST_FG_COFFE_{test_suffix}")
ids.append(pid)
# GoMag input with space must still locate the partner stored without space
found = _call_lookup(oracle_pool, f"RO {cuf_bare}", strict=1)
assert found == pid, (
f"Strict lookup for 'RO {cuf_bare}' must find partner stored as '{ro_no_space}'"
)
finally:
_cleanup_partners(oracle_pool, ids)
def test_input_ro_no_space_finds_partner_ro_space(self, oracle_pool, test_suffix):
"""Partner stored as 'RO 34963277' (with space) found via 'RO34963277' input."""
cuf_bare = _unique_bare(oracle_pool, "9922")
ro_space = f"RO {cuf_bare}"
ids = []
try:
pid = _seed_partner(oracle_pool, ro_space, f"TEST_AUTOKLASS_{test_suffix}")
ids.append(pid)
found = _call_lookup(oracle_pool, f"RO{cuf_bare}", strict=1)
assert found == pid
finally:
_cleanup_partners(oracle_pool, ids)
def test_strict_bare_input_does_not_match_ro_form(self, oracle_pool, test_suffix):
"""Business rule: neplatitor TVA (bare '123') must NOT match platitor stored as 'RO123'."""
cuf_bare = _unique_bare(oracle_pool, "9933")
ro_form = f"RO{cuf_bare}"
ids = []
try:
pid = _seed_partner(oracle_pool, ro_form, f"TEST_OLLYS_{test_suffix}")
ids.append(pid)
# Bare input + strict=1 → must NOT find the RO-form partner
found = _call_lookup(oracle_pool, cuf_bare, strict=1)
assert found is None, (
f"Strict bare '{cuf_bare}' must not cross-match 'RO{cuf_bare}' "
f"(different fiscal entities)"
)
finally:
_cleanup_partners(oracle_pool, ids)
def test_strict_ro_input_does_not_match_bare_form(self, oracle_pool, test_suffix):
"""Business rule: RO input (platitor) must NOT match bare stored form (neplatitor)."""
cuf_bare = _unique_bare(oracle_pool, "9944")
ids = []
try:
pid = _seed_partner(oracle_pool, cuf_bare, f"TEST_VENUS_{test_suffix}")
ids.append(pid)
found = _call_lookup(oracle_pool, f"RO{cuf_bare}", strict=1)
assert found is None, (
f"Strict 'RO{cuf_bare}' must not cross-match bare '{cuf_bare}'"
)
finally:
_cleanup_partners(oracle_pool, ids)
# ===========================================================================
# Non-strict mode: backward compat — match any of 3 forms
# ===========================================================================
class TestNonStrict:
"""Non-strict (p_strict_search=NULL) matches all 3 forms (anti-dedup fallback)."""
def test_non_strict_bare_finds_ro_form(self, oracle_pool, test_suffix):
cuf_bare = _unique_bare(oracle_pool, "9955")
ids = []
try:
pid = _seed_partner(oracle_pool, f"RO{cuf_bare}", f"TEST_CONVER_{test_suffix}")
ids.append(pid)
found = _call_lookup(oracle_pool, cuf_bare, strict=None)
assert found == pid, "Non-strict must cross-match (anti-dedup fallback)"
finally:
_cleanup_partners(oracle_pool, ids)

View File

@@ -0,0 +1,149 @@
"""Tests for _phase_wrap + escalation check in sync_service.
These cover:
- _record_phase_err persists a sync_phase_failures row
- _check_escalation returns None below threshold
- _check_escalation halts when a phase has failed 3 runs in a row
- run_sync short-circuits when escalation flags a phase
"""
import os
import sys
import sqlite3
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_phase.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, sync_service
@pytest.fixture(autouse=True)
async def _reset():
database.init_sqlite()
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM sync_phase_failures")
await db.execute("DELETE FROM sync_runs")
await db.commit()
finally:
await db.close()
yield
async def _make_run(run_id: str, offset: int = 0):
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT INTO sync_runs (run_id, started_at, status) VALUES (?, datetime('now', ?), 'running')",
(run_id, f"{offset} seconds"),
)
await db.commit()
finally:
await db.close()
async def test_record_phase_err_inserts_row():
await _make_run("rec-1")
err = sqlite3.IntegrityError("simulated NOT NULL")
await sync_service._record_phase_err("rec-1", "price_sync", err)
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute(
"SELECT phase, error_summary FROM sync_phase_failures WHERE run_id = ?",
("rec-1",),
)
row = await cur.fetchone()
finally:
await db.close()
assert row is not None
assert row[0] == "price_sync"
assert "IntegrityError" in row[1]
async def test_check_escalation_below_threshold():
# 2 runs, each with invoice_check failure — below threshold.
for i in range(2):
await _make_run(f"run-{i}", offset=i)
await sqlite_service.record_phase_failure(f"run-{i}", "invoice_check", "err")
phase, counts = await sync_service._check_escalation()
assert phase is None
assert counts.get("invoice_check") == 2
async def test_check_escalation_hits_threshold():
for i in range(3):
await _make_run(f"run-{i}", offset=i)
await sqlite_service.record_phase_failure(f"run-{i}", "import_loop", "err")
phase, counts = await sync_service._check_escalation()
assert phase == "import_loop"
assert counts.get("import_loop") == 3
async def test_check_escalation_different_phases_dont_escalate():
# 3 runs, each failed on a different phase — no single phase hits 3.
phases = ["price_sync", "invoice_check", "anaf_backfill"]
for i, p in enumerate(phases):
await _make_run(f"run-{i}", offset=i)
await sqlite_service.record_phase_failure(f"run-{i}", p, "err")
phase, counts = await sync_service._check_escalation()
assert phase is None
assert len(counts) == 3
async def test_run_sync_short_circuits_on_escalation(monkeypatch):
"""With 3 consecutive price_sync failures, run_sync must halt without
touching gomag_client, order_reader, etc."""
for i in range(3):
await _make_run(f"prev-{i}", offset=i)
await sqlite_service.record_phase_failure(f"prev-{i}", "price_sync", "err")
# Sentinel: if sync proceeds to the download step, this will fire.
async def _boom(*args, **kwargs):
raise AssertionError("escalation should have halted before gomag download")
monkeypatch.setattr(sync_service.gomag_client, "download_orders", _boom)
result = await sync_service.run_sync(run_id="halt-test")
assert result["status"] == "halted_escalation"
assert "price_sync" in result["error"]
# sync_runs row should be persisted with halted_escalation status
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute(
"SELECT status, error_message FROM sync_runs WHERE run_id = ?", ("halt-test",)
)
row = await cur.fetchone()
finally:
await db.close()
assert row is not None
assert row[0] == "halted_escalation"
assert "ESCALATED" in row[1]
async def test_data_errors_tuple_shape():
"""Contract: DATA_ERRORS is a tuple covering the structural error types."""
assert sqlite3.IntegrityError in sync_service.DATA_ERRORS
assert ValueError in sync_service.DATA_ERRORS
assert TypeError in sync_service.DATA_ERRORS
assert UnicodeError in sync_service.DATA_ERRORS
# Must NOT include OperationalError — that halts the sync.
assert sqlite3.OperationalError not in sync_service.DATA_ERRORS

View File

@@ -0,0 +1,412 @@
"""Tests for validation_service.pre_validate_order_prices and retry pre-validation.
Regression source: production VENDING orders #485841978 and #485841895 (2026-04-28)
crashed with PL/SQL COM-001 'Pretul pentru acest articol nu a fost gasit in lista
de preturi' because the Retry button skipped the price-list pre-population step
that bulk sync runs.
These tests verify:
- pre_validate_order_prices auto-inserts PRET=0 in CRM_POLITICI_PRET_ART for
CODMATs missing entries (so PL/SQL doesn't crash).
- Dual-policy routing: cont 341/345 → id_pol_productie; else → id_pol.
- Empty input returns empty result without DB calls.
- Idempotent: running twice when prices already exist does no inserts.
- retry_service propagates pre-validation failures as ERROR with clear message.
"""
import os
import sys
from unittest.mock import patch, MagicMock, AsyncMock
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.services.order_reader import OrderData, OrderItem, OrderShipping, OrderBilling
def _make_order(number: str, items: list[tuple[str, float, float]]) -> OrderData:
"""items = [(sku, quantity, price), ...]"""
return OrderData(
id=number,
number=number,
date="2026-04-28 10:00:00",
items=[
OrderItem(sku=sku, name=f"Product {sku}", price=price,
quantity=qty, vat=21.0, baseprice=price)
for sku, qty, price in items
],
billing=OrderBilling(firstname="Ion", lastname="Test"),
shipping=OrderShipping(firstname="Ion", lastname="Test"),
)
# ============================================================
# UNIT TESTS — no Oracle
# ============================================================
@pytest.mark.unit
def test_pre_validate_empty_orders_returns_empty():
"""Empty orders list short-circuits without any DB calls."""
from app.services import validation_service
app_settings = {}
mock_conn = MagicMock()
result = validation_service.pre_validate_order_prices(
orders=[],
app_settings=app_settings,
conn=mock_conn,
id_pol=1,
)
assert result["codmat_policy_map"] == {}
assert result["kit_missing"] == {}
# No DB cursor was opened
mock_conn.cursor.assert_not_called()
# app_settings unchanged
assert "_codmat_policy_map" not in app_settings
@pytest.mark.unit
def test_pre_validate_no_skus_in_orders():
"""Orders with no items skip price validation entirely."""
from app.services import validation_service
order = OrderData(id="1", number="1", date="2026-04-28", items=[],
billing=OrderBilling())
app_settings = {}
mock_conn = MagicMock()
# validation passed-in is empty
validation = {"mapped": set(), "direct": set(), "missing": set(),
"direct_id_map": {}}
result = validation_service.pre_validate_order_prices(
orders=[order],
app_settings=app_settings,
conn=mock_conn,
id_pol=1,
validation=validation,
)
assert result["codmat_policy_map"] == {}
assert result["kit_missing"] == {}
@pytest.mark.unit
async def test_retry_propagates_pre_validation_error():
"""Pre-validation failure in retry path returns ERROR with clear message."""
from app.services import retry_service
target_order = _make_order("RETRY-FAIL-1", [("SKU-X", 1, 100)])
fake_pool = MagicMock()
fake_conn = MagicMock()
fake_pool.release = MagicMock()
with patch("app.services.gomag_client.download_orders",
new=AsyncMock(return_value=None)), \
patch("app.services.order_reader.read_json_orders",
return_value=([target_order], 1)), \
patch("app.services.sqlite_service.upsert_order",
new=AsyncMock()) as mock_upsert, \
patch("app.services.validation_service.validate_skus",
return_value={"mapped": set(), "direct": {"SKU-X"},
"missing": set(), "direct_id_map": {"SKU-X": 1}}), \
patch("app.services.validation_service.pre_validate_order_prices",
side_effect=RuntimeError("ORA-12541: TNS no listener")), \
patch("app.database.pool", fake_pool), \
patch("app.database.get_oracle_connection", return_value=fake_conn):
app_settings = {"id_pol": "1", "id_gestiune": "1", "discount_vat": "21"}
result = await retry_service._download_and_reimport(
order_number="RETRY-FAIL-1",
order_date_str="2026-04-28T10:00:00",
customer_name="Ion Test",
app_settings=app_settings,
)
assert result["success"] is False
assert "pre-validare" in result["message"].lower()
assert "TNS" in result["message"]
# Verify ERROR persisted to SQLite
mock_upsert.assert_called_once()
call_kwargs = mock_upsert.call_args.kwargs
assert call_kwargs["status"] == "ERROR"
# ============================================================
# ORACLE INTEGRATION TESTS — require live Oracle
# ============================================================
# Oracle connection setup (lazy import to keep unit tests isolated)
def _get_oracle_conn():
import oracledb
from dotenv import load_dotenv
load_dotenv('.env')
user = os.environ['ORACLE_USER']
password = os.environ['ORACLE_PASSWORD']
dsn = os.environ['ORACLE_DSN']
try:
instantclient_path = os.environ.get(
'INSTANTCLIENTPATH', '/opt/oracle/instantclient_23_9'
)
oracledb.init_oracle_client(lib_dir=instantclient_path)
except Exception:
pass
return oracledb.connect(user=user, password=password, dsn=dsn)
def _has_price_entry(cur, id_pol: int, id_articol: int) -> tuple[bool, float | None]:
"""Returns (exists, pret). pret is None if row doesn't exist."""
cur.execute("""
SELECT PRET FROM crm_politici_pret_art
WHERE id_pol = :p AND id_articol = :a
""", {"p": id_pol, "a": id_articol})
row = cur.fetchone()
return (row is not None, row[0] if row else None)
def _pick_unpriced_article(cur, id_pol: int, count: int = 1) -> list[tuple[int, str]]:
"""Find existing NOM_ARTICOLE rows without CRM_POLITICI_PRET_ART entry for id_pol.
Returns: [(id_articol, codmat), ...]. Skips test if not enough found.
"""
cur.execute("""
SELECT id_articol, codmat FROM nom_articole na
WHERE sters = 0 AND inactiv = 0
AND NOT EXISTS (
SELECT 1 FROM crm_politici_pret_art pa
WHERE pa.id_articol = na.id_articol AND pa.id_pol = :pol
)
AND ROWNUM <= :n
""", {"pol": id_pol, "n": count})
return cur.fetchall()
def _pick_priced_article(cur, id_pol: int) -> tuple[int, str, float] | None:
"""Find any (id_articol, codmat, pret) with existing CRM_POLITICI_PRET_ART entry."""
cur.execute("""
SELECT na.id_articol, na.codmat, pa.pret
FROM nom_articole na
JOIN crm_politici_pret_art pa ON pa.id_articol = na.id_articol
WHERE pa.id_pol = :pol AND na.sters = 0 AND na.inactiv = 0
AND ROWNUM <= 1
""", {"pol": id_pol})
return cur.fetchone()
def _pick_default_id_pol(cur) -> int | None:
"""Pick first usable id_pol from CRM_POLITICI_PRETURI."""
cur.execute("""
SELECT id_pol FROM crm_politici_preturi
WHERE sters = 0 AND ROWNUM <= 1
ORDER BY id_pol
""")
row = cur.fetchone()
return row[0] if row else None
@pytest.mark.oracle
def test_pre_validate_inserts_missing_prices_for_direct_sku():
"""REGRESSION (prod orders #485841978, #485841895):
A SKU that resolves directly to a CODMAT in NOM_ARTICOLE with NO entry
in CRM_POLITICI_PRET_ART must auto-insert PRET=0 so the import doesn't
crash with COM-001.
Uses a real unpriced article from the test schema. Cleans up after.
"""
from app.services import validation_service
with _get_oracle_conn() as conn:
with conn.cursor() as cur:
id_pol = _pick_default_id_pol(cur)
assert id_pol is not None, "No usable id_pol found in CRM_POLITICI_PRETURI"
unpriced = _pick_unpriced_article(cur, id_pol, count=1)
if not unpriced:
pytest.skip(f"All articles in policy {id_pol} already have prices")
id_art, codmat = unpriced[0]
inserted = False
try:
# Pre-condition
exists, _ = _has_price_entry(cur, id_pol, id_art)
assert not exists, f"Pre-condition: {codmat} should be unpriced"
# Use codmat as direct SKU. validate_skus → direct (matches NOM_ARTICOLE)
order = _make_order("VEN-PV-DIRECT", [(codmat, 1, 100)])
app_settings = {}
validation = {
"mapped": set(),
"direct": {codmat},
"missing": set(),
"direct_id_map": {codmat: {"id_articol": id_art, "cont": None}},
}
validation_service.pre_validate_order_prices(
orders=[order], app_settings=app_settings, conn=conn,
id_pol=id_pol, validation=validation, cota_tva=21,
)
conn.commit()
inserted = True
# Post-condition: PRET=0 row created
exists, pret = _has_price_entry(cur, id_pol, id_art)
assert exists, (
f"REGRESSION: price entry for {codmat} (id={id_art}) "
f"in policy {id_pol} should be auto-created"
)
assert pret == 0, f"Auto-inserted price should be 0, got {pret}"
finally:
if inserted:
cur.execute(
"DELETE FROM crm_politici_pret_art "
"WHERE id_pol = :p AND id_articol = :a AND pret = 0",
{"p": id_pol, "a": id_art},
)
conn.commit()
@pytest.mark.oracle
def test_pre_validate_idempotent_when_prices_exist():
"""When all CODMATs already have CRM_POLITICI_PRET_ART entries, no INSERTs run.
Verifies idempotency on a second pre-validation pass — existing prices untouched."""
from app.services import validation_service
with _get_oracle_conn() as conn:
with conn.cursor() as cur:
id_pol = _pick_default_id_pol(cur)
assert id_pol is not None, "No usable id_pol found"
priced = _pick_priced_article(cur, id_pol)
if not priced:
pytest.skip(f"No priced articles in policy {id_pol}")
id_art, codmat, pret_orig = priced
cur.execute("""SELECT COUNT(*) FROM crm_politici_pret_art
WHERE id_articol = :a AND id_pol = :p""",
{"a": id_art, "p": id_pol})
count_before = cur.fetchone()[0]
order = _make_order("VEN-IDEM", [(codmat, 1, 200)])
app_settings = {}
validation = {
"mapped": set(), "direct": {codmat}, "missing": set(),
"direct_id_map": {codmat: {"id_articol": id_art, "cont": None}},
}
for _ in range(2): # Run twice
validation_service.pre_validate_order_prices(
orders=[order], app_settings=app_settings, conn=conn,
id_pol=id_pol, validation=validation, cota_tva=21,
)
conn.commit()
cur.execute("""SELECT COUNT(*), MAX(pret) FROM crm_politici_pret_art
WHERE id_articol = :a AND id_pol = :p""",
{"a": id_art, "p": id_pol})
count_after, pret_after = cur.fetchone()
assert count_after == count_before, (
f"Idempotency violated: {count_before}{count_after} rows"
)
assert pret_after == pret_orig, (
f"Existing price changed: {pret_orig}{pret_after}"
)
@pytest.mark.oracle
def test_pre_validate_dual_policy_routing():
"""Articles with cont 341/345 route to id_pol_productie; others to id_pol_vanzare.
Picks two existing unpriced articles, marks one with cont=341, runs
pre_validate, asserts each landed in the expected policy.
"""
from app.services import validation_service
with _get_oracle_conn() as conn:
with conn.cursor() as cur:
id_pol = _pick_default_id_pol(cur)
assert id_pol is not None, "No usable id_pol"
# Find a second policy to use as productie (any other usable id_pol)
cur.execute("""SELECT id_pol FROM crm_politici_preturi
WHERE sters = 0 AND id_pol != :p AND ROWNUM <= 1
ORDER BY id_pol""", {"p": id_pol})
row = cur.fetchone()
if not row:
pytest.skip("Need 2 distinct id_pol values for dual-policy test")
id_pol_productie = row[0]
unpriced = _pick_unpriced_article(cur, id_pol, count=2)
if len(unpriced) < 2:
pytest.skip("Need 2 unpriced articles for dual-policy test")
(id_prod, codmat_prod), (id_sales, codmat_sales) = unpriced[0], unpriced[1]
# Save original cont values for cleanup
cur.execute("SELECT cont FROM nom_articole WHERE id_articol = :a",
{"a": id_prod})
cont_prod_orig = cur.fetchone()[0]
try:
cur.execute("UPDATE nom_articole SET cont = '341' "
"WHERE id_articol = :a", {"a": id_prod})
conn.commit()
order = _make_order(
"VEN-DUAL",
[(codmat_prod, 1, 50), (codmat_sales, 1, 80)],
)
app_settings = {}
validation = {
"mapped": set(),
"direct": {codmat_prod, codmat_sales},
"missing": set(),
"direct_id_map": {
codmat_prod: {"id_articol": id_prod, "cont": "341"},
codmat_sales: {"id_articol": id_sales, "cont": cont_prod_orig or "302"},
},
}
result = validation_service.pre_validate_order_prices(
orders=[order], app_settings=app_settings, conn=conn,
id_pol=id_pol, id_pol_productie=id_pol_productie,
validation=validation, cota_tva=21,
)
conn.commit()
policy_map = result["codmat_policy_map"]
assert policy_map.get(codmat_prod) == id_pol_productie, (
f"cont=341 article ({codmat_prod}) should route to "
f"productie={id_pol_productie}, got {policy_map.get(codmat_prod)}"
)
assert policy_map.get(codmat_sales) == id_pol, (
f"non-341 article ({codmat_sales}) should route to "
f"vanzare={id_pol}, got {policy_map.get(codmat_sales)}"
)
# Verify rows landed in the right policy
exists_prod_in_prod, _ = _has_price_entry(cur, id_pol_productie, id_prod)
exists_prod_in_sales, _ = _has_price_entry(cur, id_pol, id_prod)
exists_sales_in_sales, _ = _has_price_entry(cur, id_pol, id_sales)
exists_sales_in_prod, _ = _has_price_entry(cur, id_pol_productie, id_sales)
assert exists_prod_in_prod and not exists_prod_in_sales, (
"cont=341 row should be in productie policy only"
)
assert exists_sales_in_sales and not exists_sales_in_prod, (
"Non-341 row should be in sales policy only"
)
finally:
# Cleanup: restore cont, delete inserted PRET=0 rows
cur.execute("UPDATE nom_articole SET cont = :c "
"WHERE id_articol = :a",
{"c": cont_prod_orig, "a": id_prod})
cur.execute(
"DELETE FROM crm_politici_pret_art "
"WHERE id_pol IN (:p1, :p2) "
"AND id_articol IN (:a1, :a2) AND pret = 0",
{"p1": id_pol, "p2": id_pol_productie,
"a1": id_prod, "a2": id_sales},
)
conn.commit()

View File

@@ -36,6 +36,7 @@ import pytest_asyncio
from app.database import init_sqlite
from app.services import sqlite_service
from app.constants import OrderStatus
# Initialize SQLite once before any tests run
init_sqlite()
@@ -70,10 +71,10 @@ def seed_baseline_data():
# Add the first order (IMPORTED) with items
await sqlite_service.upsert_order(
"RUN001", "ORD001", "2025-01-15", "Test Client", "IMPORTED",
"RUN001", "ORD001", "2025-01-15", "Test Client", OrderStatus.IMPORTED.value,
id_comanda=100, id_partener=200, items_count=2
)
await sqlite_service.add_sync_run_order("RUN001", "ORD001", "IMPORTED")
await sqlite_service.add_sync_run_order("RUN001", "ORD001", OrderStatus.IMPORTED.value)
items = [
{
@@ -103,15 +104,15 @@ def seed_baseline_data():
# Add more orders for filter tests
await sqlite_service.upsert_order(
"RUN001", "ORD002", "2025-01-16", "Client 2", "SKIPPED",
"RUN001", "ORD002", "2025-01-16", "Client 2", OrderStatus.SKIPPED.value,
missing_skus=["SKU99"], items_count=1
)
await sqlite_service.add_sync_run_order("RUN001", "ORD002", "SKIPPED")
await sqlite_service.add_sync_run_order("RUN001", "ORD002", OrderStatus.SKIPPED.value)
await sqlite_service.upsert_order(
"RUN001", "ORD003", "2025-01-17", "Client 3", "ERROR",
"RUN001", "ORD003", "2025-01-17", "Client 3", OrderStatus.ERROR.value,
error_message="Test error", items_count=3
)
await sqlite_service.add_sync_run_order("RUN001", "ORD003", "ERROR")
await sqlite_service.add_sync_run_order("RUN001", "ORD003", OrderStatus.ERROR.value)
asyncio.run(_seed())
yield
@@ -212,7 +213,7 @@ async def test_get_order_detail_not_found():
async def test_get_order_detail_status():
"""Seeded ORD001 should have IMPORTED status."""
detail = await sqlite_service.get_order_detail("ORD001")
assert detail["order"]["status"] == "IMPORTED"
assert detail["order"]["status"] == OrderStatus.IMPORTED.value
# ---------------------------------------------------------------------------
@@ -232,7 +233,7 @@ async def test_get_run_orders_filtered_all():
@pytest.mark.asyncio
async def test_get_run_orders_filtered_imported():
"""Filter IMPORTED should return only ORD001."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "IMPORTED", 1, 50)
result = await sqlite_service.get_run_orders_filtered("RUN001", OrderStatus.IMPORTED.value, 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD001"
@@ -240,7 +241,7 @@ async def test_get_run_orders_filtered_imported():
@pytest.mark.asyncio
async def test_get_run_orders_filtered_skipped():
"""Filter SKIPPED should return only ORD002."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "SKIPPED", 1, 50)
result = await sqlite_service.get_run_orders_filtered("RUN001", OrderStatus.SKIPPED.value, 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD002"
@@ -248,7 +249,7 @@ async def test_get_run_orders_filtered_skipped():
@pytest.mark.asyncio
async def test_get_run_orders_filtered_error():
"""Filter ERROR should return only ORD003."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "ERROR", 1, 50)
result = await sqlite_service.get_run_orders_filtered("RUN001", OrderStatus.ERROR.value, 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD003"
@@ -360,10 +361,10 @@ def test_api_sync_run_orders(client):
def test_api_sync_run_orders_filtered(client):
"""R1: Filtering by status=IMPORTED returns only IMPORTED orders."""
resp = client.get("/api/sync/run/RUN001/orders?status=IMPORTED")
resp = client.get(f"/api/sync/run/RUN001/orders?status={OrderStatus.IMPORTED.value}")
assert resp.status_code == 200
data = resp.json()
assert all(o["status"] == "IMPORTED" for o in data["orders"])
assert all(o["status"] == OrderStatus.IMPORTED.value for o in data["orders"])
def test_api_sync_run_orders_pagination_fields(client):
@@ -618,3 +619,79 @@ def test_get_all_skus():
]
skus = get_all_skus(orders)
assert skus == {"A", "B", "C"}
# ── reconcile_unresolved_missing_skus unit tests ──────────────────────────────
@pytest.mark.asyncio
async def test_reconcile_empty_unresolved():
"""reconcile returns zeros immediately when no unresolved SKUs exist."""
from app.services import sqlite_service, validation_service
# Ensure any previously tracked SKUs are resolved
db = await sqlite_service.get_sqlite()
try:
await db.execute("UPDATE missing_skus SET resolved = 1 WHERE resolved = 0")
await db.commit()
finally:
await db.close()
rec = await validation_service.reconcile_unresolved_missing_skus()
assert rec == {"checked": 0, "resolved": 0, "error": None}
@pytest.mark.asyncio
async def test_reconcile_oracle_down(monkeypatch):
"""reconcile is fail-soft: returns resolved=0 and error string when Oracle raises."""
from app.services import sqlite_service, validation_service
await sqlite_service.track_missing_sku("ORACLE_DOWN_SKU", "Test product")
def _raise(*args, **kwargs):
raise RuntimeError("Oracle unavailable")
monkeypatch.setattr(validation_service, "validate_skus", _raise)
rec = await validation_service.reconcile_unresolved_missing_skus()
assert rec["resolved"] == 0
assert rec["error"] is not None
assert "Oracle" in rec["error"] or "unavailable" in rec["error"]
# Cleanup
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM missing_skus WHERE sku = 'ORACLE_DOWN_SKU'")
await db.commit()
finally:
await db.close()
@pytest.mark.asyncio
async def test_reconcile_resolves_stale(monkeypatch):
"""reconcile marks resolved=1 for SKUs that validate_skus says are mapped."""
from app.services import sqlite_service, validation_service
await sqlite_service.track_missing_sku("STALE_MAPPED_SKU", "Stale product")
def _mock_validate(skus, conn=None, id_gestiuni=None):
return {
"mapped": {"STALE_MAPPED_SKU"},
"direct": set(),
"missing": set(),
"direct_id_map": {},
}
monkeypatch.setattr(validation_service, "validate_skus", _mock_validate)
rec = await validation_service.reconcile_unresolved_missing_skus()
assert rec["resolved"] >= 1
db = await sqlite_service.get_sqlite()
try:
cursor = await db.execute(
"SELECT resolved FROM missing_skus WHERE sku = 'STALE_MAPPED_SKU'"
)
row = await cursor.fetchone()
assert row is not None and row[0] == 1
finally:
await db.close()

View File

@@ -0,0 +1,307 @@
"""Integration tests for hybrid save_orders_batch with per-order isolation.
Covers:
- Regression 485224762 (dup SKU in one order)
- Structural pre-flight → MALFORMED rows
- Batch failure → per-order fallback with SAVEPOINT
- Rollback-failure → commit-close-reconnect path
"""
import os
import sys
import sqlite3
import tempfile
import pytest
pytestmark = pytest.mark.unit
_tmpdir = tempfile.mkdtemp()
_sqlite_path = os.path.join(_tmpdir, "test_hybrid.db")
os.environ.setdefault("FORCE_THIN_MODE", "true")
os.environ.setdefault("SQLITE_DB_PATH", _sqlite_path)
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 _reset_db():
database.init_sqlite()
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM order_items")
await db.execute("DELETE FROM sync_run_orders")
await db.execute("DELETE FROM orders")
await db.execute("DELETE FROM sync_runs")
await db.execute("INSERT INTO sync_runs (run_id, started_at, status) VALUES (?, datetime('now'), 'running')", ("test-run",))
await db.commit()
finally:
await db.close()
yield
def _order(order_number, status=OrderStatus.IMPORTED.value, items=None, **overrides):
base = {
"sync_run_id": "test-run",
"order_number": order_number,
"order_date": "2026-04-22 10:00:00",
"customer_name": "Test Customer",
"status": status,
"status_at_run": status,
"items_count": len(items) if items else 0,
"items": items or [],
}
base.update(overrides)
return base
def _item(sku="SKU-A", qty=1, price=10.0):
return {
"sku": sku, "product_name": f"Product {sku}",
"quantity": qty, "price": price, "baseprice": price,
"vat": 19, "mapping_status": "direct", "codmat": None,
"id_articol": None, "cantitate_roa": None,
}
async def _orders_with_status(status):
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute("SELECT order_number FROM orders WHERE status = ?", (status,))
rows = await cur.fetchall()
return [r[0] for r in rows]
finally:
await db.close()
async def _items_of(order_number):
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute("SELECT sku, quantity FROM order_items WHERE order_number = ?", (order_number,))
rows = await cur.fetchall()
return [(r[0], r[1]) for r in rows]
finally:
await db.close()
# ── 1. Regression 485224762 — dup SKU on one order ──────────────
async def test_regression_dup_sku_485224762():
"""Dedup helper must let this order through; hybrid path must import it."""
orders = [
_order("485224762", items=[_item("SKU-X", qty=2), _item("SKU-X", qty=3)])
]
await sqlite_service.save_orders_batch(orders)
imported = await _orders_with_status(OrderStatus.IMPORTED.value)
assert "485224762" in imported
items = await _items_of("485224762")
assert len(items) == 1
assert items[0][0] == "SKU-X"
# Qty summed by _dedup_items_by_sku
assert items[0][1] == 5
# ── 2. Structural pre-flight → MALFORMED ────────────────────────
async def test_structural_fail_empty_items():
orders = [_order("MAL-1", items=[])]
await sqlite_service.save_orders_batch(orders)
mal = await _orders_with_status(OrderStatus.MALFORMED.value)
assert "MAL-1" in mal
async def test_structural_fail_mixed_batch():
orders = [
_order("GOOD-1", items=[_item()]),
_order("MAL-2", order_date="not-a-date", items=[_item()]),
_order("GOOD-2", items=[_item("SKU-B", qty=1)]),
]
await sqlite_service.save_orders_batch(orders)
assert set(await _orders_with_status(OrderStatus.IMPORTED.value)) == {"GOOD-1", "GOOD-2"}
assert await _orders_with_status(OrderStatus.MALFORMED.value) == ["MAL-2"]
async def test_malformed_error_message_persisted():
orders = [_order("MAL-3", order_date="", items=[_item()])]
await sqlite_service.save_orders_batch(orders)
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute("SELECT error_message FROM orders WHERE order_number = ?", ("MAL-3",))
row = await cur.fetchone()
assert row is not None
assert "INVALID_DATE" in row[0]
finally:
await db.close()
# ── 3. Runtime-fail mid-batch → per-order fallback ───────────────
async def test_runtime_failure_isolated_per_order(monkeypatch):
"""One order triggers IntegrityError on insert; rest still land."""
import aiosqlite
real_executemany = aiosqlite.core.Connection.executemany
real_execute = aiosqlite.core.Connection.execute
def _is_orders_insert(sql: str) -> bool:
s = sql.upper()
return "INTO ORDERS" in s and "ORDER_ITEMS" not in s and "SYNC_RUN_ORDERS" not in s
def _is_poison(row):
# row[0] = order_number, row[3] = status. Fail only when simulating
# the real runtime crash; let the MALFORMED fallback write succeed.
return row[0] == "POISON" and row[3] != OrderStatus.MALFORMED.value
async def flaky_executemany(self, sql, rows):
rows_list = list(rows)
if _is_orders_insert(sql) and any(_is_poison(r) for r in rows_list):
raise sqlite3.IntegrityError("simulated NOT NULL violation")
return await real_executemany(self, sql, rows_list)
async def flaky_execute(self, sql, params=None):
if params and _is_orders_insert(sql) and _is_poison(params):
raise sqlite3.IntegrityError("simulated NOT NULL violation per-order")
return await real_execute(self, sql, params) if params is not None else await real_execute(self, sql)
monkeypatch.setattr(aiosqlite.core.Connection, "executemany", flaky_executemany)
monkeypatch.setattr(aiosqlite.core.Connection, "execute", flaky_execute)
orders = [
_order("BATCH-1", items=[_item("SKU-1")]),
_order("POISON", items=[_item("SKU-P")]),
_order("BATCH-2", items=[_item("SKU-2")]),
]
await sqlite_service.save_orders_batch(orders)
imported = set(await _orders_with_status(OrderStatus.IMPORTED.value))
malformed = set(await _orders_with_status(OrderStatus.MALFORMED.value))
# BATCH-1 and BATCH-2 land as IMPORTED via per-order SAVEPOINT path.
# POISON gets tagged MALFORMED because its single-order insert also raises.
assert {"BATCH-1", "BATCH-2"}.issubset(imported)
assert "POISON" in malformed
# ── 4. Empty batch is a no-op ───────────────────────────────────
async def test_empty_batch_noop():
await sqlite_service.save_orders_batch([])
assert await _orders_with_status(OrderStatus.IMPORTED.value) == []
# ── 5. Caller dict not mutated on MALFORMED ─────────────────────
async def test_caller_dict_not_mutated():
raw = _order("OK-1", items=[]) # structural-fail
snapshot = dict(raw)
await sqlite_service.save_orders_batch([raw])
# Caller's dict should be untouched
assert raw["status"] == snapshot["status"]
assert raw.get("error_message") == snapshot.get("error_message")
assert raw["items"] == snapshot["items"]
# ── 6. Reconnect path preserves prior work ──────────────────────
async def test_reconnect_preserves_malformed_and_continues(monkeypatch):
"""If ROLLBACK TO SAVEPOINT itself fails, we commit, reconnect, keep going.
We can't easily simulate the exact OperationalError, so we verify the
helper is wired by inspecting its behaviour on a live connection.
"""
db = await sqlite_service.get_sqlite()
try:
# Insert a MALFORMED row directly, then invoke _safe_reconnect.
await db.execute(
"INSERT OR REPLACE INTO orders (order_number, status, order_date) VALUES (?, ?, ?)",
("BEFORE-RECON", OrderStatus.MALFORMED.value, "2026-04-22"),
)
fresh = await sqlite_service._safe_reconnect(db)
assert fresh is not None
# Previous insert must be durable on fresh connection.
cur = await fresh.execute(
"SELECT status FROM orders WHERE order_number = ?", ("BEFORE-RECON",)
)
row = await cur.fetchone()
assert row is not None
assert row[0] == OrderStatus.MALFORMED.value
await fresh.close()
finally:
# fresh was already closed; nothing else to do
pass
# ── 7. _safe_upsert_order_items — success + savepoint rollback ──
async def test_safe_upsert_items_happy_path():
# Seed parent order so FK context is valid.
await sqlite_service.save_orders_batch([_order("SAFE-1", items=[])])
db = await sqlite_service.get_sqlite()
try:
ok = await sqlite_service._safe_upsert_order_items(
db, "SAFE-1", [_item("SKU-H", qty=2)]
)
await db.commit()
finally:
await db.close()
assert ok is True
items = await _items_of("SAFE-1")
assert items == [("SKU-H", 2)]
async def test_safe_upsert_items_rolls_back_and_marks_malformed(monkeypatch):
await sqlite_service.save_orders_batch([_order("SAFE-2", items=[_item("PRE", qty=1)])])
import aiosqlite
real_executemany = aiosqlite.core.Connection.executemany
async def boom_on_items(self, sql, rows):
if "INSERT INTO order_items" in sql.upper().replace("\n", " ").replace(" ", " ").upper() or "ORDER_ITEMS" in sql.upper():
raise sqlite3.IntegrityError("simulated items insert crash")
return await real_executemany(self, sql, rows)
monkeypatch.setattr(aiosqlite.core.Connection, "executemany", boom_on_items)
db = await sqlite_service.get_sqlite()
try:
ok = await sqlite_service._safe_upsert_order_items(
db, "SAFE-2", [_item("SKU-BAD", qty=1)]
)
await db.commit()
finally:
await db.close()
assert ok is False
# Parent order was tagged MALFORMED, pre-existing items were wiped by DELETE
# (which ran inside the rolled-back savepoint, so they should survive).
malformed = await _orders_with_status(OrderStatus.MALFORMED.value)
assert "SAFE-2" in malformed
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute(
"SELECT error_message FROM orders WHERE order_number = ?", ("SAFE-2",)
)
row = await cur.fetchone()
assert row is not None and "ITEMS_FAIL" in row[0]
finally:
await db.close()

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

View File

@@ -0,0 +1,256 @@
"""
CUI Gate Tests
==============
Unit tests for evaluate_cui_gate() and _record_order_error() in sync_service.
Tests 1-6: pure predicate, no IO.
Test 7: integration — _record_order_error with pre-seeded SQLite IMPORTED row
verifies COALESCE preserves existing id_partener.
Run:
cd api && python -m pytest tests/test_sync_cui_gate.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_cui_gate.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.services.sync_service import evaluate_cui_gate, _record_order_error
from app.services.order_reader import OrderBilling, OrderShipping, OrderData, OrderItem
from app.constants import OrderStatus
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_VALID_ANAF_FOUND = {"scpTVA": True, "denumire_anaf": "NONA ROYAL SRL", "checked_at": "2026-04-22T10:00:00"}
_ANAF_NOT_FOUND = {"scpTVA": None, "denumire_anaf": "", "checked_at": "2026-04-22T10:00:00"}
# A CUI with valid format and valid checksum (MATTEO&OANA CAFFE 2022 SRL)
_VALID_CUI = "49033051"
# Same body but last digit modified → fails checksum
_BAD_CHECKSUM_CUI = "49033052"
# J-format — the incident CUI (registru number in the CUI field)
_J_FORMAT = "J1994000194225"
def _make_pj_order(company_code=_VALID_CUI, number="O-001"):
billing = OrderBilling(
firstname="Ion", lastname="Pop", phone="0700", email="x@x.ro",
address="Str A 1", city="Cluj", region="Cluj", country="Romania",
company_name="TEST SRL", company_code=company_code,
company_reg="J12/123/2020", is_company=True,
)
shipping = OrderShipping(
firstname="Ion", lastname="Pop", phone="0700", email="x@x.ro",
address="Str A 1", city="Cluj", region="Cluj", country="Romania",
)
return OrderData(
id=number, number=number, date="2026-04-22",
billing=billing, shipping=shipping,
items=[OrderItem(sku="SKU1", name="Prod", price=10.0, quantity=1, vat=19)],
)
def _make_pf_order(number="O-PF-1"):
billing = OrderBilling(
firstname="Ana", lastname="Pop", phone="0700", email="a@x.ro",
address="Str B 2", city="Iasi", region="Iasi", country="Romania",
is_company=False,
)
shipping = OrderShipping(
firstname="Ana", lastname="Pop", phone="0700", email="a@x.ro",
address="Str B 2", city="Iasi", region="Iasi", country="Romania",
)
return OrderData(
id=number, number=number, date="2026-04-22",
billing=billing, shipping=shipping,
items=[OrderItem(sku="SKU1", name="Prod", price=10.0, quantity=1, vat=19)],
)
# ---------------------------------------------------------------------------
# Tests 1-6: pure predicate — no IO
# ---------------------------------------------------------------------------
class TestEvaluateCuiGate:
def test_format_invalid_incident_case(self):
"""Test 1: J-format in cod_fiscal field (the 22-Apr-2026 incident) → blocked."""
# bare_cui from sanitize_cui("J1994000194225") = "J1994000194225" (not digits)
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_J_FORMAT,
bare_cui=_J_FORMAT,
anaf_data=_VALID_ANAF_FOUND,
)
assert result is not None
assert "format" in result
def test_checksum_invalid(self):
"""Test 2: valid format, wrong check digit → blocked."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_BAD_CHECKSUM_CUI,
bare_cui=_BAD_CHECKSUM_CUI,
anaf_data=_VALID_ANAF_FOUND,
)
assert result is not None
assert "cifra de control" in result
def test_anaf_not_found_explicit(self):
"""Test 3: ANAF explicit notFound → blocked with registry hint."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data=_ANAF_NOT_FOUND,
)
assert result is not None
assert "nu exista in registrul ANAF" in result
assert "registrul comertului" in result
def test_anaf_found_vat_payer_passes(self):
"""Test 4: ANAF found + platitor TVA → pass."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data=_VALID_ANAF_FOUND,
)
assert result is None
def test_anaf_down_fallback_passes(self):
"""Test 5 [CRITICAL REGRESSION]: ANAF down (anaf_data=None) + valid CUI → pass.
If this test fails, the gate is breaking the ANAF-down fallback and all
RO company orders would error when ANAF is unavailable.
"""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data=None, # ANAF down / transient error
)
assert result is None, (
"ANAF down must NOT block orders — gate must only block on explicit notFound"
)
def test_pf_always_passes(self):
"""Test 6: PF order (is_ro_company=False) → always pass, regardless of CUI."""
result = evaluate_cui_gate(
is_ro_company=False,
company_code_raw=_J_FORMAT,
bare_cui=_J_FORMAT,
anaf_data=_ANAF_NOT_FOUND,
)
assert result is None
def test_no_company_code_passes(self):
"""PJ without company_code → pass (nothing to validate)."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=None,
bare_cui="",
anaf_data=None,
)
assert result is None
def test_anaf_found_non_vat_passes(self):
"""ANAF found non-platitor TVA (scpTVA=False) → pass."""
result = evaluate_cui_gate(
is_ro_company=True,
company_code_raw=_VALID_CUI,
bare_cui=_VALID_CUI,
anaf_data={"scpTVA": False, "denumire_anaf": "FIRMA SRL", "checked_at": "2026-04-22T10:00:00"},
)
assert result is None
# ---------------------------------------------------------------------------
# Test 7: integration — COALESCE preserves id_partener on gate block
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _init_db():
database.init_sqlite()
@pytest.mark.asyncio
async def test_record_order_error_preserves_id_partener():
"""Test 7: _record_order_error called with id_partener=None preserves existing id_partener
via SQLite COALESCE in upsert_order.
Scenario: order was previously IMPORTED with id_partener=9001.
At resync the gate blocks it (bad CUI). _record_order_error passes id_partener=None.
After upsert, the row should have status=ERROR and id_partener=9001 (preserved).
"""
order = _make_pj_order(company_code=_J_FORMAT, number="O-COALESCE-1")
run_id = "test-run-coalesce"
# Seed an existing IMPORTED row with id_partener=9001
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"""INSERT OR REPLACE INTO orders
(order_number, order_date, customer_name, status, id_partener, items_count)
VALUES (?, ?, ?, ?, ?, ?)""",
(order.number, order.date, "TEST SRL", OrderStatus.IMPORTED.value, 9001, 1),
)
await db.commit()
finally:
await db.close()
# Gate fires → calls _record_order_error with id_partener=None (gate doesn't know it)
await _record_order_error(
run_id=run_id,
order=order,
customer="TEST SRL",
shipping_name="Ion Pop",
billing_name="TEST SRL",
payment_method="card",
delivery_method="curier",
discount_split_json=None,
order_items_data=[{
"sku": "SKU1", "product_name": "Prod", "quantity": 1,
"price": 10.0, "baseprice": None, "vat": 19,
"mapping_status": "direct", "codmat": None, "id_articol": None, "cantitate_roa": None,
}],
reason=f"CUI invalid (format): {_J_FORMAT!r}",
id_partener=None, # gate doesn't have it
)
# Verify: status=ERROR, id_partener=9001 (COALESCE preserved), error_message populated
db = await sqlite_service.get_sqlite()
try:
row = await db.execute(
"SELECT status, id_partener, error_message FROM orders WHERE order_number = ?",
(order.number,),
)
row = await row.fetchone()
finally:
await db.close()
assert row is not None, "Order row missing after _record_order_error"
assert row[0] == OrderStatus.ERROR.value, f"Expected ERROR, got {row[0]}"
assert row[1] == 9001, f"Expected id_partener=9001 (preserved by COALESCE), got {row[1]}"
assert row[2] and "format" in row[2], f"Expected error_message with 'format', got {row[2]!r}"

View File

@@ -0,0 +1,110 @@
"""Tests for GET /api/sync/health."""
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_health.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 fastapi.testclient import TestClient
from app import database
from app.services import sqlite_service
from app.main import app
client = TestClient(app)
@pytest.fixture(autouse=True)
async def _reset():
database.init_sqlite()
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM sync_phase_failures")
await db.execute("DELETE FROM sync_runs")
await db.commit()
finally:
await db.close()
yield
async def _make_run(run_id: str, status: str = "completed", offset: int = 0,
error_message: str | None = None):
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"""INSERT INTO sync_runs (run_id, started_at, status, error_message)
VALUES (?, datetime('now', ?), ?, ?)""",
(run_id, f"{offset} seconds", status, error_message),
)
await db.commit()
finally:
await db.close()
async def test_health_empty_state():
r = client.get("/api/sync/health")
assert r.status_code == 200
data = r.json()
assert data["last_sync_at"] is None
assert data["last_sync_status"] is None
assert data["recent_phase_failures"] == {}
assert data["escalation_phase"] is None
assert data["is_healthy"] is True
async def test_health_completed_is_healthy():
await _make_run("ok-1", status="completed")
r = client.get("/api/sync/health")
data = r.json()
assert data["last_sync_status"] == "completed"
assert data["is_healthy"] is True
async def test_health_reports_last_failure():
await _make_run("fail-1", status="failed", error_message="boom")
r = client.get("/api/sync/health")
data = r.json()
assert data["last_sync_status"] == "failed"
assert data["last_halt_reason"] == "boom"
assert data["is_healthy"] is False
async def test_health_detects_escalation():
# 3 consecutive runs each with price_sync failure → escalation flagged.
for i in range(3):
run_id = f"esc-{i}"
await _make_run(run_id, status="failed", offset=i,
error_message="ESCALATED: phase price_sync failed 3 consecutive runs")
await sqlite_service.record_phase_failure(run_id, "price_sync", "IntegrityError: X")
r = client.get("/api/sync/health")
data = r.json()
assert data["escalation_phase"] == "price_sync"
assert data["is_healthy"] is False
assert data["recent_phase_failures"]["price_sync"] == 3
assert "ESCALATED" in (data["last_halt_reason"] or "")
async def test_health_one_phase_failure_still_warning_not_healthy():
await _make_run("recent-fail", status="completed")
await sqlite_service.record_phase_failure("recent-fail", "invoice_check", "err")
r = client.get("/api/sync/health")
data = r.json()
# 1 recent phase failure → is_healthy stays True (<=1 tolerance); healthy
assert data["is_healthy"] is True
assert data["recent_phase_failures"]["invoice_check"] == 1

View File

@@ -0,0 +1,121 @@
"""Tests for sync_phase_failures table + helpers."""
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_spf.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
@pytest.fixture(autouse=True)
async def _reset():
database.init_sqlite()
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM sync_phase_failures")
await db.execute("DELETE FROM sync_runs")
await db.commit()
finally:
await db.close()
yield
async def _make_run(run_id: str, offset_seconds: int = 0):
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"INSERT INTO sync_runs (run_id, started_at, status) VALUES (?, datetime('now', ?), 'running')",
(run_id, f"{offset_seconds} seconds"),
)
await db.commit()
finally:
await db.close()
async def test_table_created_on_init():
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='sync_phase_failures'"
)
row = await cur.fetchone()
assert row is not None
finally:
await db.close()
async def test_record_and_read_phase_failures():
await _make_run("run-1")
await _make_run("run-2", offset_seconds=1)
await sqlite_service.record_phase_failure("run-1", "price_sync", "IntegrityError: X")
await sqlite_service.record_phase_failure("run-2", "price_sync", "IntegrityError: Y")
counts = await sqlite_service.get_recent_phase_failures(limit=3)
assert counts.get("price_sync") == 2
async def test_get_recent_limit_respected():
# 5 runs, each with a price_sync failure. Limit=3 should only count the latest 3.
for i in range(5):
run_id = f"run-{i}"
await _make_run(run_id, offset_seconds=i)
await sqlite_service.record_phase_failure(run_id, "price_sync", "fail")
counts = await sqlite_service.get_recent_phase_failures(limit=3)
assert counts["price_sync"] == 3
async def test_record_prunes_to_100_runs():
# Insert 105 runs each with a failure → table should end at <=100 rows after prune.
for i in range(105):
run_id = f"R{i:03d}"
await _make_run(run_id, offset_seconds=i)
await sqlite_service.record_phase_failure(run_id, "import_loop", "x")
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute("SELECT COUNT(*) FROM sync_phase_failures")
(total,) = await cur.fetchone()
finally:
await db.close()
assert total <= 100
async def test_empty_phase_failures_returns_empty_dict():
counts = await sqlite_service.get_recent_phase_failures(limit=3)
assert counts == {}
async def test_record_phase_failure_idempotent_per_run_phase():
"""PRIMARY KEY (run_id, phase) → second insert same run+phase updates in place."""
await _make_run("run-idem")
await sqlite_service.record_phase_failure("run-idem", "invoice_check", "first")
await sqlite_service.record_phase_failure("run-idem", "invoice_check", "second")
db = await sqlite_service.get_sqlite()
try:
cur = await db.execute(
"SELECT COUNT(*), MAX(error_summary) FROM sync_phase_failures WHERE run_id=? AND phase=?",
("run-idem", "invoice_check"),
)
(count, latest) = await cur.fetchone()
finally:
await db.close()
assert count == 1
assert latest == "second"

View File

@@ -0,0 +1,166 @@
"""Unit tests for validation_service.validate_structural().
Structural pre-flight validator — only catches malformed payloads that
would crash downstream inserts. Does NOT check SKU existence (handled
by validate_skus) or duplicate SKUs (handled by _dedup_items_by_sku).
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.services.validation_service import validate_structural
def _valid_order(**overrides):
base = {
"order_number": "123456",
"order_date": "2026-04-22 10:00:00",
"items": [{"sku": "ABC", "quantity": 2, "price": 15.50}],
}
base.update(overrides)
return base
@pytest.mark.unit
def test_valid_order_passes():
ok, err_type, err_msg = validate_structural(_valid_order())
assert ok is True
assert err_type is None
assert err_msg is None
@pytest.mark.unit
def test_missing_order_number():
ok, err_type, _ = validate_structural(_valid_order(order_number=""))
assert ok is False
assert err_type == "MISSING_FIELD"
ok, err_type, _ = validate_structural(_valid_order(order_number=None))
assert ok is False
assert err_type == "MISSING_FIELD"
@pytest.mark.unit
def test_non_dict_order():
ok, err_type, _ = validate_structural("not a dict")
assert ok is False
assert err_type == "MISSING_FIELD"
@pytest.mark.unit
def test_invalid_date_unparseable():
ok, err_type, _ = validate_structural(_valid_order(order_date="not-a-date"))
assert ok is False
assert err_type == "INVALID_DATE"
@pytest.mark.unit
def test_invalid_date_missing():
ok, err_type, _ = validate_structural(_valid_order(order_date=None))
assert ok is False
assert err_type == "INVALID_DATE"
@pytest.mark.unit
def test_date_iso_format_passes():
ok, _, _ = validate_structural(_valid_order(order_date="2026-04-22T10:00:00"))
assert ok is True
@pytest.mark.unit
def test_empty_items():
ok, err_type, _ = validate_structural(_valid_order(items=[]))
assert ok is False
assert err_type == "EMPTY_ITEMS"
ok, err_type, _ = validate_structural(_valid_order(items=None))
assert ok is False
assert err_type == "EMPTY_ITEMS"
@pytest.mark.unit
def test_items_not_list():
ok, err_type, _ = validate_structural(_valid_order(items="ABC"))
assert ok is False
assert err_type == "EMPTY_ITEMS"
@pytest.mark.unit
def test_item_not_dict():
ok, err_type, _ = validate_structural(_valid_order(items=["just-a-string"]))
assert ok is False
assert err_type == "EMPTY_ITEMS"
@pytest.mark.unit
def test_invalid_quantity_zero():
ok, err_type, _ = validate_structural(
_valid_order(items=[{"sku": "A", "quantity": 0, "price": 1}])
)
assert ok is False
assert err_type == "INVALID_QUANTITY"
@pytest.mark.unit
def test_invalid_quantity_negative():
ok, err_type, _ = validate_structural(
_valid_order(items=[{"sku": "A", "quantity": -3, "price": 1}])
)
assert ok is False
assert err_type == "INVALID_QUANTITY"
@pytest.mark.unit
def test_invalid_quantity_non_numeric():
ok, err_type, _ = validate_structural(
_valid_order(items=[{"sku": "A", "quantity": "abc", "price": 1}])
)
assert ok is False
assert err_type == "INVALID_QUANTITY"
@pytest.mark.unit
def test_invalid_quantity_missing():
ok, err_type, _ = validate_structural(
_valid_order(items=[{"sku": "A", "price": 1}])
)
assert ok is False
assert err_type == "INVALID_QUANTITY"
@pytest.mark.unit
def test_invalid_price_non_numeric():
ok, err_type, _ = validate_structural(
_valid_order(items=[{"sku": "A", "quantity": 1, "price": "NaN-text"}])
)
assert ok is False
assert err_type == "INVALID_PRICE"
@pytest.mark.unit
def test_invalid_price_missing():
ok, err_type, _ = validate_structural(
_valid_order(items=[{"sku": "A", "quantity": 1}])
)
assert ok is False
assert err_type == "INVALID_PRICE"
@pytest.mark.unit
def test_price_zero_allowed():
"""Complex sets can legitimately have price=0 on one leg."""
ok, _, _ = validate_structural(
_valid_order(items=[{"sku": "A", "quantity": 1, "price": 0}])
)
assert ok is True
@pytest.mark.unit
def test_sku_null_passes_structural():
"""SKU validation is handled downstream, NOT here."""
ok, _, _ = validate_structural(
_valid_order(items=[{"sku": None, "quantity": 1, "price": 1}])
)
assert ok is True

View File

@@ -0,0 +1,59 @@
# Adrese Facturare — Regula PJ vs PF
## Cum funcționează ACUM
| Tip client | Adresă livrare ROA | Adresă facturare ROA |
|------------|-------------------|----------------------|
| **PJ** (company.name SAU company.code populat) | GoMag shipping | GoMag **billing** (sediul firmei) |
| **PF** (fără companie) | GoMag shipping | GoMag **shipping** (ramburs curier pe numele destinatarului) |
**Motivație PF:** Banii ramburs de la curier se întorc pe numele de pe adresa de livrare, deci factura trebuie să fie pe aceeași adresă.
**Motivație PJ:** Firma vrea factura pe sediul social (adresa billing din GoMag), nu pe adresa de livrare a curierului.
## Detectie companie (is_company)
```python
is_company = isinstance(company, dict) and (
bool(company.get("name")) or bool(company.get("code"))
)
```
Fallback CUI: dacă GoMag trimite `company.name=""` dar `company.code="RO12345678"` → tot PJ.
Dacă `company_name` e gol dar există CUI → `denumire` = billing person name.
## Implementare
`api/app/services/import_service.py` — Step 3 (billing address):
```python
if is_pj:
# PJ: billing address = GoMag billing (company HQ)
billing_addr = format_address_for_oracle(order.billing.address, ...)
if billing_addr == shipping_addr:
addr_fact_id = addr_livr_id # optimizare: reuse dacă identice
else:
addr_fact_id = cauta_sau_creeaza_adresa(billing_addr)
else:
# PF: billing = shipping
addr_fact_id = addr_livr_id
```
## Verificare
```bash
# Audit comenzi existente
python3 scripts/verify_address_rules.py --days 7
# Teste Oracle E2E
./test.sh oracle
```
## Istoricul deciziei
**Înainte (greșit):** logica `different_person` — compara numele billing vs shipping.
Dacă difereau → shipping pt ambele. Dacă identice → billing GoMag pt facturare.
Problema: PJ cu persoane diferite primeau factura pe adresa de shipping (nu pe sediul firmei).
**Decizie (2026-04-08):** Regula simplă PJ/PF, indiferent de compararea numelor.
Doar comenzile NOI sunt afectate — comenzile existente rămân cu adresele curente.

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""One-shot recovery: re-populate SQLite `order_items` for orders where the
table was wiped (e.g. DELETED_IN_ROA → retry flow, before the retry items fix).
Reads settings from SQLite, downloads orders from GoMag API for a ~14-day window
around the order date, finds the target order, rebuilds the items rows.
Does NOT touch Oracle. Does NOT change order status / id_comanda.
Usage (inside the venv, on the prod server):
python scripts/backfill_order_items.py 485224762
python scripts/backfill_order_items.py 485224762 485224763 # multiple
"""
import asyncio
import os
import sys
import tempfile
from datetime import datetime, timedelta
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from api.app.services import sqlite_service, gomag_client, order_reader, validation_service
from api.app import database
async def _backfill_one(order_number: str, app_settings: dict, use_oracle: bool) -> dict:
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
return {"ok": False, "msg": f"#{order_number}: nu e in SQLite"}
order_data = detail["order"]
existing_items = len(detail["items"])
order_date_str = order_data.get("order_date") or ""
try:
order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date()
except (ValueError, AttributeError):
order_date = datetime.now().date() - timedelta(days=1)
days_back = max((datetime.now().date() - order_date).days + 2, 2)
with tempfile.TemporaryDirectory() as tmp:
await gomag_client.download_orders(
tmp,
days_back=days_back,
api_key=app_settings.get("gomag_api_key"),
api_shop=app_settings.get("gomag_api_shop"),
limit=200,
)
orders, _ = order_reader.read_json_orders(json_dir=tmp)
target = next((o for o in orders if str(o.number) == str(order_number)), None)
if not target:
return {"ok": False, "msg": f"#{order_number}: nu e in GoMag (fereastra {days_back}z)"}
validation = {"mapped": set(), "direct": set()}
if use_oracle:
skus = {item.sku for item in target.items if item.sku}
id_gestiune = app_settings.get("id_gestiune", "")
id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None
try:
validation = await asyncio.to_thread(
validation_service.validate_skus, skus, None, id_gestiuni
)
except Exception as e:
print(f" [WARN] validate_skus a esuat, mapping_status default='direct': {e}")
items_data = [
{
"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price,
"baseprice": item.baseprice, "vat": item.vat,
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
"codmat": None, "id_articol": None, "cantitate_roa": None,
}
for item in target.items
]
await sqlite_service.add_order_items(order_number, items_data)
return {
"ok": True,
"msg": f"#{order_number}: {len(items_data)} items scrise (era {existing_items})",
}
async def main(order_numbers: list[str]):
database.init_sqlite()
app_settings = await sqlite_service.get_app_settings()
use_oracle = False
try:
database.init_oracle()
use_oracle = True
print("Oracle conectat — mapping_status va fi calculat corect.")
except Exception as e:
print(f"Oracle indisponibil ({e}) — mapping_status default 'direct'.")
try:
for on in order_numbers:
result = await _backfill_one(on, app_settings, use_oracle)
tag = "OK " if result["ok"] else "FAIL"
print(f"[{tag}] {result['msg']}")
finally:
if use_oracle:
try:
database.close_oracle()
except Exception:
pass
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python scripts/backfill_order_items.py <order_number> [<order_number>...]")
sys.exit(1)
asyncio.run(main(sys.argv[1:]))