Compare commits
60 Commits
fc1013bff6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32974e3b85 | ||
|
|
ab20856cd6 | ||
|
|
956667086d | ||
|
|
9b62b2b457 | ||
|
|
b13d9a466c | ||
|
|
18acfd2226 | ||
|
|
bcd65d9fd6 | ||
|
|
874ba4ca4e | ||
|
|
e42b1f63b7 | ||
|
|
c8bed18978 | ||
|
|
6620b28ed1 | ||
|
|
7e30523242 | ||
|
|
bb6f3a3b87 | ||
|
|
41b142effb | ||
|
|
1e4e3279f7 | ||
|
|
47a6bd83a4 | ||
|
|
f448f74b2d | ||
|
|
d7610a6f33 | ||
|
|
38498bec6d | ||
|
|
f6d283b743 | ||
|
|
51790accf9 | ||
|
|
404bc094cd | ||
|
|
819af221d8 | ||
|
|
b8a9480784 | ||
|
|
d15f8b085d | ||
|
|
3bcb26b0bd | ||
|
|
5397bec35d | ||
|
|
5cdd919226 | ||
|
|
db60d955bf | ||
|
|
520f0836bf | ||
|
|
84e5d55592 | ||
|
|
e223128565 | ||
|
|
25f73db64d | ||
|
|
90a4906d87 | ||
|
|
5eba87976b | ||
|
|
f48c2d62c6 | ||
|
|
f049b0bf12 | ||
|
|
1d59f1a484 | ||
|
|
5584dd3c4f | ||
|
|
aa581e5cd9 | ||
|
|
b2f1687920 | ||
|
|
07df807719 | ||
|
|
d3d72032ef | ||
|
|
89c3d1d07f | ||
|
|
bf194eb088 | ||
|
|
b28f9d7611 | ||
|
|
057e62fc04 | ||
|
|
0f817b2130 | ||
|
|
5b4b317636 | ||
|
|
ecde7fe440 | ||
|
|
a8ad54a604 | ||
|
|
51910148ef | ||
|
|
86e8d54d5e | ||
|
|
9977ec28cf | ||
|
|
47fe7efd92 | ||
|
|
c8e3a4e8d1 | ||
|
|
4bff1aada1 | ||
|
|
2b212b933e | ||
|
|
68ab7f664a | ||
|
|
31095c07f7 |
14
CLAUDE.md
14
CLAUDE.md
@@ -73,9 +73,19 @@ Documentatie completa: [README.md](README.md)
|
|||||||
- Recovery: la fiecare sync, comenzile ERROR sunt reverificate in Oracle
|
- Recovery: la fiecare sync, comenzile ERROR sunt reverificate in Oracle
|
||||||
|
|
||||||
### Parteneri
|
### Parteneri
|
||||||
- Prioritate: **companie** (PJ, cod_fiscal + registru) daca exista in GoMag, altfel persoana fizica cu **shipping name**
|
- Prioritate: **companie** (PJ, cod_fiscal + registru) daca exista in GoMag (name SAU code), altfel persoana fizica cu **shipping name**
|
||||||
- Adresa livrare: intotdeauna GoMag shipping
|
- Adresa livrare: intotdeauna GoMag shipping
|
||||||
- Adresa facturare: daca shipping ≠ billing person → shipping pt ambele; altfel → 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 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
|
### Preturi
|
||||||
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
|
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
|
||||||
|
|||||||
11
DESIGN.md
11
DESIGN.md
@@ -97,6 +97,10 @@ Every admin tool is blue. This one uses amber — reads as "operational" and "at
|
|||||||
|
|
||||||
--cancelled: #78716C;
|
--cancelled: #78716C;
|
||||||
--cancelled-light: #F5F5F4;
|
--cancelled-light: #F5F5F4;
|
||||||
|
|
||||||
|
--compare: #EA580C;
|
||||||
|
--compare-light: #FFF7ED;
|
||||||
|
--compare-text: #9A3412;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -140,6 +144,10 @@ Strategy: invert surfaces, reduce accent saturation ~15%, keep semantic colors r
|
|||||||
|
|
||||||
--cancelled: #78716C;
|
--cancelled: #78716C;
|
||||||
--cancelled-light: rgba(120,113,108,0.15);
|
--cancelled-light: rgba(120,113,108,0.15);
|
||||||
|
|
||||||
|
--compare: #EA580C;
|
||||||
|
--compare-light: rgba(234,88,12,0.15);
|
||||||
|
--compare-text: #FB923C;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -152,9 +160,12 @@ Strategy: invert surfaces, reduce accent saturation ~15%, keep semantic colors r
|
|||||||
| ALREADY_IMPORTED | `--info` | `--info-light` | none |
|
| ALREADY_IMPORTED | `--info` | `--info-light` | none |
|
||||||
| CANCELLED | `--cancelled` | `--cancelled-light` | none |
|
| CANCELLED | `--cancelled` | `--cancelled-light` | none |
|
||||||
| DELETED_IN_ROA | `--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.
|
**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
|
## Spacing
|
||||||
- **Base unit:** 4px
|
- **Base unit:** 4px
|
||||||
- **Density:** Comfortable — not cramped, not wasteful
|
- **Density:** Comfortable — not cramped, not wasteful
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -195,9 +195,10 @@ gomag-vending/
|
|||||||
### Reguli Business
|
### Reguli Business
|
||||||
|
|
||||||
**Parteneri & Adrese:**
|
**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 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)
|
- Cautare partener in Oracle: cod_fiscal → denumire → create new (ID_UTIL = -3)
|
||||||
|
|
||||||
**Articole & Mapari:**
|
**Articole & Mapari:**
|
||||||
@@ -411,10 +412,10 @@ Loguri aplicatie: `logs/sync_comenzi_*.log`
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Conectare SSH (PowerShell remote, cheie publica)
|
# 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
|
# 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
|
# 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()"
|
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
|
# Verificare tnsnames.ora
|
||||||
cmd /c type C:\roa\instantclient_11_2_0_2\tnsnames.ora
|
cmd /c type C:\roa\instantclient_11_2_0_2\tnsnames.ora
|
||||||
|
|
||||||
# Verificare procese Python
|
# Verificare procese Python (ID-uri pentru kill/restart)
|
||||||
Get-Process *python* | Select-Object Id,ProcessName,Path
|
powershell -Command "Get-Process python -ErrorAction SilentlyContinue | Format-Table Id, CPU -AutoSize"
|
||||||
|
|
||||||
# Verificare loguri recente
|
# Verificare loguri recente
|
||||||
Get-ChildItem C:\gomag-vending\logs\*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 3
|
Get-ChildItem C:\gomag-vending\logs\*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 3
|
||||||
|
|
||||||
# Test sync manual (verifica ca Oracle pool porneste)
|
# Test app (prin nginx reverse proxy)
|
||||||
curl http://localhost:5003/health
|
powershell -Command "Invoke-WebRequest -Uri 'http://localhost/gomag/' -UseBasicParsing | Select-Object StatusCode"
|
||||||
curl -X POST http://localhost:5003/api/sync/start
|
|
||||||
|
|
||||||
# Refresh facturi manual
|
# Retry comanda din linie de comanda
|
||||||
curl -X POST http://localhost:5003/api/dashboard/refresh-invoices
|
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
|
### Probleme frecvente
|
||||||
|
|
||||||
| Eroare | Cauza | Solutie |
|
| Eroare | Cauza | Solutie |
|
||||||
|
|||||||
11
TODOS.md
11
TODOS.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## P2: Refactor sync_service.py in module separate
|
## P2: Refactor sync_service.py in module separate
|
||||||
**What:** Split sync_service.py (870 linii) in: download_service, parse_service, sync_orchestrator.
|
**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)
|
**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.
|
**Depends on:** Finalizarea planului Command Center.
|
||||||
|
|
||||||
## P2: Email/webhook alert pe sync esuat
|
## P2: Email/webhook alert pe sync esuat
|
||||||
@@ -20,3 +20,10 @@
|
|||||||
**Effort:** S (human: ~10min / CC: ~2min)
|
**Effort:** S (human: ~10min / CC: ~2min)
|
||||||
**Context:** Part of the address overflow fix (Pas 5). The fix script re-downloads from GoMag API to get original address text, but doesn't verify the API response. Add empty-response check + report.
|
**Context:** Part of the address overflow fix (Pas 5). The fix script re-downloads from GoMag API to get original address text, but doesn't verify the API response. Add empty-response check + report.
|
||||||
**Depends on:** Address parser fix (Pas 1-2) deployed.
|
**Depends on:** Address parser fix (Pas 1-2) deployed.
|
||||||
|
|
||||||
|
## P3: Cleanup orphan VFP-era addresses in Oracle
|
||||||
|
**What:** One-time script to find and soft-delete partner addresses created by VFP that have no linked orders and incorrect street data.
|
||||||
|
**Why:** After TIER 2 removal, old addresses that were incorrectly reused remain attached to partners. They're cosmetic clutter but not harmful — new addresses are created correctly now.
|
||||||
|
**Effort:** S (human: ~2h / CC: ~10min)
|
||||||
|
**Context:** TIER 2 matched county+city without street, reusing VFP-era addresses with wrong streets. After removal (2026-04-06), new imports create correct addresses. Old wrong addresses stay. Could identify them by: address has id_loc but no linked order rows, and was last modified before 2026-04-06.
|
||||||
|
**Depends on:** TIER 2 removal deployed and verified.
|
||||||
|
|||||||
22
api/app/constants.py
Normal file
22
api/app/constants.py
Normal 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"
|
||||||
@@ -3,7 +3,6 @@ import aiosqlite
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -170,6 +169,7 @@ CREATE TABLE IF NOT EXISTS order_items (
|
|||||||
product_name TEXT,
|
product_name TEXT,
|
||||||
quantity REAL,
|
quantity REAL,
|
||||||
price REAL,
|
price REAL,
|
||||||
|
baseprice REAL,
|
||||||
vat REAL,
|
vat REAL,
|
||||||
mapping_status TEXT,
|
mapping_status TEXT,
|
||||||
codmat TEXT,
|
codmat TEXT,
|
||||||
@@ -186,6 +186,15 @@ CREATE TABLE IF NOT EXISTS anaf_cache (
|
|||||||
denumire_anaf TEXT,
|
denumire_anaf TEXT,
|
||||||
checked_at TEXT NOT NULL
|
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
|
_sqlite_db_path = None
|
||||||
@@ -352,18 +361,59 @@ def init_sqlite():
|
|||||||
("adresa_facturare_roa", "TEXT"),
|
("adresa_facturare_roa", "TEXT"),
|
||||||
("anaf_denumire_mismatch", "INTEGER DEFAULT 0"),
|
("anaf_denumire_mismatch", "INTEGER DEFAULT 0"),
|
||||||
("denumire_anaf", "TEXT"),
|
("denumire_anaf", "TEXT"),
|
||||||
|
("address_mismatch", "INTEGER DEFAULT 0"),
|
||||||
|
("partner_mismatch", "INTEGER DEFAULT 0"),
|
||||||
]:
|
]:
|
||||||
if col not in order_cols:
|
if col not in order_cols:
|
||||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||||
logger.info(f"Migrated orders: added column {col}")
|
logger.info(f"Migrated orders: added column {col}")
|
||||||
|
|
||||||
|
# Migrate order_items: add baseprice column
|
||||||
|
cursor = conn.execute("PRAGMA table_info(order_items)")
|
||||||
|
oi_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
if "baseprice" not in oi_cols:
|
||||||
|
conn.execute("ALTER TABLE order_items ADD COLUMN baseprice REAL")
|
||||||
|
conn.execute("UPDATE orders SET price_match = NULL WHERE price_match = 0")
|
||||||
|
logger.info("Migrated order_items: added baseprice; reset price_match for re-check")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Backfill address_mismatch from stored address JSON
|
||||||
|
_backfill_address_mismatch(conn)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Migration check failed: {e}")
|
logger.warning(f"Migration check failed: {e}")
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
logger.info(f"SQLite initialized: {_sqlite_db_path}")
|
logger.info(f"SQLite initialized: {_sqlite_db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_address_mismatch(conn):
|
||||||
|
"""Recompute address_mismatch from stored address JSON for all orders."""
|
||||||
|
from .services.sync_service import _addr_match
|
||||||
|
try:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT order_number, adresa_livrare_gomag, adresa_livrare_roa,
|
||||||
|
adresa_facturare_gomag, adresa_facturare_roa
|
||||||
|
FROM orders
|
||||||
|
WHERE adresa_livrare_roa IS NOT NULL OR adresa_facturare_roa IS NOT NULL
|
||||||
|
""").fetchall()
|
||||||
|
updated = 0
|
||||||
|
for r in rows:
|
||||||
|
livr_ok = _addr_match(r[1], r[2])
|
||||||
|
fact_ok = _addr_match(r[3], r[4])
|
||||||
|
new_val = 1 if (not livr_ok or not fact_ok) else 0
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE orders SET address_mismatch = ? WHERE order_number = ?",
|
||||||
|
(new_val, r[0])
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
if updated:
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Backfill address_mismatch: {updated} orders recomputed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Backfill address_mismatch failed: {e}")
|
||||||
|
|
||||||
async def get_sqlite():
|
async def get_sqlite():
|
||||||
"""Get async SQLite connection."""
|
"""Get async SQLite connection."""
|
||||||
if _sqlite_db_path is None:
|
if _sqlite_db_path is None:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -9,7 +8,6 @@ import os
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import init_oracle, close_oracle, init_sqlite
|
from .database import init_oracle, close_oracle, init_sqlite
|
||||||
from .routers.sync import backfill_price_match
|
|
||||||
|
|
||||||
# Configure logging with both stream and file handlers
|
# Configure logging with both stream and file handlers
|
||||||
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||||
@@ -58,8 +56,6 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
asyncio.create_task(backfill_price_match())
|
|
||||||
|
|
||||||
logger.info("GoMag Import Manager started")
|
logger.info("GoMag Import Manager started")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from fastapi.responses import HTMLResponse
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..services import sqlite_service
|
from ..services import sqlite_service
|
||||||
|
from ..constants import OrderStatus
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||||
|
templates.env.globals["OrderStatus"] = OrderStatus
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Optional
|
|||||||
import io
|
import io
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from ..services import mapping_service, sqlite_service
|
from ..services import mapping_service, sqlite_service, validation_service
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -168,6 +168,7 @@ async def import_csv(file: UploadFile = File(...)):
|
|||||||
content = await file.read()
|
content = await file.read()
|
||||||
text = content.decode("utf-8-sig")
|
text = content.decode("utf-8-sig")
|
||||||
result = mapping_service.import_csv(text)
|
result = mapping_service.import_csv(text)
|
||||||
|
await validation_service.reconcile_unresolved_missing_skus()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@router.get("/api/mappings/export-csv")
|
@router.get("/api/mappings/export-csv")
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ from datetime import datetime
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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 .. import database
|
||||||
|
from ..constants import OrderStatus
|
||||||
|
|
||||||
router = APIRouter(tags=["sync"])
|
router = APIRouter(tags=["sync"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||||
|
templates.env.globals["OrderStatus"] = OrderStatus
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_items_with_codmat(items: list) -> None:
|
async def _enrich_items_with_codmat(items: list) -> None:
|
||||||
@@ -40,53 +42,6 @@ async def _enrich_items_with_codmat(items: list) -> None:
|
|||||||
"denumire": nom_map[sku], "direct": True}]
|
"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:
|
|
||||||
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):
|
class ScheduleConfig(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
interval_minutes: int = 5
|
interval_minutes: int = 5
|
||||||
@@ -113,9 +68,6 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
kit_discount_codmat: str = ""
|
kit_discount_codmat: str = ""
|
||||||
kit_discount_id_pol: str = ""
|
kit_discount_id_pol: str = ""
|
||||||
price_sync_enabled: str = "1"
|
price_sync_enabled: str = "1"
|
||||||
catalog_sync_enabled: str = "0"
|
|
||||||
price_sync_schedule: str = ""
|
|
||||||
gomag_products_url: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
@@ -208,37 +160,58 @@ async def sync_status():
|
|||||||
return result
|
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")
|
@router.get("/api/sync/history")
|
||||||
async def sync_history(page: int = 1, per_page: int = 20):
|
async def sync_history(page: int = 1, per_page: int = 20):
|
||||||
"""Get sync run history."""
|
"""Get sync run history."""
|
||||||
return await sqlite_service.get_sync_runs(page, per_page)
|
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)
|
@router.get("/logs", response_class=HTMLResponse)
|
||||||
async def logs_page(request: Request, run: str = None):
|
async def logs_page(request: Request, run: str = None):
|
||||||
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
|
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
|
||||||
@@ -306,13 +279,13 @@ def _format_text_log_from_detail(detail: dict) -> str:
|
|||||||
customer = o.get("customer_name", "?")
|
customer = o.get("customer_name", "?")
|
||||||
order_date = o.get("order_date") or "?"
|
order_date = o.get("order_date") or "?"
|
||||||
|
|
||||||
if status == "IMPORTED":
|
if status == OrderStatus.IMPORTED.value:
|
||||||
id_cmd = o.get("id_comanda", "?")
|
id_cmd = o.get("id_comanda", "?")
|
||||||
lines.append(f"#{number} [{order_date}] {customer} → IMPORTAT (ID: {id_cmd})")
|
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", "?")
|
id_cmd = o.get("id_comanda", "?")
|
||||||
lines.append(f"#{number} [{order_date}] {customer} → DEJA IMPORTAT (ID: {id_cmd})")
|
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", "")
|
missing = o.get("missing_skus", "")
|
||||||
if isinstance(missing, str):
|
if isinstance(missing, str):
|
||||||
try:
|
try:
|
||||||
@@ -321,7 +294,7 @@ def _format_text_log_from_detail(detail: dict) -> str:
|
|||||||
missing = [missing] if missing else []
|
missing = [missing] if missing else []
|
||||||
skus_str = ", ".join(missing) if isinstance(missing, list) else str(missing)
|
skus_str = ", ".join(missing) if isinstance(missing, list) else str(missing)
|
||||||
lines.append(f"#{number} [{order_date}] {customer} → OMIS (lipsa: {skus_str})")
|
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")
|
err = o.get("error_message", "necunoscuta")
|
||||||
lines.append(f"#{number} [{order_date}] {customer} → EROARE: {err}")
|
lines.append(f"#{number} [{order_date}] {customer} → EROARE: {err}")
|
||||||
|
|
||||||
@@ -451,35 +424,8 @@ async def order_detail(order_number: str):
|
|||||||
items = detail.get("items", [])
|
items = detail.get("items", [])
|
||||||
await _enrich_items_with_codmat(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
|
# Enrich with invoice data
|
||||||
order = detail.get("order", {})
|
order = detail.get("order", {})
|
||||||
order["price_check"] = order_price_check
|
|
||||||
if order.get("factura_numar") and order.get("factura_data"):
|
if order.get("factura_numar") and order.get("factura_data"):
|
||||||
order["invoice"] = {
|
order["invoice"] = {
|
||||||
"facturat": True,
|
"facturat": True,
|
||||||
@@ -541,6 +487,7 @@ async def order_detail(order_number: str):
|
|||||||
"anaf_cod_fiscal_adjusted": order.get("anaf_cod_fiscal_adjusted") == 1,
|
"anaf_cod_fiscal_adjusted": order.get("anaf_cod_fiscal_adjusted") == 1,
|
||||||
"anaf_denumire_mismatch": order.get("anaf_denumire_mismatch") == 1,
|
"anaf_denumire_mismatch": order.get("anaf_denumire_mismatch") == 1,
|
||||||
"denumire_anaf": order.get("denumire_anaf"),
|
"denumire_anaf": order.get("denumire_anaf"),
|
||||||
|
"partner_mismatch": order.get("partner_mismatch") == 1,
|
||||||
}
|
}
|
||||||
# Parse JSON address strings
|
# Parse JSON address strings
|
||||||
for key in ("adresa_livrare_gomag", "adresa_facturare_gomag",
|
for key in ("adresa_livrare_gomag", "adresa_facturare_gomag",
|
||||||
@@ -558,7 +505,8 @@ async def order_detail(order_number: str):
|
|||||||
"facturare_roa": order.get("adresa_facturare_roa"),
|
"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_vat"] = app_settings.get("transport_vat") or "21"
|
||||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||||
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
||||||
@@ -576,6 +524,97 @@ async def retry_order(order_number: str):
|
|||||||
return result
|
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.
|
||||||
|
|
||||||
|
Auto-resync handles uninvoiced orders during sync loop.
|
||||||
|
This endpoint is for edge case: operator wants to fix an already-invoiced order.
|
||||||
|
"""
|
||||||
|
detail = await sqlite_service.get_order_detail(order_number)
|
||||||
|
if not detail:
|
||||||
|
raise HTTPException(status_code=404, detail="Comanda nu a fost gasita")
|
||||||
|
|
||||||
|
order_data = detail["order"]
|
||||||
|
if not order_data.get("partner_mismatch"):
|
||||||
|
return {"success": False, "message": "Comanda nu are mismatch de partener"}
|
||||||
|
|
||||||
|
if sync_service._sync_lock.locked():
|
||||||
|
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
|
||||||
|
|
||||||
|
stored = {
|
||||||
|
"id_comanda": order_data.get("id_comanda"),
|
||||||
|
"id_partener": order_data.get("id_partener"),
|
||||||
|
"denumire_roa": order_data.get("denumire_roa"),
|
||||||
|
"cod_fiscal_gomag": order_data.get("cod_fiscal_gomag"),
|
||||||
|
"factura_numar": order_data.get("factura_numar"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download order from GoMag to get current data
|
||||||
|
import tempfile
|
||||||
|
from ..services import order_reader, gomag_client
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
gomag_key = app_settings.get("gomag_api_key") or None
|
||||||
|
gomag_shop = app_settings.get("gomag_api_shop") or None
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
order_date_str = order_data.get("order_date", "")
|
||||||
|
try:
|
||||||
|
order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date()
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
order_date = datetime.now().date() - timedelta(days=1)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
try:
|
||||||
|
days_back = (datetime.now().date() - order_date).days + 2
|
||||||
|
await gomag_client.download_orders(
|
||||||
|
tmp_dir, days_back=days_back,
|
||||||
|
api_key=gomag_key, api_shop=gomag_shop, limit=200,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "message": f"Eroare download GoMag: {e}"}
|
||||||
|
|
||||||
|
target_order = None
|
||||||
|
orders, _ = order_reader.read_json_orders(json_dir=tmp_dir)
|
||||||
|
for o in orders:
|
||||||
|
if str(o.number) == str(order_number):
|
||||||
|
target_order = o
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_order:
|
||||||
|
return {"success": False, "message": f"Comanda {order_number} nu a fost gasita in GoMag API"}
|
||||||
|
|
||||||
|
run_id = f"resync_{order_number}"
|
||||||
|
try:
|
||||||
|
await sync_service._resync_partner_for_order(
|
||||||
|
order=target_order,
|
||||||
|
stored=stored,
|
||||||
|
app_settings=app_settings,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
return {"success": True, "message": "Partener actualizat in ROA"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Manual resync failed for {order_number}: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/orders/by-sku/{sku}/pending")
|
@router.get("/api/orders/by-sku/{sku}/pending")
|
||||||
async def get_pending_orders_for_sku(sku: str):
|
async def get_pending_orders_for_sku(sku: str):
|
||||||
"""Get SKIPPED orders that contain the given SKU."""
|
"""Get SKIPPED orders that contain the given SKU."""
|
||||||
@@ -627,7 +666,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
is_invoiced_filter = (status == "INVOICED")
|
is_invoiced_filter = (status == "INVOICED")
|
||||||
|
|
||||||
# For UNINVOICED/INVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
# 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_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
|
fetch_page = 1 if (is_uninvoiced_filter or is_invoiced_filter) else page
|
||||||
|
|
||||||
@@ -642,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
|
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
||||||
all_orders = result["orders"]
|
all_orders = result["orders"]
|
||||||
for o in all_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"):
|
if o.get("factura_numar") and o.get("factura_data"):
|
||||||
# Use cached invoice data from SQLite (only if complete)
|
# Use cached invoice data from SQLite (only if complete)
|
||||||
o["invoice"] = {
|
o["invoice"] = {
|
||||||
@@ -699,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"))
|
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(
|
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
||||||
1 for o in all_orders
|
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)
|
counts["nefacturate"] = max(0, uninvoiced_base - newly_invoiced)
|
||||||
imported_total = counts.get("imported_all") or counts.get("imported", 0)
|
imported_total = counts.get("imported_all") or counts.get("imported", 0)
|
||||||
@@ -725,7 +761,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
|
|
||||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||||
if is_uninvoiced_filter:
|
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)
|
total = len(filtered)
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
result["orders"] = filtered[offset:offset + per_page]
|
result["orders"] = filtered[offset:offset + per_page]
|
||||||
@@ -734,7 +770,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
result["per_page"] = per_page
|
result["per_page"] = per_page
|
||||||
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
||||||
elif is_invoiced_filter:
|
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)
|
total = len(filtered)
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
result["orders"] = filtered[offset:offset + per_page]
|
result["orders"] = filtered[offset:offset + per_page]
|
||||||
@@ -815,6 +851,55 @@ async def refresh_invoices():
|
|||||||
await sqlite_service.mark_order_deleted_in_roa(o["order_number"])
|
await sqlite_service.mark_order_deleted_in_roa(o["order_number"])
|
||||||
orders_deleted += 1
|
orders_deleted += 1
|
||||||
|
|
||||||
|
# Cherry-pick A: Batch refresh Oracle addresses for all orders with stored address IDs
|
||||||
|
addr_rows = await sqlite_service.get_orders_with_address_ids()
|
||||||
|
if addr_rows:
|
||||||
|
def _fetch_addresses(rows):
|
||||||
|
unique_ids = list(
|
||||||
|
{r["id_adresa_livrare"] for r in rows if r.get("id_adresa_livrare")}
|
||||||
|
| {r["id_adresa_facturare"] for r in rows if r.get("id_adresa_facturare")}
|
||||||
|
)
|
||||||
|
conn = database.get_oracle_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
placeholders = ",".join([f":{i}" for i in range(len(unique_ids))])
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id_adresa, strada, numar, bloc, scara, apart, etaj, localitate, judet"
|
||||||
|
f" FROM vadrese_parteneri WHERE id_adresa IN ({placeholders})",
|
||||||
|
unique_ids,
|
||||||
|
)
|
||||||
|
return {row[0]: row for row in cur.fetchall()}
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
addr_map = await asyncio.to_thread(_fetch_addresses, addr_rows)
|
||||||
|
|
||||||
|
def _row_to_dict(r):
|
||||||
|
return {"strada": r[1], "numar": r[2], "bloc": r[3], "scara": r[4],
|
||||||
|
"apart": r[5], "etaj": r[6], "localitate": r[7], "judet": r[8]}
|
||||||
|
|
||||||
|
addresses_refreshed = 0
|
||||||
|
for row in addr_rows:
|
||||||
|
livr_id = row.get("id_adresa_livrare")
|
||||||
|
fact_id = row.get("id_adresa_facturare")
|
||||||
|
livr_raw = addr_map.get(livr_id)
|
||||||
|
fact_raw = addr_map.get(fact_id) if fact_id and fact_id != livr_id else livr_raw
|
||||||
|
if not livr_raw:
|
||||||
|
continue
|
||||||
|
livr_roa = _row_to_dict(livr_raw)
|
||||||
|
fact_roa = _row_to_dict(fact_raw) if fact_raw else livr_roa
|
||||||
|
mismatch = not sync_service._addr_match(
|
||||||
|
row.get("adresa_livrare_gomag"), json.dumps(livr_roa)
|
||||||
|
)
|
||||||
|
await sqlite_service.update_order_address_cache(
|
||||||
|
row["order_number"], livr_roa, fact_roa, mismatch
|
||||||
|
)
|
||||||
|
addresses_refreshed += 1
|
||||||
|
logger.info(f"refresh_invoices: refreshed {addresses_refreshed} order addresses from Oracle")
|
||||||
|
except Exception as addr_err:
|
||||||
|
logger.warning(f"refresh_invoices: address batch refresh failed: {addr_err}")
|
||||||
|
|
||||||
checked = len(uninvoiced) + len(invoiced) + len(all_imported)
|
checked = len(uninvoiced) + len(invoiced) + len(all_imported)
|
||||||
return {
|
return {
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
@@ -826,6 +911,63 @@ async def refresh_invoices():
|
|||||||
return {"error": str(e), "invoices_added": 0}
|
return {"error": str(e), "invoices_added": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/orders/{order_number}/refresh-address")
|
||||||
|
async def refresh_order_address(order_number: str):
|
||||||
|
"""Re-fetch ROA address from Oracle for an existing order and update SQLite cache."""
|
||||||
|
row = await sqlite_service.get_order_address_ids(order_number)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
|
||||||
|
id_livr = row.get("id_adresa_livrare")
|
||||||
|
id_fact = row.get("id_adresa_facturare")
|
||||||
|
|
||||||
|
if not id_livr and not id_fact:
|
||||||
|
raise HTTPException(status_code=422, detail="Order has no Oracle address IDs")
|
||||||
|
|
||||||
|
def _fetch():
|
||||||
|
conn = database.get_oracle_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
def fetch_one(id_adresa):
|
||||||
|
if not id_adresa:
|
||||||
|
return None
|
||||||
|
cur.execute(
|
||||||
|
"SELECT strada, numar, bloc, scara, apart, etaj, localitate, judet"
|
||||||
|
" FROM vadrese_parteneri WHERE id_adresa = :1",
|
||||||
|
[id_adresa],
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
return {"strada": r[0], "numar": r[1], "bloc": r[2], "scara": r[3],
|
||||||
|
"apart": r[4], "etaj": r[5], "localitate": r[6], "judet": r[7]}
|
||||||
|
|
||||||
|
livr = fetch_one(id_livr)
|
||||||
|
fact = fetch_one(id_fact) if id_fact and id_fact != id_livr else livr
|
||||||
|
return livr, fact
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
livr_roa, fact_roa = await asyncio.to_thread(_fetch)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Oracle unavailable: {e}")
|
||||||
|
|
||||||
|
old_livr = row.get("adresa_livrare_roa")
|
||||||
|
mismatch = not sync_service._addr_match(
|
||||||
|
row.get("adresa_livrare_gomag"), json.dumps(livr_roa)
|
||||||
|
) if livr_roa else True
|
||||||
|
|
||||||
|
if livr_roa:
|
||||||
|
old_strada = json.loads(old_livr or "{}").get("strada", "?")
|
||||||
|
logger.info(
|
||||||
|
f"refresh_address: {order_number} strada {old_strada!r}→{livr_roa['strada']!r} mismatch→{mismatch}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await sqlite_service.update_order_address_cache(order_number, livr_roa, fact_roa, mismatch)
|
||||||
|
return {"adresa_livrare_roa": livr_roa, "adresa_facturare_roa": fact_roa, "address_mismatch": mismatch}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/sync/schedule")
|
@router.put("/api/sync/schedule")
|
||||||
async def update_schedule(config: ScheduleConfig):
|
async def update_schedule(config: ScheduleConfig):
|
||||||
"""Update scheduler configuration."""
|
"""Update scheduler configuration."""
|
||||||
@@ -877,9 +1019,6 @@ async def get_app_settings():
|
|||||||
"kit_discount_codmat": s.get("kit_discount_codmat", ""),
|
"kit_discount_codmat": s.get("kit_discount_codmat", ""),
|
||||||
"kit_discount_id_pol": s.get("kit_discount_id_pol", ""),
|
"kit_discount_id_pol": s.get("kit_discount_id_pol", ""),
|
||||||
"price_sync_enabled": s.get("price_sync_enabled", "1"),
|
"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", ""),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -906,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_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("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("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}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ async def scan_and_validate():
|
|||||||
if tracked:
|
if tracked:
|
||||||
new_missing += 1
|
new_missing += 1
|
||||||
|
|
||||||
|
rec = await validation_service.reconcile_unresolved_missing_skus()
|
||||||
|
|
||||||
total_skus_scanned = len(all_skus)
|
total_skus_scanned = len(all_skus)
|
||||||
new_missing_count = len(result["missing"])
|
new_missing_count = len(result["missing"])
|
||||||
unchanged = total_skus_scanned - new_missing_count
|
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
|
# Fields consumed by the rescan progress banner in missing_skus.html
|
||||||
"total_skus_scanned": total_skus_scanned,
|
"total_skus_scanned": total_skus_scanned,
|
||||||
"new_missing": new_missing_count,
|
"new_missing": new_missing_count,
|
||||||
"auto_resolved": 0,
|
"auto_resolved": rec["resolved"],
|
||||||
"unchanged": unchanged,
|
"unchanged": unchanged,
|
||||||
"skus": {
|
"skus": {
|
||||||
"mapped": len(result["mapped"]),
|
"mapped": len(result["mapped"]),
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def sanitize_cui(raw_cf: str) -> tuple[str, str | None]:
|
|||||||
return bare, f"CUI {raw_cf!r} contine caractere invalide dupa sanitizare: {bare!r}"
|
return bare, f"CUI {raw_cf!r} contine caractere invalide dupa sanitizare: {bare!r}"
|
||||||
|
|
||||||
|
|
||||||
async def check_vat_status_batch(cui_list: list[str], date: str = None) -> dict[str, dict]:
|
async def check_vat_status_batch(cui_list: list[str], date: str = None, log_fn=None) -> dict[str, dict]:
|
||||||
"""POST to ANAF API to check VAT status for a batch of CUIs.
|
"""POST to ANAF API to check VAT status for a batch of CUIs.
|
||||||
|
|
||||||
Chunks in batches of 500 (ANAF API limit).
|
Chunks in batches of 500 (ANAF API limit).
|
||||||
@@ -91,35 +91,49 @@ async def check_vat_status_batch(cui_list: list[str], date: str = None) -> dict[
|
|||||||
if not body:
|
if not body:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
chunk_results = await _call_anaf_api(body)
|
chunk_results = await _call_anaf_api(body, log_fn=log_fn)
|
||||||
results.update(chunk_results)
|
results.update(chunk_results)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
async def _call_anaf_api(body: list[dict], retry: int = 0, log_fn=None) -> dict[str, dict]:
|
||||||
"""Internal: single ANAF API call with retry logic."""
|
"""Internal: single ANAF API call with retry logic."""
|
||||||
url = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva"
|
url = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva"
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
|
def _log_error(msg: str):
|
||||||
|
logger.error(msg)
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"ANAF eroare: {msg}")
|
||||||
|
|
||||||
|
def _log_warning(msg: str):
|
||||||
|
logger.warning(msg)
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"ANAF warn: {msg}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.post(url, json=body)
|
response = await client.post(url, json=body)
|
||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning("ANAF API rate limited (429), retrying in 10s...")
|
_log_warning("ANAF API rate limited (429), retrying in 10s...")
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error("ANAF API rate limited after retry")
|
_log_error("ANAF API rate limited after retry")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if response.status_code >= 500:
|
if response.status_code >= 500:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning(f"ANAF API server error ({response.status_code}), retrying in 3s...")
|
_log_warning(f"ANAF API server error ({response.status_code}), retrying in 3s...")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error(f"ANAF API server error after retry: {response.status_code}")
|
_log_error(f"ANAF API server error after retry: {response.status_code}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
_log_error(f"ANAF API client error {response.status_code} (nu se reincearca)")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -127,6 +141,12 @@ async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
|||||||
|
|
||||||
checked_at = datetime.now().isoformat()
|
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
|
# Parse ANAF response
|
||||||
found_list = data.get("found", [])
|
found_list = data.get("found", [])
|
||||||
for item in found_list:
|
for item in found_list:
|
||||||
@@ -138,11 +158,14 @@ async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
|||||||
"checked_at": checked_at,
|
"checked_at": checked_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Not found CUIs
|
# Not found CUIs — ANAF returns plain integers (CUI values), not dicts
|
||||||
notfound_list = data.get("notFound", [])
|
notfound_list = data.get("notFound", [])
|
||||||
for item in notfound_list:
|
for item in notfound_list:
|
||||||
date_gen = item.get("date_generale", {})
|
if isinstance(item, int):
|
||||||
cui_str = str(date_gen.get("cui", item.get("cui", "")))
|
cui_str = str(item)
|
||||||
|
else:
|
||||||
|
date_gen = item.get("date_generale", {})
|
||||||
|
cui_str = str(date_gen.get("cui", item.get("cui", "")))
|
||||||
results[cui_str] = {
|
results[cui_str] = {
|
||||||
"scpTVA": None,
|
"scpTVA": None,
|
||||||
"denumire_anaf": "",
|
"denumire_anaf": "",
|
||||||
@@ -153,16 +176,16 @@ async def _call_anaf_api(body: list[dict], retry: int = 0) -> dict[str, dict]:
|
|||||||
|
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning("ANAF API timeout, retrying in 3s...")
|
_log_warning("ANAF API timeout, retrying in 3s...")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error("ANAF API timeout after retry")
|
_log_error("ANAF API timeout after retry")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if retry < 1:
|
if retry < 1:
|
||||||
logger.warning(f"ANAF API error: {e}, retrying in 3s...")
|
_log_warning(f"ANAF API error: {e}, retrying in 3s...")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
return await _call_anaf_api(body, retry + 1)
|
return await _call_anaf_api(body, retry + 1, log_fn)
|
||||||
logger.error(f"ANAF API error after retry: {e}")
|
_log_error(f"ANAF API error after retry: {e}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -183,8 +206,10 @@ def normalize_company_name(name: str) -> str:
|
|||||||
result = name.strip().upper()
|
result = name.strip().upper()
|
||||||
# Strip diacritics
|
# Strip diacritics
|
||||||
result = result.translate(_DIACRITICS)
|
result = result.translate(_DIACRITICS)
|
||||||
# Remove common suffixes
|
# Remove common suffixes and legal forms
|
||||||
result = re.sub(r'\b(S\.?R\.?L\.?|S\.?A\.?|S\.?C\.?|S\.?N\.?C\.?|S\.?C\.?S\.?)\b', '', result)
|
result = re.sub(r'\b(S\.?R\.?L\.?|S\.?A\.?|S\.?C\.?|S\.?N\.?C\.?|S\.?C\.?S\.?|P\.?F\.?A\.?|INTREPRINDERE\s+INDIVIDUALA)\b', '', result)
|
||||||
|
# Strip II only at start of name (avoid matching Roman numeral II in "TEHNICA II SRL")
|
||||||
|
result = re.sub(r'^I\.?I\.?\s+', '', result)
|
||||||
# Remove punctuation and extra spaces
|
# Remove punctuation and extra spaces
|
||||||
result = re.sub(r'[^\w\s]', '', result)
|
result = re.sub(r'[^\w\s]', '', result)
|
||||||
result = re.sub(r'\s+', ' ', result).strip()
|
result = re.sub(r'\s+', ' ', result).strip()
|
||||||
|
|||||||
@@ -103,80 +103,3 @@ async def download_orders(
|
|||||||
return {"pages": total_pages, "total": total_orders, "files": saved_files}
|
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
|
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
import oracledb
|
import oracledb
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from .. import database
|
from .. import database
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Diacritics to ASCII mapping (Romanian)
|
# Stroke/ligature letters NFKD does not decompose (structural mod, not a
|
||||||
_DIACRITICS = str.maketrans({
|
# combining mark). Everything else — RO cedilla ş/ţ, RO comma-below ș/ț,
|
||||||
'\u0103': 'a', # ă
|
# HU ő/ű, DE umlaut, CZ háček, FR accent, ES tilde — is handled
|
||||||
'\u00e2': 'a', # â
|
# universally by unicodedata.normalize('NFKD') + Mn-category strip below.
|
||||||
'\u00ee': 'i', # î
|
_NFKD_OVERRIDES = str.maketrans({
|
||||||
'\u0219': 's', # ș
|
'ß': 'ss', # ß
|
||||||
'\u021b': 't', # ț
|
'æ': 'ae', 'Æ': 'AE', # æ Æ
|
||||||
'\u0102': 'A', # Ă
|
'œ': 'oe', 'Œ': 'OE', # œ Œ
|
||||||
'\u00c2': 'A', # Â
|
'ł': 'l', 'Ł': 'L', # ł Ł (Polish)
|
||||||
'\u00ce': 'I', # Î
|
'đ': 'd', 'Đ': 'D', # đ Đ (Croatian)
|
||||||
'\u0218': 'S', # Ș
|
'ø': 'o', 'Ø': 'O', # ø Ø (Danish/Norwegian)
|
||||||
'\u021a': 'T', # Ț
|
|
||||||
# Older Unicode variants
|
|
||||||
'\u015f': 's', # ş (cedilla)
|
|
||||||
'\u0163': 't', # ţ (cedilla)
|
|
||||||
'\u015e': 'S', # Ş
|
|
||||||
'\u0162': 'T', # Ţ
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def clean_web_text(text: str) -> str:
|
def clean_web_text(text: str) -> str:
|
||||||
"""Port of VFP CleanWebText: unescape HTML entities + diacritics to ASCII."""
|
"""Port of VFP CleanWebText: unescape HTML entities + strip diacritics to ASCII.
|
||||||
|
|
||||||
|
NFKD decomposition + combining-mark filter covers RO/HU/DE/CZ/PL/FR/ES in
|
||||||
|
one pass; _NFKD_OVERRIDES handles stroke letters NFKD leaves alone.
|
||||||
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
result = html.unescape(text)
|
result = html.unescape(text)
|
||||||
result = result.translate(_DIACRITICS)
|
result = result.translate(_NFKD_OVERRIDES)
|
||||||
|
decomposed = unicodedata.normalize('NFKD', result)
|
||||||
|
result = ''.join(ch for ch in decomposed if not unicodedata.combining(ch))
|
||||||
# Remove any remaining <br> tags
|
# Remove any remaining <br> tags
|
||||||
for br in ('<br>', '<br/>', '<br />'):
|
for br in ('<br>', '<br/>', '<br />'):
|
||||||
result = result.replace(br, ' ')
|
result = result.replace(br, ' ')
|
||||||
@@ -52,11 +54,63 @@ def convert_web_date(date_str: str) -> datetime:
|
|||||||
return datetime.now()
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
def determine_partner_data(order) -> dict:
|
||||||
|
"""Extract partner identification from a GoMag order (no Oracle calls).
|
||||||
|
|
||||||
|
Returns: {denumire, cod_fiscal, registru, is_pj}
|
||||||
|
Identical logic to import_single_order partner block — reuse to avoid drift.
|
||||||
|
"""
|
||||||
|
if order.billing.is_company:
|
||||||
|
denumire = clean_web_text(order.billing.company_name).upper()
|
||||||
|
if not denumire:
|
||||||
|
# CUI-only fallback: company has code but no name → use billing person name
|
||||||
|
denumire = clean_web_text(
|
||||||
|
f"{order.billing.lastname} {order.billing.firstname}"
|
||||||
|
).upper()
|
||||||
|
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:
|
||||||
|
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
|
||||||
|
raw_name = clean_web_text(
|
||||||
|
f"{order.shipping.lastname} {order.shipping.firstname}"
|
||||||
|
).upper()
|
||||||
|
else:
|
||||||
|
raw_name = clean_web_text(
|
||||||
|
f"{order.billing.lastname} {order.billing.firstname}"
|
||||||
|
).upper()
|
||||||
|
denumire = " ".join(sorted(raw_name.split()))
|
||||||
|
cod_fiscal = None
|
||||||
|
registru = None
|
||||||
|
is_pj = 0
|
||||||
|
return {"denumire": denumire, "cod_fiscal": cod_fiscal, "registru": registru, "is_pj": is_pj}
|
||||||
|
|
||||||
|
|
||||||
def format_address_for_oracle(address: str, city: str, region: str) -> str:
|
def format_address_for_oracle(address: str, city: str, region: str) -> str:
|
||||||
"""Port of VFP FormatAddressForOracle."""
|
"""Port of VFP FormatAddressForOracle."""
|
||||||
region_clean = clean_web_text(region)
|
region_clean = clean_web_text(region)
|
||||||
city_clean = clean_web_text(city)
|
city_clean = clean_web_text(city)
|
||||||
address_clean = clean_web_text(address)
|
address_clean = clean_web_text(address)
|
||||||
|
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 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}"
|
return f"JUD:{region_clean};{city_clean};{address_clean}"
|
||||||
|
|
||||||
|
|
||||||
@@ -201,7 +255,7 @@ def build_articles_json(items, order=None, settings=None) -> str:
|
|||||||
return json.dumps(articles)
|
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.
|
"""Import a single order into Oracle ROA.
|
||||||
|
|
||||||
Returns dict with:
|
Returns dict with:
|
||||||
@@ -237,26 +291,15 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
# Step 1: Process partner — use shipping person data for name
|
# Step 1: Process partner — use shipping person data for name
|
||||||
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
|
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
|
|
||||||
if order.billing.is_company:
|
_pdata = determine_partner_data(order)
|
||||||
denumire = clean_web_text(order.billing.company_name).upper()
|
# PJ: prefer ANAF official name (denumire_override) over GoMag company_name
|
||||||
cod_fiscal = cod_fiscal_override or clean_web_text(order.billing.company_code) or None
|
# (for new partner creation; existing partner lookup is CUI-based)
|
||||||
registru = clean_web_text(order.billing.company_reg) or None
|
denumire = (denumire_override
|
||||||
is_pj = 1
|
if (_pdata["is_pj"] and denumire_override)
|
||||||
else:
|
else _pdata["denumire"])
|
||||||
# Use shipping person for partner name (person on shipping label)
|
cod_fiscal = (cod_fiscal_override or _pdata["cod_fiscal"]) if _pdata["is_pj"] else None
|
||||||
# Sort words alphabetically to normalize firstname/lastname swap
|
registru = _pdata["registru"]
|
||||||
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
|
is_pj = _pdata["is_pj"]
|
||||||
raw_name = clean_web_text(
|
|
||||||
f"{order.shipping.lastname} {order.shipping.firstname}"
|
|
||||||
).upper()
|
|
||||||
else:
|
|
||||||
raw_name = clean_web_text(
|
|
||||||
f"{order.billing.lastname} {order.billing.firstname}"
|
|
||||||
).upper()
|
|
||||||
denumire = " ".join(sorted(raw_name.split()))
|
|
||||||
cod_fiscal = None
|
|
||||||
registru = None
|
|
||||||
is_pj = 0
|
|
||||||
|
|
||||||
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
|
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
|
||||||
cod_fiscal, denumire, registru, is_pj, anaf_strict, id_partener
|
cod_fiscal, denumire, registru, is_pj, anaf_strict, id_partener
|
||||||
@@ -275,19 +318,6 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
result["denumire_roa"] = row[0] if row else None
|
result["denumire_roa"] = row[0] if row else None
|
||||||
result["cod_fiscal_roa"] = row[1] if row else None
|
result["cod_fiscal_roa"] = row[1] if row else None
|
||||||
|
|
||||||
# Determine if billing and shipping are different persons
|
|
||||||
billing_name = clean_web_text(
|
|
||||||
f"{order.billing.lastname} {order.billing.firstname}"
|
|
||||||
).strip().upper()
|
|
||||||
shipping_name = ""
|
|
||||||
if order.shipping:
|
|
||||||
shipping_name = clean_web_text(
|
|
||||||
f"{order.shipping.lastname} {order.shipping.firstname}"
|
|
||||||
).strip().upper()
|
|
||||||
different_person = bool(
|
|
||||||
shipping_name and billing_name and shipping_name != billing_name
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 2: Process shipping address (primary — person on shipping label)
|
# Step 2: Process shipping address (primary — person on shipping label)
|
||||||
# Use shipping person phone/email for partner contact
|
# Use shipping person phone/email for partner contact
|
||||||
shipping_phone = ""
|
shipping_phone = ""
|
||||||
@@ -325,33 +355,37 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
result["error"] = err_msg
|
result["error"] = err_msg
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Step 3: Process billing address
|
# Step 3: Process billing address — PJ vs PF rule
|
||||||
if different_person:
|
if is_pj:
|
||||||
# Different person: use shipping address for BOTH billing and shipping in ROA
|
# PJ (company): billing address = GoMag billing (company HQ)
|
||||||
addr_fact_id = addr_livr_id
|
|
||||||
else:
|
|
||||||
# Same person: use billing address as-is
|
|
||||||
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
|
|
||||||
billing_addr = format_address_for_oracle(
|
billing_addr = format_address_for_oracle(
|
||||||
order.billing.address, order.billing.city, order.billing.region
|
order.billing.address, order.billing.city, order.billing.region
|
||||||
)
|
)
|
||||||
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
|
if addr_livr_id and order.shipping and billing_addr == shipping_addr:
|
||||||
partner_id, billing_addr,
|
# billing = shipping: reuse addr_livr_id to avoid duplicate Oracle address
|
||||||
order.billing.phone or "",
|
addr_fact_id = addr_livr_id
|
||||||
order.billing.email or "",
|
else:
|
||||||
id_adresa_fact
|
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
])
|
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
|
||||||
addr_fact_id = id_adresa_fact.getvalue()
|
partner_id, billing_addr,
|
||||||
|
order.billing.phone or "",
|
||||||
|
order.billing.email or "",
|
||||||
|
id_adresa_fact
|
||||||
|
])
|
||||||
|
addr_fact_id = id_adresa_fact.getvalue()
|
||||||
|
|
||||||
if addr_fact_id is None:
|
if addr_fact_id is None:
|
||||||
cur.execute("SELECT PACK_IMPORT_PARTENERI.get_last_error FROM dual")
|
cur.execute("SELECT PACK_IMPORT_PARTENERI.get_last_error FROM dual")
|
||||||
plsql_err = cur.fetchone()[0]
|
plsql_err = cur.fetchone()[0]
|
||||||
err_msg = f"Billing address creation failed for partner {partner_id}"
|
err_msg = f"Billing address creation failed for partner {partner_id}"
|
||||||
if plsql_err:
|
if plsql_err:
|
||||||
err_msg += f": {plsql_err}"
|
err_msg += f": {plsql_err}"
|
||||||
logger.error(f"Order {order_number}: {err_msg}")
|
logger.error(f"Order {order_number}: {err_msg}")
|
||||||
result["error"] = err_msg
|
result["error"] = err_msg
|
||||||
return result
|
return result
|
||||||
|
else:
|
||||||
|
# PF (individual): billing = shipping (ramburs curier pe numele destinatarului)
|
||||||
|
addr_fact_id = addr_livr_id
|
||||||
|
|
||||||
if addr_fact_id is not None:
|
if addr_fact_id is not None:
|
||||||
result["id_adresa_facturare"] = int(addr_fact_id)
|
result["id_adresa_facturare"] = int(addr_fact_id)
|
||||||
@@ -360,13 +394,21 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
|
|||||||
|
|
||||||
# Query address details from Oracle for sync back to SQLite
|
# Query address details from Oracle for sync back to SQLite
|
||||||
if addr_livr_id:
|
if addr_livr_id:
|
||||||
cur.execute("SELECT strada, numar, localitate, judet FROM vadrese_parteneri WHERE id_adresa = :1", [int(addr_livr_id)])
|
cur.execute("""SELECT strada, numar, bloc, scara, apart, etaj, localitate, judet
|
||||||
|
FROM vadrese_parteneri WHERE id_adresa = :1""", [int(addr_livr_id)])
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
result["adresa_livrare_roa"] = {"strada": row[0], "numar": row[1], "localitate": row[2], "judet": row[3]} if row else None
|
result["adresa_livrare_roa"] = {
|
||||||
|
"strada": row[0], "numar": row[1], "bloc": row[2], "scara": row[3],
|
||||||
|
"apart": row[4], "etaj": row[5], "localitate": row[6], "judet": row[7]
|
||||||
|
} if row else None
|
||||||
if addr_fact_id and addr_fact_id != addr_livr_id:
|
if addr_fact_id and addr_fact_id != addr_livr_id:
|
||||||
cur.execute("SELECT strada, numar, localitate, judet FROM vadrese_parteneri WHERE id_adresa = :1", [int(addr_fact_id)])
|
cur.execute("""SELECT strada, numar, bloc, scara, apart, etaj, localitate, judet
|
||||||
|
FROM vadrese_parteneri WHERE id_adresa = :1""", [int(addr_fact_id)])
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
result["adresa_facturare_roa"] = {"strada": row[0], "numar": row[1], "localitate": row[2], "judet": row[3]} if row else None
|
result["adresa_facturare_roa"] = {
|
||||||
|
"strada": row[0], "numar": row[1], "bloc": row[2], "scara": row[3],
|
||||||
|
"apart": row[4], "etaj": row[5], "localitate": row[6], "judet": row[7]
|
||||||
|
} if row else None
|
||||||
elif addr_fact_id and addr_fact_id == addr_livr_id:
|
elif addr_fact_id and addr_fact_id == addr_livr_id:
|
||||||
result["adresa_facturare_roa"] = result.get("adresa_livrare_roa")
|
result["adresa_facturare_roa"] = result.get("adresa_livrare_roa")
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class OrderItem:
|
|||||||
price: float
|
price: float
|
||||||
quantity: float
|
quantity: float
|
||||||
vat: float
|
vat: float
|
||||||
|
baseprice: float = 0.0
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OrderBilling:
|
class OrderBilling:
|
||||||
@@ -116,13 +117,16 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
|
|||||||
name=str(item.get("name", "")),
|
name=str(item.get("name", "")),
|
||||||
price=float(item.get("price", 0) or 0),
|
price=float(item.get("price", 0) or 0),
|
||||||
quantity=float(item.get("quantity", 0) or 0),
|
quantity=float(item.get("quantity", 0) or 0),
|
||||||
vat=float(item.get("vat", 0) or 0)
|
vat=float(item.get("vat", 0) or 0),
|
||||||
|
baseprice=float(item.get("baseprice", 0) or 0)
|
||||||
))
|
))
|
||||||
|
|
||||||
# Parse billing
|
# Parse billing
|
||||||
billing_data = data.get("billing", {}) or {}
|
billing_data = data.get("billing", {}) or {}
|
||||||
company = billing_data.get("company")
|
company = billing_data.get("company")
|
||||||
is_company = isinstance(company, dict) and bool(company.get("name"))
|
is_company = isinstance(company, dict) and (
|
||||||
|
bool(company.get("name")) or bool(company.get("code"))
|
||||||
|
)
|
||||||
|
|
||||||
billing = OrderBilling(
|
billing = OrderBilling(
|
||||||
firstname=str(billing_data.get("firstname", "")),
|
firstname=str(billing_data.get("firstname", "")),
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -4,40 +4,18 @@ import logging
|
|||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from ..constants import OrderStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def retry_single_order(order_number: str, app_settings: dict) -> dict:
|
async def _download_and_reimport(order_number: str, order_date_str: str, customer_name: str, app_settings: dict) -> dict:
|
||||||
"""Re-download and re-import a single order from GoMag.
|
"""Download order from GoMag and re-import it into Oracle.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
Does NOT check status guard — caller is responsible.
|
||||||
Returns: {"success": bool, "message": str, "status": str|None}
|
Returns: {"success": bool, "message": str, "status": str|None}
|
||||||
"""
|
"""
|
||||||
from . import sqlite_service, sync_service, gomag_client, import_service, order_reader
|
from . import sqlite_service, gomag_client, import_service, order_reader, validation_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 ("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", "")
|
|
||||||
|
|
||||||
# Parse order date for narrow download window
|
# Parse order date for narrow download window
|
||||||
try:
|
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_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
|
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:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
import_service.import_single_order,
|
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_number=order_number,
|
||||||
order_date=order_date_str,
|
order_date=order_date_str,
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
status="ERROR",
|
status=OrderStatus.ERROR.value,
|
||||||
error_message=f"Retry failed: {e}",
|
error_message=f"Retry failed: {e}",
|
||||||
)
|
)
|
||||||
return {"success": False, "message": f"Eroare import: {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"):
|
if result.get("success"):
|
||||||
await sqlite_service.upsert_order(
|
await sqlite_service.upsert_order(
|
||||||
sync_run_id="retry",
|
sync_run_id="retry",
|
||||||
order_number=order_number,
|
order_number=order_number,
|
||||||
order_date=order_date_str,
|
order_date=order_date_str,
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
status="IMPORTED",
|
status=OrderStatus.IMPORTED.value,
|
||||||
id_comanda=result.get("id_comanda"),
|
id_comanda=result.get("id_comanda"),
|
||||||
id_partener=result.get("id_partener"),
|
id_partener=result.get("id_partener"),
|
||||||
error_message=None,
|
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_facturare=result.get("id_adresa_facturare"),
|
||||||
id_adresa_livrare=result.get("id_adresa_livrare"),
|
id_adresa_livrare=result.get("id_adresa_livrare"),
|
||||||
)
|
)
|
||||||
logger.info(f"Retry successful for order {order_number} → IMPORTED")
|
await sqlite_service.add_order_items(order_number, order_items_data)
|
||||||
return {"success": True, "message": "Comanda reimportata cu succes", "status": "IMPORTED"}
|
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:
|
else:
|
||||||
error = result.get("error", "Unknown error")
|
error = result.get("error", "Unknown error")
|
||||||
await sqlite_service.upsert_order(
|
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_number=order_number,
|
||||||
order_date=order_date_str,
|
order_date=order_date_str,
|
||||||
customer_name=customer_name,
|
customer_name=customer_name,
|
||||||
status="ERROR",
|
status=OrderStatus.ERROR.value,
|
||||||
error_message=f"Retry: {error}",
|
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"}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,23 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
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")
|
_tz_bucharest = ZoneInfo("Europe/Bucharest")
|
||||||
|
|
||||||
|
|
||||||
@@ -15,9 +28,59 @@ def _now():
|
|||||||
from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client, anaf_service
|
from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client, anaf_service
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from .. import database
|
from .. import database
|
||||||
|
from ..constants import OrderStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _addr_match(gomag_json, roa_json):
|
||||||
|
"""Server-side address comparison matching JS addrMatch()."""
|
||||||
|
if not gomag_json or not roa_json:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
g = json.loads(gomag_json) if isinstance(gomag_json, str) else gomag_json
|
||||||
|
r = json.loads(roa_json) if isinstance(roa_json, str) else roa_json
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return True
|
||||||
|
_ADDR_WORDS = re.compile(
|
||||||
|
r'\bSECTORUL\s*\d*'
|
||||||
|
r'|\b(STR|STRADA|NR|NUMAR|NUMARUL|BL|BLOC|SC|SCARA|AP|APART|APARTAMENT|'
|
||||||
|
r'ET|ETAJ|COM|COMUNA|SAT|MUN|MUNICIPIUL|JUD|JUDETUL|CARTIER|PARTER|SECTOR|SECTORUL|ORAS)(?:\b|(?=\d))'
|
||||||
|
)
|
||||||
|
def norm(s):
|
||||||
|
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('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 _soundex(g_city) == _soundex(r_city) and g_region == r_region
|
||||||
|
|
||||||
|
|
||||||
# Sync state
|
# Sync state
|
||||||
_sync_lock = asyncio.Lock()
|
_sync_lock = asyncio.Lock()
|
||||||
_current_sync = None # dict with run_id, status, progress info
|
_current_sync = None # dict with run_id, status, progress info
|
||||||
@@ -34,6 +97,90 @@ def _log_line(run_id: str, message: str):
|
|||||||
_run_logs[run_id].append(f"[{ts}] {message}")
|
_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:
|
def get_run_text_log(run_id: str) -> str | None:
|
||||||
"""Return the accumulated text log for a run, or None if not found."""
|
"""Return the accumulated text log for a run, or None if not found."""
|
||||||
lines = _run_logs.get(run_id)
|
lines = _run_logs.get(run_id)
|
||||||
@@ -116,20 +263,20 @@ async def _fix_stale_error_orders(existing_map: dict, run_id: str):
|
|||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
try:
|
try:
|
||||||
cursor = await db.execute(
|
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()]
|
error_orders = [row["order_number"] for row in await cursor.fetchall()]
|
||||||
fixed = 0
|
fixed = 0
|
||||||
for order_number in error_orders:
|
for order_number in error_orders:
|
||||||
if order_number in existing_map:
|
if order_number in existing_map:
|
||||||
id_comanda = existing_map[order_number]
|
id_comanda = existing_map[order_number]
|
||||||
await db.execute("""
|
await db.execute(f"""
|
||||||
UPDATE orders SET
|
UPDATE orders SET
|
||||||
status = 'ALREADY_IMPORTED',
|
status = '{OrderStatus.ALREADY_IMPORTED.value}',
|
||||||
id_comanda = ?,
|
id_comanda = ?,
|
||||||
error_message = NULL,
|
error_message = NULL,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE order_number = ? AND status = 'ERROR'
|
WHERE order_number = ? AND status = '{OrderStatus.ERROR.value}'
|
||||||
""", (id_comanda, order_number))
|
""", (id_comanda, order_number))
|
||||||
fixed += 1
|
fixed += 1
|
||||||
_log_line(run_id, f"#{order_number} → status corectat ERROR → ALREADY_IMPORTED (ID: {id_comanda})")
|
_log_line(run_id, f"#{order_number} → status corectat ERROR → ALREADY_IMPORTED (ID: {id_comanda})")
|
||||||
@@ -174,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
|
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:
|
try:
|
||||||
# Phase 0: Download orders from GoMag API
|
# Phase 0: Download orders from GoMag API
|
||||||
_update_progress("downloading", "Descărcare comenzi din GoMag API...")
|
_update_progress("downloading", "Descărcare comenzi din GoMag API...")
|
||||||
@@ -234,7 +397,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||||
order_items_data = [
|
order_items_data = [
|
||||||
{"sku": item.sku, "product_name": item.name,
|
{"sku": item.sku, "product_name": item.name,
|
||||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
"quantity": item.quantity, "price": item.price,
|
||||||
|
"baseprice": item.baseprice, "vat": item.vat,
|
||||||
"mapping_status": "unknown", "codmat": None,
|
"mapping_status": "unknown", "codmat": None,
|
||||||
"id_articol": None, "cantitate_roa": None}
|
"id_articol": None, "cantitate_roa": None}
|
||||||
for item in order.items
|
for item in order.items
|
||||||
@@ -242,7 +406,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
cancelled_batch.append({
|
cancelled_batch.append({
|
||||||
"sync_run_id": run_id, "order_number": order.number,
|
"sync_run_id": run_id, "order_number": order.number,
|
||||||
"order_date": order.date, "customer_name": customer,
|
"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,
|
"id_comanda": None, "id_partener": None,
|
||||||
"error_message": "Comanda anulata in GoMag",
|
"error_message": "Comanda anulata in GoMag",
|
||||||
"missing_skus": None,
|
"missing_skus": None,
|
||||||
@@ -257,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")
|
_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
|
# Check if any cancelled orders were previously imported
|
||||||
from ..database import get_sqlite as _get_sqlite
|
from ..database import get_sqlite as _get_sqlite
|
||||||
@@ -269,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
|
SELECT order_number, id_comanda FROM orders
|
||||||
WHERE order_number IN ({placeholders})
|
WHERE order_number IN ({placeholders})
|
||||||
AND id_comanda IS NOT NULL
|
AND id_comanda IS NOT NULL
|
||||||
AND status = 'CANCELLED'
|
AND status = '{OrderStatus.CANCELLED.value}'
|
||||||
""", cancelled_numbers)
|
""", cancelled_numbers)
|
||||||
previously_imported = [dict(r) for r in await cursor.fetchall()]
|
previously_imported = [dict(r) for r in await cursor.fetchall()]
|
||||||
finally:
|
finally:
|
||||||
@@ -309,6 +476,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
|
|
||||||
orders = active_orders
|
orders = active_orders
|
||||||
|
|
||||||
|
# ── Sticky exclusion: skip orders previously marked DELETED_IN_ROA ──
|
||||||
|
deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||||
|
if deleted_set:
|
||||||
|
excluded_deleted = [o for o in orders if o.number in deleted_set]
|
||||||
|
orders = [o for o in orders if o.number not in deleted_set]
|
||||||
|
if excluded_deleted:
|
||||||
|
_log_line(run_id,
|
||||||
|
f"Excluse {len(excluded_deleted)} comenzi marcate DELETED_IN_ROA "
|
||||||
|
f"(stergeri sticky — foloseste 'Reimporta' pentru override)")
|
||||||
|
for o in excluded_deleted:
|
||||||
|
_log_line(run_id, f"#{o.number} [{o.date or '?'}] → IGNORAT (DELETED_IN_ROA)")
|
||||||
|
|
||||||
if not orders:
|
if not orders:
|
||||||
_log_line(run_id, "Nicio comanda activa dupa filtrare anulate.")
|
_log_line(run_id, "Nicio comanda activa dupa filtrare anulate.")
|
||||||
await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0)
|
await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0)
|
||||||
@@ -417,135 +596,42 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
if resolved_count:
|
if resolved_count:
|
||||||
_log_line(run_id, f"Auto-resolved {resolved_count} previously missing SKUs")
|
_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
|
# Step 2d: Pre-validate prices for importable articles
|
||||||
if id_pol and (truly_importable or already_in_roa):
|
if id_pol and (truly_importable or already_in_roa):
|
||||||
_update_progress("validation", "Validating prices...", 0, len(truly_importable))
|
_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)
|
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
|
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
|
||||||
codmat_policy_map = {}
|
|
||||||
|
|
||||||
if all_codmats:
|
pv_result = await asyncio.to_thread(
|
||||||
if id_pol_productie:
|
validation_service.pre_validate_order_prices,
|
||||||
# Dual-policy: classify articles by cont (sales vs production)
|
truly_importable + already_in_roa,
|
||||||
codmat_policy_map = await asyncio.to_thread(
|
app_settings, conn, id_pol, id_pol_productie,
|
||||||
validation_service.validate_and_ensure_prices_dual,
|
id_gestiuni, validation,
|
||||||
all_codmats, id_pol, id_pol_productie,
|
lambda msg: _log_line(run_id, msg),
|
||||||
conn, validation.get("direct_id_map"),
|
cota_tva,
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Also validate mapped SKU prices (cherry-pick 1)
|
# Filter truly_importable for kits with missing component prices
|
||||||
mapped_skus_in_orders = set()
|
kit_missing = pv_result.get("kit_missing") or {}
|
||||||
for order in (truly_importable + already_in_roa):
|
if kit_missing:
|
||||||
for item in order.items:
|
kit_skus_missing = set(kit_missing.keys())
|
||||||
if item.sku in validation["mapped"]:
|
new_truly = []
|
||||||
mapped_skus_in_orders.add(item.sku)
|
for order in truly_importable:
|
||||||
|
order_skus = {item.sku for item in order.items}
|
||||||
mapped_codmat_data = {}
|
if order_skus & kit_skus_missing:
|
||||||
if mapped_skus_in_orders:
|
missing_list = list(order_skus & kit_skus_missing)
|
||||||
mapped_codmat_data = await asyncio.to_thread(
|
skipped.append((order, missing_list))
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
mp_result = await asyncio.to_thread(
|
new_truly.append(order)
|
||||||
validation_service.validate_prices,
|
truly_importable = new_truly
|
||||||
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
|
|
||||||
|
|
||||||
# Mode B config validation
|
# 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"):
|
if not app_settings.get("kit_discount_codmat"):
|
||||||
_log_line(run_id, "EROARE: Kit mode 'separate_line' dar kit_discount_codmat nu e configurat!")
|
_log_line(run_id, "EROARE: Kit mode 'separate_line' dar kit_discount_codmat nu e configurat!")
|
||||||
finally:
|
finally:
|
||||||
@@ -554,12 +640,14 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
# Step 3a: Record already-imported orders (batch)
|
# Step 3a: Record already-imported orders (batch)
|
||||||
already_imported_count = len(already_in_roa)
|
already_imported_count = len(already_in_roa)
|
||||||
already_batch = []
|
already_batch = []
|
||||||
|
_already_phase_failed = False
|
||||||
for order in already_in_roa:
|
for order in already_in_roa:
|
||||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||||
id_comanda_roa = existing_map.get(order.number)
|
id_comanda_roa = existing_map.get(order.number)
|
||||||
order_items_data = [
|
order_items_data = [
|
||||||
{"sku": item.sku, "product_name": item.name,
|
{"sku": item.sku, "product_name": item.name,
|
||||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
"quantity": item.quantity, "price": item.price,
|
||||||
|
"baseprice": item.baseprice, "vat": item.vat,
|
||||||
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
|
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
|
||||||
"codmat": None, "id_articol": None, "cantitate_roa": None}
|
"codmat": None, "id_articol": None, "cantitate_roa": None}
|
||||||
for item in order.items
|
for item in order.items
|
||||||
@@ -567,7 +655,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
already_batch.append({
|
already_batch.append({
|
||||||
"sync_run_id": run_id, "order_number": order.number,
|
"sync_run_id": run_id, "order_number": order.number,
|
||||||
"order_date": order.date, "customer_name": customer,
|
"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,
|
"id_comanda": id_comanda_roa, "id_partener": None,
|
||||||
"error_message": None, "missing_skus": None,
|
"error_message": None, "missing_skus": None,
|
||||||
"items_count": len(order.items),
|
"items_count": len(order.items),
|
||||||
@@ -580,7 +668,94 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
"items": order_items_data,
|
"items": order_items_data,
|
||||||
})
|
})
|
||||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})")
|
_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 = []
|
||||||
|
for order in already_in_roa:
|
||||||
|
addr_updates.append({
|
||||||
|
"order_number": order.number,
|
||||||
|
"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}),
|
||||||
|
})
|
||||||
|
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 and not _already_phase_failed:
|
||||||
|
stored_partner_data = await sqlite_service.get_orders_partner_data_batch(
|
||||||
|
[o.number for o in already_in_roa]
|
||||||
|
)
|
||||||
|
mismatch_map = {}
|
||||||
|
mismatch_updates = []
|
||||||
|
for order in already_in_roa:
|
||||||
|
stored = stored_partner_data.get(order.number, {})
|
||||||
|
stored_cf = stored.get("cod_fiscal_gomag")
|
||||||
|
new_data = import_service.determine_partner_data(order)
|
||||||
|
new_cf = new_data["cod_fiscal"]
|
||||||
|
|
||||||
|
def _strip_ro(cf):
|
||||||
|
if not cf:
|
||||||
|
return ""
|
||||||
|
# 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 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):
|
||||||
|
is_mismatch = True # CUI schimbat
|
||||||
|
|
||||||
|
val = 1 if is_mismatch else 0
|
||||||
|
mismatch_map[order.number] = val
|
||||||
|
mismatch_updates.append({"order_number": order.number, "partner_mismatch": val})
|
||||||
|
|
||||||
|
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,
|
||||||
|
stored=stored_partner_data.get(_order.number, {}),
|
||||||
|
app_settings=app_settings,
|
||||||
|
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}")
|
||||||
|
if resync_ok:
|
||||||
|
_log_line(run_id, f"Resync parteneri: {resync_ok} comenzi actualizate")
|
||||||
|
|
||||||
# Step 3b: Record skipped orders + store items (batch)
|
# Step 3b: Record skipped orders + store items (batch)
|
||||||
skipped_count = len(skipped)
|
skipped_count = len(skipped)
|
||||||
@@ -589,7 +764,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||||
order_items_data = [
|
order_items_data = [
|
||||||
{"sku": item.sku, "product_name": item.name,
|
{"sku": item.sku, "product_name": item.name,
|
||||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
"quantity": item.quantity, "price": item.price,
|
||||||
|
"baseprice": item.baseprice, "vat": item.vat,
|
||||||
"mapping_status": "missing" if item.sku in validation["missing"] else
|
"mapping_status": "missing" if item.sku in validation["missing"] else
|
||||||
"mapped" if item.sku in validation["mapped"] else "direct",
|
"mapped" if item.sku in validation["mapped"] else "direct",
|
||||||
"codmat": None, "id_articol": None, "cantitate_roa": None}
|
"codmat": None, "id_articol": None, "cantitate_roa": None}
|
||||||
@@ -598,7 +774,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
skipped_batch.append({
|
skipped_batch.append({
|
||||||
"sync_run_id": run_id, "order_number": order.number,
|
"sync_run_id": run_id, "order_number": order.number,
|
||||||
"order_date": order.date, "customer_name": customer,
|
"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,
|
"id_comanda": None, "id_partener": None,
|
||||||
"error_message": None, "missing_skus": missing_skus,
|
"error_message": None, "missing_skus": missing_skus,
|
||||||
"items_count": len(order.items),
|
"items_count": len(order.items),
|
||||||
@@ -611,7 +787,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
"items": order_items_data,
|
"items": order_items_data,
|
||||||
})
|
})
|
||||||
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
|
_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 ──
|
# ── Price sync from orders ──
|
||||||
if app_settings.get("price_sync_enabled") == "1":
|
if app_settings.get("price_sync_enabled") == "1":
|
||||||
@@ -630,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")
|
_log_line(run_id, f"Sync prețuri: {len(price_updates)} prețuri actualizate")
|
||||||
for pu in price_updates:
|
for pu in price_updates:
|
||||||
_log_line(run_id, f" {pu['codmat']}: {pu['old_price']:.2f} → {pu['new_price']:.2f}")
|
_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:
|
except Exception as e:
|
||||||
_log_line(run_id, f"Eroare sync prețuri din comenzi: {e}")
|
_log_line(run_id, f"Eroare sync prețuri din comenzi: {e}")
|
||||||
logger.error(f"Price sync error: {e}")
|
logger.error(f"Price sync error: {e}")
|
||||||
@@ -643,7 +824,9 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
prepop_cuis = await sqlite_service.get_expired_cuis_for_prepopulate()
|
prepop_cuis = await sqlite_service.get_expired_cuis_for_prepopulate()
|
||||||
if prepop_cuis:
|
if prepop_cuis:
|
||||||
_log_line(run_id, f"ANAF pre-populare: {len(prepop_cuis)} CUI-uri cu cache expirat")
|
_log_line(run_id, f"ANAF pre-populare: {len(prepop_cuis)} CUI-uri cu cache expirat")
|
||||||
prepop_results = await anaf_service.check_vat_status_batch(prepop_cuis)
|
prepop_results = await anaf_service.check_vat_status_batch(
|
||||||
|
prepop_cuis, log_fn=lambda msg: _log_line(run_id, msg)
|
||||||
|
)
|
||||||
if prepop_results:
|
if prepop_results:
|
||||||
await sqlite_service.bulk_populate_anaf_cache(prepop_results)
|
await sqlite_service.bulk_populate_anaf_cache(prepop_results)
|
||||||
_log_line(run_id, f"ANAF pre-populare: {len(prepop_results)} rezultate stocate")
|
_log_line(run_id, f"ANAF pre-populare: {len(prepop_results)} rezultate stocate")
|
||||||
@@ -676,7 +859,9 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
# Batch ANAF call for uncached CUIs only
|
# Batch ANAF call for uncached CUIs only
|
||||||
if uncached_cuis:
|
if uncached_cuis:
|
||||||
_log_line(run_id, f"ANAF: verificare {len(uncached_cuis)} CUI-uri noi...")
|
_log_line(run_id, f"ANAF: verificare {len(uncached_cuis)} CUI-uri noi...")
|
||||||
anaf_results = await anaf_service.check_vat_status_batch(uncached_cuis)
|
anaf_results = await anaf_service.check_vat_status_batch(
|
||||||
|
uncached_cuis, log_fn=lambda msg: _log_line(run_id, msg)
|
||||||
|
)
|
||||||
if anaf_results:
|
if anaf_results:
|
||||||
await sqlite_service.bulk_populate_anaf_cache(anaf_results)
|
await sqlite_service.bulk_populate_anaf_cache(anaf_results)
|
||||||
cached_results.update(anaf_results)
|
cached_results.update(anaf_results)
|
||||||
@@ -700,6 +885,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
cod_fiscal_override = None
|
cod_fiscal_override = None
|
||||||
anaf_data_for_order = None
|
anaf_data_for_order = None
|
||||||
raw_cf = ""
|
raw_cf = ""
|
||||||
|
bare_cui = ""
|
||||||
if order.billing.is_company and order.billing.company_code:
|
if order.billing.is_company and order.billing.company_code:
|
||||||
raw_cf = import_service.clean_web_text(order.billing.company_code) or ""
|
raw_cf = import_service.clean_web_text(order.billing.company_code) or ""
|
||||||
bare_cui, cui_warning = anaf_service.sanitize_cui(raw_cf)
|
bare_cui, cui_warning = anaf_service.sanitize_cui(raw_cf)
|
||||||
@@ -719,29 +905,53 @@ 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:
|
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
|
anaf_strict = 1 # ANAF data available → strict search
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
# Build order items data and discount split (needed by gate error path)
|
||||||
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)
|
|
||||||
order_items_data = []
|
order_items_data = []
|
||||||
for item in order.items:
|
for item in order.items:
|
||||||
ms = "mapped" if item.sku in validation["mapped"] else "direct"
|
ms = "mapped" if item.sku in validation["mapped"] else "direct"
|
||||||
order_items_data.append({
|
order_items_data.append({
|
||||||
"sku": item.sku, "product_name": item.name,
|
"sku": item.sku, "product_name": item.name,
|
||||||
"quantity": item.quantity, "price": item.price, "vat": item.vat,
|
"quantity": item.quantity, "price": item.price,
|
||||||
|
"baseprice": item.baseprice, "vat": item.vat,
|
||||||
"mapping_status": ms, "codmat": None, "id_articol": None,
|
"mapping_status": ms, "codmat": None, "id_articol": None,
|
||||||
"cantitate_roa": None
|
"cantitate_roa": None
|
||||||
})
|
})
|
||||||
|
|
||||||
# Compute discount split for SQLite storage
|
|
||||||
ds = import_service.compute_discount_split(order, app_settings)
|
ds = import_service.compute_discount_split(order, app_settings)
|
||||||
discount_split_json = json.dumps(ds) if ds else None
|
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"]:
|
if result["success"]:
|
||||||
imported_count += 1
|
imported_count += 1
|
||||||
await sqlite_service.upsert_order(
|
await sqlite_service.upsert_order(
|
||||||
@@ -749,7 +959,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
order_number=order.number,
|
order_number=order.number,
|
||||||
order_date=order.date,
|
order_date=order.date,
|
||||||
customer_name=customer,
|
customer_name=customer,
|
||||||
status="IMPORTED",
|
status=OrderStatus.IMPORTED.value,
|
||||||
id_comanda=result["id_comanda"],
|
id_comanda=result["id_comanda"],
|
||||||
id_partener=result["id_partener"],
|
id_partener=result["id_partener"],
|
||||||
items_count=len(order.items),
|
items_count=len(order.items),
|
||||||
@@ -763,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,
|
web_status=order.status or None,
|
||||||
discount_split=discount_split_json,
|
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)
|
# Store ROA address IDs (R9)
|
||||||
await sqlite_service.update_import_order_addresses(
|
await sqlite_service.update_import_order_addresses(
|
||||||
order.number,
|
order.number,
|
||||||
@@ -802,32 +1012,22 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
partner_data["anaf_denumire_mismatch"] = 1
|
partner_data["anaf_denumire_mismatch"] = 1
|
||||||
partner_data["denumire_anaf"] = anaf_data_for_order["denumire_anaf"]
|
partner_data["denumire_anaf"] = anaf_data_for_order["denumire_anaf"]
|
||||||
|
|
||||||
|
# Address mismatch check (server-side, mirrors JS addrMatch)
|
||||||
|
livr_match = _addr_match(partner_data.get("adresa_livrare_gomag"), partner_data.get("adresa_livrare_roa"))
|
||||||
|
fact_match = _addr_match(partner_data.get("adresa_facturare_gomag"), partner_data.get("adresa_facturare_roa"))
|
||||||
|
partner_data["address_mismatch"] = 1 if (not livr_match or not fact_match) else 0
|
||||||
|
|
||||||
await sqlite_service.update_order_partner_data(order.number, partner_data)
|
await sqlite_service.update_order_partner_data(order.number, partner_data)
|
||||||
|
|
||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
error_count += 1
|
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']}")
|
_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
|
# Safety: stop if too many errors
|
||||||
if error_count > 10:
|
if error_count > 10:
|
||||||
@@ -892,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")
|
_log_line(run_id, f"Facturi sterse: {invoices_cleared} facturi eliminate din cache")
|
||||||
if orders_deleted:
|
if orders_deleted:
|
||||||
_log_line(run_id, f"Comenzi sterse din ROA: {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:
|
except Exception as e:
|
||||||
logger.warning(f"Invoice/order status check failed: {e}")
|
logger.warning(f"Invoice/order status check failed: {e}")
|
||||||
|
|
||||||
@@ -942,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)
|
await sqlite_service.bulk_update_order_anaf_data(db_updates)
|
||||||
if db_updates:
|
if db_updates:
|
||||||
_log_line(run_id, f"ANAF backfill: {len(db_updates)}/{len(orders_needing_anaf)} comenzi actualizate")
|
_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:
|
except Exception as e:
|
||||||
logger.warning(f"ANAF backfill failed: {e}")
|
logger.warning(f"ANAF backfill failed: {e}")
|
||||||
_log_line(run_id, f"ANAF backfill eroare: {e}")
|
_log_line(run_id, f"ANAF backfill eroare: {e}")
|
||||||
@@ -1022,3 +1226,204 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
def stop_sync():
|
def stop_sync():
|
||||||
"""Signal sync to stop. Currently sync runs to completion."""
|
"""Signal sync to stop. Currently sync runs to completion."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _resync_partner_for_order(order, stored: dict, app_settings: dict, run_id: str) -> None:
|
||||||
|
"""Resync partner for a single already-imported uninvoiced order.
|
||||||
|
|
||||||
|
Safety: double-checks factura_numar before Oracle call.
|
||||||
|
Reads existing comanda row and calls PACK_COMENZI.modifica_comanda.
|
||||||
|
"""
|
||||||
|
import oracledb
|
||||||
|
|
||||||
|
order_number = order.number
|
||||||
|
id_comanda = stored.get("id_comanda")
|
||||||
|
if not id_comanda:
|
||||||
|
_log_line(run_id, f"#{order_number} SKIP resync partener: id_comanda lipsa")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Double-check factura_numar — may have been invoiced since mismatch detection
|
||||||
|
current_detail = await sqlite_service.get_order_detail(order_number)
|
||||||
|
if current_detail and current_detail.get("order", {}).get("factura_numar"):
|
||||||
|
_log_line(run_id, f"#{order_number} SKIP resync partener: comanda facturata in tranzit")
|
||||||
|
return
|
||||||
|
|
||||||
|
old_partner_id = stored.get("id_partener")
|
||||||
|
old_partner_name = stored.get("denumire_roa") or "?"
|
||||||
|
|
||||||
|
new_partner_data = import_service.determine_partner_data(order)
|
||||||
|
|
||||||
|
# ANAF check for PF→PJ transition
|
||||||
|
cod_fiscal_override = None
|
||||||
|
anaf_data = None
|
||||||
|
if new_partner_data["is_pj"] and new_partner_data["cod_fiscal"]:
|
||||||
|
raw_cf = new_partner_data["cod_fiscal"]
|
||||||
|
bare_cui, _ = anaf_service.sanitize_cui(raw_cf)
|
||||||
|
if bare_cui:
|
||||||
|
anaf_data = await sqlite_service.get_anaf_cache(bare_cui)
|
||||||
|
if not anaf_data:
|
||||||
|
try:
|
||||||
|
fresh = await anaf_service.check_vat_status_batch([bare_cui])
|
||||||
|
if fresh:
|
||||||
|
await sqlite_service.bulk_populate_anaf_cache(fresh)
|
||||||
|
anaf_data = fresh.get(bare_cui)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"ANAF check failed for {bare_cui}: {e}")
|
||||||
|
if anaf_data and anaf_data.get("scpTVA") is not None:
|
||||||
|
cod_fiscal_override = anaf_service.determine_correct_cod_fiscal(
|
||||||
|
bare_cui, anaf_data["scpTVA"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_resync():
|
||||||
|
if database.pool is None:
|
||||||
|
raise RuntimeError("Oracle pool not initialized")
|
||||||
|
conn = database.pool.acquire()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Create/find partner
|
||||||
|
id_partener_var = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
|
anaf_strict = 1 if (anaf_data and anaf_data.get("scpTVA") is not None) else None
|
||||||
|
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
|
||||||
|
cod_fiscal_override or new_partner_data["cod_fiscal"],
|
||||||
|
new_partner_data["denumire"],
|
||||||
|
new_partner_data["registru"],
|
||||||
|
new_partner_data["is_pj"],
|
||||||
|
anaf_strict,
|
||||||
|
id_partener_var,
|
||||||
|
])
|
||||||
|
new_partner_id = id_partener_var.getvalue()
|
||||||
|
if not new_partner_id or new_partner_id <= 0:
|
||||||
|
raise RuntimeError(f"Partner creation failed for {new_partner_data['denumire']}")
|
||||||
|
new_partner_id = int(new_partner_id)
|
||||||
|
|
||||||
|
# Same partner — just clear mismatch
|
||||||
|
if new_partner_id == (old_partner_id or -1):
|
||||||
|
return {"same_partner": True, "new_partner_id": new_partner_id}
|
||||||
|
|
||||||
|
# Get new partner details for audit log
|
||||||
|
cur.execute(
|
||||||
|
"SELECT denumire, cod_fiscal FROM nom_parteneri WHERE id_part = :1",
|
||||||
|
[new_partner_id]
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
new_partner_name = row[0] if row else new_partner_data["denumire"]
|
||||||
|
new_cod_fiscal_roa = row[1] if row else None
|
||||||
|
|
||||||
|
# Create addresses under new partner
|
||||||
|
addr_livr_id = None
|
||||||
|
shipping_addr = None
|
||||||
|
if order.shipping:
|
||||||
|
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
|
shipping_addr = import_service.format_address_for_oracle(
|
||||||
|
order.shipping.address, order.shipping.city, order.shipping.region
|
||||||
|
)
|
||||||
|
shipping_phone = order.shipping.phone or order.billing.phone or ""
|
||||||
|
shipping_email = order.shipping.email or order.billing.email or ""
|
||||||
|
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
|
||||||
|
new_partner_id, shipping_addr, shipping_phone, shipping_email, id_adresa_livr
|
||||||
|
])
|
||||||
|
addr_livr_id = id_adresa_livr.getvalue()
|
||||||
|
if addr_livr_id is None:
|
||||||
|
raise RuntimeError(f"Shipping address creation failed for partner {new_partner_id}")
|
||||||
|
addr_livr_id = int(addr_livr_id)
|
||||||
|
|
||||||
|
billing_name_str = import_service.clean_web_text(
|
||||||
|
f"{order.billing.lastname} {order.billing.firstname}"
|
||||||
|
).strip().upper()
|
||||||
|
ship_name_str = ""
|
||||||
|
if order.shipping:
|
||||||
|
ship_name_str = import_service.clean_web_text(
|
||||||
|
f"{order.shipping.lastname} {order.shipping.firstname}"
|
||||||
|
).strip().upper()
|
||||||
|
different_person = bool(ship_name_str and billing_name_str and ship_name_str != billing_name_str)
|
||||||
|
|
||||||
|
if different_person and addr_livr_id:
|
||||||
|
addr_fact_id = addr_livr_id
|
||||||
|
else:
|
||||||
|
billing_addr = import_service.format_address_for_oracle(
|
||||||
|
order.billing.address, order.billing.city, order.billing.region
|
||||||
|
)
|
||||||
|
if addr_livr_id and order.shipping and billing_addr == shipping_addr:
|
||||||
|
addr_fact_id = addr_livr_id
|
||||||
|
else:
|
||||||
|
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
|
||||||
|
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
|
||||||
|
new_partner_id, billing_addr,
|
||||||
|
order.billing.phone or "",
|
||||||
|
order.billing.email or "",
|
||||||
|
id_adresa_fact,
|
||||||
|
])
|
||||||
|
addr_fact_id = id_adresa_fact.getvalue()
|
||||||
|
if addr_fact_id is None:
|
||||||
|
raise RuntimeError(f"Billing address creation failed for partner {new_partner_id}")
|
||||||
|
addr_fact_id = int(addr_fact_id)
|
||||||
|
|
||||||
|
# Read existing comanda row for modifica_comanda params
|
||||||
|
cur.execute("""
|
||||||
|
SELECT nr_comanda, data_comanda, data_livrare, proc_discount,
|
||||||
|
interna, id_util_um, id_codclient, comanda_externa, id_ctr
|
||||||
|
FROM comenzi WHERE id_comanda = :1
|
||||||
|
""", [id_comanda])
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise RuntimeError(f"Comanda {id_comanda} not found in Oracle")
|
||||||
|
nr_comanda, data_comanda, data_livrare, proc_discount, interna, id_util_um, id_codclient, comanda_externa, id_ctr = row
|
||||||
|
|
||||||
|
cur.callproc("PACK_COMENZI.modifica_comanda", [
|
||||||
|
id_comanda,
|
||||||
|
nr_comanda,
|
||||||
|
data_comanda,
|
||||||
|
new_partner_id,
|
||||||
|
data_livrare,
|
||||||
|
proc_discount,
|
||||||
|
interna,
|
||||||
|
id_util_um,
|
||||||
|
addr_fact_id,
|
||||||
|
addr_livr_id,
|
||||||
|
id_codclient,
|
||||||
|
comanda_externa,
|
||||||
|
id_ctr,
|
||||||
|
])
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"same_partner": False,
|
||||||
|
"new_partner_id": new_partner_id,
|
||||||
|
"new_partner_name": new_partner_name,
|
||||||
|
"new_cod_fiscal_roa": new_cod_fiscal_roa,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|
||||||
|
resync_result = await asyncio.to_thread(_do_resync)
|
||||||
|
|
||||||
|
if resync_result.get("same_partner"):
|
||||||
|
# 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"]
|
||||||
|
new_partner_name = resync_result.get("new_partner_name", "?")
|
||||||
|
new_cod_fiscal_roa = resync_result.get("new_cod_fiscal_roa")
|
||||||
|
await sqlite_service.update_partner_resync_data(order_number, {
|
||||||
|
"id_partener": new_partner_id,
|
||||||
|
"cod_fiscal_gomag": cod_fiscal_override or new_partner_data["cod_fiscal"],
|
||||||
|
"cod_fiscal_roa": new_cod_fiscal_roa,
|
||||||
|
"denumire_roa": new_partner_name,
|
||||||
|
"partner_mismatch": 0,
|
||||||
|
})
|
||||||
|
_log_line(
|
||||||
|
run_id,
|
||||||
|
f"#{order_number} RESYNC partener: {old_partner_id} ({old_partner_name}) → {new_partner_id} ({new_partner_name})"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,105 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from .. import database
|
from .. import database
|
||||||
|
from . import sqlite_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def check_orders_in_roa(min_date, conn) -> dict:
|
||||||
"""Check which orders already exist in Oracle COMENZI by date range.
|
"""Check which orders already exist in Oracle COMENZI by date range.
|
||||||
Returns: {comanda_externa: id_comanda} for all existing orders.
|
Returns: {comanda_externa: id_comanda} for all existing orders.
|
||||||
@@ -432,6 +529,147 @@ def resolve_mapped_codmats(mapped_skus: set[str], conn,
|
|||||||
return result
|
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,
|
def validate_kit_component_prices(mapped_codmat_data: dict, id_pol: int,
|
||||||
id_pol_productie: int = None, conn=None) -> dict:
|
id_pol_productie: int = None, conn=None) -> dict:
|
||||||
"""Pre-validate that kit components have non-zero prices in crm_politici_pret_art.
|
"""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
|
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 = abs(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)
|
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
--cancelled: #78716C;
|
--cancelled: #78716C;
|
||||||
--cancelled-light: #F5F5F4;
|
--cancelled-light: #F5F5F4;
|
||||||
|
|
||||||
|
--compare: #EA580C;
|
||||||
|
--compare-light: #FFF7ED;
|
||||||
|
--compare-text: #9A3412;
|
||||||
|
|
||||||
/* Border radius */
|
/* Border radius */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
@@ -93,6 +97,10 @@
|
|||||||
|
|
||||||
--cancelled: #78716C;
|
--cancelled: #78716C;
|
||||||
--cancelled-light: rgba(120,113,108,0.15);
|
--cancelled-light: rgba(120,113,108,0.15);
|
||||||
|
|
||||||
|
--compare: #EA580C;
|
||||||
|
--compare-light: rgba(234,88,12,0.15);
|
||||||
|
--compare-text: #FB923C;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode overrides for elements with hardcoded colors */
|
/* Dark mode overrides for elements with hardcoded colors */
|
||||||
@@ -359,6 +367,55 @@ input[type="checkbox"] {
|
|||||||
border-color: var(--info-hover);
|
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 ───────────────────────────────────────── */
|
/* ── Forms ───────────────────────────────────────── */
|
||||||
.form-control, .form-select {
|
.form-control, .form-select {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
@@ -438,6 +495,7 @@ input[type="checkbox"] {
|
|||||||
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px 2px rgba(202,138,4,0.3); }
|
.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-red { background: var(--error); box-shadow: 0 0 8px 2px rgba(220,38,38,0.35); }
|
||||||
.dot-gray { background: var(--cancelled); }
|
.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); }
|
.dot-blue { background: var(--info); }
|
||||||
|
|
||||||
/* ── Flat row (mobile + desktop) ────────────────── */
|
/* ── Flat row (mobile + desktop) ────────────────── */
|
||||||
@@ -643,6 +701,9 @@ tr.mapping-deleted td {
|
|||||||
.autocomplete-item:hover, .autocomplete-item.active {
|
.autocomplete-item:hover, .autocomplete-item.active {
|
||||||
background-color: var(--surface-raised);
|
background-color: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
.autocomplete-dropdown.keyboard-active .autocomplete-item:hover:not(.active) {
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
.autocomplete-item .codmat {
|
.autocomplete-item .codmat {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -745,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.running { background: var(--info); animation: pulse-dot 2s ease-in-out infinite; }
|
||||||
.sync-status-dot.completed { background: var(--success); }
|
.sync-status-dot.completed { background: var(--success); }
|
||||||
.sync-status-dot.failed { background: var(--error); }
|
.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 ──────────────────── */
|
/* ── Custom period range inputs ──────────────────── */
|
||||||
.period-custom-range {
|
.period-custom-range {
|
||||||
@@ -1176,6 +1276,12 @@ tr.mapping-deleted td {
|
|||||||
.anaf-badge-warn { background: var(--warning-light); color: var(--warning-text); }
|
.anaf-badge-warn { background: var(--warning-light); color: var(--warning-text); }
|
||||||
.anaf-badge-gray { background: var(--cancelled-light); color: var(--text-muted); }
|
.anaf-badge-gray { background: var(--cancelled-light); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Diff-type badges (reuses .anaf-badge sizing per DESIGN.md type scale minimum) */
|
||||||
|
.diff-badge { display:inline-block; font-family:var(--font-body); font-size:12px; font-weight:500; padding:2px 8px; border-radius:9999px; margin-left:4px; vertical-align:middle; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
/* ── Compact order detail layout ──────────────── */
|
/* ── Compact order detail layout ──────────────── */
|
||||||
.detail-col-label {
|
.detail-col-label {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
@@ -1203,9 +1309,15 @@ tr.mapping-deleted td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Address compact lines */
|
/* Address compact lines */
|
||||||
|
.addr-row {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.addr-row .addr-line-label {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
.addr-line {
|
.addr-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1221,9 +1333,9 @@ tr.mapping-deleted td {
|
|||||||
.addr-line-text {
|
.addr-line-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
overflow: hidden;
|
word-break: break-word;
|
||||||
text-overflow: ellipsis;
|
text-transform: uppercase;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.addr-line .bi-check-lg {
|
.addr-line .bi-check-lg {
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────
|
// ── Sync Controls ─────────────────────────────────
|
||||||
|
|
||||||
async function startSync() {
|
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 {
|
try {
|
||||||
const res = await fetch('/api/sync/start', { method: 'POST' });
|
const res = await fetch('/api/sync/start', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -174,6 +241,7 @@ async function startSync() {
|
|||||||
}
|
}
|
||||||
// Polling will detect the running state — just speed it up immediately
|
// Polling will detect the running state — just speed it up immediately
|
||||||
pollSyncStatus();
|
pollSyncStatus();
|
||||||
|
pollSyncHealth();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Eroare: ' + err.message);
|
alert('Eroare: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -329,6 +397,7 @@ async function loadDashOrders() {
|
|||||||
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
||||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||||
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 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;
|
if (el('cntDiff')) el('cntDiff').textContent = c.diffs || 0;
|
||||||
|
|
||||||
// Attention card
|
// Attention card
|
||||||
@@ -340,8 +409,9 @@ async function loadDashOrders() {
|
|||||||
const diffs = c.diffs || 0;
|
const diffs = c.diffs || 0;
|
||||||
|
|
||||||
const incompleteAddr = c.incomplete_addresses || 0;
|
const incompleteAddr = c.incomplete_addresses || 0;
|
||||||
|
const partnerMismatches = c.partner_mismatches || 0;
|
||||||
|
|
||||||
if (errors === 0 && unmapped === 0 && nefact === 0 && incompleteAddr === 0 && diffs === 0) {
|
if (errors === 0 && unmapped === 0 && nefact === 0 && incompleteAddr === 0 && diffs === 0 && partnerMismatches === 0) {
|
||||||
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
||||||
} else {
|
} else {
|
||||||
let items = [];
|
let items = [];
|
||||||
@@ -350,6 +420,7 @@ async function loadDashOrders() {
|
|||||||
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
|
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
|
||||||
if (c.incomplete_addresses > 0) items.push(`<span class="attention-item attention-warning"><i class="bi bi-geo-alt"></i> ${c.incomplete_addresses} adrese incomplete</span>`);
|
if (c.incomplete_addresses > 0) items.push(`<span class="attention-item attention-warning"><i class="bi bi-geo-alt"></i> ${c.incomplete_addresses} adrese incomplete</span>`);
|
||||||
if (diffs > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=DIFFS]')?.click()"><i class="bi bi-exclamation-diamond"></i> ${diffs} diferente ANAF</span>`);
|
if (diffs > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=DIFFS]')?.click()"><i class="bi bi-exclamation-diamond"></i> ${diffs} diferente ANAF</span>`);
|
||||||
|
if (partnerMismatches > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=DIFFS]')?.click()"><i class="bi bi-people"></i> ${partnerMismatches} partener schimbat</span>`);
|
||||||
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,15 +437,15 @@ async function loadDashOrders() {
|
|||||||
|
|
||||||
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
||||||
<td>${statusDot(o.status)}</td>
|
<td>${statusDot(o.status)}</td>
|
||||||
|
<td class="text-center">${invoiceDot(o)}</td>
|
||||||
<td class="text-nowrap">${dateStr}</td>
|
<td class="text-nowrap">${dateStr}</td>
|
||||||
${renderClientCell(o)}
|
${renderClientCell(o)}
|
||||||
<td><code>${esc(o.order_number)}</code></td>
|
<td><code>${esc(o.order_number)}</code>${diffDots(o)}</td>
|
||||||
<td>${o.items_count || 0}</td>
|
<td>${o.items_count || 0}</td>
|
||||||
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
|
<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 text-muted">${fmtCost(o.discount_total)}</td>
|
||||||
<td class="text-end fw-bold">${orderTotal}</td>
|
<td class="text-end fw-bold">${orderTotal}</td>
|
||||||
<td class="text-center">${invoiceDot(o)}</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>
|
||||||
<td class="text-center">${priceDot(o)}</td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -394,12 +465,11 @@ async function loadDashOrders() {
|
|||||||
}
|
}
|
||||||
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
||||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
||||||
const priceMismatch = o.price_match === false ? '<span class="dot dot-red" style="width:6px;height:6px" title="Pret!="></span> ' : '';
|
|
||||||
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||||
${statusDot(o.status)}
|
${statusDot(o.status)}
|
||||||
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
||||||
<span class="grow truncate fw-bold">${esc(name)}</span>
|
<span class="grow truncate fw-bold">${esc(name)}</span>
|
||||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '<strong>' + totalStr + '</strong>' : ''}</span>
|
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + diffDots(o, true) + '<strong>' + totalStr + '</strong>' : ''}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -408,12 +478,13 @@ async function loadDashOrders() {
|
|||||||
// Mobile segmented control
|
// Mobile segmented control
|
||||||
renderMobileSegmented('dashMobileSeg', [
|
renderMobileSegmented('dashMobileSeg', [
|
||||||
{ label: 'Toate', count: c.total || 0, value: 'all', active: (activeStatus || 'all') === 'all', colorClass: 'fc-neutral' },
|
{ 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: '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: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' },
|
{ 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: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
|
{ 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: '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: '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' }
|
{ label: 'Dif.', count: c.diffs || 0, value: 'DIFFS', active: activeStatus === 'DIFFS', colorClass: 'fc-orange' }
|
||||||
], (val) => {
|
], (val) => {
|
||||||
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
||||||
@@ -442,7 +513,7 @@ async function loadDashOrders() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('dashOrdersBody').innerHTML =
|
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 display = (order.customer_name || order.shipping_name || '').trim();
|
||||||
const billing = (order.billing_name || '').trim();
|
const billing = (order.billing_name || '').trim();
|
||||||
const shipping = (order.shipping_name || '').trim();
|
const shipping = (order.shipping_name || '').trim();
|
||||||
const isDiff = display !== shipping && shipping;
|
// PJ: invoice party (company = display) differs from shipping person
|
||||||
if (isDiff) {
|
// PF ramburs: invoice party = shipping, but billing person differs from shipping
|
||||||
return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)} <sup class="client-diff-indicator">▲</sup></td>`;
|
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)} <sup class="client-diff-indicator" aria-label="${tip}" title="${tip}">▲</sup></td>`;
|
||||||
}
|
}
|
||||||
return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
|
return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
|
||||||
}
|
}
|
||||||
@@ -490,24 +566,36 @@ function escHtml(s) {
|
|||||||
|
|
||||||
function statusLabelText(status) {
|
function statusLabelText(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED': return 'Importat';
|
case ORDER_STATUS.IMPORTED: return 'Importat';
|
||||||
case 'ALREADY_IMPORTED': return 'Deja imp.';
|
case ORDER_STATUS.ALREADY_IMPORTED: return 'Deja imp.';
|
||||||
case 'SKIPPED': return 'Omis';
|
case ORDER_STATUS.SKIPPED: return 'Omis';
|
||||||
case 'ERROR': return 'Eroare';
|
case ORDER_STATUS.ERROR: return 'Eroare';
|
||||||
default: return esc(status);
|
default: return esc(status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceDot(order) {
|
function diffDots(o, mobile) {
|
||||||
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
|
const sz = mobile ? 6 : 7;
|
||||||
if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
|
const ml = mobile ? 'margin-right:2px' : 'margin-left:3px';
|
||||||
return '<span class="dot dot-gray" title="Neverificat"></span>';
|
let d = '';
|
||||||
|
const s = `display:inline-block;width:${sz}px;height:${sz}px;border-radius:50%;${ml};vertical-align:middle`;
|
||||||
|
if (o.anaf_cod_fiscal_adjusted===1 ||
|
||||||
|
(o.cod_fiscal_gomag && o.anaf_platitor_tva!==null && o.anaf_cod_fiscal_adjusted!==1 &&
|
||||||
|
(/^RO/i.test(o.cod_fiscal_gomag)!==(o.anaf_platitor_tva===1))))
|
||||||
|
d += `<span style="${s};background:var(--error)" title="CUI/TVA ANAF"></span>`;
|
||||||
|
if (o.anaf_denumire_mismatch===1)
|
||||||
|
d += `<span style="${s};background:var(--compare)" title="Denumire ANAF"></span>`;
|
||||||
|
if (o.address_mismatch===1)
|
||||||
|
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>`;
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invoiceDot(order) {
|
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" title="Facturat"></span>';
|
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" title="Nefacturat"></span>';
|
return '<span class="dot dot-red" style="box-shadow:none" title="Nefacturat"></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Refresh Invoices ──────────────────────────────
|
// ── Refresh Invoices ──────────────────────────────
|
||||||
@@ -535,10 +623,31 @@ async function refreshInvoices() {
|
|||||||
|
|
||||||
// ── Order Detail Modal ────────────────────────────
|
// ── Order Detail Modal ────────────────────────────
|
||||||
|
|
||||||
|
async function refreshOrderAddress(orderNumber) {
|
||||||
|
if (!orderNumber) return;
|
||||||
|
const btn = document.getElementById('refreshAddrBtn');
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${orderNumber}/refresh-address`, {method: 'POST'});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
showToast('Eroare refresh adresă: ' + (err.detail || res.status), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Adresă actualizată din Oracle', 'success');
|
||||||
|
renderOrderDetailModal(orderNumber, {onQuickMap: openDashQuickMap});
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Eroare conexiune', 'danger');
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openDashOrderDetail(orderNumber) {
|
function openDashOrderDetail(orderNumber) {
|
||||||
_sharedModalQuickMapFn = openDashQuickMap;
|
_sharedModalQuickMapFn = openDashQuickMap;
|
||||||
renderOrderDetailModal(orderNumber, {
|
renderOrderDetailModal(orderNumber, {
|
||||||
onQuickMap: openDashQuickMap,
|
onQuickMap: openDashQuickMap,
|
||||||
|
onStatusChange: loadDashOrders,
|
||||||
onAfterRender: function() { /* nothing extra needed */ }
|
onAfterRender: function() { /* nothing extra needed */ }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -563,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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ function runStatusBadge(status) {
|
|||||||
|
|
||||||
function logStatusText(status) {
|
function logStatusText(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED': return 'Importat';
|
case ORDER_STATUS.IMPORTED: return 'Importat';
|
||||||
case 'ALREADY_IMPORTED': return 'Deja imp.';
|
case ORDER_STATUS.ALREADY_IMPORTED: return 'Deja imp.';
|
||||||
case 'SKIPPED': return 'Omis';
|
case ORDER_STATUS.SKIPPED: return 'Omis';
|
||||||
case 'ERROR': return 'Eroare';
|
case ORDER_STATUS.ERROR: return 'Eroare';
|
||||||
default: return esc(status);
|
default: return esc(status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,8 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
document.getElementById('countError').textContent = counts.error || 0;
|
document.getElementById('countError').textContent = counts.error || 0;
|
||||||
const alreadyEl = document.getElementById('countAlreadyImported');
|
const alreadyEl = document.getElementById('countAlreadyImported');
|
||||||
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
|
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 tbody = document.getElementById('runOrdersBody');
|
||||||
const orders = data.orders || [];
|
const orders = data.orders || [];
|
||||||
@@ -144,9 +146,9 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
if (orders.length === 0) {
|
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="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
|
const problemOrders = orders.filter(o => [ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED].includes(o.status));
|
||||||
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
const okOrders = orders.filter(o => [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(o.status));
|
||||||
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', '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) {
|
function orderRow(o, i) {
|
||||||
const dateStr = fmtDate(o.order_date);
|
const dateStr = fmtDate(o.order_date);
|
||||||
@@ -195,9 +197,9 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
||||||
} else {
|
} else {
|
||||||
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
|
const problemOrders = orders.filter(o => [ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED].includes(o.status));
|
||||||
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
const okOrders = orders.filter(o => [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(o.status));
|
||||||
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', '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) {
|
function mobileRow(o) {
|
||||||
const d = o.order_date || '';
|
const d = o.order_date || '';
|
||||||
@@ -235,10 +237,11 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
// Mobile segmented control
|
// Mobile segmented control
|
||||||
renderMobileSegmented('logsMobileSeg', [
|
renderMobileSegmented('logsMobileSeg', [
|
||||||
{ label: 'Toate', count: counts.total || 0, value: 'all', active: currentFilter === 'all', colorClass: 'fc-neutral' },
|
{ 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: '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: 'ALREADY_IMPORTED', active: currentFilter === 'ALREADY_IMPORTED', colorClass: 'fc-blue' },
|
{ 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: 'SKIPPED', active: currentFilter === 'SKIPPED', colorClass: 'fc-yellow' },
|
{ 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: 'ERROR', active: currentFilter === 'ERROR', colorClass: 'fc-red' }
|
{ 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));
|
], (val) => filterOrders(val));
|
||||||
|
|
||||||
// Orders pagination
|
// Orders pagination
|
||||||
|
|||||||
@@ -279,8 +279,6 @@ function goPage(p) {
|
|||||||
|
|
||||||
// ── Multi-CODMAT Add Modal (R11) ─────────────────
|
// ── Multi-CODMAT Add Modal (R11) ─────────────────
|
||||||
|
|
||||||
let acTimeouts = {};
|
|
||||||
|
|
||||||
function initAddModal() {
|
function initAddModal() {
|
||||||
const modal = document.getElementById('addModal');
|
const modal = document.getElementById('addModal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -373,7 +371,7 @@ function addCodmatLine() {
|
|||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="qm-row">
|
<div class="qm-row">
|
||||||
<div class="qm-codmat-wrap position-relative">
|
<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 class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
|
||||||
</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">
|
<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 dropdown = div.querySelector('.cl-ac-dropdown');
|
||||||
const selected = div.querySelector('.cl-selected');
|
const selected = div.querySelector('.cl-selected');
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
setupAutocomplete(input, dropdown, selected, clAutocomplete);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clAutocomplete(input, dropdown, selectedEl) {
|
async function clAutocomplete(input, dropdown, selectedEl) {
|
||||||
@@ -407,22 +398,16 @@ async function clAutocomplete(input, dropdown, selectedEl) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||||
|
|
||||||
dropdown.innerHTML = data.results.map(r =>
|
dropdown.innerHTML = data.results.map((r, i) => {
|
||||||
`<div class="autocomplete-item" onmousedown="clSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
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>` : ''}
|
<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>`
|
</div>`;
|
||||||
).join('');
|
}).join('');
|
||||||
dropdown.classList.remove('d-none');
|
dropdown.classList.remove('d-none');
|
||||||
} catch { dropdown.classList.add('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() {
|
async function saveMapping() {
|
||||||
const sku = document.getElementById('inputSku').value.trim();
|
const sku = document.getElementById('inputSku').value.trim();
|
||||||
if (!sku) { alert('SKU este obligatoriu'); return; }
|
if (!sku) { alert('SKU este obligatoriu'); return; }
|
||||||
@@ -528,7 +513,7 @@ function showInlineAddRow() {
|
|||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
<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">
|
<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>
|
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
|
||||||
<small class="text-muted" id="inlineSelected"></small>
|
<small class="text-muted" id="inlineSelected"></small>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,15 +528,8 @@ function showInlineAddRow() {
|
|||||||
const input = document.getElementById('inlineCodmat');
|
const input = document.getElementById('inlineCodmat');
|
||||||
const dropdown = document.getElementById('inlineAcDropdown');
|
const dropdown = document.getElementById('inlineAcDropdown');
|
||||||
const selected = document.getElementById('inlineSelected');
|
const selected = document.getElementById('inlineSelected');
|
||||||
let inlineAcTimeout = null;
|
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
setupAutocomplete(input, dropdown, selected, inlineAutocomplete);
|
||||||
clearTimeout(inlineAcTimeout);
|
|
||||||
inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
|
|
||||||
});
|
|
||||||
input.addEventListener('blur', () => {
|
|
||||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inlineAutocomplete(input, dropdown, selectedEl) {
|
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 res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||||
dropdown.innerHTML = data.results.map(r =>
|
dropdown.innerHTML = data.results.map((r, i) => {
|
||||||
`<div class="autocomplete-item" onmousedown="inlineSelectArticle('${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
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>` : ''}
|
<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>`
|
</div>`;
|
||||||
).join('');
|
}).join('');
|
||||||
dropdown.classList.remove('d-none');
|
dropdown.classList.remove('d-none');
|
||||||
} catch { dropdown.classList.add('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() {
|
async function saveInlineMapping() {
|
||||||
const sku = document.getElementById('inlineSku').value.trim();
|
const sku = document.getElementById('inlineSku').value.trim();
|
||||||
const codmat = document.getElementById('inlineCodmat').value.trim();
|
const codmat = document.getElementById('inlineCodmat').value.trim();
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Kit pricing mode radio toggle
|
// Kit pricing mode radio toggle
|
||||||
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
|
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
|
||||||
r.addEventListener('change', () => {
|
r.addEventListener('change', () => {
|
||||||
|
const mode = document.querySelector('input[name="kitPricingMode"]:checked')?.value || '';
|
||||||
document.getElementById('kitModeBFields').style.display =
|
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 => {
|
document.querySelectorAll('input[name="kitPricingMode"]').forEach(r => {
|
||||||
r.checked = r.value === kitMode;
|
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('settKitDiscountCodmat')) el('settKitDiscountCodmat').value = data.kit_discount_codmat || '';
|
||||||
if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || '';
|
if (el('settKitDiscountIdPol')) el('settKitDiscountIdPol').value = data.kit_discount_id_pol || '';
|
||||||
|
|
||||||
// Price sync
|
// Price sync
|
||||||
if (el('settPriceSyncEnabled')) el('settPriceSyncEnabled').checked = data.price_sync_enabled !== "0";
|
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) {
|
} catch (err) {
|
||||||
console.error('loadSettings error:', err);
|
console.error('loadSettings error:', err);
|
||||||
}
|
}
|
||||||
@@ -187,9 +173,6 @@ async function saveSettings() {
|
|||||||
kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '',
|
kit_discount_codmat: el('settKitDiscountCodmat')?.value?.trim() || '',
|
||||||
kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '',
|
kit_discount_id_pol: el('settKitDiscountIdPol')?.value?.trim() || '',
|
||||||
price_sync_enabled: el('settPriceSyncEnabled')?.checked ? "1" : "0",
|
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 {
|
try {
|
||||||
const res = await fetch('/api/settings', {
|
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) {
|
function wireAutocomplete(inputId, dropdownId) {
|
||||||
const input = document.getElementById(inputId);
|
const input = document.getElementById(inputId);
|
||||||
const dropdown = document.getElementById(dropdownId);
|
const dropdown = document.getElementById(dropdownId);
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────
|
// ── HTML escaping ─────────────────────────────────
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
if (s == null) return '';
|
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 ────────────────────────
|
// ── Shared Quick Map Modal ────────────────────────
|
||||||
let _qmOnSave = null;
|
let _qmOnSave = null;
|
||||||
let _qmAcTimeout = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the shared quick-map modal.
|
* Open the shared quick-map modal.
|
||||||
@@ -276,13 +404,7 @@ function addQmCodmatLine(prefill) {
|
|||||||
const dropdown = div.querySelector('.qm-ac-dropdown');
|
const dropdown = div.querySelector('.qm-ac-dropdown');
|
||||||
const selected = div.querySelector('.qm-selected');
|
const selected = div.querySelector('.qm-selected');
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
setupAutocomplete(input, dropdown, selected, _qmAutocomplete);
|
||||||
clearTimeout(_qmAcTimeout);
|
|
||||||
_qmAcTimeout = setTimeout(() => _qmAutocomplete(input, dropdown, selected), 250);
|
|
||||||
});
|
|
||||||
input.addEventListener('blur', () => {
|
|
||||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _qmAutocomplete(input, dropdown, selectedEl) {
|
async function _qmAutocomplete(input, dropdown, selectedEl) {
|
||||||
@@ -294,22 +416,16 @@ async function _qmAutocomplete(input, dropdown, selectedEl) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||||
|
|
||||||
dropdown.innerHTML = data.results.map(r =>
|
dropdown.innerHTML = data.results.map((r, i) => {
|
||||||
`<div class="autocomplete-item" onmousedown="_qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
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> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
<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>`
|
</div>`;
|
||||||
).join('');
|
}).join('');
|
||||||
dropdown.classList.remove('d-none');
|
dropdown.classList.remove('d-none');
|
||||||
} catch { dropdown.classList.add('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() {
|
async function saveQuickMapping() {
|
||||||
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
|
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
|
||||||
const mappings = [];
|
const mappings = [];
|
||||||
@@ -398,12 +514,13 @@ function fmtNum(v) {
|
|||||||
|
|
||||||
function orderStatusBadge(status) {
|
function orderStatusBadge(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
case ORDER_STATUS.IMPORTED: return '<span class="badge bg-success">Importat</span>';
|
||||||
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
case ORDER_STATUS.ALREADY_IMPORTED: return '<span class="badge bg-info">Deja importat</span>';
|
||||||
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
|
case ORDER_STATUS.SKIPPED: return '<span class="badge bg-warning">Omis</span>';
|
||||||
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
|
case ORDER_STATUS.ERROR: return '<span class="badge bg-danger">Eroare</span>';
|
||||||
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
|
case ORDER_STATUS.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.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>`;
|
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +601,142 @@ function _renderReceipt(items, order) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Order Detail Modal (shared) ──────────────────
|
// ── Order Detail Modal (shared) ──────────────────
|
||||||
|
function _configureDetailButtons(order, orderNumber, opts) {
|
||||||
|
const status = (order.status || '').toUpperCase();
|
||||||
|
const isInvoiced = !!(order.factura_numar);
|
||||||
|
|
||||||
|
const retryBtn = document.getElementById('detailRetryBtn');
|
||||||
|
if (retryBtn) {
|
||||||
|
const canRetry = [ORDER_STATUS.ERROR, ORDER_STATUS.SKIPPED, ORDER_STATUS.DELETED_IN_ROA].includes(status);
|
||||||
|
retryBtn.style.display = canRetry ? '' : 'none';
|
||||||
|
if (canRetry) {
|
||||||
|
retryBtn.onclick = async () => {
|
||||||
|
retryBtn.disabled = true;
|
||||||
|
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
|
||||||
|
retryBtn.className = 'btn btn-sm btn-success';
|
||||||
|
if (opts.onStatusChange) opts.onStatusChange();
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||||
|
} else {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||||
|
retryBtn.className = 'btn btn-sm btn-danger';
|
||||||
|
setTimeout(() => {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
|
||||||
|
retryBtn.className = 'btn btn-sm btn-outline-primary';
|
||||||
|
retryBtn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
retryBtn.innerHTML = 'Eroare: ' + err.message;
|
||||||
|
retryBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resyncBtn = document.getElementById('detailResyncBtn');
|
||||||
|
if (resyncBtn) {
|
||||||
|
const canResync = [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(status);
|
||||||
|
resyncBtn.style.display = canResync ? '' : 'none';
|
||||||
|
if (canResync) {
|
||||||
|
if (isInvoiced) {
|
||||||
|
resyncBtn.disabled = true;
|
||||||
|
resyncBtn.style.opacity = '0.5';
|
||||||
|
resyncBtn.style.pointerEvents = 'none';
|
||||||
|
resyncBtn.title = 'Comanda facturata';
|
||||||
|
} else {
|
||||||
|
resyncBtn.disabled = false;
|
||||||
|
resyncBtn.style.opacity = '';
|
||||||
|
resyncBtn.style.pointerEvents = '';
|
||||||
|
resyncBtn.title = '';
|
||||||
|
resyncBtn.onclick = () => {
|
||||||
|
inlineConfirmAction(resyncBtn, 'Confirmi resync?', async (btn) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/resync`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-circle"></i> Reimportat';
|
||||||
|
btn.className = 'btn btn-sm btn-success';
|
||||||
|
if (opts.onStatusChange) opts.onStatusChange();
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||||
|
btn.className = 'btn btn-sm btn-danger';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Resync';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-warning';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
btn.innerHTML = 'Eroare: ' + err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
defaultHtml: '<i class="bi bi-arrow-repeat"></i> Resync',
|
||||||
|
loadingText: 'Resync...',
|
||||||
|
confirmClass: 'btn-warning',
|
||||||
|
defaultBtnClass: 'btn-outline-warning'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBtn = document.getElementById('detailDeleteBtn');
|
||||||
|
if (deleteBtn) {
|
||||||
|
const canDelete = [ORDER_STATUS.IMPORTED, ORDER_STATUS.ALREADY_IMPORTED].includes(status);
|
||||||
|
deleteBtn.style.display = canDelete ? '' : 'none';
|
||||||
|
if (canDelete) {
|
||||||
|
if (isInvoiced) {
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
deleteBtn.style.opacity = '0.5';
|
||||||
|
deleteBtn.style.pointerEvents = 'none';
|
||||||
|
deleteBtn.title = 'Comanda facturata';
|
||||||
|
} else {
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
deleteBtn.style.opacity = '';
|
||||||
|
deleteBtn.style.pointerEvents = '';
|
||||||
|
deleteBtn.title = '';
|
||||||
|
deleteBtn.onclick = () => {
|
||||||
|
inlineConfirmAction(deleteBtn, 'Confirmi stergerea?', async (btn) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/delete`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-circle"></i> Sters';
|
||||||
|
btn.className = 'btn btn-sm btn-danger';
|
||||||
|
if (opts.onStatusChange) opts.onStatusChange();
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||||
|
btn.className = 'btn btn-sm btn-danger';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Sterge din ROA';
|
||||||
|
btn.className = 'btn btn-sm btn-outline-danger';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
btn.innerHTML = 'Eroare: ' + err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
defaultHtml: '<i class="bi bi-trash"></i> Sterge din ROA',
|
||||||
|
loadingText: 'Stergere...',
|
||||||
|
confirmClass: 'btn-danger',
|
||||||
|
defaultBtnClass: 'btn-outline-danger'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render and show the order detail modal.
|
* Render and show the order detail modal.
|
||||||
* @param {string} orderNumber
|
* @param {string} orderNumber
|
||||||
@@ -494,6 +747,8 @@ function _renderReceipt(items, order) {
|
|||||||
async function renderOrderDetailModal(orderNumber, opts) {
|
async function renderOrderDetailModal(orderNumber, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
|
||||||
|
window._detailOrderNumber = orderNumber;
|
||||||
|
|
||||||
// Reset modal state
|
// Reset modal state
|
||||||
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
||||||
document.getElementById('detailCustomer').textContent = '...';
|
document.getElementById('detailCustomer').textContent = '...';
|
||||||
@@ -505,6 +760,10 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
document.getElementById('detailError').style.display = 'none';
|
document.getElementById('detailError').style.display = 'none';
|
||||||
const retryBtn = document.getElementById('detailRetryBtn');
|
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'; }
|
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');
|
const receiptEl = document.getElementById('detailReceipt');
|
||||||
if (receiptEl) receiptEl.innerHTML = '';
|
if (receiptEl) receiptEl.innerHTML = '';
|
||||||
const receiptMEl = document.getElementById('detailReceiptMobile');
|
const receiptMEl = document.getElementById('detailReceiptMobile');
|
||||||
@@ -513,8 +772,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
if (invInfo) invInfo.style.display = 'none';
|
if (invInfo) invInfo.style.display = 'none';
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||||
const priceCheckEl = document.getElementById('detailPriceCheck');
|
|
||||||
if (priceCheckEl) priceCheckEl.innerHTML = '';
|
|
||||||
const reconEl = document.getElementById('detailInvoiceRecon');
|
const reconEl = document.getElementById('detailInvoiceRecon');
|
||||||
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
|
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
|
||||||
// Remove diff badge from previous render
|
// Remove diff badge from previous render
|
||||||
@@ -531,6 +788,8 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
// Restore original structure (may have been replaced by PF indicator)
|
// Restore original structure (may have been replaced by PF indicator)
|
||||||
cuiRoa.innerHTML = '<small class="text-muted">CUI:</small> <span class="font-data" id="detailCuiRoaVal"></span><span id="detailPartnerAnafArea"></span>';
|
cuiRoa.innerHTML = '<small class="text-muted">CUI:</small> <span class="font-data" id="detailCuiRoaVal"></span><span id="detailPartnerAnafArea"></span>';
|
||||||
}
|
}
|
||||||
|
const partnerMismatchEl = document.getElementById('detailPartnerMismatch');
|
||||||
|
if (partnerMismatchEl) { partnerMismatchEl.style.display = 'none'; partnerMismatchEl.innerHTML = ''; }
|
||||||
const denomMismatch = document.getElementById('detailDenomMismatch');
|
const denomMismatch = document.getElementById('detailDenomMismatch');
|
||||||
if (denomMismatch) { denomMismatch.style.display = 'none'; denomMismatch.innerHTML = ''; }
|
if (denomMismatch) { denomMismatch.style.display = 'none'; denomMismatch.innerHTML = ''; }
|
||||||
const addressBlock = document.getElementById('detailAddressBlock');
|
const addressBlock = document.getElementById('detailAddressBlock');
|
||||||
@@ -557,19 +816,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
|
||||||
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
|
||||||
|
|
||||||
// Price check badge
|
|
||||||
const priceCheckEl = document.getElementById('detailPriceCheck');
|
|
||||||
if (priceCheckEl) {
|
|
||||||
const pc = order.price_check;
|
|
||||||
if (!pc || pc.oracle_available === false) {
|
|
||||||
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--cancelled-light);color:var(--text-muted)">Preturi ROA indisponibile</span>';
|
|
||||||
} else if (pc.mismatches === 0) {
|
|
||||||
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Preturi OK</span>';
|
|
||||||
} else {
|
|
||||||
priceCheckEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">${pc.mismatches} diferente de pret</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
||||||
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
||||||
|
|
||||||
@@ -607,9 +853,14 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
document.getElementById('detailError').style.display = '';
|
document.getElementById('detailError').style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure footer action buttons BEFORE any early-return on items —
|
||||||
|
// DELETED_IN_ROA orders have no items but must still expose the Reimporta button.
|
||||||
|
_configureDetailButtons(order, orderNumber, opts);
|
||||||
|
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
|
||||||
|
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,10 +877,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
: `<code>${esc(item.codmat || '–')}</code>`;
|
: `<code>${esc(item.codmat || '–')}</code>`;
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
|
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 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">
|
return `<div class="dif-item">
|
||||||
<div class="dif-row">
|
<div class="dif-row">
|
||||||
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
|
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
|
||||||
@@ -641,7 +888,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
<span class="dif-val">${fmtNum(valoare)} lei</span>
|
<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>
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
${priceMismatchHtml}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -695,32 +941,14 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
|
|
||||||
let tableHtml = items.map((item, idx) => {
|
let tableHtml = items.map((item, idx) => {
|
||||||
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
return `<tr>
|
||||||
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}>
|
|
||||||
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
|
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
|
||||||
<td>${esc(item.product_name || '-')}</td>
|
<td>${esc(item.product_name || '-')}</td>
|
||||||
<td>${renderCodmatCell(item)}</td>
|
<td>${renderCodmatCell(item)}</td>
|
||||||
<td class="text-end">${item.quantity || 0}</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">${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">${item.vat != null ? Number(item.vat) : '-'}</td>
|
||||||
<td class="text-end font-data">${fmtNum(valoare)}</td>
|
<td class="text-end font-data">${fmtNum(valoare)}</td>
|
||||||
<td class="text-center">${matchDot}</td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -732,9 +960,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
<td></td><td class="text-muted">Transport</td>
|
<td></td><td class="text-muted">Transport</td>
|
||||||
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</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 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 class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
|
||||||
<td></td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,9 +976,7 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
<td></td><td class="text-muted">Discount</td>
|
<td></td><td class="text-muted">Discount</td>
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</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 class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
|
||||||
<td></td>
|
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -770,40 +994,6 @@ async function renderOrderDetailModal(orderNumber, opts) {
|
|||||||
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
||||||
_renderReceipt(items, order);
|
_renderReceipt(items, order);
|
||||||
|
|
||||||
// Retry button (only for ERROR/SKIPPED orders)
|
|
||||||
const retryBtn = document.getElementById('detailRetryBtn');
|
|
||||||
if (retryBtn) {
|
|
||||||
const canRetry = ['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);
|
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('detailError').textContent = err.message;
|
document.getElementById('detailError').textContent = err.message;
|
||||||
@@ -817,23 +1007,46 @@ function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) {
|
|||||||
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(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 ────────────────────────────────────
|
// ── Dot helper ────────────────────────────────────
|
||||||
function statusDot(status) {
|
function statusDot(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED':
|
case ORDER_STATUS.IMPORTED:
|
||||||
case 'ALREADY_IMPORTED':
|
case ORDER_STATUS.ALREADY_IMPORTED:
|
||||||
case 'COMPLETED':
|
case 'COMPLETED':
|
||||||
case 'RESOLVED':
|
case 'RESOLVED':
|
||||||
return '<span class="dot dot-green"></span>';
|
return '<span class="dot dot-green"></span>';
|
||||||
case 'SKIPPED':
|
case ORDER_STATUS.SKIPPED:
|
||||||
case 'UNRESOLVED':
|
case 'UNRESOLVED':
|
||||||
case 'INCOMPLETE':
|
case 'INCOMPLETE':
|
||||||
return '<span class="dot dot-yellow"></span>';
|
return '<span class="dot dot-yellow"></span>';
|
||||||
case 'ERROR':
|
case ORDER_STATUS.ERROR:
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
return '<span class="dot dot-red"></span>';
|
return '<span class="dot dot-red"></span>';
|
||||||
case 'CANCELLED':
|
case ORDER_STATUS.MALFORMED:
|
||||||
case 'DELETED_IN_ROA':
|
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>';
|
return '<span class="dot dot-gray"></span>';
|
||||||
default:
|
default:
|
||||||
return '<span class="dot dot-gray"></span>';
|
return '<span class="dot dot-gray"></span>';
|
||||||
@@ -846,6 +1059,13 @@ function fmtAddr(a) {
|
|||||||
if (!a) return '\u2014';
|
if (!a) return '\u2014';
|
||||||
if (typeof a === 'string') return a;
|
if (typeof a === 'string') return a;
|
||||||
const parts = [a.address || a.strada || '', a.numar || ''].filter(Boolean);
|
const parts = [a.address || a.strada || '', a.numar || ''].filter(Boolean);
|
||||||
|
const extras = [
|
||||||
|
a.bloc ? 'Bl.' + a.bloc : '',
|
||||||
|
a.scara ? 'Sc.' + a.scara : '',
|
||||||
|
a.apart ? 'Ap.' + a.apart : '',
|
||||||
|
a.etaj ? 'Et.' + a.etaj : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
if (extras) parts.push(extras);
|
||||||
const line1 = parts.join(' ').trim();
|
const line1 = parts.join(' ').trim();
|
||||||
const line2 = [a.city || a.localitate || '', a.region || a.judet || ''].filter(Boolean).join(', ');
|
const line2 = [a.city || a.localitate || '', a.region || a.judet || ''].filter(Boolean).join(', ');
|
||||||
return [line1, line2].filter(Boolean).join(', ');
|
return [line1, line2].filter(Boolean).join(', ');
|
||||||
@@ -853,19 +1073,41 @@ function fmtAddr(a) {
|
|||||||
|
|
||||||
function addrMatch(gomag, roa) {
|
function addrMatch(gomag, roa) {
|
||||||
if (!gomag || !roa) return true; // can't compare
|
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) {
|
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()
|
.toUpperCase()
|
||||||
.replace(/\b(STR|NR|BL|SC|AP|ET|ETAJ|APART)\b/g, '')
|
.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, '');
|
.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 gStreet = norm(gomag.address || gomag.strada || '');
|
||||||
const rStreet = norm((roa.strada || '') + (roa.numar || ''));
|
const rStreet = norm((roa.strada||'') + (roa.numar||'') + (roa.bloc||'') + (roa.scara||'') + (roa.etaj||'') + (roa.apart||''));
|
||||||
const gCity = norm(gomag.city || gomag.localitate || '');
|
const gCity = norm(gomag.city || gomag.localitate || '');
|
||||||
const rCity = norm(roa.localitate || '');
|
const rCity = norm(roa.localitate || '');
|
||||||
const gRegion = norm(gomag.region || gomag.judet || '');
|
const gRegion = norm(gomag.region || gomag.judet || '');
|
||||||
const rRegion = norm(roa.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) {
|
function hasEfacturaRisk(roa) {
|
||||||
@@ -889,11 +1131,14 @@ function _renderHeaderInfo(order) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ROA column — show partner name for both PJ and PF
|
// ROA column — show partner name for both PJ and PF
|
||||||
if (pi && pi.denumire_roa) {
|
const partenerRoa = document.getElementById('detailPartenerRoa');
|
||||||
const partenerRoa = document.getElementById('detailPartenerRoa');
|
if (partenerRoa) {
|
||||||
if (partenerRoa) {
|
if (pi && pi.denumire_roa) {
|
||||||
partenerRoa.textContent = pi.denumire_roa;
|
partenerRoa.textContent = pi.denumire_roa;
|
||||||
partenerRoa.style.display = '';
|
partenerRoa.style.display = '';
|
||||||
|
} else if (pi && pi.partner_mismatch) {
|
||||||
|
partenerRoa.innerHTML = '<span class="text-muted" style="font-style:italic">necunoscut — se va actualiza la urmatorul sync</span>';
|
||||||
|
partenerRoa.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,11 +1186,38 @@ function _renderHeaderInfo(order) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ERROR orders: muted dashes for ROA fields
|
// 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('detailIdComanda').innerHTML = '<span class="text-muted">\u2014</span>';
|
||||||
document.getElementById('detailIdPartener').innerHTML = '<span class="text-muted">\u2014</span>';
|
document.getElementById('detailIdPartener').innerHTML = '<span class="text-muted">\u2014</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partner mismatch alert
|
||||||
|
if (pi && pi.partner_mismatch) {
|
||||||
|
const pmEl = document.getElementById('detailPartnerMismatch');
|
||||||
|
if (pmEl) {
|
||||||
|
const isInvoiced = !!(order.invoice && order.invoice.facturat);
|
||||||
|
let mismatchType = '';
|
||||||
|
if (pi.cod_fiscal_gomag && !pi.cod_fiscal_roa) {
|
||||||
|
mismatchType = 'PF → PJ: comanda importata ca persoana fizica, acum are CUI in GoMag.';
|
||||||
|
} else if (!pi.cod_fiscal_gomag && pi.cod_fiscal_roa) {
|
||||||
|
mismatchType = 'PJ → PF: comanda importata cu CUI, acum GoMag nu mai are companie.';
|
||||||
|
} else if (pi.cod_fiscal_gomag && pi.cod_fiscal_roa && pi.cod_fiscal_gomag !== pi.cod_fiscal_roa) {
|
||||||
|
mismatchType = `CUI schimbat: GoMag are ${esc(pi.cod_fiscal_gomag)}, ROA are ${esc(pi.cod_fiscal_roa)}.`;
|
||||||
|
} else {
|
||||||
|
mismatchType = 'Date partener diferite fata de momentul importului.';
|
||||||
|
}
|
||||||
|
const resyncBtn = isInvoiced
|
||||||
|
? `<button class="btn btn-sm btn-outline-warning mt-1" onclick="resyncPartner('${esc(order.order_number)}', this)"><i class="bi bi-person-check"></i> Actualizeaza partener in ROA</button>`
|
||||||
|
: '';
|
||||||
|
pmEl.innerHTML = `<div class="denom-mismatch" style="border-color:var(--error)">
|
||||||
|
<span class="denom-mismatch-title" style="color:var(--error-text)"><i class="bi bi-people"></i> Partener schimbat in GoMag</span><br>
|
||||||
|
<span style="font-size:13px">${mismatchType}</span>
|
||||||
|
${resyncBtn}
|
||||||
|
</div>`;
|
||||||
|
pmEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Denomination mismatch alert
|
// Denomination mismatch alert
|
||||||
if (isPJ && pi.anaf_denumire_mismatch && pi.denumire_anaf) {
|
if (isPJ && pi.anaf_denumire_mismatch && pi.denumire_anaf) {
|
||||||
const denomEl = document.getElementById('detailDenomMismatch');
|
const denomEl = document.getElementById('detailDenomMismatch');
|
||||||
@@ -989,46 +1261,78 @@ function _renderHeaderInfo(order) {
|
|||||||
|
|
||||||
// Livrare
|
// Livrare
|
||||||
if (addr.livrare_gomag || addr.livrare_roa) {
|
if (addr.livrare_gomag || addr.livrare_roa) {
|
||||||
html += addrLine('Livrare GoMag:', addr.livrare_gomag, null);
|
|
||||||
const livrRisk = hasEfacturaRisk(addr.livrare_roa);
|
const livrRisk = hasEfacturaRisk(addr.livrare_roa);
|
||||||
const livrMatch = addrMatch(addr.livrare_gomag, addr.livrare_roa);
|
const livrMatch = addrMatch(addr.livrare_gomag, addr.livrare_roa);
|
||||||
let matchType = null;
|
let matchType = null;
|
||||||
if (addr.livrare_roa) {
|
if (addr.livrare_roa) {
|
||||||
matchType = livrRisk ? 'risk' : (livrMatch ? 'match' : 'mismatch');
|
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
|
// Facturare
|
||||||
if (addr.facturare_gomag || addr.facturare_roa) {
|
if (addr.facturare_gomag || addr.facturare_roa) {
|
||||||
html += addrLine('Facturare GoMag:', addr.facturare_gomag, null);
|
|
||||||
const factRisk = hasEfacturaRisk(addr.facturare_roa);
|
const factRisk = hasEfacturaRisk(addr.facturare_roa);
|
||||||
const factMatch = addrMatch(addr.facturare_gomag, addr.facturare_roa);
|
const factMatch = addrMatch(addr.facturare_gomag, addr.facturare_roa);
|
||||||
let matchType = null;
|
let matchType = null;
|
||||||
if (addr.facturare_roa) {
|
if (addr.facturare_roa) {
|
||||||
matchType = factRisk ? 'risk' : (factMatch ? 'match' : 'mismatch');
|
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;
|
addressLines.innerHTML = html;
|
||||||
|
|
||||||
// Diff summary badge in modal header
|
// Typed diff badges in modal header
|
||||||
let diffCount = 0;
|
|
||||||
if (isPJ && pi.anaf_denumire_mismatch) diffCount++;
|
|
||||||
if (order.price_check && order.price_check.mismatches > 0) diffCount += order.price_check.mismatches;
|
|
||||||
if (addr) {
|
|
||||||
if (addr.livrare_roa && !addrMatch(addr.livrare_gomag, addr.livrare_roa)) diffCount++;
|
|
||||||
if (addr.facturare_roa && !addrMatch(addr.facturare_gomag, addr.facturare_roa)) diffCount++;
|
|
||||||
}
|
|
||||||
const orderNumEl = document.getElementById('detailOrderNumber');
|
const orderNumEl = document.getElementById('detailOrderNumber');
|
||||||
if (orderNumEl && diffCount > 0) {
|
if (orderNumEl) {
|
||||||
const existing = orderNumEl.querySelector('.diff-badge');
|
orderNumEl.parentNode.querySelectorAll('.diff-badge').forEach(b => b.remove());
|
||||||
if (existing) existing.remove();
|
const badges = [];
|
||||||
const badge = document.createElement('span');
|
if (isPJ && pi.anaf_cod_fiscal_adjusted) badges.push({label:'CUI', cls:'diff-badge-anaf', aria:'CUI ajustat conform ANAF'});
|
||||||
badge.className = 'diff-badge badge ms-2';
|
if (isPJ && pi.anaf_denumire_mismatch) badges.push({label:'Denumire', cls:'diff-badge-denumire', aria:'Denumire diferita fata de ANAF'});
|
||||||
badge.style.cssText = 'background:var(--warning-light);color:var(--warning-text);font-size:11px;vertical-align:middle';
|
if (isPJ && !pi.anaf_cod_fiscal_adjusted && pi.anaf_platitor_tva !== null && pi.anaf_platitor_tva !== undefined) {
|
||||||
badge.textContent = diffCount + ' diferente';
|
const gomagImpliesPlatitor = pi.cod_fiscal_gomag && /^RO/i.test(pi.cod_fiscal_gomag);
|
||||||
orderNumEl.parentNode.insertBefore(badge, orderNumEl.nextSibling);
|
const anafPlatitor = pi.anaf_platitor_tva === 1;
|
||||||
|
if (gomagImpliesPlatitor !== anafPlatitor) badges.push({label:'TVA', cls:'diff-badge-anaf', aria: anafPlatitor ? 'Platitor TVA conform ANAF (GoMag fara RO)' : 'Neplatitor TVA conform ANAF (GoMag cu RO)'});
|
||||||
|
}
|
||||||
|
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 (pi && pi.partner_mismatch) badges.push({label:'Partener', cls:'diff-badge-anaf', aria:'Partener schimbat in GoMag'});
|
||||||
|
let insertAfter = orderNumEl;
|
||||||
|
badges.forEach(b => {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.className = 'diff-badge ' + b.cls;
|
||||||
|
el.setAttribute('aria-label', b.aria);
|
||||||
|
el.textContent = b.label;
|
||||||
|
insertAfter.parentNode.insertBefore(el, insertAfter.nextSibling);
|
||||||
|
insertAfter = el;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Partner Resync ────────────────────────────────
|
||||||
|
|
||||||
|
async function resyncPartner(orderNumber, btnEl) {
|
||||||
|
if (!confirm('Actualizeaza partenerul acestei comenzi in ROA Oracle?\n\nAtentie: Comanda este facturata. Verificati manual dupa actualizare.')) return;
|
||||||
|
if (btnEl) { btnEl.disabled = true; btnEl.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Se actualizeaza...'; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${window.ROOT_PATH || ''}/api/orders/${encodeURIComponent(orderNumber)}/resync-partner`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (btnEl) { btnEl.innerHTML = '<i class="bi bi-check-circle"></i> Actualizat'; btnEl.className = 'btn btn-sm btn-success mt-1'; }
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, {}), 1500);
|
||||||
|
} else {
|
||||||
|
if (btnEl) { btnEl.disabled = false; btnEl.innerHTML = '<i class="bi bi-person-check"></i> Actualizeaza partener in ROA'; }
|
||||||
|
alert('Eroare: ' + (data.message || 'Resync esuat'));
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
if (btnEl) { btnEl.disabled = false; btnEl.innerHTML = '<i class="bi bi-person-check"></i> Actualizeaza partener in ROA'; }
|
||||||
|
alert('Eroare de retea: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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@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">
|
<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', '') %}
|
{% set rp = request.scope.get('root_path', '') %}
|
||||||
<link href="{{ rp }}/static/css/style.css?v=34" rel="stylesheet">
|
<link href="{{ rp }}/static/css/style.css?v=46" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Navbar (hidden on mobile via CSS) -->
|
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<small class="text-muted">CUI:</small> <span class="font-data" id="detailCuiGomagVal"></span>
|
<small class="text-muted">CUI:</small> <span class="font-data" id="detailCuiGomagVal"></span>
|
||||||
</div>
|
</div>
|
||||||
<div><small class="text-muted">Data:</small> <span id="detailDate"></span></div>
|
<div><small class="text-muted">Data:</small> <span id="detailDate"></span></div>
|
||||||
<div><small class="text-muted">Status:</small> <span id="detailStatus"></span><span id="detailPriceCheck" class="ms-2"></span></div>
|
<div><small class="text-muted">Status:</small> <span id="detailStatus"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- ROA Column -->
|
<!-- ROA Column -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -120,11 +120,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Partner mismatch alert -->
|
||||||
|
<div id="detailPartnerMismatch" style="display:none" class="mb-2"></div>
|
||||||
<!-- Denomination mismatch alert -->
|
<!-- Denomination mismatch alert -->
|
||||||
<div id="detailDenomMismatch" style="display:none" class="mb-2"></div>
|
<div id="detailDenomMismatch" style="display:none" class="mb-2"></div>
|
||||||
<!-- Compact Address Lines -->
|
<!-- Compact Address Lines -->
|
||||||
<div id="detailAddressBlock" style="display:none" class="mb-3">
|
<div id="detailAddressBlock" style="display:none" class="mb-3">
|
||||||
<div class="detail-col-label" style="border-bottom:1px solid var(--border);margin-bottom:8px;padding-bottom:4px">ADRESE</div>
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="detailAddressLines"></div>
|
<div id="detailAddressLines"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive d-none d-md-block">
|
<div class="table-responsive d-none d-md-block">
|
||||||
@@ -136,10 +144,8 @@
|
|||||||
<th>CODMAT</th>
|
<th>CODMAT</th>
|
||||||
<th class="text-end">Cant.</th>
|
<th class="text-end">Cant.</th>
|
||||||
<th class="text-end">Pret GoMag</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">TVA%</th>
|
||||||
<th class="text-end">Valoare</th>
|
<th class="text-end">Valoare</th>
|
||||||
<th class="text-center">✓</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="detailItemsBody">
|
<tbody id="detailItemsBody">
|
||||||
@@ -151,8 +157,10 @@
|
|||||||
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
<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 id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||||
</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="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>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +169,7 @@
|
|||||||
|
|
||||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="{{ rp }}/static/js/shared.js?v=25"></script>
|
<script src="{{ rp }}/static/js/shared.js?v=49"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
<div class="sync-card-controls">
|
<div class="sync-card-controls">
|
||||||
<span id="syncStatusDot" class="sync-status-dot idle"></span>
|
<span id="syncStatusDot" class="sync-status-dot idle"></span>
|
||||||
<span id="syncStatusText" class="text-secondary">Inactiv</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">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<label class="d-flex align-items-center gap-1 text-muted">
|
<label class="d-flex align-items-center gap-1 text-muted">
|
||||||
Auto:
|
Auto:
|
||||||
@@ -70,12 +75,13 @@
|
|||||||
<input type="search" id="orderSearch" placeholder="Cauta comanda, client..." class="search-input">
|
<input type="search" id="orderSearch" placeholder="Cauta comanda, client..." class="search-input">
|
||||||
<!-- Status pills -->
|
<!-- 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 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="{{ 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="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="{{ 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="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.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="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="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="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">↻</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">↻</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +98,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:24px"></th>
|
<th style="width:24px"></th>
|
||||||
|
<th style="width:28px" title="Facturat">F</th>
|
||||||
<th class="sortable" onclick="dashSortBy('order_date')">Data <span class="sort-icon" data-col="order_date"></span></th>
|
<th class="sortable" onclick="dashSortBy('order_date')">Data <span class="sort-icon" data-col="order_date"></span></th>
|
||||||
<th class="sortable" onclick="dashSortBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
|
<th class="sortable" onclick="dashSortBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
|
||||||
<th class="sortable" onclick="dashSortBy('order_number')">Nr Comanda <span class="sort-icon" data-col="order_number"></span></th>
|
<th class="sortable" onclick="dashSortBy('order_number')">Nr Comanda <span class="sort-icon" data-col="order_number"></span></th>
|
||||||
@@ -99,8 +106,7 @@
|
|||||||
<th class="text-end">Transport</th>
|
<th class="text-end">Transport</th>
|
||||||
<th class="text-end">Discount</th>
|
<th class="text-end">Discount</th>
|
||||||
<th class="text-end">Total</th>
|
<th class="text-end">Total</th>
|
||||||
<th style="width:28px" title="Facturat">F</th>
|
<th style="width:44px"></th>
|
||||||
<th class="text-center" style="width:30px" title="Preturi ROA">₽</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dashOrdersBody">
|
<tbody id="dashOrdersBody">
|
||||||
@@ -115,5 +121,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=36"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=52"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -59,10 +59,11 @@
|
|||||||
<!-- Filter pills -->
|
<!-- Filter pills -->
|
||||||
<div class="filter-bar mb-3" id="orderFilterPills">
|
<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 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="{{ 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="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="{{ 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="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="{{ 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="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.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>
|
||||||
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
|
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
|
||||||
|
|
||||||
@@ -109,5 +110,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -159,5 +159,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-12">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
|
<div class="card-header py-2 px-3 fw-semibold">Pricing Kituri / Pachete</div>
|
||||||
<div class="card-body py-2 px-3">
|
<div class="card-body py-2 px-3">
|
||||||
@@ -207,37 +207,11 @@
|
|||||||
<option value="">— implicită —</option>
|
<option value="">— implicită —</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-check mt-2">
|
||||||
</div>
|
<input type="checkbox" class="form-check-input" id="settPriceSyncEnabled" checked>
|
||||||
</div>
|
<label class="form-check-label small" for="settPriceSyncEnabled">Sync automat prețuri din comenzi</label>
|
||||||
</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>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,5 +227,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ create or replace package PACK_COMENZI is
|
|||||||
-- Created : 18/08/2006
|
-- Created : 18/08/2006
|
||||||
-- Purpose :
|
-- Purpose :
|
||||||
|
|
||||||
-- 20.03.2026 - duplicate CODMAT pe comanda: discriminare pe PRET + SIGN(CANTITATE)
|
|
||||||
|
|
||||||
id_comanda COMENZI.ID_COMANDA%TYPE;
|
id_comanda COMENZI.ID_COMANDA%TYPE;
|
||||||
|
|
||||||
procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER,
|
procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER,
|
||||||
@@ -122,6 +120,9 @@ create or replace package PACK_COMENZI is
|
|||||||
V_ID_UTIL IN NUMBER,
|
V_ID_UTIL IN NUMBER,
|
||||||
V_ID_SECTIE IN NUMBER);
|
V_ID_SECTIE IN NUMBER);
|
||||||
|
|
||||||
|
procedure adauga_comanda_pe_factura(V_ID_COMANDA IN NUMBER,
|
||||||
|
V_ID_VANZARE IN NUMBER);
|
||||||
|
|
||||||
procedure livreaza_comanda(V_ID_COMANDA IN NUMBER,
|
procedure livreaza_comanda(V_ID_COMANDA IN NUMBER,
|
||||||
V_ID_AGENT IN NUMBER,
|
V_ID_AGENT IN NUMBER,
|
||||||
V_ID_DELEGAT IN NUMBER,
|
V_ID_DELEGAT IN NUMBER,
|
||||||
@@ -315,6 +316,10 @@ create or replace package body PACK_COMENZI is
|
|||||||
-- 19.03.2026
|
-- 19.03.2026
|
||||||
-- adauga_articol_comanda permite de 2 ori acelasi articol cu cote tva diferite (ex: discount 11% si discount 21%)
|
-- adauga_articol_comanda permite de 2 ori acelasi articol cu cote tva diferite (ex: discount 11% si discount 21%)
|
||||||
|
|
||||||
|
-- 20.03.2026 - duplicate CODMAT pe comanda: discriminare pe PRET + SIGN(CANTITATE)
|
||||||
|
-- 15.04.2026 - adaugare adauga_comanda_pe_factura()
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------
|
||||||
procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER,
|
procedure adauga_masina(V_ID_MODEL_MASINA IN NUMBER,
|
||||||
V_NRINMAT IN VARCHAR2,
|
V_NRINMAT IN VARCHAR2,
|
||||||
@@ -927,6 +932,25 @@ create or replace package body PACK_COMENZI is
|
|||||||
V_ID_UTIL,
|
V_ID_UTIL,
|
||||||
V_ID_SECTIE);
|
V_ID_SECTIE);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------------
|
||||||
|
-- asociez comanda cu vanzari.id_comanda pe o factura fara comanda
|
||||||
|
-- ca sa inchid comenzile facturate separat prin facturi lista preturi
|
||||||
|
----------------------------------------------------------------------------------
|
||||||
|
procedure adauga_comanda_pe_factura(V_ID_COMANDA IN NUMBER,
|
||||||
|
V_ID_VANZARE IN NUMBER) is
|
||||||
|
V_EXISTA NUMBER(10);
|
||||||
|
begin
|
||||||
|
SELECT COUNT(*) INTO V_EXISTA FROM VANZARI WHERE ID_VANZARE = V_ID_VANZARE AND NVL(ID_COMANDA,0) <> 0;
|
||||||
|
|
||||||
|
IF V_EXISTA > 0 THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20000,
|
||||||
|
'Factura are deja o comanda asociata. Alegeti alta factura!');
|
||||||
|
ELSE
|
||||||
|
UPDATE VANZARI SET ID_COMANDA = V_ID_COMANDA, TIP = 3
|
||||||
|
WHERE ID_VANZARE = V_ID_VANZARE AND NVL(ID_COMANDA, 0) = 0;
|
||||||
|
END IF;
|
||||||
|
end;
|
||||||
----------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------
|
||||||
procedure livreaza_comanda(V_ID_COMANDA IN NUMBER,
|
procedure livreaza_comanda(V_ID_COMANDA IN NUMBER,
|
||||||
V_ID_AGENT IN NUMBER,
|
V_ID_AGENT IN NUMBER,
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_PARTENERI AS
|
|||||||
-- 02.04.2026 - cautare CUI strict (p_strict_search=1) sau dual anti-dedup (NULL)
|
-- 02.04.2026 - cautare CUI strict (p_strict_search=1) sau dual anti-dedup (NULL)
|
||||||
-- 02.04.2026 - parser adrese: extrage APARTAMENT/SCARA/ETAJ embedded in strada (fix "Nr17 apartament 8")
|
-- 02.04.2026 - parser adrese: extrage APARTAMENT/SCARA/ETAJ embedded in strada (fix "Nr17 apartament 8")
|
||||||
-- 02.04.2026 - fallback cautare PF cu permutari nume (evita duplicate la swap firstname/lastname)
|
-- 02.04.2026 - fallback cautare PF cu permutari nume (evita duplicate la swap firstname/lastname)
|
||||||
|
-- 06.04.2026 - eliminat TIER 2 cautare adresa (judet+loc fara strada) — creeaza adresa noua cand strada difera
|
||||||
|
-- 06.04.2026 - fix strip_diacritics: UNISTR encoding-safe (TRANSLATE cu UTF-8 literal se corupea pe Windows)
|
||||||
|
-- 06.04.2026 - fix TIER 1: strip_diacritics si pe localitate (nu doar strada)
|
||||||
|
-- 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
|
-- CONSTANTS
|
||||||
@@ -159,15 +179,23 @@ END PACK_IMPORT_PARTENERI;
|
|||||||
CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
||||||
|
|
||||||
-- 01.04.2026 - strip_diacritics la stocare adrese si parteneri
|
-- 01.04.2026 - strip_diacritics la stocare adrese si parteneri
|
||||||
|
-- 06.04.2026 - fix: UNISTR encoding-safe (TRANSLATE cu UTF-8 literal se corupea pe Windows sqlplus)
|
||||||
|
-- Hybrid: REPLACE comma-below Ș/Ț → cedilla Ş/Ţ, apoi CONVERT US7ASCII (strips Ă/Â/Î/Ş/Ţ)
|
||||||
FUNCTION strip_diacritics(p_text IN VARCHAR2) RETURN VARCHAR2 IS
|
FUNCTION strip_diacritics(p_text IN VARCHAR2) RETURN VARCHAR2 IS
|
||||||
BEGIN
|
BEGIN
|
||||||
IF p_text IS NULL THEN
|
IF p_text IS NULL THEN
|
||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
END IF;
|
END IF;
|
||||||
RETURN TRANSLATE(
|
RETURN CONVERT(
|
||||||
UPPER(TRIM(p_text)),
|
UPPER(TRIM(
|
||||||
'ĂăÂâÎîȘșȚțŞşŢţ',
|
REPLACE(REPLACE(REPLACE(REPLACE(
|
||||||
'AAAAIISSTTSSTT'
|
p_text,
|
||||||
|
UNISTR('\0218'), UNISTR('\015E')), -- Ș → Ş (comma-below → cedilla)
|
||||||
|
UNISTR('\0219'), UNISTR('\015F')), -- ș → ş
|
||||||
|
UNISTR('\021A'), UNISTR('\0162')), -- Ț → Ţ
|
||||||
|
UNISTR('\021B'), UNISTR('\0163')) -- ț → ţ
|
||||||
|
)),
|
||||||
|
'US7ASCII', 'AL32UTF8'
|
||||||
);
|
);
|
||||||
END strip_diacritics;
|
END strip_diacritics;
|
||||||
|
|
||||||
@@ -259,13 +287,16 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
IF p_strict_search = 1 THEN
|
IF p_strict_search = 1 THEN
|
||||||
-- Cautare STRICT: doar forma primita + varianta cu spatiu
|
-- Cautare STRICT: regula business ANAF platitor/neplatitor TVA
|
||||||
IF REGEXP_LIKE(v_cod_fiscal_curat, '^RO\d') THEN
|
-- Platitor (prefix RO) → cauta doar RO<bare> si RO <bare> (cu spatiu)
|
||||||
-- Input "RO123" → cauta si "RO 123"
|
-- 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 INTO v_id_part FROM (
|
||||||
SELECT id_part
|
SELECT id_part
|
||||||
FROM nom_parteneri
|
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
|
AND NVL(sters, 0) = 0
|
||||||
ORDER BY NVL(inactiv, 0) ASC, id_part DESC
|
ORDER BY NVL(inactiv, 0) ASC, id_part DESC
|
||||||
) WHERE ROWNUM = 1;
|
) WHERE ROWNUM = 1;
|
||||||
@@ -318,38 +349,21 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
|
|
||||||
v_denumire_curata := curata_text_cautare(p_denumire);
|
v_denumire_curata := curata_text_cautare(p_denumire);
|
||||||
|
|
||||||
-- pINFO('Cautare partener dupa denumire: ' || v_denumire_curata, 'IMPORT_PARTENERI');
|
-- Cautare in NOM_PARTENERI - exclude sters=1, prioritizeaza active (inactiv=0)
|
||||||
|
|
||||||
-- Cautare in NOM_PARTENERI
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT id_part
|
SELECT id_part INTO v_id_part FROM (
|
||||||
INTO v_id_part
|
SELECT id_part
|
||||||
FROM nom_parteneri
|
FROM nom_parteneri
|
||||||
WHERE UPPER(TRIM(denumire)) = v_denumire_curata
|
WHERE UPPER(TRIM(denumire)) = v_denumire_curata
|
||||||
AND ROWNUM = 1; -- In caz de duplicate, luam primul
|
AND NVL(sters, 0) = 0
|
||||||
|
ORDER BY NVL(inactiv, 0) ASC, id_part DESC
|
||||||
|
) WHERE ROWNUM = 1;
|
||||||
|
|
||||||
-- pINFO('Gasit partener cu denumirea ' || v_denumire_curata || ': ID_PART=' || v_id_part, 'IMPORT_PARTENERI');
|
|
||||||
RETURN v_id_part;
|
RETURN v_id_part;
|
||||||
|
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
WHEN NO_DATA_FOUND THEN
|
WHEN NO_DATA_FOUND THEN
|
||||||
-- pINFO('Nu s-a gasit partener cu denumirea: ' || v_denumire_curata, 'IMPORT_PARTENERI');
|
|
||||||
RETURN NULL;
|
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;
|
END;
|
||||||
|
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
@@ -427,6 +441,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
END separa_nume_prenume;
|
END separa_nume_prenume;
|
||||||
|
|
||||||
-- 31.03.2026 - parser inteligent: split numar in bloc/scara/apart/etaj (fix ORA-12899 pe NUMAR max 10 chars)
|
-- 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,
|
PROCEDURE parseaza_adresa_semicolon(p_adresa_text IN VARCHAR2,
|
||||||
p_judet OUT VARCHAR2,
|
p_judet OUT VARCHAR2,
|
||||||
p_localitate OUT VARCHAR2,
|
p_localitate OUT VARCHAR2,
|
||||||
@@ -504,6 +520,13 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
p_strada := SUBSTR(v_componente(3), 1, 100);
|
p_strada := SUBSTR(v_componente(3), 1, 100);
|
||||||
v_strada := p_strada;
|
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
|
-- Separa strada de tot ce e dupa prima virgula
|
||||||
v_pozitie := INSTR(v_strada, ',');
|
v_pozitie := INSTR(v_strada, ',');
|
||||||
IF v_pozitie > 0 THEN
|
IF v_pozitie > 0 THEN
|
||||||
@@ -526,14 +549,14 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
IF p_strada IS NOT NULL THEN
|
IF p_strada IS NOT NULL THEN
|
||||||
v_token_upper := UPPER(p_strada);
|
v_token_upper := UPPER(p_strada);
|
||||||
-- Extrage NR din strada
|
-- Extrage NR din strada
|
||||||
IF REGEXP_LIKE(v_token_upper, '(\s)(NUMARUL|NUMAR|NR\.?)\s*(\S+)') THEN
|
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_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'));
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(NUMARUL|NUMAR|NR\.?)[\s.]+\S+', '', 1, 1, 'i'));
|
||||||
END IF;
|
END IF;
|
||||||
-- Extrage BLOC din strada
|
-- Extrage BLOC din strada
|
||||||
IF REGEXP_LIKE(v_token_upper, '(\s)(BLOC|BL\.?)\s*(\S+)') THEN
|
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_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'));
|
p_strada := TRIM(REGEXP_REPLACE(p_strada, '(\s)(BLOC|BL\.?)[\s.]+\S+', '', 1, 1, 'i'));
|
||||||
END IF;
|
END IF;
|
||||||
-- Re-read v_token_upper after BLOC removal may have changed p_strada
|
-- Re-read v_token_upper after BLOC removal may have changed p_strada
|
||||||
v_token_upper := UPPER(p_strada);
|
v_token_upper := UPPER(p_strada);
|
||||||
@@ -574,6 +597,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
-- Tokenii sunt separati prin virgula
|
-- Tokenii sunt separati prin virgula
|
||||||
-- Patterns: NR/NUMAR, BL/BLOC, SC/SCARA, AP/APART, ET/ETAJ
|
-- Patterns: NR/NUMAR, BL/BLOC, SC/SCARA, AP/APART, ET/ETAJ
|
||||||
-- ================================================================
|
-- ================================================================
|
||||||
|
-- Insert commas before address keywords to create proper tokens
|
||||||
|
-- No guard on existing commas — double commas produce empty tokens (harmless)
|
||||||
|
IF v_raw_numar IS NOT NULL THEN
|
||||||
|
v_raw_numar := REGEXP_REPLACE(v_raw_numar,
|
||||||
|
'(\s)(BLOC|BL|SCARA|SC|APARTAMENT|APART|AP|ETAJ|ET|NUMARUL|NUMAR|NR)(\s|\.|\d)',
|
||||||
|
',\2\3', 1, 0, 'i');
|
||||||
|
v_raw_numar := LTRIM(v_raw_numar, ', ');
|
||||||
|
END IF;
|
||||||
|
|
||||||
IF v_raw_numar IS NOT NULL THEN
|
IF v_raw_numar IS NOT NULL THEN
|
||||||
-- Loop prin tokeni separati de virgula (fara BULK COLLECT — compatibil Oracle 11)
|
-- Loop prin tokeni separati de virgula (fara BULK COLLECT — compatibil Oracle 11)
|
||||||
v_rest_parts := NULL;
|
v_rest_parts := NULL;
|
||||||
@@ -605,6 +637,17 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
p_etaj := TRIM(REGEXP_REPLACE(v_token, '^(ETAJ|ET\.?)(\s|\.)*', '', 1, 1, 'i'));
|
p_etaj := TRIM(REGEXP_REPLACE(v_token, '^(ETAJ|ET\.?)(\s|\.)*', '', 1, 1, 'i'));
|
||||||
ELSIF REGEXP_LIKE(v_token_upper, '^(NUMARUL|NUMAR|NR\.?)(\s|\.)') THEN
|
ELSIF REGEXP_LIKE(v_token_upper, '^(NUMARUL|NUMAR|NR\.?)(\s|\.)') THEN
|
||||||
p_numar := TRIM(REGEXP_REPLACE(v_token, '^(NUMARUL|NUMAR|NR\.?)(\s|\.)*', '', 1, 1, 'i'));
|
p_numar := TRIM(REGEXP_REPLACE(v_token, '^(NUMARUL|NUMAR|NR\.?)(\s|\.)*', '', 1, 1, 'i'));
|
||||||
|
-- Glued tokens: Ap78, BL30, SC2, ET3, NR15 (no separator between keyword and digit)
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '^(BLOC|BL)(\d)') THEN
|
||||||
|
p_bloc := TRIM(REGEXP_REPLACE(v_token, '^(BLOC|BL)', '', 1, 1, 'i'));
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '^(SCARA|SC)(\d)') THEN
|
||||||
|
p_scara := TRIM(REGEXP_REPLACE(v_token, '^(SCARA|SC)', '', 1, 1, 'i'));
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '^(APARTAMENT|APART|AP)(\d)') THEN
|
||||||
|
p_apart := TRIM(REGEXP_REPLACE(v_token, '^(APARTAMENT|APART|AP)', '', 1, 1, 'i'));
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '^(ETAJ|ET)(\d)') THEN
|
||||||
|
p_etaj := TRIM(REGEXP_REPLACE(v_token, '^(ETAJ|ET)', '', 1, 1, 'i'));
|
||||||
|
ELSIF REGEXP_LIKE(v_token_upper, '^(NUMARUL|NUMAR|NR)(\d)') THEN
|
||||||
|
p_numar := TRIM(REGEXP_REPLACE(v_token, '^(NUMARUL|NUMAR|NR)', '', 1, 1, 'i'));
|
||||||
ELSE
|
ELSE
|
||||||
-- Primul token necunoscut devine numar (daca numar e inca gol)
|
-- Primul token necunoscut devine numar (daca numar e inca gol)
|
||||||
IF p_numar IS NULL AND v_tok_idx = 1 THEN
|
IF p_numar IS NULL AND v_tok_idx = 1 THEN
|
||||||
@@ -637,7 +680,46 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
p_apart := UPPER(TRIM(p_apart));
|
p_apart := UPPER(TRIM(p_apart));
|
||||||
p_etaj := UPPER(TRIM(p_etaj));
|
p_etaj := UPPER(TRIM(p_etaj));
|
||||||
|
|
||||||
|
-- Strip localitate from end of strada (users type city into address)
|
||||||
|
IF p_strada IS NOT NULL AND p_localitate IS NOT NULL THEN
|
||||||
|
IF p_strada LIKE '%' || p_localitate THEN
|
||||||
|
v_token := RTRIM(SUBSTR(p_strada, 1, LENGTH(p_strada) - LENGTH(p_localitate)));
|
||||||
|
IF v_token IS NOT NULL THEN
|
||||||
|
p_strada := v_token;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- Truncare de siguranta (limita coloanelor Oracle)
|
-- 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_numar := SUBSTR(p_numar, 1, 10);
|
||||||
p_bloc := SUBSTR(p_bloc, 1, 30);
|
p_bloc := SUBSTR(p_bloc, 1, 30);
|
||||||
p_scara := SUBSTR(p_scara, 1, 10);
|
p_scara := SUBSTR(p_scara, 1, 10);
|
||||||
@@ -896,10 +978,10 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
v_strada VARCHAR2(1000);
|
v_strada VARCHAR2(1000);
|
||||||
v_numar VARCHAR2(1000);
|
v_numar VARCHAR2(1000);
|
||||||
v_sector VARCHAR2(100);
|
v_sector VARCHAR2(100);
|
||||||
v_bloc VARCHAR2(30);
|
v_bloc VARCHAR2(100);
|
||||||
v_scara VARCHAR2(10);
|
v_scara VARCHAR2(100);
|
||||||
v_apart VARCHAR2(10);
|
v_apart VARCHAR2(100);
|
||||||
v_etaj VARCHAR2(20);
|
v_etaj VARCHAR2(100);
|
||||||
v_id_tara NUMBER(10);
|
v_id_tara NUMBER(10);
|
||||||
v_principala NUMBER(1);
|
v_principala NUMBER(1);
|
||||||
begin
|
begin
|
||||||
@@ -931,15 +1013,81 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
v_apart,
|
v_apart,
|
||||||
v_etaj);
|
v_etaj);
|
||||||
|
|
||||||
-- 01.04.2026 - cautare adresa pe strada + diacritics + id_loc validation
|
-- 07.04.2026 - normalize MUNICIPIUL BUCURESTI → BUCURESTI SECTORUL X before TIER 1
|
||||||
-- TIER 1: county + city + street (diacritics normalized) + valid id_loc
|
IF UPPER(TRIM(v_localitate)) IN ('MUNICIPIUL BUCURESTI', 'MUN BUCURESTI', 'MUN. BUCURESTI', 'BUCURESTI') THEN
|
||||||
|
IF v_sector IS NOT NULL AND TRIM(v_sector) IS NOT NULL THEN
|
||||||
|
v_localitate := 'BUCURESTI SECTORUL ' || TRIM(v_sector);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Resolve id_judet inainte de TIER 1
|
||||||
|
BEGIN
|
||||||
|
SELECT id_judet INTO v_id_judet
|
||||||
|
FROM syn_nom_judete
|
||||||
|
WHERE judet = v_judet
|
||||||
|
AND sters = 0;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN v_id_judet := N_ID_JUD_DEFAULT;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Resolve id_localitate inainte de TIER 1
|
||||||
|
BEGIN
|
||||||
|
SELECT id_loc, id_judet, id_tara
|
||||||
|
INTO v_id_localitate, v_id_judet, v_id_tara
|
||||||
|
FROM (SELECT id_loc, id_judet, id_tara, rownum rn
|
||||||
|
FROM syn_nom_localitati l
|
||||||
|
WHERE id_judet = v_id_judet
|
||||||
|
AND strip_diacritics(localitate) = strip_diacritics(v_localitate)
|
||||||
|
AND inactiv = 0
|
||||||
|
AND sters = 0
|
||||||
|
ORDER BY localitate)
|
||||||
|
WHERE rn = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN
|
||||||
|
-- TIER L2: fuzzy match prin SOUNDEX (ex: CRAMPOIA → CRAMPOAIA, edit distance 1)
|
||||||
|
-- Aplica si pentru localitati scurte (< 5 chars) — SOUNDEX e suficient de specific pe judet
|
||||||
|
BEGIN
|
||||||
|
SELECT id_loc, id_judet, id_tara
|
||||||
|
INTO v_id_localitate, v_id_judet, v_id_tara
|
||||||
|
FROM (SELECT id_loc, id_judet, id_tara
|
||||||
|
FROM syn_nom_localitati
|
||||||
|
WHERE id_judet = v_id_judet
|
||||||
|
AND SOUNDEX(strip_diacritics(localitate)) = SOUNDEX(strip_diacritics(v_localitate))
|
||||||
|
AND inactiv = 0 AND sters = 0
|
||||||
|
ORDER BY LENGTH(localitate) ASC) -- cel mai scurt = cel mai apropiat
|
||||||
|
WHERE ROWNUM = 1;
|
||||||
|
pINFO('WARNING addr fuzzy match: ' || v_localitate || ' -> SOUNDEX in judet ' || v_judet,
|
||||||
|
'IMPORT_PARTENERI');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN
|
||||||
|
-- TIER L3: localitate cu totul necunoscuta — pastreaza judetul corect deja rezolvat
|
||||||
|
-- Prima localitate alfabetic din judet (v_id_judet ramas din lookup reusit)
|
||||||
|
BEGIN
|
||||||
|
SELECT id_loc, id_tara INTO v_id_localitate, v_id_tara
|
||||||
|
FROM (SELECT id_loc, id_tara FROM syn_nom_localitati
|
||||||
|
WHERE id_judet = v_id_judet AND inactiv = 0 AND sters = 0
|
||||||
|
ORDER BY localitate)
|
||||||
|
WHERE ROWNUM = 1;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN NO_DATA_FOUND THEN
|
||||||
|
-- Judet fara localitati (imposibil in practica) — fallback global
|
||||||
|
v_id_localitate := N_ID_LOCALITATE_DEFAULT;
|
||||||
|
v_id_judet := N_ID_JUD_DEFAULT;
|
||||||
|
v_id_tara := N_ID_TARA_DEFAULT;
|
||||||
|
END;
|
||||||
|
pINFO('WARNING addr localitate necunoscuta: ' || v_localitate || ', judet=' || v_judet ||
|
||||||
|
' -> prima din judet', 'IMPORT_PARTENERI');
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- 07.04.2026 - fix duplicate: normalize localitate + resolve id_localitate inainte de TIER 1 (match pe id_loc)
|
||||||
|
-- TIER 1: match pe id_loc + strada (evita duplicate MUNICIPIUL BUCURESTI vs BUCURESTI SECTORUL X)
|
||||||
begin
|
begin
|
||||||
select id_adresa into p_id_adresa from (
|
select id_adresa into p_id_adresa from (
|
||||||
select id_adresa
|
select id_adresa
|
||||||
from vadrese_parteneri
|
from vadrese_parteneri
|
||||||
where id_part = p_id_part
|
where id_part = p_id_part
|
||||||
and judet = v_judet
|
and id_loc = v_id_localitate
|
||||||
and localitate = v_localitate
|
|
||||||
and strip_diacritics(strada) = strip_diacritics(v_strada)
|
and strip_diacritics(strada) = strip_diacritics(v_strada)
|
||||||
and id_loc IS NOT NULL
|
and id_loc IS NOT NULL
|
||||||
order by principala desc, id_adresa desc
|
order by principala desc, id_adresa desc
|
||||||
@@ -948,69 +1096,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
|
|||||||
when NO_DATA_FOUND then p_id_adresa := null;
|
when NO_DATA_FOUND then p_id_adresa := null;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
-- TIER 2: county + city (no street) but ONLY with valid id_loc
|
|
||||||
if p_id_adresa is null then
|
|
||||||
begin
|
|
||||||
select id_adresa into p_id_adresa from (
|
|
||||||
select id_adresa
|
|
||||||
from vadrese_parteneri
|
|
||||||
where id_part = p_id_part
|
|
||||||
and judet = v_judet
|
|
||||||
and localitate = v_localitate
|
|
||||||
and id_loc IS NOT NULL
|
|
||||||
order by principala desc, id_adresa desc
|
|
||||||
) where rownum = 1;
|
|
||||||
exception
|
|
||||||
when NO_DATA_FOUND then p_id_adresa := null;
|
|
||||||
end;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Adaug o adresa
|
-- Adaug o adresa
|
||||||
if p_id_adresa is null then
|
if p_id_adresa is null then
|
||||||
-- caut judetul
|
|
||||||
begin
|
|
||||||
select id_judet
|
|
||||||
into v_id_judet
|
|
||||||
from syn_nom_judete
|
|
||||||
where judet = v_judet
|
|
||||||
and sters = 0;
|
|
||||||
exception
|
|
||||||
when NO_DATA_FOUND then
|
|
||||||
v_id_judet := N_ID_JUD_DEFAULT;
|
|
||||||
end;
|
|
||||||
|
|
||||||
-- caut localitatea
|
|
||||||
begin
|
|
||||||
select id_loc, id_judet, id_tara
|
|
||||||
into v_id_localitate, v_id_judet, v_id_tara
|
|
||||||
from (select id_loc, id_judet, id_tara, rownum rn
|
|
||||||
from syn_nom_localitati l
|
|
||||||
where id_judet = v_id_judet
|
|
||||||
and localitate = v_localitate
|
|
||||||
and inactiv = 0
|
|
||||||
and sters = 0
|
|
||||||
order by localitate)
|
|
||||||
where rn = 1;
|
|
||||||
exception
|
|
||||||
when NO_DATA_FOUND then
|
|
||||||
begin
|
|
||||||
select id_loc, id_judet, id_tara
|
|
||||||
into v_id_localitate, v_id_judet, v_id_tara
|
|
||||||
from (select id_loc, id_judet, id_tara, rownum rn
|
|
||||||
from syn_nom_localitati l
|
|
||||||
where id_judet = v_id_judet
|
|
||||||
and inactiv = 0
|
|
||||||
and sters = 0
|
|
||||||
order by localitate)
|
|
||||||
where rn = 1;
|
|
||||||
exception
|
|
||||||
when NO_DATA_FOUND then
|
|
||||||
v_id_localitate := N_ID_LOCALITATE_DEFAULT;
|
|
||||||
v_id_judet := N_ID_JUD_DEFAULT;
|
|
||||||
v_id_tara := N_ID_TARA_DEFAULT;
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
|
|
||||||
-- 01.04.2026 - strip_diacritics la stocare adrese
|
-- 01.04.2026 - strip_diacritics la stocare adrese
|
||||||
v_strada := strip_diacritics(v_strada);
|
v_strada := strip_diacritics(v_strada);
|
||||||
v_localitate := strip_diacritics(v_localitate);
|
v_localitate := strip_diacritics(v_localitate);
|
||||||
|
|||||||
@@ -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
|
-- 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 - skip negative kit discount (markup), ROUND prices to nzecimale_pretv
|
||||||
-- 25.03.2026 - kit discount inserat per-kit sub componente (nu deferred cross-kit)
|
-- 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
|
-- Constante pentru configurare
|
||||||
@@ -535,15 +536,15 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
|
|
||||||
IF v_disc_amt > 0 THEN
|
IF v_disc_amt > 0 THEN
|
||||||
BEGIN
|
BEGIN
|
||||||
PACK_COMENZI.adauga_articol_comanda(
|
merge_or_insert_articol(
|
||||||
V_ID_COMANDA => v_id_comanda,
|
p_id_comanda => v_id_comanda,
|
||||||
V_ID_ARTICOL => v_disc_artid,
|
p_id_articol => v_disc_artid,
|
||||||
V_ID_POL => NVL(p_kit_discount_id_pol, p_id_pol),
|
p_id_pol => NVL(p_kit_discount_id_pol, p_id_pol),
|
||||||
V_CANTITATE => -1 * v_cantitate_web,
|
p_cantitate => -1 * v_cantitate_web,
|
||||||
V_PRET => ROUND(v_disc_amt, v_nzec_pretv),
|
p_pret => ROUND(v_disc_amt, v_nzec_pretv),
|
||||||
V_ID_UTIL => c_id_util,
|
p_id_util => c_id_util,
|
||||||
V_ID_SECTIE => p_id_sectie,
|
p_id_sectie => p_id_sectie,
|
||||||
V_PTVA => v_vat_key);
|
p_ptva => v_vat_key);
|
||||||
v_articole_procesate := v_articole_procesate + 1;
|
v_articole_procesate := v_articole_procesate + 1;
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
WHEN OTHERS THEN
|
WHEN OTHERS THEN
|
||||||
@@ -619,14 +620,14 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
v_pret_unitar := NVL(v_pret_web, 0);
|
v_pret_unitar := NVL(v_pret_web, 0);
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
|
merge_or_insert_articol(p_id_comanda => v_id_comanda,
|
||||||
V_ID_ARTICOL => v_id_articol,
|
p_id_articol => v_id_articol,
|
||||||
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
|
p_id_pol => NVL(v_id_pol_articol, p_id_pol),
|
||||||
V_CANTITATE => v_cantitate_web,
|
p_cantitate => v_cantitate_web,
|
||||||
V_PRET => v_pret_unitar,
|
p_pret => v_pret_unitar,
|
||||||
V_ID_UTIL => c_id_util,
|
p_id_util => c_id_util,
|
||||||
V_ID_SECTIE => p_id_sectie,
|
p_id_sectie => p_id_sectie,
|
||||||
V_PTVA => v_vat);
|
p_ptva => v_vat);
|
||||||
v_articole_procesate := v_articole_procesate + 1;
|
v_articole_procesate := v_articole_procesate + 1;
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
WHEN OTHERS THEN
|
WHEN OTHERS THEN
|
||||||
|
|||||||
95
api/database-scripts/cleanup_comenzi_sterse_nefacturate.sql
Normal file
95
api/database-scripts/cleanup_comenzi_sterse_nefacturate.sql
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- cleanup_comenzi_sterse_nefacturate.sql
|
||||||
|
-- 2026-04-08
|
||||||
|
--
|
||||||
|
-- Soft-delete (sters=1) comenzile din ROA care sunt:
|
||||||
|
-- 1. Active (sters=0)
|
||||||
|
-- 2. Nu au factura activa in VANZARI
|
||||||
|
-- 3. Mai vechi de 3 zile (DATA_COMANDA < SYSDATE - 3)
|
||||||
|
--
|
||||||
|
-- Motivatie: comenzi de test importate din GoMag care au fost facturate manual
|
||||||
|
-- (direct, nu factura din comanda). Raman pe veci ca active nefacturate.
|
||||||
|
--
|
||||||
|
-- IMPORTANT: Ruleaza intai SELECT-ul de preview inainte de UPDATE!
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
SET SERVEROUTPUT ON;
|
||||||
|
SET LINESIZE 200;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 1: PREVIEW — vezi ce se va marca sters
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
PROMPT;
|
||||||
|
PROMPT === PREVIEW: Comenzi active, nefacturate, mai vechi de 3 zile ===;
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
SELECT c.id_comanda,
|
||||||
|
c.nr_comanda,
|
||||||
|
c.comanda_externa,
|
||||||
|
c.data_comanda,
|
||||||
|
c.id_part,
|
||||||
|
(SELECT COUNT(*) FROM comenzi_elemente e
|
||||||
|
WHERE e.id_comanda = c.id_comanda AND e.sters = 0) AS nr_elemente
|
||||||
|
FROM comenzi c
|
||||||
|
WHERE c.sters = 0
|
||||||
|
AND c.data_comanda < SYSDATE - 3
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM vanzari v
|
||||||
|
WHERE v.id_comanda = c.id_comanda
|
||||||
|
AND v.sters = 0
|
||||||
|
)
|
||||||
|
ORDER BY c.data_comanda;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STEP 2: SOFT-DELETE — decomentati blocul dupa verificarea preview-ului
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
DECLARE
|
||||||
|
v_elemente_count NUMBER := 0;
|
||||||
|
v_comenzi_count NUMBER := 0;
|
||||||
|
BEGIN
|
||||||
|
-- Mai intai soft-delete pe detalii (COMENZI_ELEMENTE)
|
||||||
|
UPDATE comenzi_elemente SET sters = 1
|
||||||
|
WHERE sters = 0
|
||||||
|
AND id_comanda IN (
|
||||||
|
SELECT c.id_comanda
|
||||||
|
FROM comenzi c
|
||||||
|
WHERE c.sters = 0
|
||||||
|
AND c.data_comanda < SYSDATE - 3
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM vanzari v
|
||||||
|
WHERE v.id_comanda = c.id_comanda
|
||||||
|
AND v.sters = 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
v_elemente_count := SQL%ROWCOUNT;
|
||||||
|
|
||||||
|
-- Apoi soft-delete pe header (COMENZI)
|
||||||
|
UPDATE comenzi SET sters = 1
|
||||||
|
WHERE sters = 0
|
||||||
|
AND data_comanda < SYSDATE - 3
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM vanzari v
|
||||||
|
WHERE v.id_comanda = comenzi.id_comanda
|
||||||
|
AND v.sters = 0
|
||||||
|
);
|
||||||
|
v_comenzi_count := SQL%ROWCOUNT;
|
||||||
|
|
||||||
|
DBMS_OUTPUT.PUT_LINE('=== REZULTAT CLEANUP ===');
|
||||||
|
DBMS_OUTPUT.PUT_LINE('Elemente marcate sters: ' || v_elemente_count);
|
||||||
|
DBMS_OUTPUT.PUT_LINE('Comenzi marcate sters: ' || v_comenzi_count);
|
||||||
|
|
||||||
|
-- COMMIT explicit — decomentati doar dupa ce sunteti siguri
|
||||||
|
-- COMMIT;
|
||||||
|
|
||||||
|
-- Sau ROLLBACK daca ceva nu arata bine:
|
||||||
|
-- ROLLBACK;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
*/
|
||||||
|
|
||||||
|
PROMPT;
|
||||||
|
PROMPT === Pentru a executa, decomentati blocul PL/SQL si COMMIT ===;
|
||||||
|
PROMPT;
|
||||||
13
api/database-scripts/pre_deploy_verify_soundex.sql
Normal file
13
api/database-scripts/pre_deploy_verify_soundex.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- pre_deploy_verify_soundex.sql
|
||||||
|
-- Rulat pe production Oracle INAINTE de deploy 05_pack_import_parteneri.pck (fix SOUNDEX L2)
|
||||||
|
-- Verifica ca premisa e adevarata: "Crampoaia/Crimpoia" exista in nomenclator pentru OLT
|
||||||
|
|
||||||
|
SELECT l.localitate,
|
||||||
|
SOUNDEX(CONVERT(UPPER(TRIM(l.localitate)), 'US7ASCII', 'AL32UTF8')) soundex_val
|
||||||
|
FROM syn_nom_localitati l
|
||||||
|
JOIN syn_nom_judete j ON l.id_judet = j.id_judet
|
||||||
|
WHERE j.judet = 'OLT' AND j.sters = 0
|
||||||
|
AND SOUNDEX(CONVERT(UPPER(TRIM(l.localitate)), 'US7ASCII', 'AL32UTF8')) = SOUNDEX('CRAMPOIA')
|
||||||
|
AND l.inactiv = 0 AND l.sters = 0;
|
||||||
|
-- Rezultat asteptat: >=1 row (ex: CRIMPOIA cu SOUNDEX C651)
|
||||||
|
-- Daca 0 rows: Crampoaia nu exista in nomenclator → SOUNDEX nu rezolva → alt plan necesar
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"""E2E: Mappings page with flat-row list, sorting, multi-CODMAT modal."""
|
"""E2E: Mappings page with flat-row list, sorting, multi-CODMAT modal."""
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from playwright.sync_api import Page, expect
|
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."""
|
"""Verify 'Adauga Mapare' button is present."""
|
||||||
btn = page.locator("button", has_text="Adauga Mapare")
|
btn = page.locator("button", has_text="Adauga Mapare")
|
||||||
expect(btn).to_be_visible()
|
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()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
|
|||||||
texts = headers.all_text_contents()
|
texts = headers.all_text_contents()
|
||||||
|
|
||||||
# Current columns (may evolve — check dashboard.html for source of truth)
|
# 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:
|
for col in required_columns:
|
||||||
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ _SESSION_WINDOW_HOURS = 1
|
|||||||
# These are real bugs that need fixing but should not block test runs.
|
# These are real bugs that need fixing but should not block test runs.
|
||||||
_KNOWN_ISSUES = [
|
_KNOWN_ISSUES = [
|
||||||
"soft-deleting order ID=533: ORA-00942", # Pre-existing: missing table/view
|
"soft-deleting order ID=533: ORA-00942", # Pre-existing: missing table/view
|
||||||
|
"Oracle init failed: DPY-4000", # Dev env: no Oracle tnsnames
|
||||||
|
"ANAF API client error 404", # Dev env: ANAF mock returns 404
|
||||||
|
"ANAF API server error after retry: 500", # Dev env: ANAF mock returns 500
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
469
api/tests/test_address_rules_oracle.py
Normal file
469
api/tests/test_address_rules_oracle.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
"""
|
||||||
|
Oracle Integration Tests — Regula adrese PJ/PF
|
||||||
|
===============================================
|
||||||
|
Verifică că comenzile importate respectă regula:
|
||||||
|
PF (fără CUI): id_adresa_facturare = id_adresa_livrare
|
||||||
|
PJ (cu CUI): adresa_facturare_roa se potrivește cu adresa billing GoMag
|
||||||
|
|
||||||
|
Testele principale sunt E2E (importă comenzi sintetice în Oracle și verifică).
|
||||||
|
Testele de regresie verifică comenzile existente din SQLite.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pytest api/tests/test_address_rules_oracle.py -v
|
||||||
|
./test.sh oracle
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.oracle
|
||||||
|
|
||||||
|
_script_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||||
|
_project_root = os.path.dirname(_script_dir)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def oracle_env():
|
||||||
|
"""Re-aplică .env și actualizează settings pentru Oracle."""
|
||||||
|
load_dotenv(_env_path, override=True)
|
||||||
|
_tns = os.environ.get("TNS_ADMIN", "")
|
||||||
|
if _tns and os.path.isfile(_tns):
|
||||||
|
os.environ["TNS_ADMIN"] = os.path.dirname(_tns)
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
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"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(oracle_env):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def oracle_pool(oracle_env):
|
||||||
|
"""Pool Oracle direct pentru verificări în DB."""
|
||||||
|
from app import database
|
||||||
|
database.init_oracle()
|
||||||
|
yield database.pool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def real_codmat(client):
|
||||||
|
"""CODMAT real din Oracle pentru liniile comenzii sintetice."""
|
||||||
|
for term in ["01", "PH", "CA", "A"]:
|
||||||
|
resp = client.get("/api/articles/search", params={"q": term})
|
||||||
|
if resp.status_code == 200:
|
||||||
|
results = resp.json().get("results", [])
|
||||||
|
if results:
|
||||||
|
return results[0]["codmat"]
|
||||||
|
pytest.skip("Nu s-a găsit niciun CODMAT în Oracle pentru test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_settings(client):
|
||||||
|
"""Setările aplicației (id_pol, id_sectie, etc.)."""
|
||||||
|
resp = client.get("/api/sync/schedule")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
import sqlite3
|
||||||
|
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()
|
||||||
|
conn.close()
|
||||||
|
return {r["key"]: r["value"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def run_id():
|
||||||
|
return f"pytest-addr-{int(time.time())}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pj_order(run_id, real_codmat):
|
||||||
|
"""Comandă sintetică PJ: companie cu billing ≠ shipping."""
|
||||||
|
from app.services.order_reader import OrderBilling, OrderShipping, OrderData, OrderItem
|
||||||
|
billing = OrderBilling(
|
||||||
|
firstname="Test", lastname="PJ", phone="0700000000", email="pj@pytest.local",
|
||||||
|
address="Bld Unirii 1", city="Bucuresti", region="Bucuresti", country="RO",
|
||||||
|
company_name="PYTEST COMPANY SRL", company_code="RO99000001", company_reg="J40/9999/2026",
|
||||||
|
is_company=True
|
||||||
|
)
|
||||||
|
shipping = OrderShipping(
|
||||||
|
firstname="Curier", lastname="Destinatar", phone="0799999999", email="ship@pytest.local",
|
||||||
|
address="Str Livrare 99", city="Cluj-Napoca", region="Cluj", country="RO"
|
||||||
|
)
|
||||||
|
return OrderData(
|
||||||
|
id=f"{run_id}-PJ",
|
||||||
|
number=f"{run_id}-PJ",
|
||||||
|
date="2026-01-15T10:00:00",
|
||||||
|
status="new", status_id="1",
|
||||||
|
billing=billing, shipping=shipping,
|
||||||
|
items=[OrderItem(sku="PYTEST-SKU-PJ", name="Test PJ Item",
|
||||||
|
price=10.0, quantity=1.0, vat=19.0)],
|
||||||
|
total=10.0, delivery_cost=0.0, discount_total=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pf_order(run_id, real_codmat):
|
||||||
|
"""Comandă sintetică PF: persoană fizică, billing ≠ shipping (dar billing ROA trebuie = shipping)."""
|
||||||
|
from app.services.order_reader import OrderBilling, OrderShipping, OrderData, OrderItem
|
||||||
|
billing = OrderBilling(
|
||||||
|
firstname="Ion", lastname="Popescu", phone="0700000001", email="pf@pytest.local",
|
||||||
|
address="Str Alta 5", city="Timisoara", region="Timis", country="RO",
|
||||||
|
company_name="", company_code="", company_reg="", is_company=False
|
||||||
|
)
|
||||||
|
shipping = OrderShipping(
|
||||||
|
firstname="Ion", lastname="Popescu", phone="0700000001", email="pf@pytest.local",
|
||||||
|
address="Str Livrare 10", city="Iasi", region="Iasi", country="RO"
|
||||||
|
)
|
||||||
|
return OrderData(
|
||||||
|
id=f"{run_id}-PF",
|
||||||
|
number=f"{run_id}-PF",
|
||||||
|
date="2026-01-15T10:00:00",
|
||||||
|
status="new", status_id="1",
|
||||||
|
billing=billing, shipping=shipping,
|
||||||
|
items=[OrderItem(sku="PYTEST-SKU-PF", name="Test PF Item",
|
||||||
|
price=10.0, quantity=1.0, vat=19.0)],
|
||||||
|
total=10.0, delivery_cost=0.0, discount_total=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_test_orders(oracle_pool, run_id):
|
||||||
|
"""Șterge comenzile de test din Oracle."""
|
||||||
|
try:
|
||||||
|
conn = oracle_pool.acquire()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM comenzi WHERE comanda_externa LIKE :1",
|
||||||
|
[f"{run_id}%"]
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
oracle_pool.release(conn)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Cleanup warning: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test E2E: import PJ + PF sintetice în Oracle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAddressRulesE2E:
|
||||||
|
"""Import comenzi sintetice și verifică adresele în Oracle."""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class", autouse=True)
|
||||||
|
def cleanup(self, oracle_pool, run_id):
|
||||||
|
yield
|
||||||
|
_cleanup_test_orders(oracle_pool, run_id)
|
||||||
|
|
||||||
|
def test_pj_billing_addr_is_gomag_billing(self, oracle_pool, real_codmat, app_settings, run_id):
|
||||||
|
"""PJ: adresa facturare în Oracle provine din GoMag billing (nu shipping)."""
|
||||||
|
from app.services.import_service import import_single_order
|
||||||
|
from app.services.order_reader import OrderItem
|
||||||
|
|
||||||
|
order = _build_pj_order(run_id, real_codmat)
|
||||||
|
# Replace test SKU with real codmat via mapping (or just use items with real SKU)
|
||||||
|
order.items = [OrderItem(sku=real_codmat, name="Test PJ",
|
||||||
|
price=10.0, quantity=1.0, vat=19.0)]
|
||||||
|
|
||||||
|
id_pol = int(app_settings.get("id_pol") or 0) or None
|
||||||
|
id_sectie = int(app_settings.get("id_sectie") or 0) or None
|
||||||
|
|
||||||
|
result = import_single_order(order, id_pol=id_pol, id_sectie=id_sectie,
|
||||||
|
app_settings=app_settings)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
pytest.skip(f"Import PJ eșuat (SKU probabil nemapat): {result.get('error')}")
|
||||||
|
|
||||||
|
id_fact = result["id_adresa_facturare"]
|
||||||
|
id_livr = result["id_adresa_livrare"]
|
||||||
|
|
||||||
|
assert id_fact is not None, "PJ: id_adresa_facturare lipsește din result"
|
||||||
|
assert id_livr is not None, "PJ: id_adresa_livrare lipsește din result"
|
||||||
|
|
||||||
|
# PJ cu billing ≠ shipping: adresele trebuie să fie DIFERITE
|
||||||
|
assert id_fact != id_livr, (
|
||||||
|
f"PJ cu billing≠shipping trebuie să aibă id_fact({id_fact}) ≠ id_livr({id_livr}). "
|
||||||
|
f"Regula veche (different_person) s-ar comporta la fel, dar acum PJ folosește billing GoMag."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verifică în Oracle că adresele există
|
||||||
|
conn = oracle_pool.acquire()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id_livrare, id_facturare FROM comenzi WHERE comanda_externa = :1",
|
||||||
|
[order.number]
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
oracle_pool.release(conn)
|
||||||
|
|
||||||
|
assert row is not None, f"Comanda {order.number} nu s-a găsit în Oracle comenzi"
|
||||||
|
assert row[0] == id_livr, f"id_livrare Oracle ({row[0]}) ≠ result ({id_livr})"
|
||||||
|
assert row[1] == id_fact, f"id_facturare Oracle ({row[1]}) ≠ result ({id_fact})"
|
||||||
|
|
||||||
|
def test_pf_billing_addr_equals_shipping(self, oracle_pool, real_codmat, app_settings, run_id):
|
||||||
|
"""PF: adresa facturare în Oracle = adresa livrare (ramburs curier)."""
|
||||||
|
from app.services.import_service import import_single_order
|
||||||
|
from app.services.order_reader import OrderItem
|
||||||
|
|
||||||
|
order = _build_pf_order(run_id, real_codmat)
|
||||||
|
order.items = [OrderItem(sku=real_codmat, name="Test PF",
|
||||||
|
price=10.0, quantity=1.0, vat=19.0)]
|
||||||
|
|
||||||
|
id_pol = int(app_settings.get("id_pol") or 0) or None
|
||||||
|
id_sectie = int(app_settings.get("id_sectie") or 0) or None
|
||||||
|
|
||||||
|
result = import_single_order(order, id_pol=id_pol, id_sectie=id_sectie,
|
||||||
|
app_settings=app_settings)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
pytest.skip(f"Import PF eșuat: {result.get('error')}")
|
||||||
|
|
||||||
|
id_fact = result["id_adresa_facturare"]
|
||||||
|
id_livr = result["id_adresa_livrare"]
|
||||||
|
|
||||||
|
assert id_fact is not None, "PF: id_adresa_facturare lipsește din result"
|
||||||
|
assert id_livr is not None, "PF: id_adresa_livrare lipsește din result"
|
||||||
|
|
||||||
|
# PF: id_facturare TREBUIE să fie = id_livrare
|
||||||
|
assert id_fact == id_livr, (
|
||||||
|
f"PF trebuie să aibă id_fact({id_fact}) = id_livr({id_livr}) — "
|
||||||
|
f"ramburs curier pe adresa de livrare"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verifică în Oracle
|
||||||
|
conn = oracle_pool.acquire()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id_livrare, id_facturare FROM comenzi WHERE comanda_externa = :1",
|
||||||
|
[order.number]
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
oracle_pool.release(conn)
|
||||||
|
|
||||||
|
assert row is not None, f"Comanda {order.number} nu s-a găsit în Oracle comenzi"
|
||||||
|
assert row[1] == row[0], (
|
||||||
|
f"Oracle: id_facturare({row[1]}) ≠ id_livrare({row[0]}) pentru PF"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAddressRulesRegression:
|
||||||
|
"""Verifică că comenzile existente importate după fix respectă regula PJ/PF."""
|
||||||
|
|
||||||
|
FIX_DATE = "2026-04-08" # data când a fost aplicat fix-ul
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def sqlite_rows(self):
|
||||||
|
"""Comenzi cu adrese populate importate după data fix-ului."""
|
||||||
|
import sqlite3
|
||||||
|
from app.config import settings
|
||||||
|
db_path = os.environ.get("SQLITE_DB_PATH", os.path.join(_script_dir, "orders.db"))
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
pytest.skip(f"SQLite DB lipsă: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT order_number, cod_fiscal_gomag,
|
||||||
|
id_adresa_facturare, id_adresa_livrare,
|
||||||
|
adresa_facturare_gomag, adresa_livrare_gomag,
|
||||||
|
adresa_facturare_roa, adresa_livrare_roa,
|
||||||
|
first_seen_at
|
||||||
|
FROM orders
|
||||||
|
WHERE id_adresa_facturare IS NOT NULL
|
||||||
|
AND id_adresa_livrare IS NOT NULL
|
||||||
|
AND first_seen_at >= ?
|
||||||
|
""", (self.FIX_DATE,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def test_pf_id_facturare_equals_id_livrare(self, sqlite_rows):
|
||||||
|
"""PF noi: id_adresa_facturare = id_adresa_livrare."""
|
||||||
|
pf_rows = [r for r in sqlite_rows if not r["cod_fiscal_gomag"]]
|
||||||
|
if not pf_rows:
|
||||||
|
pytest.skip(f"Nicio comandă PF importată după {self.FIX_DATE}")
|
||||||
|
|
||||||
|
violations = [
|
||||||
|
f"{r['order_number']}: id_fact={r['id_adresa_facturare']} id_livr={r['id_adresa_livrare']}"
|
||||||
|
for r in pf_rows
|
||||||
|
if r["id_adresa_facturare"] != r["id_adresa_livrare"]
|
||||||
|
]
|
||||||
|
assert not violations, (
|
||||||
|
f"PF comenzi cu id_fact ≠ id_livr ({len(violations)}):\n" + "\n".join(violations[:10])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pj_billing_roa_matches_gomag_billing(self, sqlite_rows):
|
||||||
|
"""PJ noi: adresa_facturare_roa se potrivește cu GoMag billing address."""
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
|
||||||
|
pj_rows = [
|
||||||
|
r for r in sqlite_rows
|
||||||
|
if r["cod_fiscal_gomag"] and r["adresa_facturare_gomag"] and r["adresa_facturare_roa"]
|
||||||
|
]
|
||||||
|
if not pj_rows:
|
||||||
|
pytest.skip(f"Nicio comandă PJ cu adrese populate importată după {self.FIX_DATE}")
|
||||||
|
|
||||||
|
violations = []
|
||||||
|
for r in pj_rows:
|
||||||
|
if not _addr_match(r["adresa_facturare_gomag"], r["adresa_facturare_roa"]):
|
||||||
|
violations.append(
|
||||||
|
f"{r['order_number']}: billing_gomag={r['adresa_facturare_gomag']!r} "
|
||||||
|
f"fact_roa={r['adresa_facturare_roa']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not violations, (
|
||||||
|
f"PJ comenzi cu adresa_facturare_roa care nu corespunde GoMag billing ({len(violations)}):\n"
|
||||||
|
+ "\n".join(violations[:10])
|
||||||
|
)
|
||||||
@@ -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.import_service import build_articles_json, compute_discount_split
|
||||||
from app.services.order_reader import OrderData, OrderItem
|
from app.services.order_reader import OrderData, OrderItem
|
||||||
|
from app.constants import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -280,7 +281,7 @@ class TestSyncPricesKitSkip:
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
class TestKitComponentOwnMapping:
|
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):
|
def test_component_with_own_mapping_skipped(self):
|
||||||
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
|
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
|
||||||
@@ -306,7 +307,7 @@ class TestKitComponentOwnMapping:
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
class TestVatIncludedNormalization:
|
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):
|
def _compute_price_cu_tva(self, product):
|
||||||
price = float(product.get("price", "0"))
|
price = float(product.get("price", "0"))
|
||||||
@@ -494,143 +495,638 @@ class TestResolveCodmatIds:
|
|||||||
assert "COD2" in codmats
|
assert "COD2" in codmats
|
||||||
|
|
||||||
|
|
||||||
|
# ── normalize_company_name (II, PFA, INTREPRINDERE INDIVIDUALA) ──
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeCompanyNameExtended:
|
||||||
|
"""Tests for extended legal form stripping in normalize_company_name."""
|
||||||
|
|
||||||
|
def test_strip_ii_at_start(self):
|
||||||
|
from app.services.anaf_service import normalize_company_name
|
||||||
|
assert normalize_company_name("II CHIRITA N ION") == "CHIRITA N ION"
|
||||||
|
|
||||||
|
def test_no_strip_ii_mid_name(self):
|
||||||
|
from app.services.anaf_service import normalize_company_name
|
||||||
|
result = normalize_company_name("TEHNICA II SRL")
|
||||||
|
assert "II" in result # II should remain (Roman numeral)
|
||||||
|
|
||||||
|
def test_strip_pfa(self):
|
||||||
|
from app.services.anaf_service import normalize_company_name
|
||||||
|
assert normalize_company_name("PFA POPESCU ION") == "POPESCU ION"
|
||||||
|
|
||||||
|
def test_strip_intreprindere_individuala(self):
|
||||||
|
from app.services.anaf_service import normalize_company_name
|
||||||
|
result = normalize_company_name("CHIRIȚĂ N. ION ÎNTREPRINDERE INDIVIDUALĂ")
|
||||||
|
assert "CHIRITA N ION" == result
|
||||||
|
|
||||||
|
def test_strip_ii_with_dots(self):
|
||||||
|
from app.services.anaf_service import normalize_company_name
|
||||||
|
assert normalize_company_name("I.I. CHIRITA N ION") == "CHIRITA N ION"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _addr_match (server-side address comparison) ─<><E29480><EFBFBD>
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddrMatch:
|
||||||
|
"""Tests for _addr_match server-side address comparison."""
|
||||||
|
|
||||||
|
def test_matching_addresses(self):
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"})
|
||||||
|
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"})
|
||||||
|
assert _addr_match(g, r) is True
|
||||||
|
|
||||||
|
def test_street_mismatch(self):
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
g = json.dumps({"address": "Str. Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"})
|
||||||
|
r = json.dumps({"strada": "Victoriei", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"})
|
||||||
|
assert _addr_match(g, r) is False
|
||||||
|
|
||||||
|
def test_city_mismatch(self):
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"})
|
||||||
|
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Cluj", "judet": "Brasov"})
|
||||||
|
assert _addr_match(g, r) is False
|
||||||
|
|
||||||
|
def test_county_mismatch(self):
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
g = json.dumps({"address": "Elisabeta 10", "city": "Brasov", "region": "Brasov"})
|
||||||
|
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Brasov", "judet": "Cluj"})
|
||||||
|
assert _addr_match(g, r) is False
|
||||||
|
|
||||||
|
def test_none_input_returns_true(self):
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
assert _addr_match(None, None) is True
|
||||||
|
assert _addr_match(None, '{"strada":"x"}') is True
|
||||||
|
assert _addr_match('{"address":"x"}', None) is True
|
||||||
|
|
||||||
|
def test_strada_prefix_stripping(self):
|
||||||
|
"""'Strada Elisabeta' should match 'ELISABETA' after normalization."""
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
g = json.dumps({"address": "Strada Elisabeta 10", "city": "Bucuresti", "region": "Bucuresti"})
|
||||||
|
r = json.dumps({"strada": "Elisabeta", "numar": "10", "localitate": "Bucuresti", "judet": "Bucuresti"})
|
||||||
|
assert _addr_match(g, r) is True
|
||||||
|
|
||||||
|
def test_malformed_json_returns_true(self):
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
assert _addr_match("{bad json", '{"strada":"x"}') is True
|
||||||
|
|
||||||
|
def test_addr_match_structured_fields(self):
|
||||||
|
"""addrMatch compares GoMag free-text vs ROA structured fields."""
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
gomag = json.dumps({"address": "Str Vasile Goldis Nr 19 Bl 30 Sc D Ap 78", "city": "Alba Iulia", "region": "Alba"})
|
||||||
|
roa = json.dumps({"strada": "STRADA VASILE GOLDIS", "numar": "19", "bloc": "30", "scara": "D", "apart": "78", "localitate": "ALBA IULIA", "judet": "ALBA"})
|
||||||
|
assert _addr_match(gomag, roa) is True
|
||||||
|
|
||||||
|
def test_addr_match_mismatch_structured(self):
|
||||||
|
"""addrMatch detects real mismatches with structured ROA fields."""
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
gomag = json.dumps({"address": "Str Dacia 10", "city": "Bucuresti", "region": "Bucuresti"})
|
||||||
|
roa = json.dumps({"strada": "STRADA VASILE GOLDIS", "numar": "19", "bloc": "", "scara": "", "apart": "", "localitate": "ALBA IULIA", "judet": "ALBA"})
|
||||||
|
assert _addr_match(gomag, roa) is False
|
||||||
|
|
||||||
|
def test_sectorul_in_city(self):
|
||||||
|
"""GoMag 'Municipiul București' matches ROA 'BUCURESTI SECTORUL 1'."""
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
g = json.dumps({"address": "Bld Decebal 24", "city": "Municipiul București", "region": "Bucuresti"})
|
||||||
|
r = json.dumps({"strada": "BLD DECEBAL", "numar": "24", "localitate": "BUCURESTI SECTORUL 1", "judet": "BUCURESTI"})
|
||||||
|
assert _addr_match(g, r) is True
|
||||||
|
|
||||||
|
def test_keyword_digit_gluing(self):
|
||||||
|
"""Keywords glued to digits like 'sc1', 'ap94' are stripped correctly."""
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
import json
|
||||||
|
g = json.dumps({"address": "Bld Decebal nr24 bl S2B sc1 ap94", "city": "Bucuresti", "region": "Bucuresti"})
|
||||||
|
r = json.dumps({"strada": "BLD DECEBAL", "numar": "24", "bloc": "S2B", "scara": "1", "apart": "94", "localitate": "BUCURESTI", "judet": "BUCURESTI"})
|
||||||
|
assert _addr_match(g, r) is True
|
||||||
|
|
||||||
|
def test_etaj_in_street(self):
|
||||||
|
"""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 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
|
||||||
|
shipping_addr = format_address_for_oracle("Bld Decebal 24", "Bucuresti", "Bucuresti")
|
||||||
|
billing_addr = format_address_for_oracle("Bld Decebal 24", "Bucuresti", "Bucuresti")
|
||||||
|
addr_livr_id = 123
|
||||||
|
# Simulate the short-circuit condition
|
||||||
|
assert addr_livr_id and billing_addr == shipping_addr
|
||||||
|
|
||||||
|
def test_billing_differs_shipping_no_short_circuit(self):
|
||||||
|
"""When billing != shipping, short-circuit does NOT apply."""
|
||||||
|
from app.services.import_service import format_address_for_oracle
|
||||||
|
shipping_addr = format_address_for_oracle("Str. Victoriei 10", "Cluj", "Cluj")
|
||||||
|
billing_addr = format_address_for_oracle("Bld Decebal 24", "Bucuresti", "Bucuresti")
|
||||||
|
addr_livr_id = 123
|
||||||
|
assert not (addr_livr_id and billing_addr == shipping_addr)
|
||||||
|
|
||||||
|
def test_pf_billing_address_equals_shipping(self):
|
||||||
|
"""PF (individual): is_pj=0 → billing address = shipping (ramburs curier)."""
|
||||||
|
from app.services.import_service import determine_partner_data
|
||||||
|
from app.services.order_reader import OrderBilling, OrderShipping, OrderData
|
||||||
|
billing = OrderBilling(
|
||||||
|
firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com",
|
||||||
|
address="Str Victoriei 10", city="Cluj", region="Cluj", country="RO",
|
||||||
|
company_name="", company_code="", company_reg="", is_company=False
|
||||||
|
)
|
||||||
|
shipping = OrderShipping(
|
||||||
|
firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com",
|
||||||
|
address="Str Victoriei 10", city="Cluj", region="Cluj", country="RO"
|
||||||
|
)
|
||||||
|
order = OrderData(id="PF001", number="PF001", date="2024-01-01T10:00:00",
|
||||||
|
billing=billing, shipping=shipping)
|
||||||
|
pdata = determine_partner_data(order)
|
||||||
|
assert pdata["is_pj"] == 0, "PF order must have is_pj=0"
|
||||||
|
|
||||||
|
def test_pj_uses_billing_from_gomag(self):
|
||||||
|
"""PJ (company): is_pj=1 → billing address from GoMag billing."""
|
||||||
|
from app.services.import_service import determine_partner_data
|
||||||
|
from app.services.order_reader import OrderBilling, OrderShipping, OrderData
|
||||||
|
billing = OrderBilling(
|
||||||
|
firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com",
|
||||||
|
address="Bld Unirii 5", city="Bucuresti", region="Bucuresti", country="RO",
|
||||||
|
company_name="FIRMA SRL", company_code="RO12345678", company_reg="J40/1234/2020",
|
||||||
|
is_company=True
|
||||||
|
)
|
||||||
|
shipping = OrderShipping(
|
||||||
|
firstname="Mihai", lastname="Ionescu", phone="0711111111", email="mihai@test.com",
|
||||||
|
address="Str Libertatii 20", city="Ploiesti", region="Prahova", country="RO"
|
||||||
|
)
|
||||||
|
order = OrderData(id="PJ001", number="PJ001", date="2024-01-01T10:00:00",
|
||||||
|
billing=billing, shipping=shipping)
|
||||||
|
pdata = determine_partner_data(order)
|
||||||
|
assert pdata["is_pj"] == 1, "PJ order must have is_pj=1"
|
||||||
|
assert pdata["denumire"] == "FIRMA SRL"
|
||||||
|
assert pdata["cod_fiscal"] == "RO12345678"
|
||||||
|
|
||||||
|
def test_pj_different_person_still_uses_billing(self):
|
||||||
|
"""Regression: PJ with different billing/shipping persons → still is_pj=1 (billing addr used)."""
|
||||||
|
from app.services.import_service import determine_partner_data
|
||||||
|
from app.services.order_reader import OrderBilling, OrderShipping, OrderData
|
||||||
|
billing = OrderBilling(
|
||||||
|
firstname="Secretara", lastname="Firma", phone="0700000000", email="office@firma.ro",
|
||||||
|
address="Calea Victoriei 1", city="Bucuresti", region="Bucuresti", country="RO",
|
||||||
|
company_name="FIRMA SA", company_code="RO99999999", company_reg="J40/9999/2019",
|
||||||
|
is_company=True
|
||||||
|
)
|
||||||
|
shipping = OrderShipping(
|
||||||
|
firstname="Curier", lastname="Destinatar", phone="0799999999", email="d@test.com",
|
||||||
|
address="Str Livrare 5", city="Iasi", region="Iasi", country="RO"
|
||||||
|
)
|
||||||
|
order = OrderData(id="PJ002", number="PJ002", date="2024-01-01T10:00:00",
|
||||||
|
billing=billing, shipping=shipping)
|
||||||
|
pdata = determine_partner_data(order)
|
||||||
|
assert pdata["is_pj"] == 1, "PJ with different persons must still be is_pj=1"
|
||||||
|
|
||||||
|
def test_pf_different_billing_still_uses_shipping(self):
|
||||||
|
"""Regression: PF with different billing address → still is_pj=0 (shipping addr used for billing)."""
|
||||||
|
from app.services.import_service import determine_partner_data
|
||||||
|
from app.services.order_reader import OrderBilling, OrderShipping, OrderData
|
||||||
|
billing = OrderBilling(
|
||||||
|
firstname="Ana", lastname="Gheorghe", phone="0700000000", email="ana@test.com",
|
||||||
|
address="Str Alta 99", city="Timisoara", region="Timis", country="RO",
|
||||||
|
company_name="", company_code="", company_reg="", is_company=False
|
||||||
|
)
|
||||||
|
shipping = OrderShipping(
|
||||||
|
firstname="Ana", lastname="Gheorghe", phone="0700000000", email="ana@test.com",
|
||||||
|
address="Str Livrare 7", city="Cluj", region="Cluj", country="RO"
|
||||||
|
)
|
||||||
|
order = OrderData(id="PF002", number="PF002", date="2024-01-01T10:00:00",
|
||||||
|
billing=billing, shipping=shipping)
|
||||||
|
pdata = determine_partner_data(order)
|
||||||
|
assert pdata["is_pj"] == 0, "PF must remain is_pj=0 regardless of billing address"
|
||||||
|
|
||||||
|
def test_is_company_cui_fallback(self):
|
||||||
|
"""Company with no name but CUI populated → is_company=True (order_reader parsing)."""
|
||||||
|
from app.services.order_reader import _parse_order
|
||||||
|
order_data = {
|
||||||
|
"number": "CUI001",
|
||||||
|
"date": "2024-01-01T10:00:00",
|
||||||
|
"statusId": 1,
|
||||||
|
"status": "new",
|
||||||
|
"billing": {
|
||||||
|
"firstname": "Ion",
|
||||||
|
"lastname": "Popescu",
|
||||||
|
"phone": "0700000000",
|
||||||
|
"email": "ion@test.com",
|
||||||
|
"address": "Str Test 1",
|
||||||
|
"city": "Bucuresti",
|
||||||
|
"region": "Bucuresti",
|
||||||
|
"country": "RO",
|
||||||
|
"company": {"name": "", "code": "RO12345678", "registrationNo": ""}
|
||||||
|
},
|
||||||
|
"items": [],
|
||||||
|
"total": 100.0,
|
||||||
|
"discountTotal": 0.0,
|
||||||
|
"shippingTotal": 0.0
|
||||||
|
}
|
||||||
|
order = _parse_order("CUI001", order_data, "test.json")
|
||||||
|
assert order.billing.is_company is True, "CUI-only company must be detected as is_company"
|
||||||
|
assert order.billing.company_code == "RO12345678"
|
||||||
|
|
||||||
|
def test_pj_denomination_fallback_empty_company_name(self):
|
||||||
|
"""PJ with CUI but no company_name → denumire falls back to billing person name."""
|
||||||
|
from app.services.import_service import determine_partner_data
|
||||||
|
from app.services.order_reader import OrderBilling, OrderData
|
||||||
|
billing = OrderBilling(
|
||||||
|
firstname="Ion", lastname="Popescu", phone="0700000000", email="ion@test.com",
|
||||||
|
address="Str Test 1", city="Bucuresti", region="Bucuresti", country="RO",
|
||||||
|
company_name="", company_code="RO12345678", company_reg="", is_company=True
|
||||||
|
)
|
||||||
|
order = OrderData(id="CUI002", number="CUI002", date="2024-01-01T10:00:00",
|
||||||
|
billing=billing, shipping=None)
|
||||||
|
pdata = determine_partner_data(order)
|
||||||
|
assert pdata["is_pj"] == 1
|
||||||
|
assert pdata["denumire"] == "POPESCU ION", "Fallback denumire must use billing person name"
|
||||||
|
assert pdata["cod_fiscal"] == "RO12345678"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatAddressForOracle:
|
||||||
|
"""Tests for format_address_for_oracle city stripping."""
|
||||||
|
|
||||||
|
def test_city_strip_from_address_end(self):
|
||||||
|
"""City name at end of address is stripped."""
|
||||||
|
from app.services.import_service import format_address_for_oracle
|
||||||
|
result = format_address_for_oracle("Strada Vasile Goldis nr 19 Alba Iulia", "Alba Iulia", "Alba")
|
||||||
|
assert result == "JUD:Alba;Alba Iulia;Strada Vasile Goldis nr 19"
|
||||||
|
|
||||||
|
def test_city_strip_case_insensitive(self):
|
||||||
|
"""City strip works regardless of case."""
|
||||||
|
from app.services.import_service import format_address_for_oracle
|
||||||
|
result = format_address_for_oracle("Str Dacia alba iulia", "Alba Iulia", "Alba")
|
||||||
|
assert result == "JUD:Alba;Alba Iulia;Str Dacia"
|
||||||
|
|
||||||
|
def test_city_no_strip_when_not_at_end(self):
|
||||||
|
"""Don't strip city if it's in the middle of the address."""
|
||||||
|
from app.services.import_service import format_address_for_oracle
|
||||||
|
result = format_address_for_oracle("Alba Iulia Str Dacia 5", "Alba Iulia", "Alba")
|
||||||
|
assert "Alba Iulia Str Dacia 5" in result
|
||||||
|
|
||||||
|
def test_city_no_strip_when_empty_remains(self):
|
||||||
|
"""Don't strip if it would leave empty address."""
|
||||||
|
from app.services.import_service import format_address_for_oracle
|
||||||
|
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é") == "Cafe"
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
from app.services.import_service import clean_web_text
|
||||||
|
assert clean_web_text("") == ""
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Group 6: get_prices_for_order() — cantitate_roa price normalization
|
# Group 11: TestRefreshOrderAddress
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
from app.services.validation_service import get_prices_for_order
|
import sqlite3
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
"""TestClient for refresh-address endpoint tests (Oracle mocked via FORCE_THIN_MODE)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
with TestClient(app, raise_server_exceptions=False) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
def _mock_oracle_conn(pol_cu_tva=False, price_map=None):
|
@pytest.fixture
|
||||||
"""Build a mock Oracle connection for get_prices_for_order.
|
def db():
|
||||||
|
"""Synchronous SQLite connection to the test DB."""
|
||||||
price_map: {id_articol: (pret, proc_tvav)}
|
conn = sqlite3.connect(_sqlite_path)
|
||||||
"""
|
conn.row_factory = sqlite3.Row
|
||||||
if price_map is None:
|
yield conn
|
||||||
price_map = {}
|
# Clean up test rows inserted during the test
|
||||||
conn = MagicMock()
|
conn.execute("DELETE FROM orders WHERE order_number LIKE 'test-%'")
|
||||||
|
conn.commit()
|
||||||
def cursor_ctx():
|
conn.close()
|
||||||
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:
|
class TestRefreshOrderAddress:
|
||||||
"""Regression: cantitate_roa < 1 must be treated as kit for price normalization.
|
"""Tests for POST /api/orders/{order_number}/refresh-address endpoint."""
|
||||||
|
|
||||||
Bug: SKU with cantitate_roa=0.5 (GoMag 50buc=7lei, ROA 100buc=14lei)
|
def test_order_not_found_returns_404(self, client):
|
||||||
was reported as price mismatch because is_kit only checked > 1.
|
"""Non-existent order_number returns 404."""
|
||||||
"""
|
res = client.post("/api/orders/nonexistent-99999/refresh-address")
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
def test_cantitate_roa_half_matches(self):
|
def test_null_address_ids_returns_422(self, client, db):
|
||||||
"""cantitate_roa=0.5: kit item — price check skipped entirely."""
|
"""Orders without Oracle address IDs return 422."""
|
||||||
items = [{
|
db.execute(f"INSERT OR IGNORE INTO orders (order_number, status) VALUES ('test-no-addr', '{OrderStatus.IMPORTED.value}')")
|
||||||
"sku": "1057308134545",
|
db.commit()
|
||||||
"price": 7.00,
|
res = client.post("/api/orders/test-no-addr/refresh-address")
|
||||||
"quantity": 60,
|
assert res.status_code == 422
|
||||||
"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
|
def test_oracle_unavailable_returns_503(self, client, db, monkeypatch):
|
||||||
assert result["items"][0]["kit"] is True
|
"""Oracle connection failure returns 503."""
|
||||||
assert result["summary"]["mismatches"] == 0
|
db.execute(f"INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-oracle-fail', '{OrderStatus.IMPORTED.value}', 4116)")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
def test_cantitate_roa_half_mismatch(self):
|
import asyncio as _asyncio
|
||||||
"""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
|
async def _mock_to_thread(fn, *args, **kwargs):
|
||||||
assert result["items"][0]["kit"] is True
|
raise Exception("Oracle down")
|
||||||
assert result["summary"]["mismatches"] == 0
|
|
||||||
|
|
||||||
def test_cantitate_roa_one_simple_item(self):
|
monkeypatch.setattr(_asyncio, "to_thread", _mock_to_thread)
|
||||||
"""cantitate_roa=1 (default): simple item, direct price comparison."""
|
res = client.post("/api/orders/test-oracle-fail/refresh-address")
|
||||||
items = [{
|
assert res.status_code == 503
|
||||||
"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
|
def test_refresh_returns_8_fields(self, client, db, monkeypatch):
|
||||||
assert result["summary"]["mismatches"] == 0
|
"""Successful refresh returns 8-field address dict."""
|
||||||
|
db.execute(f"INSERT OR IGNORE INTO orders (order_number, status, id_adresa_livrare) VALUES ('test-refresh-ok', '{OrderStatus.IMPORTED.value}', 4116)")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
def test_cantitate_roa_gt1_kit(self):
|
mock_result = (
|
||||||
"""cantitate_roa=2: kit item — price check skipped."""
|
{"strada": "VASILE GOLDIS", "numar": "19", "bloc": "30",
|
||||||
items = [{
|
"scara": "D", "apart": "78", "etaj": None,
|
||||||
"sku": "SKU-KIT2",
|
"localitate": "ALBA IULIA", "judet": "ALBA"},
|
||||||
"price": 20.00,
|
None,
|
||||||
"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
|
import asyncio as _asyncio
|
||||||
assert result["items"][0]["kit"] is True
|
|
||||||
assert result["summary"]["mismatches"] == 0
|
|
||||||
|
|
||||||
def test_multi_component_kit_skipped(self):
|
async def _mock_to_thread(fn, *args, **kwargs):
|
||||||
"""Multi-component kit (2 CODMATs): price check skipped, kit=True."""
|
return mock_result
|
||||||
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
|
monkeypatch.setattr(_asyncio, "to_thread", _mock_to_thread)
|
||||||
assert result["items"][0]["kit"] is True
|
res = client.post("/api/orders/test-refresh-ok/refresh-address")
|
||||||
assert result["summary"]["mismatches"] == 0
|
assert res.status_code == 200
|
||||||
|
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()
|
||||||
|
|||||||
@@ -901,6 +901,98 @@ def test_duplicate_codmat_different_prices():
|
|||||||
return False
|
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():
|
def test_pf_reverse_name_dedup():
|
||||||
"""Test that PF partner with reversed name order is found, not duplicated.
|
"""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.
|
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),
|
("Markup no negative discount", test_kit_markup_no_negative_discount),
|
||||||
("Component price=0 import", test_kit_component_price_zero_import),
|
("Component price=0 import", test_kit_component_price_zero_import),
|
||||||
("Duplicate CODMAT different prices", test_duplicate_codmat_different_prices),
|
("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
|
biz_passed = 0
|
||||||
for name, test_fn in biz_tests:
|
for name, test_fn in biz_tests:
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ _api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
if _api_dir not in sys.path:
|
if _api_dir not in sys.path:
|
||||||
sys.path.insert(0, _api_dir)
|
sys.path.insert(0, _api_dir)
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
from app.services.anaf_service import (
|
from app.services.anaf_service import (
|
||||||
strip_ro_prefix,
|
strip_ro_prefix,
|
||||||
validate_cui,
|
validate_cui,
|
||||||
validate_cui_checksum,
|
validate_cui_checksum,
|
||||||
sanitize_cui,
|
sanitize_cui,
|
||||||
|
_call_anaf_api,
|
||||||
|
check_vat_status_batch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,3 +214,178 @@ class TestSanitizeCui:
|
|||||||
bare, warning = sanitize_cui("Ro 50519951")
|
bare, warning = sanitize_cui("Ro 50519951")
|
||||||
assert bare == "50519951"
|
assert bare == "50519951"
|
||||||
assert warning is None
|
assert warning is None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _call_anaf_api — notFound parsing + error handling
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestCallAnafApi:
|
||||||
|
"""Tests for ANAF API response parsing and error handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notfound_as_integers(self):
|
||||||
|
"""ANAF notFound items are plain integers (CUI values), not dicts."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"found": [],
|
||||||
|
"notFound": [12345678, 87654321],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api([{"cui": 12345678, "data": "2026-04-07"}])
|
||||||
|
|
||||||
|
assert "12345678" in results
|
||||||
|
assert "87654321" in results
|
||||||
|
assert results["12345678"]["scpTVA"] is None
|
||||||
|
assert results["87654321"]["scpTVA"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notfound_as_dicts_still_works(self):
|
||||||
|
"""Backward compat: if ANAF ever returns notFound as dicts, still parse them."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"found": [],
|
||||||
|
"notFound": [{"date_generale": {"cui": 99999999}}],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api([{"cui": 99999999, "data": "2026-04-07"}])
|
||||||
|
|
||||||
|
assert "99999999" in results
|
||||||
|
assert results["99999999"]["scpTVA"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_found_items_parsed(self):
|
||||||
|
"""Normal found items are parsed correctly."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"found": [{
|
||||||
|
"date_generale": {"cui": 15134434, "denumire": "AUTOKLASS CENTER SRL"},
|
||||||
|
"inregistrare_scop_Tva": {"scpTVA": True},
|
||||||
|
}],
|
||||||
|
"notFound": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api([{"cui": 15134434, "data": "2026-04-07"}])
|
||||||
|
|
||||||
|
assert results["15134434"]["scpTVA"] is True
|
||||||
|
assert results["15134434"]["denumire_anaf"] == "AUTOKLASS CENTER SRL"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_4xx_error_no_retry(self):
|
||||||
|
"""4xx client errors (like 404) should not retry."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 404
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
log_messages = []
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await _call_anaf_api(
|
||||||
|
[{"cui": 12345678, "data": "2026-04-07"}],
|
||||||
|
log_fn=lambda msg: log_messages.append(msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == {}
|
||||||
|
# Should only call once (no retry for 4xx)
|
||||||
|
assert mock_client.post.call_count == 1
|
||||||
|
assert any("404" in msg for msg in log_messages)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_fn_receives_errors(self):
|
||||||
|
"""log_fn callback receives error messages for UI display."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
log_messages = []
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
results = await _call_anaf_api(
|
||||||
|
[{"cui": 12345678, "data": "2026-04-07"}],
|
||||||
|
log_fn=lambda msg: log_messages.append(msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert results == {}
|
||||||
|
assert len(log_messages) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckVatStatusBatch:
|
||||||
|
"""Tests for check_vat_status_batch with log_fn propagation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_fn_passed_through(self):
|
||||||
|
"""log_fn is forwarded from check_vat_status_batch to _call_anaf_api."""
|
||||||
|
log_messages = []
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {"found": [], "notFound": [12345678]}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await check_vat_status_batch(
|
||||||
|
["12345678"], log_fn=lambda msg: log_messages.append(msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "12345678" in results
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_list_returns_empty(self):
|
||||||
|
assert await check_vat_status_batch([]) == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_digit_cuis_filtered(self):
|
||||||
|
"""CUIs that aren't pure digits are filtered out before API call."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {"found": [], "notFound": []}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("app.services.anaf_service.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
results = await check_vat_status_batch(["ABC", "12345678"])
|
||||||
|
|
||||||
|
# Only the digit CUI should be in the body
|
||||||
|
call_body = mock_client.post.call_args[1]["json"]
|
||||||
|
assert len(call_body) == 1
|
||||||
|
assert call_body[0]["cui"] == 12345678
|
||||||
|
|||||||
75
api/tests/test_error_history_log.py
Normal file
75
api/tests/test_error_history_log.py
Normal 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")
|
||||||
202
api/tests/test_order_items_overwrite.py
Normal file
202
api/tests/test_order_items_overwrite.py
Normal 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
|
||||||
215
api/tests/test_partner_anaf_override.py
Normal file
215
api/tests/test_partner_anaf_override.py
Normal 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"]
|
||||||
216
api/tests/test_partner_cui_lookup.py
Normal file
216
api/tests/test_partner_cui_lookup.py
Normal 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)
|
||||||
149
api/tests/test_phase_isolation.py
Normal file
149
api/tests/test_phase_isolation.py
Normal 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
|
||||||
412
api/tests/test_pre_validate_order_prices.py
Normal file
412
api/tests/test_pre_validate_order_prices.py
Normal 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()
|
||||||
@@ -36,6 +36,7 @@ import pytest_asyncio
|
|||||||
|
|
||||||
from app.database import init_sqlite
|
from app.database import init_sqlite
|
||||||
from app.services import sqlite_service
|
from app.services import sqlite_service
|
||||||
|
from app.constants import OrderStatus
|
||||||
|
|
||||||
# Initialize SQLite once before any tests run
|
# Initialize SQLite once before any tests run
|
||||||
init_sqlite()
|
init_sqlite()
|
||||||
@@ -70,10 +71,10 @@ def seed_baseline_data():
|
|||||||
|
|
||||||
# Add the first order (IMPORTED) with items
|
# Add the first order (IMPORTED) with items
|
||||||
await sqlite_service.upsert_order(
|
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
|
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 = [
|
items = [
|
||||||
{
|
{
|
||||||
@@ -103,15 +104,15 @@ def seed_baseline_data():
|
|||||||
|
|
||||||
# Add more orders for filter tests
|
# Add more orders for filter tests
|
||||||
await sqlite_service.upsert_order(
|
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
|
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(
|
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
|
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())
|
asyncio.run(_seed())
|
||||||
yield
|
yield
|
||||||
@@ -212,7 +213,7 @@ async def test_get_order_detail_not_found():
|
|||||||
async def test_get_order_detail_status():
|
async def test_get_order_detail_status():
|
||||||
"""Seeded ORD001 should have IMPORTED status."""
|
"""Seeded ORD001 should have IMPORTED status."""
|
||||||
detail = await sqlite_service.get_order_detail("ORD001")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_run_orders_filtered_imported():
|
async def test_get_run_orders_filtered_imported():
|
||||||
"""Filter IMPORTED should return only ORD001."""
|
"""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["total"] == 1
|
||||||
assert result["orders"][0]["order_number"] == "ORD001"
|
assert result["orders"][0]["order_number"] == "ORD001"
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ async def test_get_run_orders_filtered_imported():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_run_orders_filtered_skipped():
|
async def test_get_run_orders_filtered_skipped():
|
||||||
"""Filter SKIPPED should return only ORD002."""
|
"""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["total"] == 1
|
||||||
assert result["orders"][0]["order_number"] == "ORD002"
|
assert result["orders"][0]["order_number"] == "ORD002"
|
||||||
|
|
||||||
@@ -248,7 +249,7 @@ async def test_get_run_orders_filtered_skipped():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_run_orders_filtered_error():
|
async def test_get_run_orders_filtered_error():
|
||||||
"""Filter ERROR should return only ORD003."""
|
"""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["total"] == 1
|
||||||
assert result["orders"][0]["order_number"] == "ORD003"
|
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):
|
def test_api_sync_run_orders_filtered(client):
|
||||||
"""R1: Filtering by status=IMPORTED returns only IMPORTED orders."""
|
"""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
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
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):
|
def test_api_sync_run_orders_pagination_fields(client):
|
||||||
@@ -618,3 +619,79 @@ def test_get_all_skus():
|
|||||||
]
|
]
|
||||||
skus = get_all_skus(orders)
|
skus = get_all_skus(orders)
|
||||||
assert skus == {"A", "B", "C"}
|
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()
|
||||||
|
|||||||
307
api/tests/test_save_orders_batch_hybrid.py
Normal file
307
api/tests/test_save_orders_batch_hybrid.py
Normal 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()
|
||||||
140
api/tests/test_sticky_deleted_filter.py
Normal file
140
api/tests/test_sticky_deleted_filter.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Sticky DELETED_IN_ROA Filter Tests
|
||||||
|
===================================
|
||||||
|
Unit tests for get_deleted_in_roa_order_numbers() helper and integration
|
||||||
|
test for the sticky-exclusion filter applied in sync_service before
|
||||||
|
order classification.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd api && python -m pytest tests/test_sticky_deleted_filter.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
_tmpdir = tempfile.mkdtemp()
|
||||||
|
os.environ.setdefault("FORCE_THIN_MODE", "true")
|
||||||
|
os.environ.setdefault("SQLITE_DB_PATH", os.path.join(_tmpdir, "test_sticky_deleted.db"))
|
||||||
|
os.environ.setdefault("ORACLE_DSN", "dummy")
|
||||||
|
os.environ.setdefault("ORACLE_USER", "dummy")
|
||||||
|
os.environ.setdefault("ORACLE_PASSWORD", "dummy")
|
||||||
|
os.environ.setdefault("JSON_OUTPUT_DIR", _tmpdir)
|
||||||
|
|
||||||
|
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _api_dir not in sys.path:
|
||||||
|
sys.path.insert(0, _api_dir)
|
||||||
|
|
||||||
|
from app import database
|
||||||
|
from app.services import sqlite_service
|
||||||
|
from app.constants import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _clean_orders():
|
||||||
|
"""Ensure schema exists, clear orders table before each test."""
|
||||||
|
database.init_sqlite()
|
||||||
|
db = await database.get_sqlite()
|
||||||
|
try:
|
||||||
|
await db.execute("DELETE FROM orders")
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_order(order_number: str, status: str, id_comanda: int | None = None):
|
||||||
|
db = await database.get_sqlite()
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO orders (order_number, order_date, customer_name, status, id_comanda)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(order_number, "2026-04-22", "Test Customer", status, id_comanda),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_empty_set_when_no_orders():
|
||||||
|
"""Helper unit: empty table → empty set."""
|
||||||
|
result = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||||
|
assert result == set()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_only_deleted_in_roa_status():
|
||||||
|
"""Helper unit: filters only DELETED_IN_ROA, ignores other statuses."""
|
||||||
|
await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100)
|
||||||
|
await _insert_order("ORD-2", OrderStatus.DELETED_IN_ROA.value)
|
||||||
|
await _insert_order("ORD-3", OrderStatus.CANCELLED.value)
|
||||||
|
await _insert_order("ORD-4", OrderStatus.ERROR.value)
|
||||||
|
await _insert_order("ORD-5", OrderStatus.DELETED_IN_ROA.value)
|
||||||
|
await _insert_order("ORD-6", OrderStatus.SKIPPED.value)
|
||||||
|
|
||||||
|
result = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||||
|
assert result == {"ORD-2", "ORD-5"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_order_deleted_then_helper_returns_it():
|
||||||
|
"""Integration: mark_order_deleted_in_roa → helper picks it up."""
|
||||||
|
await _insert_order("ORD-100", OrderStatus.IMPORTED.value, id_comanda=500)
|
||||||
|
|
||||||
|
before = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||||
|
assert "ORD-100" not in before
|
||||||
|
|
||||||
|
await sqlite_service.mark_order_deleted_in_roa("ORD-100")
|
||||||
|
|
||||||
|
after = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||||
|
assert "ORD-100" in after
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_excludes_deleted_orders():
|
||||||
|
"""Integration: simulates sync filter step.
|
||||||
|
|
||||||
|
Pre-mark ORD-2 as DELETED_IN_ROA, run the same filter logic from
|
||||||
|
sync_service:478-489, assert ORD-2 is excluded.
|
||||||
|
"""
|
||||||
|
await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100)
|
||||||
|
await _insert_order("ORD-2", OrderStatus.DELETED_IN_ROA.value)
|
||||||
|
await _insert_order("ORD-3", OrderStatus.IMPORTED.value, id_comanda=300)
|
||||||
|
|
||||||
|
incoming = [
|
||||||
|
type("O", (), {"number": "ORD-1", "date": "2026-04-22"})(),
|
||||||
|
type("O", (), {"number": "ORD-2", "date": "2026-04-22"})(),
|
||||||
|
type("O", (), {"number": "ORD-3", "date": "2026-04-22"})(),
|
||||||
|
type("O", (), {"number": "ORD-NEW", "date": "2026-04-22"})(),
|
||||||
|
]
|
||||||
|
|
||||||
|
deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||||
|
excluded = [o for o in incoming if o.number in deleted_set]
|
||||||
|
survivors = [o for o in incoming if o.number not in deleted_set]
|
||||||
|
|
||||||
|
assert {o.number for o in excluded} == {"ORD-2"}
|
||||||
|
assert {o.number for o in survivors} == {"ORD-1", "ORD-3", "ORD-NEW"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_with_no_deleted_is_noop():
|
||||||
|
"""Integration: deleted_set empty → all orders pass through."""
|
||||||
|
await _insert_order("ORD-1", OrderStatus.IMPORTED.value, id_comanda=100)
|
||||||
|
|
||||||
|
incoming = [
|
||||||
|
type("O", (), {"number": "ORD-1", "date": "2026-04-22"})(),
|
||||||
|
type("O", (), {"number": "ORD-NEW", "date": "2026-04-22"})(),
|
||||||
|
]
|
||||||
|
|
||||||
|
deleted_set = await sqlite_service.get_deleted_in_roa_order_numbers()
|
||||||
|
survivors = [o for o in incoming if o.number not in deleted_set]
|
||||||
|
|
||||||
|
assert deleted_set == set()
|
||||||
|
assert {o.number for o in survivors} == {"ORD-1", "ORD-NEW"}
|
||||||
256
api/tests/test_sync_cui_gate.py
Normal file
256
api/tests/test_sync_cui_gate.py
Normal 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}"
|
||||||
110
api/tests/test_sync_health_endpoint.py
Normal file
110
api/tests/test_sync_health_endpoint.py
Normal 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
|
||||||
121
api/tests/test_sync_phase_failures.py
Normal file
121
api/tests/test_sync_phase_failures.py
Normal 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"
|
||||||
166
api/tests/test_validate_structural.py
Normal file
166
api/tests/test_validate_structural.py
Normal 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
|
||||||
59
docs/adrese_facturare_variante.md
Normal file
59
docs/adrese_facturare_variante.md
Normal 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.
|
||||||
116
scripts/backfill_order_items.py
Normal file
116
scripts/backfill_order_items.py
Normal 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:]))
|
||||||
88
scripts/cleanup_duplicate_addresses.sql
Normal file
88
scripts/cleanup_duplicate_addresses.sql
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
-- cleanup_duplicate_addresses.sql
|
||||||
|
-- Diagnostic and cleanup script for duplicate Oracle partner addresses
|
||||||
|
-- Run on ROA Oracle database AFTER deploying 07.04.2026 PL/SQL fix
|
||||||
|
-- IMPORTANT: Review Step 2 output BEFORE running Step 3 COMMIT
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 1: Diagnostic — find partners with duplicate addresses (same id_loc + strada)
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT p.id_part,
|
||||||
|
p.denumire,
|
||||||
|
strip_diacritics(a.strada) as strada_norm,
|
||||||
|
a.id_loc,
|
||||||
|
COUNT(*) as nr_duplicate,
|
||||||
|
MIN(a.id_adresa) as keep_id,
|
||||||
|
MAX(a.id_adresa) as dup_id
|
||||||
|
FROM vadrese_parteneri a
|
||||||
|
JOIN syn_parteneri p ON p.id_part = a.id_part
|
||||||
|
WHERE a.id_loc IS NOT NULL
|
||||||
|
AND a.strada IS NOT NULL
|
||||||
|
GROUP BY p.id_part, p.denumire, strip_diacritics(a.strada), a.id_loc
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY nr_duplicate DESC, p.denumire;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 2: FK references for each duplicate address
|
||||||
|
-- Review this before proceeding to Step 3
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT 'LIVRARE' as tip,
|
||||||
|
c.numar_comanda,
|
||||||
|
c.id_adresa_livrare as id_adresa
|
||||||
|
FROM comenzi c
|
||||||
|
WHERE c.id_adresa_livrare IN (
|
||||||
|
SELECT MAX(a.id_adresa)
|
||||||
|
FROM vadrese_parteneri a
|
||||||
|
WHERE a.id_loc IS NOT NULL AND a.strada IS NOT NULL
|
||||||
|
GROUP BY a.id_part, strip_diacritics(a.strada), a.id_loc
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'FACTURARE',
|
||||||
|
c.numar_comanda,
|
||||||
|
c.id_adresa_facturare
|
||||||
|
FROM comenzi c
|
||||||
|
WHERE c.id_adresa_facturare IN (
|
||||||
|
SELECT MAX(a.id_adresa)
|
||||||
|
FROM vadrese_parteneri a
|
||||||
|
WHERE a.id_loc IS NOT NULL AND a.strada IS NOT NULL
|
||||||
|
GROUP BY a.id_part, strip_diacritics(a.strada), a.id_loc
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
ORDER BY id_adresa;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 3: Consolidation — update FK references, then soft-delete duplicates
|
||||||
|
-- IMPORTANT: Run STEP 1 and 2 first. Manual COMMIT required after review.
|
||||||
|
-- =============================================================================
|
||||||
|
-- Update comenzi references from dup_id → keep_id
|
||||||
|
BEGIN
|
||||||
|
FOR rec IN (
|
||||||
|
SELECT MIN(id_adresa) as keep_id, MAX(id_adresa) as dup_id
|
||||||
|
FROM vadrese_parteneri
|
||||||
|
WHERE id_loc IS NOT NULL AND strada IS NOT NULL
|
||||||
|
GROUP BY id_part, strip_diacritics(strada), id_loc
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) LOOP
|
||||||
|
UPDATE comenzi SET id_adresa_livrare = rec.keep_id
|
||||||
|
WHERE id_adresa_livrare = rec.dup_id;
|
||||||
|
UPDATE comenzi SET id_adresa_facturare = rec.keep_id
|
||||||
|
WHERE id_adresa_facturare = rec.dup_id;
|
||||||
|
-- Soft-delete duplicate address
|
||||||
|
UPDATE vadrese_parteneri SET sters = 1
|
||||||
|
WHERE id_adresa = rec.dup_id;
|
||||||
|
DBMS_OUTPUT.PUT_LINE('Merged dup_id=' || rec.dup_id || ' → keep_id=' || rec.keep_id);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
-- COMMIT; -- Uncomment after reviewing DBMS_OUTPUT
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- STEP 4: Find addresses with principala=1 and strada IS NULL (empty principals)
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT a.id_adresa, a.id_part, p.denumire, a.principala
|
||||||
|
FROM vadrese_parteneri a
|
||||||
|
JOIN syn_parteneri p ON p.id_part = a.id_part
|
||||||
|
WHERE a.principala = 1
|
||||||
|
AND (a.strada IS NULL OR TRIM(a.strada) = '')
|
||||||
|
AND a.sters = 0
|
||||||
|
ORDER BY p.denumire;
|
||||||
170
scripts/verify_address_rules.py
Normal file
170
scripts/verify_address_rules.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Verifică regula adrese PJ/PF pe comenzile importate din SQLite.
|
||||||
|
|
||||||
|
Logica:
|
||||||
|
PF (cod_fiscal_gomag IS NULL): id_adresa_facturare = id_adresa_livrare
|
||||||
|
PJ (cod_fiscal_gomag IS NOT NULL): adresa_facturare_roa se potriveste cu GoMag billing
|
||||||
|
(nu cu GoMag shipping)
|
||||||
|
|
||||||
|
Rulare:
|
||||||
|
python3 scripts/verify_address_rules.py
|
||||||
|
python3 scripts/verify_address_rules.py --days 7 # ultimele 7 zile
|
||||||
|
python3 scripts/verify_address_rules.py --all # toate comenzile
|
||||||
|
python3 scripts/verify_address_rules.py --status IMPORTED
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add api/ to path for app imports
|
||||||
|
_repo_root = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(_repo_root / "api"))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(_repo_root / "api" / ".env")
|
||||||
|
|
||||||
|
from app.services.sync_service import _addr_match
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Verifică regula adrese PJ/PF în SQLite")
|
||||||
|
parser.add_argument("--days", type=int, default=30,
|
||||||
|
help="Număr de zile în urmă (default: 30)")
|
||||||
|
parser.add_argument("--all", action="store_true",
|
||||||
|
help="Toate comenzile, indiferent de dată")
|
||||||
|
parser.add_argument("--status", default=None,
|
||||||
|
help="Filtrează după status (ex: IMPORTED)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_raw_path = os.environ.get("SQLITE_DB_PATH", "data/import.db")
|
||||||
|
db_path = _raw_path if os.path.isabs(_raw_path) else str(_repo_root / "api" / _raw_path)
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"EROARE: SQLite DB nu există: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
where_clauses = ["id_adresa_facturare IS NOT NULL", "id_adresa_livrare IS NOT NULL"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if not args.all:
|
||||||
|
where_clauses.append("first_seen_at >= datetime('now', ?)")
|
||||||
|
params.append(f"-{args.days} days")
|
||||||
|
|
||||||
|
if args.status:
|
||||||
|
where_clauses.append("status = ?")
|
||||||
|
params.append(args.status)
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
rows = conn.execute(f"""
|
||||||
|
SELECT order_number, status, cod_fiscal_gomag,
|
||||||
|
id_adresa_facturare, id_adresa_livrare,
|
||||||
|
adresa_facturare_gomag, adresa_livrare_gomag,
|
||||||
|
adresa_facturare_roa, adresa_livrare_roa,
|
||||||
|
first_seen_at
|
||||||
|
FROM orders
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY first_seen_at DESC
|
||||||
|
""", params).fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
scope = "toate comenzile" if args.all else f"ultimele {args.days} zile"
|
||||||
|
print(f"Nicio comandă cu adrese populate ({scope}).")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
pf_ok = pf_err = pj_ok = pj_err = pj_skip = 0
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
is_pj = bool(r["cod_fiscal_gomag"])
|
||||||
|
id_fact = r["id_adresa_facturare"]
|
||||||
|
id_livr = r["id_adresa_livrare"]
|
||||||
|
order = r["order_number"]
|
||||||
|
date = (r["first_seen_at"] or "")[:10]
|
||||||
|
|
||||||
|
if not is_pj:
|
||||||
|
# PF: id_facturare trebuie = id_livrare
|
||||||
|
if id_fact == id_livr:
|
||||||
|
pf_ok += 1
|
||||||
|
else:
|
||||||
|
pf_err += 1
|
||||||
|
violations.append({
|
||||||
|
"order": order, "date": date, "type": "PF",
|
||||||
|
"issue": f"id_fact={id_fact} != id_livr={id_livr}",
|
||||||
|
"detail": None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# PJ: adresa_facturare_roa trebuie sa se potriveasca cu GoMag billing
|
||||||
|
fact_roa = r["adresa_facturare_roa"]
|
||||||
|
fact_gomag = r["adresa_facturare_gomag"]
|
||||||
|
livr_gomag = r["adresa_livrare_gomag"]
|
||||||
|
|
||||||
|
if not fact_roa or not fact_gomag:
|
||||||
|
pj_skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check 1: billing ROA matches GoMag billing
|
||||||
|
billing_match = _addr_match(fact_gomag, fact_roa)
|
||||||
|
# Check 2: billing ROA does NOT match GoMag shipping (wrong old behavior)
|
||||||
|
shipping_match = _addr_match(livr_gomag, fact_roa) if livr_gomag else False
|
||||||
|
|
||||||
|
if billing_match:
|
||||||
|
pj_ok += 1
|
||||||
|
else:
|
||||||
|
pj_err += 1
|
||||||
|
detail = "billing_ROA matches shipping GoMag" if shipping_match else "billing_ROA mismatch"
|
||||||
|
violations.append({
|
||||||
|
"order": order, "date": date, "type": "PJ",
|
||||||
|
"issue": detail,
|
||||||
|
"detail": f"billing_gomag={_short(fact_gomag)} | fact_roa={fact_roa}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Output
|
||||||
|
total = len(rows)
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
scope = "toate" if args.all else f"ultimele {args.days} zile"
|
||||||
|
print(f" Verificare adrese PJ/PF ({scope}, {total} comenzi cu adrese)")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f" PF (fara CUI): {pf_ok:4d} OK | {pf_err:4d} ERORI")
|
||||||
|
print(f" PJ (cu CUI): {pj_ok:4d} OK | {pj_err:4d} ERORI | {pj_skip:4d} skip (date lipsa)")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
if not violations:
|
||||||
|
print(" ✓ Toate comenzile respecta regula PJ/PF.\n")
|
||||||
|
else:
|
||||||
|
print(f"\n VIOLARI ({len(violations)}):\n")
|
||||||
|
for v in violations[:20]:
|
||||||
|
print(f" [{v['date']}] {v['order']:25s} {v['type']} {v['issue']}")
|
||||||
|
if v["detail"]:
|
||||||
|
print(f" {v['detail']}")
|
||||||
|
if len(violations) > 20:
|
||||||
|
print(f" ... si inca {len(violations)-20} violari.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
sys.exit(1 if violations else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _short(json_str):
|
||||||
|
"""Returnează un rezumat scurt al unui JSON de adresă."""
|
||||||
|
if not json_str:
|
||||||
|
return "(null)"
|
||||||
|
try:
|
||||||
|
d = json.loads(json_str)
|
||||||
|
return f"{d.get('address','?')}, {d.get('city','?')}"
|
||||||
|
except Exception:
|
||||||
|
return json_str[:40]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user