Compare commits

...

4 Commits

Author SHA1 Message Date
Claude Agent
c534a972a9 feat: multi-gestiune stock verification setting
Replace single-select gestiune dropdown with multi-select checkboxes.
Settings stores comma-separated IDs, Python builds IN clause with bind
variables, Oracle PL/SQL splits CSV via REGEXP_SUBSTR for stock lookup.
Empty selection = all warehouses (unchanged behavior).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:15:40 +00:00
Claude Agent
6fc2f34ba9 docs: simplify CLAUDE.md, update README with accurate business rules
CLAUDE.md reduced from 214 to 60 lines — moved architecture, API endpoints,
and detailed docs to README. Kept only AI-critical rules (TeamCreate, import
flow gotchas, partner/pricing logic).

README updated: added CANCELLED status, dual pricing policy, discount VAT
splitting, stale error recovery, accurate partner/address logic, settings
page references. Removed outdated Status Implementare section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:48:33 +00:00
Claude Agent
c1d8357956 gitignore 2026-03-18 15:11:09 +00:00
Claude Agent
695dafacd5 feat: dual pricing policies + discount VAT splitting
Add production pricing policy (id_pol_productie) for articles with cont 341/345,
smart discount VAT splitting across multiple rates, per-article id_pol support,
and mapped SKU price validation. Settings UI updated with new controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:10:05 +00:00
14 changed files with 546 additions and 299 deletions

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ api/api/
# Logs directory # Logs directory
logs/ logs/
.gstack/

220
CLAUDE.md
View File

@@ -1,206 +1,60 @@
# CLAUDE.md # CLAUDE.md
## REGULI OBLIGATORII
**Pentru task-uri paralele foloseste INTOTDEAUNA TeamCreate + TaskCreate, NU Agent tool cu subagenti paraleli.**
Skill-ul `superpowers:dispatching-parallel-agents` NU se aplica in acest proiect. In loc de dispatch cu Agent tool, creeaza o echipa cu TeamCreate, defineste task-uri cu TaskCreate, si spawneaza teammates cu Agent tool + `team_name`.
## Project Overview ## Project Overview
**System:** Import Comenzi Web GoMag → Sistem ROA Oracle **System:** Import Comenzi Web GoMag → Sistem ROA Oracle
Stack: FastAPI + Jinja2 + Bootstrap 5.3 + Oracle PL/SQL + SQLite
Importa automat comenzi din GoMag in sistemul ERP ROA Oracle. Stack complet Python/FastAPI. Documentatie completa: [README.md](README.md)
### Tech Stack ## Implementare cu TeamCreate
- **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
- **GoMag Integration:** Python (`gomag_client.py` — API download with pagination) **OBLIGATORIU:** Folosim TeamCreate + TaskCreate, NU Agent tool cu subagenti paraleli. Skill-ul `superpowers:dispatching-parallel-agents` NU se aplica in acest proiect.
- **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
- **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking) - Team lead citeste TOATE fisierele implicate, creeaza planul
- **ASTEAPTA aprobare explicita** de la user inainte de implementare
- Task-uri pe fisiere non-overlapping (evita conflicte)
- Cache-bust static assets (`?v=N`) la fiecare schimbare UI
## Development Commands ## Development Commands
```bash ```bash
# Run FastAPI server — INTOTDEAUNA via start.sh (seteaza Oracle env vars) # INTOTDEAUNA via start.sh (seteaza Oracle env vars)
./start.sh ./start.sh
# NU folosi uvicorn direct — lipsesc LD_LIBRARY_PATH si TNS_ADMIN pentru Oracle # NU folosi uvicorn direct — lipsesc LD_LIBRARY_PATH si TNS_ADMIN
# Tests # Tests
python api/test_app_basic.py # Test A - fara Oracle python api/test_app_basic.py # fara Oracle
python api/test_integration.py # Test C - cu Oracle python api/test_integration.py # cu Oracle
``` ```
## UI Development Workflow: Preview → Implement → Verify ## Reguli critice (nu le incalca)
**OBLIGATORIU**: Respecta ordinea exacta. NU treci la pasul urmator fara aprobare explicita. ### Flux import comenzi
1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle
2. Ordinea: **parteneri** (cauta/creeaza) → **adrese****comanda****factura cache**
3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct)
4. Complex sets: un SKU → multiple CODMAT-uri cu `procent_pret` (trebuie sa fie sum=100%)
5. Comenzi anulate (GoMag statusId=7): verifica daca au factura inainte de stergere din Oracle
### 1. Plan & Preview — ASTEAPTA APROBARE ### Statusuri comenzi
1. Citeste TOATE fisierele implicate `IMPORTED` / `ALREADY_IMPORTED` / `SKIPPED` / `ERROR` / `CANCELLED` / `DELETED_IN_ROA`
2. Scrie planul de implementare cu decizii de design - Upsert: `IMPORTED` existent NU se suprascrie cu `ALREADY_IMPORTED`
3. Genereaza **mockup-uri Markdown** care descriu rezultatul asteptat (tabele, liste, cod pseudo-CSS) — NU HTML static - Recovery: la fiecare sync, comenzile ERROR sunt reverificate in Oracle
4. **Prezinta mockup-urile userului si ASTEAPTA aprobare explicita**
5. Rafineaza planul daca userul cere modificari
6. **NU trece la implementare pana userul nu spune explicit "ok", "aprob", "executa" sau similar**
### 2. Implementation cu TeamCreate (Agent Teams) ### Parteneri
- Prioritate: **companie** (PJ, cod_fiscal + registru) daca exista in GoMag, altfel persoana fizica cu **shipping name**
- Adresa livrare: intotdeauna GoMag shipping
- Adresa facturare: daca shipping ≠ billing person → shipping pt ambele; altfel → billing din GoMag
Folosim **TeamCreate** (team agents), NU superpowers subagents. Diferenta: ### Preturi
- **TeamCreate**: agenti independenti cu task list partajat, comunicare directa intre ei, context propriu - Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
- **Subagents (Agent tool)**: agenti care raporteaza doar la main — NU se folosesc - Daca pretul lipseste, se insereaza automat pret=0
#### Workflow TeamCreate: ### Invoice cache
- Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`)
- Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA
1. **Main agent** (team lead) citeste TOATE fisierele implicate, creeaza planul ## Deploy Windows
2. **TeamCreate** creeaza echipa (ex: `ui-polish`)
3. **TaskCreate** creeaza task-uri independente, pe fisiere non-overlapping:
- Task 1: Templates + CSS (HTML templates, style.css, cache-bust)
- Task 2: JavaScript (shared.js, dashboard.js, logs.js, mappings.js)
- Task 3: Verificare Playwright (depinde de Task 1 + Task 2)
4. **Agent tool** cu `team_name` spawneaza teammates folosind agentii predefiniti din `.claude/agents/`:
- `subagent_type: ui-templates` → pentru Task 1 (templates + CSS)
- `subagent_type: ui-js` → pentru Task 2 (JavaScript)
- `subagent_type: ui-verify` → pentru Task 3 (Playwright verification)
- `subagent_type: backend-api` → pentru modificari backend/API (routers, services, Oracle/SQLite)
- `subagent_type: qa-tester` → pentru teste de integrare
5. Teammates lucreaza in paralel, comunica intre ei, marcheaza task-uri completate
6. Cand Task 1 + Task 2 sunt complete, teammate-ul de verificare preia Task 3
#### Teammate-ul de verificare (Task 3): Vezi [README.md](README.md#deploy-windows)
1. Navigheaza la fiecare pagina cu Playwright MCP la 375x812 (mobile) si 1440x900 (desktop)
2. **Foloseste browser_snapshot** (NU screenshot-uri) pentru a inspecta structura DOM
3. Verifica ca implementarea respecta fiecare punct din preview-ul aprobat (structura coloane, bold, dots, filtre etc.)
4. Raporteaza discrepante concrete la team lead (ce e diferit fata de preview)
5. NU salveaza screenshot-uri after/
#### Bucla de corectie (responsabilitatea team lead-ului):
1. Dupa ce verify-agent raporteaza, **team lead-ul analizeaza discrepantele**
2. Pentru fiecare discrepanta, creeaza un nou task de fix si spawneaza un agent sa-l rezolve
3. Dupa fix, spawneaza din nou verify-agent pentru re-verificare
4. **Repeta bucla** pana cand toate verificarile trec (implementare ≈ preview)
5. Abia atunci declara task-ul complet
```
screenshots/
└── preview/ # Mockup-uri Markdown aprobate de user (referinta pentru verificare)
```
### Principii
- Team lead citeste TOATE fisierele inainte sa creeze task-uri
- Task-uri pe fisiere non-overlapping (evita conflicte)
- Fiecare task contine prompt detaliat, self-contained
- Desktop-ul nu trebuie sa se schimbe cand se adauga imbunatatiri mobile
- Cache-bust static assets (increment `?v=N`) la fiecare schimbare UI
- Teammates comunica intre ei cu SendMessage, nu doar cu team lead-ul
## Architecture
```
[GoMag API] → [Python Sync Service] → [Oracle PL/SQL] → [FastAPI Admin]
↓ ↓ ↑ ↑
JSON Orders Download/Parse/Import Store/Update Dashboard + Config
[SQLite — tracking DB]
orders, sync_runs, missing_skus,
order_items, web_products,
invoice cache, app_settings
```
### FastAPI App Structure
- **Routers:** health, dashboard, mappings, articles, validation, sync
- **Services:** gomag_client, sync, order_reader, import, mapping, article, validation, invoice, sqlite, scheduler
- **Templates:** Jinja2 (dashboard, mappings, missing_skus, logs)
- **Static:** CSS (`style.css`), JS (`shared.js`, `dashboard.js`, `logs.js`, `mappings.js`)
- **Databases:** Oracle (ERP data) + SQLite (order tracking, sync runs)
## API Endpoints — Sync & Comenzi
### Sync
| Method | Path | Descriere |
|--------|------|-----------|
| POST | `/api/sync/start` | Porneste sync in background |
| POST | `/api/sync/stop` | Trimite semnal de stop |
| GET | `/api/sync/status` | Status curent + last_run |
| GET | `/api/sync/history` | Istoric run-uri (paginat) |
| GET | `/api/sync/run/{run_id}` | Detalii run |
| GET | `/api/sync/run/{run_id}/log` | Log per comanda (JSON) |
| GET | `/api/sync/run/{run_id}/text-log` | Log text (live sau din SQLite) |
| GET | `/api/sync/run/{run_id}/orders` | Comenzi run filtrate/paginate |
| GET | `/api/sync/order/{order_number}` | Detaliu comanda + items + factura |
### Dashboard Comenzi
| Method | Path | Descriere |
|--------|------|-----------|
| GET | `/api/dashboard/orders` | Comenzi cu date factura (cache SQLite → Oracle fallback) |
| POST | `/api/dashboard/refresh-invoices` | Force-refresh stare facturi + comenzi sterse din ROA |
**Parametri `/api/dashboard/orders`:**
- `period_days`: 3/7/30/90 sau 0 (all / custom range)
- `period_start`, `period_end`: interval custom (cand `period_days=0`)
- `status`: `all` / `IMPORTED` / `SKIPPED` / `ERROR` / `UNINVOICED` / `INVOICED`
- `search`, `sort_by`, `sort_dir`, `page`, `per_page`
**`POST /api/dashboard/refresh-invoices` face:**
1. Necacturate → verifica Oracle daca au primit factura
2. Cacturate → verifica Oracle daca factura a fost stearsa
3. Toate importate → verifica Oracle daca comanda a fost stearsa (→ `DELETED_IN_ROA`)
### Scheduler
| Method | Path | Descriere |
|--------|------|-----------|
| PUT | `/api/sync/schedule` | Configureaza scheduler (enabled, interval_minutes) |
| GET | `/api/sync/schedule` | Status curent scheduler |
### Settings
| Method | Path | Descriere |
|--------|------|-----------|
| GET | `/api/settings` | Citeste setari aplicatie |
| PUT | `/api/settings` | Salveaza setari |
| GET | `/api/settings/sectii` | Lista sectii Oracle (dropdown) |
| GET | `/api/settings/politici` | Lista politici preturi Oracle (dropdown) |
## Invoice Cache (SQLite)
Facturile sunt cacate in coloana `factura_*` pe tabelul `orders`:
- `factura_serie`, `factura_numar`, `factura_data`
- `factura_total_fara_tva`, `factura_total_tva`, `factura_total_cu_tva`
**Sursa Oracle:** `SELECT ... FROM vanzari WHERE id_comanda IN (...) AND sters=0`
**Populare cache:**
- La fiecare cerere `/api/dashboard/orders` — comenzile fara cache sunt verificate live si cacate
- La deschidere detaliu comanda `/api/sync/order/{order_number}` — verifica live daca nu e caat
- La `POST /api/dashboard/refresh-invoices` — refresh complet pentru toate comenzile
## Business Rules
### Partners
- Search priority: cod_fiscal → denumire → create new
- Individuals (CUI 13 digits): separate nume/prenume
- Default address: Bucuresti Sectorul 1
- All new partners: ID_UTIL = -3
### Articles & Mappings
- Simple SKUs: found directly in nom_articole (not stored in ARTICOLE_TERTI)
- Repackaging: SKU → CODMAT with different quantities
- Complex sets: One SKU → multiple CODMATs with percentage pricing (must sum to 100%)
- Inactive articles: activ=0 (soft delete)
### Orders
- Default: ID_GESTIUNE=1, ID_SECTIE=1, ID_POL=0
- Delivery date = order date + 1 day
- All orders: INTERNA=0 (external)
- **Statuses:** `IMPORTED` / `ALREADY_IMPORTED` / `SKIPPED` / `ERROR` / `DELETED_IN_ROA`
- Upsert rule: daca status=`IMPORTED` exista, nu se suprascrie cu `ALREADY_IMPORTED`
## Configuration
```bash
# .env
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_PASSWORD=********
ORACLE_DSN=ROA_ROMFAST
TNS_ADMIN=/app
```
## Deploy & Depanare Windows
Vezi [README.md](README.md#deploy-windows) pentru instructiuni complete de deploy si depanare pe Windows Server.

View File

@@ -101,11 +101,11 @@ gomag-vending/
│ │ ├── database.py # Oracle pool + SQLite schema + migrari │ │ ├── database.py # Oracle pool + SQLite schema + migrari
│ │ ├── routers/ # Endpoint-uri HTTP │ │ ├── routers/ # Endpoint-uri HTTP
│ │ │ ├── health.py # GET /health │ │ │ ├── health.py # GET /health
│ │ │ ├── dashboard.py # GET / (HTML) │ │ │ ├── dashboard.py # GET / (HTML) + /settings (HTML)
│ │ │ ├── mappings.py # /mappings, /api/mappings │ │ │ ├── mappings.py # /mappings, /api/mappings
│ │ │ ├── articles.py # /api/articles/search │ │ │ ├── articles.py # /api/articles/search
│ │ │ ├── validation.py # /api/validate/* │ │ │ ├── validation.py # /api/validate/*
│ │ │ └── sync.py # /api/sync/* + /api/dashboard/orders │ │ │ └── sync.py # /api/sync/* + /api/dashboard/* + /api/settings
│ │ ├── services/ │ │ ├── services/
│ │ │ ├── gomag_client.py # Download comenzi GoMag API │ │ │ ├── gomag_client.py # Download comenzi GoMag API
│ │ │ ├── sync_service.py # Orchestrare: download→validate→import │ │ │ ├── sync_service.py # Orchestrare: download→validate→import
@@ -117,8 +117,8 @@ gomag-vending/
│ │ │ ├── article_service.py │ │ │ ├── article_service.py
│ │ │ ├── invoice_service.py # Verificare facturi ROA │ │ │ ├── invoice_service.py # Verificare facturi ROA
│ │ │ └── scheduler_service.py # APScheduler timer │ │ │ └── scheduler_service.py # APScheduler timer
│ │ ├── templates/ # Jinja2 HTML │ │ ├── templates/ # Jinja2 (dashboard, mappings, missing_skus, logs, settings)
│ │ └── static/ # CSS + JS │ │ └── static/ # CSS (style.css) + JS (dashboard, logs, mappings, settings, shared)
│ ├── database-scripts/ # Oracle SQL (ARTICOLE_TERTI, packages) │ ├── database-scripts/ # Oracle SQL (ARTICOLE_TERTI, packages)
│ ├── data/ # SQLite DB (import.db) + JSON orders │ ├── data/ # SQLite DB (import.db) + JSON orders
│ ├── .env # Configurare locala (nu in git) │ ├── .env # Configurare locala (nu in git)
@@ -165,12 +165,16 @@ gomag-vending/
## Fluxul de Import ## Fluxul de Import
``` ```
1. gomag_client.py descarca comenzi GoMag API → JSON files 1. gomag_client.py descarca comenzi GoMag API → JSON files (paginat)
2. order_reader.py parseaza JSON-urile 2. order_reader.py parseaza JSON-urile, sorteaza cronologic (cele mai vechi primele)
3. validation_service.py valideaza SKU-uri contra ARTICOLE_TERTI + NOM_ARTICOLE 3. Comenzi anulate (GoMag statusId=7) → separate, sterse din Oracle daca nu au factura
4. import_service.py creeaza/cauta partener in Oracle (shipping person = facturare) 4. validation_service.py valideaza SKU-uri: ARTICOLE_TERTI (mapped) → NOM_ARTICOLE (direct) → missing
5. PACK_IMPORT_COMENZI.importa_comanda_web() insereaza comanda in ROA 5. Verificare existenta in Oracle (COMENZI by date range) → deja importate se sar
6. Rezultate salvate in SQLite (orders, sync_run_orders, order_items) 6. Stale error recovery: comenzi ERROR reverificate in Oracle (crash recovery)
7. Validare preturi + dual policy: articole rutate la id_pol_vanzare sau id_pol_productie
8. import_service.py: cauta/creeaza partener → adrese → importa comanda in Oracle
9. Invoice cache: verifica facturi + comenzi sterse din ROA
10. Rezultate salvate in SQLite (orders, sync_run_orders, order_items)
``` ```
### Statuses Comenzi ### Statuses Comenzi
@@ -180,17 +184,30 @@ gomag-vending/
| `IMPORTED` | Importata nou in ROA in acest run | | `IMPORTED` | Importata nou in ROA in acest run |
| `ALREADY_IMPORTED` | Existenta deja in Oracle, contorizata | | `ALREADY_IMPORTED` | Existenta deja in Oracle, contorizata |
| `SKIPPED` | SKU-uri lipsa → neimportata | | `SKIPPED` | SKU-uri lipsa → neimportata |
| `ERROR` | Eroare la import | | `ERROR` | Eroare la import (reverificate automat la urmatorul sync) |
| `CANCELLED` | Comanda anulata in GoMag (statusId=7) |
| `DELETED_IN_ROA` | A fost importata dar comanda a fost stearsa din ROA | | `DELETED_IN_ROA` | A fost importata dar comanda a fost stearsa din ROA |
**Regula upsert:** daca statusul existent este `IMPORTED`, nu se suprascrie cu `ALREADY_IMPORTED`. **Regula upsert:** daca statusul existent este `IMPORTED`, nu se suprascrie cu `ALREADY_IMPORTED`.
### Reguli Business ### Reguli Business
- **Persoana**: shipping name = persoana pe eticheta = beneficiarul facturii
- **Adresa**: cand billing ≠ shipping → adresa shipping pentru ambele (facturare + livrare) **Parteneri & Adrese:**
- **SKU simplu**: gasit direct in NOM_ARTICOLE → nu se stocheaza in ARTICOLE_TERTI - Prioritate partener: daca exista **companie** in GoMag (billing.company_name) → firma (PJ, cod_fiscal + registru). Altfel → persoana fizica, cu **shipping name** ca nume partener
- **SKU cu repackaging**: un SKU → CODMAT cu cantitate diferita - Adresa livrare: intotdeauna din GoMag shipping
- **SKU set complex**: un SKU → multiple CODMAT-uri cu procente de pret - Adresa facturare: daca shipping name ≠ billing name → adresa shipping pt ambele; daca aceeasi persoana → adresa billing din GoMag
- Cautare partener in Oracle: cod_fiscal → denumire → create new (ID_UTIL = -3)
**Articole & Mapari:**
- SKU lookup: ARTICOLE_TERTI (mapped, activ=1) are prioritate fata de NOM_ARTICOLE (direct)
- SKU simplu: gasit direct in NOM_ARTICOLE → nu se stocheaza in ARTICOLE_TERTI
- SKU cu repackaging: un SKU → CODMAT cu cantitate diferita (`cantitate_roa`)
- SKU set complex: un SKU → multiple CODMAT-uri cu `procent_pret` (trebuie sum = 100%)
**Preturi & Discounturi:**
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
- Daca pretul lipseste in politica, se insereaza automat pret=0
- Discount VAT splitting: daca `split_discount_vat=1`, discountul se repartizeaza proportional pe cotele TVA din comanda
--- ---
@@ -271,18 +288,7 @@ Configuratia este persistata in SQLite (`scheduler_config`).
| GET | `/api/settings/sectii` | Lista sectii Oracle | | GET | `/api/settings/sectii` | Lista sectii Oracle |
| GET | `/api/settings/politici` | Lista politici preturi Oracle | | GET | `/api/settings/politici` | Lista politici preturi Oracle |
**Setari disponibile:** `transport_codmat`, `transport_vat`, `discount_codmat`, `discount_vat`, `transport_id_pol`, `discount_id_pol`, `id_pol`, `id_sectie`, `gomag_api_key`, `gomag_api_shop`, `gomag_order_days_back`, `gomag_limit` **Setari disponibile:** `transport_codmat`, `transport_vat`, `discount_codmat`, `discount_vat`, `transport_id_pol`, `discount_id_pol`, `id_pol`, `id_pol_productie`, `id_sectie`, `split_discount_vat`, `gomag_api_key`, `gomag_api_shop`, `gomag_order_days_back`, `gomag_limit`
---
## Status Implementare
| Faza | Status | Descriere |
|------|--------|-----------|
| Phase 1: Database Foundation | Complet | ARTICOLE_TERTI, PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI |
| Phase 2: Python Integration | Complet | gomag_client.py, sync_service.py |
| Phase 3-4: FastAPI Dashboard | Complet | UI responsive, smart polling, filter bar, paginare |
| Phase 5: Production | In Progress | Logging done, Auth + SMTP pending |
--- ---

View File

@@ -110,7 +110,8 @@ CREATE TABLE IF NOT EXISTS orders (
order_total REAL, order_total REAL,
delivery_cost REAL, delivery_cost REAL,
discount_total REAL, discount_total REAL,
web_status TEXT web_status TEXT,
discount_split TEXT
); );
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
@@ -318,6 +319,7 @@ def init_sqlite():
("delivery_cost", "REAL"), ("delivery_cost", "REAL"),
("discount_total", "REAL"), ("discount_total", "REAL"),
("web_status", "TEXT"), ("web_status", "TEXT"),
("discount_split", "TEXT"),
]: ]:
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}")

View File

@@ -32,8 +32,10 @@ class AppSettingsUpdate(BaseModel):
discount_vat: str = "21" discount_vat: str = "21"
discount_id_pol: str = "" discount_id_pol: str = ""
id_pol: str = "" id_pol: str = ""
id_pol_productie: str = ""
id_sectie: str = "" id_sectie: str = ""
id_gestiune: str = "" id_gestiune: str = ""
split_discount_vat: str = ""
gomag_api_key: str = "" gomag_api_key: str = ""
gomag_api_shop: str = "" gomag_api_shop: str = ""
gomag_order_days_back: str = "7" gomag_order_days_back: str = "7"
@@ -407,6 +409,13 @@ async def order_detail(order_number: str):
except Exception: except Exception:
pass pass
# Parse discount_split JSON string
if order.get("discount_split"):
try:
order["discount_split"] = json.loads(order["discount_split"])
except (json.JSONDecodeError, TypeError):
pass
return detail return detail
@@ -636,11 +645,13 @@ async def get_app_settings():
"transport_vat": s.get("transport_vat", "21"), "transport_vat": s.get("transport_vat", "21"),
"discount_codmat": s.get("discount_codmat", ""), "discount_codmat": s.get("discount_codmat", ""),
"transport_id_pol": s.get("transport_id_pol", ""), "transport_id_pol": s.get("transport_id_pol", ""),
"discount_vat": s.get("discount_vat", "19"), "discount_vat": s.get("discount_vat", "21"),
"discount_id_pol": s.get("discount_id_pol", ""), "discount_id_pol": s.get("discount_id_pol", ""),
"id_pol": s.get("id_pol", ""), "id_pol": s.get("id_pol", ""),
"id_pol_productie": s.get("id_pol_productie", ""),
"id_sectie": s.get("id_sectie", ""), "id_sectie": s.get("id_sectie", ""),
"id_gestiune": s.get("id_gestiune", ""), "id_gestiune": s.get("id_gestiune", ""),
"split_discount_vat": s.get("split_discount_vat", ""),
"gomag_api_key": s.get("gomag_api_key", "") or config_settings.GOMAG_API_KEY, "gomag_api_key": s.get("gomag_api_key", "") or config_settings.GOMAG_API_KEY,
"gomag_api_shop": s.get("gomag_api_shop", "") or config_settings.GOMAG_API_SHOP, "gomag_api_shop": s.get("gomag_api_shop", "") or config_settings.GOMAG_API_SHOP,
"gomag_order_days_back": s.get("gomag_order_days_back", "") or str(config_settings.GOMAG_ORDER_DAYS_BACK), "gomag_order_days_back": s.get("gomag_order_days_back", "") or str(config_settings.GOMAG_ORDER_DAYS_BACK),
@@ -659,8 +670,10 @@ async def update_app_settings(config: AppSettingsUpdate):
await sqlite_service.set_app_setting("discount_vat", config.discount_vat) await sqlite_service.set_app_setting("discount_vat", config.discount_vat)
await sqlite_service.set_app_setting("discount_id_pol", config.discount_id_pol) await sqlite_service.set_app_setting("discount_id_pol", config.discount_id_pol)
await sqlite_service.set_app_setting("id_pol", config.id_pol) await sqlite_service.set_app_setting("id_pol", config.id_pol)
await sqlite_service.set_app_setting("id_pol_productie", config.id_pol_productie)
await sqlite_service.set_app_setting("id_sectie", config.id_sectie) await sqlite_service.set_app_setting("id_sectie", config.id_sectie)
await sqlite_service.set_app_setting("id_gestiune", config.id_gestiune) await sqlite_service.set_app_setting("id_gestiune", config.id_gestiune)
await sqlite_service.set_app_setting("split_discount_vat", config.split_discount_vat)
await sqlite_service.set_app_setting("gomag_api_key", config.gomag_api_key) await sqlite_service.set_app_setting("gomag_api_key", config.gomag_api_key)
await sqlite_service.set_app_setting("gomag_api_shop", config.gomag_api_shop) await sqlite_service.set_app_setting("gomag_api_shop", config.gomag_api_shop)
await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back) await sqlite_service.set_app_setting("gomag_order_days_back", config.gomag_order_days_back)

View File

@@ -60,18 +60,81 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str:
return f"JUD:{region_clean};{city_clean};{address_clean}" return f"JUD:{region_clean};{city_clean};{address_clean}"
def compute_discount_split(order, settings: dict) -> dict | None:
"""Compute proportional discount split by VAT rate from order items.
Returns: {"11": 3.98, "21": 1.43} or None if split not applicable.
Only splits when split_discount_vat is enabled AND multiple VAT rates exist.
When single VAT rate: returns {actual_rate: total} (smarter than GoMag's fixed 21%).
"""
if not order or order.discount_total <= 0:
return None
split_enabled = settings.get("split_discount_vat") == "1"
# Calculate VAT distribution from order items (exclude zero-value)
vat_totals = {}
for item in order.items:
item_value = abs(item.price * item.quantity)
if item_value > 0:
vat_key = str(int(item.vat)) if item.vat == int(item.vat) else str(item.vat)
vat_totals[vat_key] = vat_totals.get(vat_key, 0) + item_value
if not vat_totals:
return None
grand_total = sum(vat_totals.values())
if grand_total <= 0:
return None
if len(vat_totals) == 1:
# Single VAT rate — use that rate (smarter than GoMag's fixed 21%)
actual_vat = list(vat_totals.keys())[0]
return {actual_vat: round(order.discount_total, 2)}
if not split_enabled:
return None
# Multiple VAT rates — split proportionally
result = {}
discount_remaining = order.discount_total
sorted_rates = sorted(vat_totals.keys(), key=lambda x: float(x))
for i, vat_rate in enumerate(sorted_rates):
if i == len(sorted_rates) - 1:
split_amount = round(discount_remaining, 2) # last gets remainder
else:
proportion = vat_totals[vat_rate] / grand_total
split_amount = round(order.discount_total * proportion, 2)
discount_remaining -= split_amount
if split_amount > 0:
result[vat_rate] = split_amount
return result if result else None
def build_articles_json(items, order=None, settings=None) -> str: def build_articles_json(items, order=None, settings=None) -> str:
"""Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda. """Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda.
Includes transport and discount as extra articles if configured.""" Includes transport and discount as extra articles if configured.
Supports per-article id_pol from codmat_policy_map and discount VAT splitting."""
articles = [] articles = []
codmat_policy_map = settings.get("_codmat_policy_map", {}) if settings else {}
default_id_pol = settings.get("id_pol", "") if settings else ""
for item in items: for item in items:
articles.append({ article_dict = {
"sku": item.sku, "sku": item.sku,
"quantity": str(item.quantity), "quantity": str(item.quantity),
"price": str(item.price), "price": str(item.price),
"vat": str(item.vat), "vat": str(item.vat),
"name": clean_web_text(item.name) "name": clean_web_text(item.name)
}) }
# Per-article id_pol from dual-policy validation
item_pol = codmat_policy_map.get(item.sku)
if item_pol and str(item_pol) != str(default_id_pol):
article_dict["id_pol"] = str(item_pol)
articles.append(article_dict)
if order and settings: if order and settings:
transport_codmat = settings.get("transport_codmat", "") transport_codmat = settings.get("transport_codmat", "")
@@ -90,25 +153,55 @@ def build_articles_json(items, order=None, settings=None) -> str:
if settings.get("transport_id_pol"): if settings.get("transport_id_pol"):
article_dict["id_pol"] = settings["transport_id_pol"] article_dict["id_pol"] = settings["transport_id_pol"]
articles.append(article_dict) articles.append(article_dict)
# Discount total with quantity -1 (positive price)
# Discount — smart VAT splitting
if order.discount_total > 0 and discount_codmat: if order.discount_total > 0 and discount_codmat:
# Use GoMag JSON discount VAT if available, fallback to settings discount_split = compute_discount_split(order, settings)
discount_vat = getattr(order, 'discount_vat', None) or settings.get("discount_vat", "19")
article_dict = { if discount_split and len(discount_split) > 1:
"sku": discount_codmat, # Multiple VAT rates — multiple discount lines
"quantity": "-1", for vat_rate, split_amount in sorted(discount_split.items(), key=lambda x: float(x[0])):
"price": str(order.discount_total), article_dict = {
"vat": discount_vat, "sku": discount_codmat,
"name": "Discount" "quantity": "-1",
} "price": str(split_amount),
if settings.get("discount_id_pol"): "vat": vat_rate,
article_dict["id_pol"] = settings["discount_id_pol"] "name": f"Discount (TVA {vat_rate}%)"
articles.append(article_dict) }
if settings.get("discount_id_pol"):
article_dict["id_pol"] = settings["discount_id_pol"]
articles.append(article_dict)
elif discount_split and len(discount_split) == 1:
# Single VAT rate — use detected rate
actual_vat = list(discount_split.keys())[0]
article_dict = {
"sku": discount_codmat,
"quantity": "-1",
"price": str(order.discount_total),
"vat": actual_vat,
"name": "Discount"
}
if settings.get("discount_id_pol"):
article_dict["id_pol"] = settings["discount_id_pol"]
articles.append(article_dict)
else:
# Fallback — original behavior with GoMag VAT or settings default
discount_vat = getattr(order, 'discount_vat', None) or settings.get("discount_vat", "21")
article_dict = {
"sku": discount_codmat,
"quantity": "-1",
"price": str(order.discount_total),
"vat": discount_vat,
"name": "Discount"
}
if settings.get("discount_id_pol"):
article_dict["id_pol"] = settings["discount_id_pol"]
articles.append(article_dict)
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_gestiune: 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) -> dict:
"""Import a single order into Oracle ROA. """Import a single order into Oracle ROA.
Returns dict with: Returns dict with:
@@ -246,6 +339,9 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
id_comanda = cur.var(oracledb.DB_TYPE_NUMBER) id_comanda = cur.var(oracledb.DB_TYPE_NUMBER)
# Convert list[int] to CSV string for Oracle VARCHAR2 param
id_gestiune_csv = ",".join(str(g) for g in id_gestiuni) if id_gestiuni else None
cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [ cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [
order_number, # p_nr_comanda_ext order_number, # p_nr_comanda_ext
order_date, # p_data_comanda order_date, # p_data_comanda
@@ -255,7 +351,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None, app_se
addr_fact_id, # p_id_adresa_facturare addr_fact_id, # p_id_adresa_facturare
id_pol, # p_id_pol id_pol, # p_id_pol
id_sectie, # p_id_sectie id_sectie, # p_id_sectie
id_gestiune, # p_id_gestiune id_gestiune_csv, # p_id_gestiune (CSV string)
id_comanda # v_id_comanda (OUT) id_comanda # v_id_comanda (OUT)
]) ])

View File

@@ -61,7 +61,7 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
payment_method: str = None, delivery_method: str = None, payment_method: str = None, delivery_method: str = None,
order_total: float = None, order_total: float = None,
delivery_cost: float = None, discount_total: float = None, delivery_cost: float = None, discount_total: float = None,
web_status: str = None): web_status: str = None, discount_split: str = None):
"""Upsert a single order — one row per order_number, status updated in place.""" """Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite() db = await get_sqlite()
try: try:
@@ -71,8 +71,8 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
id_comanda, id_partener, error_message, missing_skus, items_count, id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name, last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total, payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status) delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name, customer_name = excluded.customer_name,
status = CASE status = CASE
@@ -97,13 +97,14 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total), discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status), web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now') updated_at = datetime('now')
""", (order_number, order_date, customer_name, status, """, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, id_comanda, id_partener, error_message,
json.dumps(missing_skus) if missing_skus else None, json.dumps(missing_skus) if missing_skus else None,
items_count, sync_run_id, shipping_name, billing_name, items_count, sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total, payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status)) delivery_cost, discount_total, web_status, discount_split))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()
@@ -142,8 +143,8 @@ async def save_orders_batch(orders_data: list[dict]):
id_comanda, id_partener, error_message, missing_skus, items_count, id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name, last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total, payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status) delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name, customer_name = excluded.customer_name,
status = CASE status = CASE
@@ -168,6 +169,7 @@ async def save_orders_batch(orders_data: list[dict]):
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost), delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total), discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status), web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now') updated_at = datetime('now')
""", [ """, [
(d["order_number"], d["order_date"], d["customer_name"], d["status"], (d["order_number"], d["order_date"], d["customer_name"], d["status"],
@@ -178,7 +180,7 @@ async def save_orders_batch(orders_data: list[dict]):
d.get("payment_method"), d.get("delivery_method"), d.get("payment_method"), d.get("delivery_method"),
d.get("order_total"), d.get("order_total"),
d.get("delivery_cost"), d.get("discount_total"), d.get("delivery_cost"), d.get("discount_total"),
d.get("web_status")) d.get("web_status"), d.get("discount_split"))
for d in orders_data for d in orders_data
]) ])

View File

@@ -346,13 +346,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
app_settings = await sqlite_service.get_app_settings() app_settings = await sqlite_service.get_app_settings()
id_pol = id_pol or int(app_settings.get("id_pol") or 0) or settings.ID_POL id_pol = id_pol or int(app_settings.get("id_pol") or 0) or settings.ID_POL
id_sectie = id_sectie or int(app_settings.get("id_sectie") or 0) or settings.ID_SECTIE id_sectie = id_sectie or int(app_settings.get("id_sectie") or 0) or settings.ID_SECTIE
id_gestiune = int(app_settings.get("id_gestiune") or 0) or None # None = orice gestiune # Parse multi-gestiune CSV: "1,3" → [1, 3], "" → None
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNE={id_gestiune}") id_gestiune_raw = (app_settings.get("id_gestiune") or "").strip()
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNE={id_gestiune}") if id_gestiune_raw and id_gestiune_raw != "0":
id_gestiuni = [int(g) for g in id_gestiune_raw.split(",") if g.strip()]
else:
id_gestiuni = None # None = orice gestiune
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNI={id_gestiuni}")
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}, ID_GESTIUNI={id_gestiuni}")
# Step 2b: Validate SKUs (reuse same connection) # Step 2b: Validate SKUs (reuse same connection)
all_skus = order_reader.get_all_skus(orders) all_skus = order_reader.get_all_skus(orders)
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn, id_gestiune) validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn, id_gestiuni)
importable, skipped = validation_service.classify_orders(orders, validation) importable, skipped = validation_service.classify_orders(orders, validation)
# ── Split importable into truly_importable vs already_in_roa ── # ── Split importable into truly_importable vs already_in_roa ──
@@ -416,21 +421,86 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
pass pass
elif item.sku in validation["direct"]: elif item.sku in validation["direct"]:
all_codmats.add(item.sku) all_codmats.add(item.sku)
# Get standard VAT rate from settings for PROC_TVAV metadata
cota_tva = float(app_settings.get("discount_vat") or 21)
# Dual pricing policy support
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
codmat_policy_map = {}
if all_codmats: if all_codmats:
price_result = await asyncio.to_thread( if id_pol_productie:
validation_service.validate_prices, all_codmats, id_pol, # Dual-policy: classify articles by cont (sales vs production)
conn, validation.get("direct_id_map") codmat_policy_map = await asyncio.to_thread(
) validation_service.validate_and_ensure_prices_dual,
if price_result["missing_price"]: all_codmats, id_pol, id_pol_productie,
logger.info( conn, validation.get("direct_id_map"),
f"Auto-adding price 0 for {len(price_result['missing_price'])} " cota_tva=cota_tva
f"direct articles in policy {id_pol}"
) )
await asyncio.to_thread( _log_line(run_id,
validation_service.ensure_prices, f"Politici duale: {sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, "
price_result["missing_price"], id_pol, 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") 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)
mapped_skus_in_orders = set()
for order in (truly_importable + already_in_roa):
for item in order.items:
if item.sku in validation["mapped"]:
mapped_skus_in_orders.add(item.sku)
if mapped_skus_in_orders:
mapped_codmat_data = await asyncio.to_thread(
validation_service.resolve_mapped_codmats, mapped_skus_in_orders, conn
)
# 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:
mp_result = await asyncio.to_thread(
validation_service.validate_prices,
mapped_codmats, id_pol, conn, mapped_id_map
)
if mp_result["missing_price"]:
await asyncio.to_thread(
validation_service.ensure_prices,
mp_result["missing_price"], id_pol,
conn, mapped_id_map, cota_tva=cota_tva
)
# Pass codmat_policy_map to import via app_settings
if codmat_policy_map:
app_settings["_codmat_policy_map"] = codmat_policy_map
finally: finally:
await asyncio.to_thread(database.pool.release, conn) await asyncio.to_thread(database.pool.release, conn)
@@ -515,7 +585,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
result = await asyncio.to_thread( result = await asyncio.to_thread(
import_service.import_single_order, import_service.import_single_order,
order, id_pol=id_pol, id_sectie=id_sectie, order, id_pol=id_pol, id_sectie=id_sectie,
app_settings=app_settings, id_gestiune=id_gestiune app_settings=app_settings, id_gestiuni=id_gestiuni
) )
# Build order items data for storage (R9) # Build order items data for storage (R9)
@@ -529,6 +599,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"cantitate_roa": None "cantitate_roa": None
}) })
# Compute discount split for SQLite storage
ds = import_service.compute_discount_split(order, app_settings)
discount_split_json = json.dumps(ds) if ds else None
if result["success"]: if result["success"]:
imported_count += 1 imported_count += 1
await sqlite_service.upsert_order( await sqlite_service.upsert_order(
@@ -548,6 +622,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
delivery_cost=order.delivery_cost or None, delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None, discount_total=order.discount_total or None,
web_status=order.status or None, web_status=order.status or None,
discount_split=discount_split_json,
) )
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED") await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
# Store ROA address IDs (R9) # Store ROA address IDs (R9)
@@ -577,6 +652,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
delivery_cost=order.delivery_cost or None, delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None, discount_total=order.discount_total or None,
web_status=order.status 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_sync_run_order(run_id, order.number, "ERROR")
await sqlite_service.add_order_items(order.number, order_items_data) await sqlite_service.add_order_items(order.number, order_items_data)

View File

@@ -28,10 +28,11 @@ def check_orders_in_roa(min_date, conn) -> dict:
return existing return existing
def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) -> dict[str, int]: def resolve_codmat_ids(codmats: set[str], id_gestiuni: list[int] = None, conn=None) -> dict[str, dict]:
"""Resolve CODMATs to best id_articol: prefers article with stock, then MAX(id_articol). """Resolve CODMATs to best id_articol + cont: prefers article with stock, then MAX(id_articol).
Filters: sters=0 AND inactiv=0. Filters: sters=0 AND inactiv=0.
Returns: {codmat: id_articol} id_gestiuni: list of warehouse IDs to check stock in, or None for all.
Returns: {codmat: {"id_articol": int, "cont": str|None}}
""" """
if not codmats: if not codmats:
return {} return {}
@@ -40,8 +41,9 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) ->
codmat_list = list(codmats) codmat_list = list(codmats)
# Build stoc subquery dynamically for index optimization # Build stoc subquery dynamically for index optimization
if id_gestiune is not None: if id_gestiuni:
stoc_filter = "AND s.id_gestiune = :id_gestiune" gest_placeholders = ",".join([f":g{k}" for k in range(len(id_gestiuni))])
stoc_filter = f"AND s.id_gestiune IN ({gest_placeholders})"
else: else:
stoc_filter = "" stoc_filter = ""
@@ -54,12 +56,13 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) ->
batch = codmat_list[i:i+500] batch = codmat_list[i:i+500]
placeholders = ",".join([f":c{j}" for j in range(len(batch))]) placeholders = ",".join([f":c{j}" for j in range(len(batch))])
params = {f"c{j}": cm for j, cm in enumerate(batch)} params = {f"c{j}": cm for j, cm in enumerate(batch)}
if id_gestiune is not None: if id_gestiuni:
params["id_gestiune"] = id_gestiune for k, gid in enumerate(id_gestiuni):
params[f"g{k}"] = gid
cur.execute(f""" cur.execute(f"""
SELECT codmat, id_articol FROM ( SELECT codmat, id_articol, cont FROM (
SELECT na.codmat, na.id_articol, SELECT na.codmat, na.id_articol, na.cont,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY na.codmat PARTITION BY na.codmat
ORDER BY ORDER BY
@@ -79,22 +82,22 @@ def resolve_codmat_ids(codmats: set[str], id_gestiune: int = None, conn=None) ->
) WHERE rn = 1 ) WHERE rn = 1
""", params) """, params)
for row in cur: for row in cur:
result[row[0]] = row[1] result[row[0]] = {"id_articol": row[1], "cont": row[2]}
finally: finally:
if own_conn: if own_conn:
database.pool.release(conn) database.pool.release(conn)
logger.info(f"resolve_codmat_ids: {len(result)}/{len(codmats)} resolved (gestiune={id_gestiune})") logger.info(f"resolve_codmat_ids: {len(result)}/{len(codmats)} resolved (gestiuni={id_gestiuni})")
return result return result
def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict: def validate_skus(skus: set[str], conn=None, id_gestiuni: list[int] = None) -> dict:
"""Validate a set of SKUs against Oracle. """Validate a set of SKUs against Oracle.
Returns: {mapped: set, direct: set, missing: set, direct_id_map: {codmat: id_articol}} Returns: {mapped: set, direct: set, missing: set, direct_id_map: {codmat: {"id_articol": int, "cont": str|None}}}
- mapped: found in ARTICOLE_TERTI (active) - mapped: found in ARTICOLE_TERTI (active)
- direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI) - direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI)
- missing: not found anywhere - missing: not found anywhere
- direct_id_map: {codmat: id_articol} for direct SKUs (saves a round-trip in validate_prices) - direct_id_map: {codmat: {"id_articol": int, "cont": str|None}} for direct SKUs
""" """
if not skus: if not skus:
return {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}} return {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}}
@@ -124,11 +127,12 @@ def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict:
# Resolve remaining SKUs via resolve_codmat_ids (consistent id_articol selection) # Resolve remaining SKUs via resolve_codmat_ids (consistent id_articol selection)
all_remaining = [s for s in sku_list if s not in mapped] all_remaining = [s for s in sku_list if s not in mapped]
if all_remaining: if all_remaining:
direct_id_map = resolve_codmat_ids(set(all_remaining), id_gestiune, conn) direct_id_map = resolve_codmat_ids(set(all_remaining), id_gestiuni, conn)
direct = set(direct_id_map.keys()) direct = set(direct_id_map.keys())
else: else:
direct_id_map = {} direct_id_map = {}
direct = set() direct = set()
finally: finally:
if own_conn: if own_conn:
database.pool.release(conn) database.pool.release(conn)
@@ -136,7 +140,8 @@ def validate_skus(skus: set[str], conn=None, id_gestiune: int = None) -> dict:
missing = skus - mapped - direct missing = skus - mapped - direct
logger.info(f"SKU validation: {len(mapped)} mapped, {len(direct)} direct, {len(missing)} missing") logger.info(f"SKU validation: {len(mapped)} mapped, {len(direct)} direct, {len(missing)} missing")
return {"mapped": mapped, "direct": direct, "missing": missing, "direct_id_map": direct_id_map} return {"mapped": mapped, "direct": direct, "missing": missing,
"direct_id_map": direct_id_map}
def classify_orders(orders, validation_result): def classify_orders(orders, validation_result):
"""Classify orders as importable or skipped based on SKU validation. """Classify orders as importable or skipped based on SKU validation.
@@ -158,6 +163,19 @@ def classify_orders(orders, validation_result):
return importable, skipped return importable, skipped
def _extract_id_map(direct_id_map: dict) -> dict:
"""Extract {codmat: id_articol} from either enriched or simple format."""
if not direct_id_map:
return {}
result = {}
for cm, val in direct_id_map.items():
if isinstance(val, dict):
result[cm] = val["id_articol"]
else:
result[cm] = val
return result
def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None) -> dict: def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None) -> dict:
"""Check which CODMATs have a price entry in CRM_POLITICI_PRET_ART for the given policy. """Check which CODMATs have a price entry in CRM_POLITICI_PRET_ART for the given policy.
If direct_id_map is provided, skips the NOM_ARTICOLE lookup for those CODMATs. If direct_id_map is provided, skips the NOM_ARTICOLE lookup for those CODMATs.
@@ -166,7 +184,7 @@ def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: di
if not codmats: if not codmats:
return {"has_price": set(), "missing_price": set()} return {"has_price": set(), "missing_price": set()}
codmat_to_id = dict(direct_id_map) if direct_id_map else {} codmat_to_id = _extract_id_map(direct_id_map)
ids_with_price = set() ids_with_price = set()
own_conn = conn is None own_conn = conn is None
@@ -199,14 +217,18 @@ def validate_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: di
logger.info(f"Price validation (policy {id_pol}): {len(has_price)} have price, {len(missing_price)} missing price") logger.info(f"Price validation (policy {id_pol}): {len(has_price)} have price, {len(missing_price)} missing price")
return {"has_price": has_price, "missing_price": missing_price} return {"has_price": has_price, "missing_price": missing_price}
def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None): def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict=None,
cota_tva: float = None):
"""Insert price 0 entries for CODMATs missing from the given price policy. """Insert price 0 entries for CODMATs missing from the given price policy.
Uses batch executemany instead of individual INSERTs. Uses batch executemany instead of individual INSERTs.
Relies on TRG_CRM_POLITICI_PRET_ART trigger for ID_POL_ART sequence. Relies on TRG_CRM_POLITICI_PRET_ART trigger for ID_POL_ART sequence.
cota_tva: VAT rate from settings (e.g. 21) — used for PROC_TVAV metadata.
""" """
if not codmats: if not codmats:
return return
proc_tvav = 1 + (cota_tva / 100) if cota_tva else 1.21
own_conn = conn is None own_conn = conn is None
if own_conn: if own_conn:
conn = database.get_oracle_connection() conn = database.get_oracle_connection()
@@ -224,7 +246,7 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict
# Build batch params using direct_id_map (already resolved via resolve_codmat_ids) # Build batch params using direct_id_map (already resolved via resolve_codmat_ids)
batch_params = [] batch_params = []
codmat_id_map = dict(direct_id_map) if direct_id_map else {} codmat_id_map = _extract_id_map(direct_id_map)
for codmat in codmats: for codmat in codmats:
id_articol = codmat_id_map.get(codmat) id_articol = codmat_id_map.get(codmat)
@@ -234,7 +256,8 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict
batch_params.append({ batch_params.append({
"id_pol": id_pol, "id_pol": id_pol,
"id_articol": id_articol, "id_articol": id_articol,
"id_valuta": id_valuta "id_valuta": id_valuta,
"proc_tvav": proc_tvav
}) })
if batch_params: if batch_params:
@@ -244,9 +267,9 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict
ID_UTIL, DATAORA, PROC_TVAV, PRETFTVA, PRETCTVA) ID_UTIL, DATAORA, PROC_TVAV, PRETFTVA, PRETCTVA)
VALUES VALUES
(:id_pol, :id_articol, 0, :id_valuta, (:id_pol, :id_articol, 0, :id_valuta,
-3, SYSDATE, 1.19, 0, 0) -3, SYSDATE, :proc_tvav, 0, 0)
""", batch_params) """, batch_params)
logger.info(f"Batch inserted {len(batch_params)} price entries for policy {id_pol}") logger.info(f"Batch inserted {len(batch_params)} price entries for policy {id_pol} (PROC_TVAV={proc_tvav})")
conn.commit() conn.commit()
finally: finally:
@@ -254,3 +277,125 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict
database.pool.release(conn) database.pool.release(conn)
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}") logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")
def validate_and_ensure_prices_dual(codmats: set[str], id_pol_vanzare: int,
id_pol_productie: int, conn, direct_id_map: dict,
cota_tva: float = 21) -> dict[str, int]:
"""Dual-policy price validation: assign each CODMAT to sales or production policy.
Logic:
1. Check both policies in one SQL
2. If article in one policy → use that
3. If article in BOTH → prefer id_pol_vanzare
4. If article in NEITHER → check cont: 341/345 → production, else → sales; insert price 0
Returns: codmat_policy_map = {codmat: assigned_id_pol}
"""
if not codmats:
return {}
codmat_policy_map = {}
id_map = _extract_id_map(direct_id_map)
# Collect all id_articol values we need to check
id_to_codmats = {} # {id_articol: [codmat, ...]}
for cm in codmats:
aid = id_map.get(cm)
if aid:
id_to_codmats.setdefault(aid, []).append(cm)
if not id_to_codmats:
return {}
# Query both policies in one SQL
existing = {} # {id_articol: set of id_pol}
id_list = list(id_to_codmats.keys())
with conn.cursor() as cur:
for i in range(0, len(id_list), 500):
batch = id_list[i:i+500]
placeholders = ",".join([f":a{j}" for j in range(len(batch))])
params = {f"a{j}": aid for j, aid in enumerate(batch)}
params["id_pol_v"] = id_pol_vanzare
params["id_pol_p"] = id_pol_productie
cur.execute(f"""
SELECT pa.ID_ARTICOL, pa.ID_POL FROM CRM_POLITICI_PRET_ART pa
WHERE pa.ID_POL IN (:id_pol_v, :id_pol_p) AND pa.ID_ARTICOL IN ({placeholders})
""", params)
for row in cur:
existing.setdefault(row[0], set()).add(row[1])
# Classify each codmat
missing_vanzare = set() # CODMATs needing price 0 in sales policy
missing_productie = set() # CODMATs needing price 0 in production policy
for aid, cms in id_to_codmats.items():
pols = existing.get(aid, set())
for cm in cms:
if pols:
if id_pol_vanzare in pols:
codmat_policy_map[cm] = id_pol_vanzare
elif id_pol_productie in pols:
codmat_policy_map[cm] = id_pol_productie
else:
# Not in any policy — classify by cont
info = direct_id_map.get(cm, {})
cont = info.get("cont", "") if isinstance(info, dict) else ""
cont_str = str(cont or "").strip()
if cont_str in ("341", "345"):
codmat_policy_map[cm] = id_pol_productie
missing_productie.add(cm)
else:
codmat_policy_map[cm] = id_pol_vanzare
missing_vanzare.add(cm)
# Ensure prices for missing articles in each policy
if missing_vanzare:
ensure_prices(missing_vanzare, id_pol_vanzare, conn, direct_id_map, cota_tva=cota_tva)
if missing_productie:
ensure_prices(missing_productie, id_pol_productie, conn, direct_id_map, cota_tva=cota_tva)
logger.info(
f"Dual-policy: {len(codmat_policy_map)} CODMATs assigned "
f"(vanzare={sum(1 for v in codmat_policy_map.values() if v == id_pol_vanzare)}, "
f"productie={sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)})"
)
return codmat_policy_map
def resolve_mapped_codmats(mapped_skus: set[str], conn) -> dict[str, list[dict]]:
"""For mapped SKUs, get their underlying CODMATs from ARTICOLE_TERTI + nom_articole.
Returns: {sku: [{"codmat": str, "id_articol": int, "cont": str|None}]}
"""
if not mapped_skus:
return {}
result = {}
sku_list = list(mapped_skus)
with conn.cursor() as cur:
for i in range(0, len(sku_list), 500):
batch = sku_list[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 at.sku, at.codmat, na.id_articol, na.cont
FROM ARTICOLE_TERTI at
JOIN NOM_ARTICOLE na ON na.codmat = at.codmat AND na.sters = 0 AND na.inactiv = 0
WHERE at.sku IN ({placeholders}) AND at.activ = 1 AND at.sters = 0
""", params)
for row in cur:
sku = row[0]
if sku not in result:
result[sku] = []
result[sku].append({
"codmat": row[1],
"id_articol": row[2],
"cont": row[3]
})
logger.info(f"resolve_mapped_codmats: {len(result)} SKUs → {sum(len(v) for v in result.values())} CODMATs")
return result

View File

@@ -578,7 +578,19 @@ async function openDashOrderDetail(orderNumber) {
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : ''; if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '';
const dscEl = document.getElementById('detailDiscount'); const dscEl = document.getElementById('detailDiscount');
if (dscEl) dscEl.textContent = order.discount_total > 0 ? '' + Number(order.discount_total).toFixed(2) + ' lei' : ''; if (dscEl) {
if (order.discount_total > 0 && order.discount_split && typeof order.discount_split === 'object') {
const entries = Object.entries(order.discount_split);
if (entries.length > 1) {
const parts = entries.map(([vat, amt]) => `${Number(amt).toFixed(2)} (TVA ${vat}%)`);
dscEl.innerHTML = parts.join('<br>');
} else {
dscEl.textContent = '' + Number(order.discount_total).toFixed(2) + ' lei';
}
} else {
dscEl.textContent = order.discount_total > 0 ? '' + Number(order.discount_total).toFixed(2) + ' lei' : '';
}
}
const items = data.items || []; const items = data.items || [];
if (items.length === 0) { if (items.length === 0) {

View File

@@ -18,12 +18,13 @@ async function loadDropdowns() {
const politici = await politiciRes.json(); const politici = await politiciRes.json();
const gestiuni = await gestiuniRes.json(); const gestiuni = await gestiuniRes.json();
const gestiuneEl = document.getElementById('settIdGestiune'); const gestContainer = document.getElementById('settGestiuniContainer');
if (gestiuneEl) { if (gestContainer) {
gestiuneEl.innerHTML = '<option value="">— orice gestiune —</option>'; gestContainer.innerHTML = '';
gestiuni.forEach(g => { gestiuni.forEach(g => {
gestiuneEl.innerHTML += `<option value="${escHtml(g.id)}">${escHtml(g.label)}</option>`; gestContainer.innerHTML += `<div class="form-check mb-0"><input class="form-check-input" type="checkbox" value="${escHtml(g.id)}" id="gestChk_${escHtml(g.id)}"><label class="form-check-label" for="gestChk_${escHtml(g.id)}">${escHtml(g.label)}</label></div>`;
}); });
if (gestiuni.length === 0) gestContainer.innerHTML = '<span class="text-muted small">Nicio gestiune disponibilă</span>';
} }
const sectieEl = document.getElementById('settIdSectie'); const sectieEl = document.getElementById('settIdSectie');
@@ -57,6 +58,14 @@ async function loadDropdowns() {
dPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`; dPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
}); });
} }
const pPolEl = document.getElementById('settIdPolProductie');
if (pPolEl) {
pPolEl.innerHTML = '<option value="">— fără politică producție —</option>';
politici.forEach(p => {
pPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
});
}
} catch (err) { } catch (err) {
console.error('loadDropdowns error:', err); console.error('loadDropdowns error:', err);
} }
@@ -71,11 +80,21 @@ async function loadSettings() {
if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21'; if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21';
if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || ''; if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || '';
if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || ''; if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || '';
if (el('settDiscountVat')) el('settDiscountVat').value = data.discount_vat || '19'; if (el('settDiscountVat')) el('settDiscountVat').value = data.discount_vat || '21';
if (el('settDiscountIdPol')) el('settDiscountIdPol').value = data.discount_id_pol || ''; if (el('settDiscountIdPol')) el('settDiscountIdPol').value = data.discount_id_pol || '';
if (el('settSplitDiscountVat')) el('settSplitDiscountVat').checked = data.split_discount_vat === "1";
if (el('settIdPol')) el('settIdPol').value = data.id_pol || ''; if (el('settIdPol')) el('settIdPol').value = data.id_pol || '';
if (el('settIdPolProductie')) el('settIdPolProductie').value = data.id_pol_productie || '';
if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || ''; if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || '';
if (el('settIdGestiune')) el('settIdGestiune').value = data.id_gestiune || ''; // Multi-gestiune checkboxes
const gestVal = data.id_gestiune || '';
if (gestVal) {
const selectedIds = gestVal.split(',').map(s => s.trim());
selectedIds.forEach(id => {
const chk = document.getElementById('gestChk_' + id);
if (chk) chk.checked = true;
});
}
if (el('settGomagApiKey')) el('settGomagApiKey').value = data.gomag_api_key || ''; if (el('settGomagApiKey')) el('settGomagApiKey').value = data.gomag_api_key || '';
if (el('settGomagApiShop')) el('settGomagApiShop').value = data.gomag_api_shop || ''; if (el('settGomagApiShop')) el('settGomagApiShop').value = data.gomag_api_shop || '';
if (el('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7'; if (el('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7';
@@ -93,11 +112,13 @@ async function saveSettings() {
transport_vat: el('settTransportVat')?.value || '21', transport_vat: el('settTransportVat')?.value || '21',
transport_id_pol: el('settTransportIdPol')?.value?.trim() || '', transport_id_pol: el('settTransportIdPol')?.value?.trim() || '',
discount_codmat: el('settDiscountCodmat')?.value?.trim() || '', discount_codmat: el('settDiscountCodmat')?.value?.trim() || '',
discount_vat: el('settDiscountVat')?.value || '19', discount_vat: el('settDiscountVat')?.value || '21',
discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '', discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '',
split_discount_vat: el('settSplitDiscountVat')?.checked ? "1" : "",
id_pol: el('settIdPol')?.value?.trim() || '', id_pol: el('settIdPol')?.value?.trim() || '',
id_pol_productie: el('settIdPolProductie')?.value?.trim() || '',
id_sectie: el('settIdSectie')?.value?.trim() || '', id_sectie: el('settIdSectie')?.value?.trim() || '',
id_gestiune: el('settIdGestiune')?.value?.trim() || '', id_gestiune: Array.from(document.querySelectorAll('#settGestiuniContainer input:checked')).map(c => c.value).join(','),
gomag_api_key: el('settGomagApiKey')?.value?.trim() || '', gomag_api_key: el('settGomagApiKey')?.value?.trim() || '',
gomag_api_shop: el('settGomagApiShop')?.value?.trim() || '', gomag_api_shop: el('settGomagApiShop')?.value?.trim() || '',
gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7', gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7',

View File

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

View File

@@ -39,10 +39,11 @@
<div class="card-header py-2 px-3 fw-semibold">Import ROA</div> <div class="card-header py-2 px-3 fw-semibold">Import ROA</div>
<div class="card-body py-2 px-3"> <div class="card-body py-2 px-3">
<div class="mb-2"> <div class="mb-2">
<label class="form-label mb-0 small">Gestiune (ID_GESTIUNE)</label> <label class="form-label mb-0 small">Gestiuni pentru verificare stoc</label>
<select class="form-select form-select-sm" id="settIdGestiune"> <div id="settGestiuniContainer" class="border rounded p-2" style="max-height:120px;overflow-y:auto;font-size:0.85rem">
<option value="">— orice gestiune —</option> <span class="text-muted small">Se încarcă...</span>
</select> </div>
<div class="form-text" style="font-size:0.75rem">Nicio selecție = orice gestiune</div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label mb-0 small">Secție (ID_SECTIE)</label> <label class="form-label mb-0 small">Secție (ID_SECTIE)</label>
@@ -51,11 +52,18 @@
</select> </select>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label mb-0 small">Politică de Preț (ID_POL)</label> <label class="form-label mb-0 small">Politică Preț Vânzare (ID_POL)</label>
<select class="form-select form-select-sm" id="settIdPol"> <select class="form-select form-select-sm" id="settIdPol">
<option value="">— selectează politică —</option> <option value="">— selectează politică —</option>
</select> </select>
</div> </div>
<div class="mb-2">
<label class="form-label mb-0 small">Politică Preț Producție</label>
<select class="form-select form-select-sm" id="settIdPolProductie">
<option value="">— fără politică producție —</option>
</select>
<div class="form-text" style="font-size:0.75rem">Pentru articole cu cont 341/345 (producție proprie)</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -113,8 +121,9 @@
<select class="form-select form-select-sm" id="settDiscountVat"> <select class="form-select form-select-sm" id="settDiscountVat">
<option value="5">5%</option> <option value="5">5%</option>
<option value="9">9%</option> <option value="9">9%</option>
<option value="19" selected>19%</option> <option value="11">11%</option>
<option value="21">21%</option> <option value="19">19%</option>
<option value="21" selected>21%</option>
</select> </select>
</div> </div>
<div class="col-6"> <div class="col-6">
@@ -124,6 +133,12 @@
</select> </select>
</div> </div>
</div> </div>
<div class="mt-2 form-check">
<input type="checkbox" class="form-check-input" id="settSplitDiscountVat">
<label class="form-check-label small" for="settSplitDiscountVat">
Împarte discount pe cote TVA (proporțional cu valoarea articolelor)
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -152,5 +167,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=4"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=6"></script>
{% endblock %} {% endblock %}

View File

@@ -61,7 +61,7 @@ CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS
p_id_adresa_facturare IN NUMBER DEFAULT NULL, p_id_adresa_facturare IN NUMBER DEFAULT NULL,
p_id_pol IN NUMBER DEFAULT NULL, p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL, p_id_sectie IN NUMBER DEFAULT NULL,
p_id_gestiune IN NUMBER DEFAULT NULL, p_id_gestiune IN VARCHAR2 DEFAULT NULL,
v_id_comanda OUT NUMBER); v_id_comanda OUT NUMBER);
-- Functii pentru managementul erorilor (pentru orchestrator VFP) -- Functii pentru managementul erorilor (pentru orchestrator VFP)
@@ -93,11 +93,11 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
-- Functie helper: selecteaza id_articol corect pentru un CODMAT -- Functie helper: selecteaza id_articol corect pentru un CODMAT
-- Prioritate: sters=0 AND inactiv=0, preferinta stoc, MAX(id_articol) fallback -- Prioritate: sters=0 AND inactiv=0, preferinta stoc, MAX(id_articol) fallback
-- ================================================================ -- ================================================================
FUNCTION resolve_id_articol(p_codmat IN VARCHAR2, p_id_gest IN NUMBER) RETURN NUMBER IS FUNCTION resolve_id_articol(p_codmat IN VARCHAR2, p_id_gest IN VARCHAR2) RETURN NUMBER IS
v_result NUMBER; v_result NUMBER;
BEGIN BEGIN
IF p_id_gest IS NOT NULL THEN IF p_id_gest IS NOT NULL THEN
-- Cu gestiune specifica — Oracle poate folosi index pe stoc(id_gestiune, an, luna) -- Cu gestiuni specifice (CSV: "1,3") — split in subquery pentru IN clause
BEGIN BEGIN
SELECT id_articol INTO v_result FROM ( SELECT id_articol INTO v_result FROM (
SELECT na.id_articol SELECT na.id_articol
@@ -107,7 +107,11 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
CASE WHEN EXISTS ( CASE WHEN EXISTS (
SELECT 1 FROM stoc s SELECT 1 FROM stoc s
WHERE s.id_articol = na.id_articol WHERE s.id_articol = na.id_articol
AND s.id_gestiune = p_id_gest AND s.id_gestiune IN (
SELECT TO_NUMBER(REGEXP_SUBSTR(p_id_gest, '[^,]+', 1, LEVEL))
FROM DUAL
CONNECT BY LEVEL <= REGEXP_COUNT(p_id_gest, ',') + 1
)
AND s.an = EXTRACT(YEAR FROM SYSDATE) AND s.an = EXTRACT(YEAR FROM SYSDATE)
AND s.luna = EXTRACT(MONTH FROM SYSDATE) AND s.luna = EXTRACT(MONTH FROM SYSDATE)
AND s.cants + s.cant - s.cante > 0 AND s.cants + s.cant - s.cante > 0
@@ -150,7 +154,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
p_id_adresa_facturare IN NUMBER DEFAULT NULL, p_id_adresa_facturare IN NUMBER DEFAULT NULL,
p_id_pol IN NUMBER DEFAULT NULL, p_id_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL, p_id_sectie IN NUMBER DEFAULT NULL,
p_id_gestiune IN NUMBER DEFAULT NULL, p_id_gestiune IN VARCHAR2 DEFAULT NULL,
v_id_comanda OUT NUMBER) IS v_id_comanda OUT NUMBER) IS
v_data_livrare DATE; v_data_livrare DATE;
v_sku VARCHAR2(100); v_sku VARCHAR2(100);