Compare commits

...

51 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
Claude Agent
69a3088579 refactor(dashboard): move search box to filter bar after period dropdown
Search was hidden in card header — now inline with filters for better
discoverability. Compact refresh button to icon-only. On mobile, search
and period dropdown share the same row via flex.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:08:58 +00:00
Claude Agent
3d212979d9 refactor(dashboard): move search box from filter bar to card header
Reduces vertical space by eliminating the second row in the filter bar.
Search input is now next to the "Comenzi" title, hidden on mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:04:10 +00:00
Claude Agent
7dd39f9712 feat(order-detail): show CODMAT for direct SKUs + mapping validations
- Enrich order detail items with NOM_ARTICOLE data for direct SKUs
  (SKU=CODMAT) that have no ARTICOLE_TERTI entry
- Validate CODMAT exists in nomenclator before saving mapping (400)
- Block redundant self-mapping when SKU is already direct CODMAT (409)
- Show "direct" badge in CODMAT column for direct SKUs
- Show info alert in quick map modal for direct SKUs
- Display backend validation errors inline in modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:10:20 +00:00
Claude Agent
f74322beab fix(dashboard): update sync card after completion + use Bucharest timezone
Sync card was showing previous run data after sync completed because the
last_run query excluded the current run_id even after it finished. Now only
excludes during active running state.

All datetime.now() and SQLite datetime('now') replaced with Europe/Bucharest
timezone to fix times displayed 2 hours behind (was using UTC).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:02:18 +00:00
Claude Agent
f5ef9e0811 chore: move working scripts to scripts/work/ (gitignored)
Prevents untracked file conflicts on git pull on Windows server.
Scripts are development/analysis tools, not part of the deployed app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:34:34 +00:00
Claude Agent
06f8fa5842 cleanup: remove 5 duplicate scripts from scripts/
Removed scripts covered by more complete alternatives:
- match_by_price.py, match_invoices.py → covered by match_all.py
- compare_detail.py → covered by match_all.py
- reset_sqlite.py → covered by delete_imported.py (includes Oracle + dry-run)
- debug_match.py → one-off hardcoded debug script

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:23:40 +00:00
Claude Agent
7a2408e310 fix(import): resolve correct id_articol for duplicate CODMATs + gestiune setting
Unified id_articol selection logic in Python (resolve_codmat_ids) and PL/SQL
(resolve_id_articol): filters sters=0 AND inactiv=0, prefers article with
stock in configured gestiune, falls back to MAX(id_articol). Eliminates
mismatch where Python and PL/SQL could pick different id_articol for the
same CODMAT, causing ORA-20000 price-not-found errors.

- Add resolve_codmat_ids helper in validation_service.py (single batch query)
- Refactor validate_skus/validate_prices/ensure_prices to use it
- Add resolve_id_articol function in PL/SQL package body
- Add p_id_gestiune parameter to importa_comanda (spec + body)
- Add /api/settings/gestiuni endpoint and id_gestiune setting
- Add gestiune dropdown in settings UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:18:18 +00:00
Claude Agent
09a5403f83 add: handoff notes for SKU mapping discovery session
Documents what was tried, what worked (order-invoice matching),
what failed (line item matching by price), and proposed strategy
for next session (subset → confirm → generalize).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:06:23 +00:00
Claude Agent
3d73d9e422 add: scripts for invoice-order matching and SKU discovery
Analysis scripts to match GoMag orders with Oracle invoices by
date/client/total, then compare line items by price to discover
SKU → id_articol mappings. Generates SQL for nom_articole codmat
updates and CSV for ARTICOLE_TERTI repackaging/set mappings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:01:51 +00:00
Claude Agent
dafc2df0d4 feat(dashboard): auto-refresh after sync, configurable polling, extra filters
- Detect missed sync completions via last_run.run_id comparison
- Load polling interval from settings (dashboard_poll_seconds, default 5s)
- Add 1min/3min scheduler interval options
- Add 1zi/2zile period filter options
- New Dashboard settings card for polling interval

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 11:48:29 +00:00
Claude Agent
5e01fefd4c feat(sync): handle cancelled GoMag orders (status Anulata / statusId 7)
- Add web_status column to orders table (generic name for platform status)
- Filter cancelled orders during sync, record as CANCELLED in SQLite
- Soft-delete previously-imported cancelled orders in Oracle (if not invoiced)
- Add CANCELLED filter pill + badge in dashboard UI
- New soft_delete_order_in_roa() and mark_order_cancelled() functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:50:38 +00:00
Claude Agent
8020b2d14b fix(dashboard): renderClientCell shows customer_name (partner) as primary
renderClientCell was showing shipping_name (person) instead of
customer_name (company/partner). Now shows customer_name with tooltip
for shipping person when different (e.g. company orders).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:28:35 +00:00
Claude Agent
172debdbdb fix(dashboard): show customer_name (partner) instead of shipping_name
Dashboard list was prioritizing shipping_name over customer_name,
so company orders showed the person instead of the company name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:25:56 +00:00
Claude Agent
ecb4777a35 fix(sqlite): update customer_name on upsert, not just on insert
customer_name was only set on INSERT but not updated on ON CONFLICT,
so re-synced orders kept the old (wrong) customer name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:18:21 +00:00
Claude Agent
cc872cfdad fix(sync): customer_name reflects invoice partner (company or shipping person)
When billing is on a company, customer_name now uses billing.company_name
instead of shipping person name. This aligns SQLite customer_name with the
partner created in ROA by import_service, making order-invoice correlation
possible in the dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:14:48 +00:00
Claude Agent
8d58e97ac6 fix(sync): clean old JSON files before downloading new orders
Previous sync runs left JSON files in the output directory, causing
order_reader to accumulate orders from multiple downloads instead of
only processing the latest batch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:29:07 +00:00
Claude Agent
b930b2bc85 documentatie 2026-03-16 18:18:45 +00:00
Claude Agent
5dfd795908 fix(sync): detect deleted orders and invoices in ROA
Previously, orders deleted from Oracle (sters=1) remained as IMPORTED
in SQLite, and deleted invoices kept stale cache data. Now the refresh
button and sync cycle re-verify all imported orders against Oracle:
- Deleted orders → marked DELETED_IN_ROA with cleared id_comanda
- Deleted invoices → invoice cache fields cleared
- New status badge for DELETED_IN_ROA in dashboard and logs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:18:36 +00:00
Claude Agent
27af22d241 update 2026-03-16 17:56:09 +00:00
Claude Agent
35e3881264 update 2026-03-16 17:55:32 +00:00
Claude Agent
2ad051efbc update 2026-03-16 17:54:09 +00:00
Claude Agent
e9cc41b282 update 2026-03-16 17:53:05 +00:00
Claude Agent
7241896749 update 2026-03-16 17:51:53 +00:00
Claude Agent
9ee61415cf feat(deploy): smart update script with skip-if-no-changes and silent mode
Only pulls and restarts the service when new commits exist.
Supports -Silent flag for Task Scheduler (logs to update.log).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:47:24 +00:00
Claude Agent
3208804966 style(ui): move invoice info to right column, single line, no bold
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:43:23 +00:00
Claude Agent
8827782aca fix(invoice): require factura_data in cache to avoid missing invoice date
Orders cached before the factura_data column was populated show "-"
for invoice date. Now both detail and dashboard endpoints require
factura_data to be present before using SQLite cache, falling through
to Oracle live query which fetches and caches the date.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:40:30 +00:00
Claude Agent
84b24b1434 feat(invoice+import): refresh facturi, detalii factura, fix duplicate CODMAT + rollback
- PL/SQL: handle duplicate CODMAT in nom_articole with MAX(id_articol)
- import_service: add explicit conn.rollback() on Oracle errors
- sync_service: auto-fix stale ERROR orders that exist in Oracle
- invoice_service: add data_act (invoice date) from vanzari table
- sync router: new POST /api/dashboard/refresh-invoices endpoint
- order detail: enrich with invoice data (serie, numar, data factura)
- dashboard: refresh invoices button (desktop + mobile icon)
- quick map modal: compact single-row layout, pre-populate existing mappings
- quick map: link on SKU column instead of CODMAT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:30:23 +00:00
Claude Agent
43327c4a70 feat(oracle): support per-article id_pol in PACK_IMPORT_COMENZI + deploy docs
- PACK_IMPORT_COMENZI: reads optional "id_pol" per article from JSON, uses it
  via NVL(v_id_pol_articol, p_id_pol) — enables separate price policy for
  transport/discount articles vs regular order articles
- README.md: add Windows deploy section (deploy.ps1, update.ps1, .env example)
- CLAUDE.md: add reference to Windows deploy docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 16:42:41 +00:00
Claude Agent
227dabd6d4 feat(settings): add GoMag API config, Oracle dropdowns, compact 2x2 layout
- Remove ID_GESTIUNE from config (unused)
- Add GoMag API settings (key, shop, days_back, limit) to SQLite — editable without restart
- sync_service reads GoMag settings from SQLite before download
- gomag_client.download_orders accepts api_key/api_shop/limit overrides
- New GET /api/settings/sectii and /api/settings/politici endpoints for Oracle dropdowns
  (nom_sectii.sectie, crm_politici_preturi.nume_lista_preturi)
- id_pol, id_sectie, transport_id_pol, discount_id_pol now use select dropdowns
- order_reader extracts discount_vat from GoMag JSON discounts[].vat
- import_service uses GoMag discount_vat as primary, settings as fallback
- settings.html redesigned to compact 2x2 grid (GoMag API | Import ROA / Transport | Discount)
- settings.js v2: loadDropdowns() sequential before loadSettings()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 16:39:59 +00:00
Claude Agent
a0649279cf log 2026-03-16 15:51:15 +00:00
Claude Agent
db29822a5b fix(js): add ROOT_PATH to window.location navigations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:25:43 +00:00
Claude Agent
49471e9f34 fix(js): patch fetch to prepend ROOT_PATH for IIS reverse proxy
All relative /api/... calls automatically get /gomag prefix via
global fetch wrapper in shared.js. ROOT_PATH injected from template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:19:50 +00:00
Claude Agent
ced6c0a2d4 fix(templates): use root_path for static assets instead of url_for
url_for generates absolute URLs with internal host (localhost:5003)
which browsers block via ERR_BLOCKED_BY_ORB. Using root_path prefix
generates correct relative paths (/gomag/static/...).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:15:56 +00:00
Claude Agent
843378061a feat(deploy): add update.ps1 for Windows server updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:07:13 +00:00
Claude Agent
a9d0cead79 chore: commit all pending changes including deploy scripts and Windows config
- deploy.ps1, iis-web.config: Windows Server deployment scripts
- api/app/routers/sync.py, dashboard.py: router updates
- api/app/services/import_service.py, sync_service.py: service updates
- api/app/static/css/style.css, js/*.js: UI updates
- api/database-scripts/08_PACK_FACTURARE.pck: Oracle package
- .gitignore: add .gittoken
- CLAUDE.md, agent configs: documentation updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:05:04 +00:00
Claude Agent
ee60a17f00 fix(templates): use url_for for static assets and root_path for nav links
Fixes 404 errors for CSS/JS when served behind IIS reverse proxy with
/gomag prefix. Replaces hardcoded /static/ paths with request.url_for()
and nav links with request.scope root_path prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:04:03 +00:00
Claude Agent
926543a2e4 fix(mappings): resolve 409 error on multi-CODMAT edit and make SKU editable
Batch create after soft-delete was rejected because create_mapping()
treated soft-deleted records as conflicts. Added auto_restore param
that restores+updates instead of 409 when called from edit flow.
Also removed readOnly on SKU input in edit modal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:31:03 +00:00
Claude Agent
25aa9e544c feat(sync): add delivery cost, discount tracking and import settings
Parse delivery.total and discounts[] from GoMag JSON into new
delivery_cost/discount_total fields. Add app_settings table for
configuring transport/discount CODMAT codes. When configured,
transport and discount are appended as extra articles in the
Oracle import JSON. Reorder Total column in dashboard/logs tables
and show transport/discount breakdown in order detail modals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:15:17 +00:00
Claude Agent
137c4a8b0b feat(ui): order totals, decimals, mobile modal cards, set editing
- Dashboard/Logs: Total column with 2 decimals (order_total)
- Order detail modal: totals summary row (items total + order total)
- Order detail modal mobile: compact article cards (d-md-none)
- Mappings: openEditModal loads all CODMATs for SKU, saveMapping
  replaces entire set via delete-all + batch POST
- Add project-specific team agents: ui-templates, ui-js, ui-verify,
  backend-api
- CLAUDE.md: mandatory preview approval before implementation,
  fix-loop after verification, server must start via start.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:55:58 +00:00
Claude Agent
ac8a01eb3e chore: add .playwright-mcp to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:21:17 +00:00
Claude Agent
c4fa643eca feat(sync): add order_total field to SQLite tracking
Parse order total from GoMag JSON, store in SQLite orders table,
and expose via sync run API. Enables total display in mobile flat rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:20:57 +00:00
Claude Agent
9a6bec33ff docs: rewrite CLAUDE.md and README.md, remove VFP references
- Remove all Visual FoxPro references (VFP fully replaced by Python)
- Add TeamCreate workflow for parallel UI development
- Document before/preview/after visual verification with Playwright
- Add mandatory rule: use TeamCreate, not superpowers subagents
- Update architecture, tech stack, project structure to current state
- Update import flow to reference Python services

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:20:36 +00:00
Claude Agent
680f670037 feat(ui): mobile UI polish with segmented controls and responsive navbar
- Replace filter pills with btn-group segmented controls on mobile (all pages)
- Add renderMobileSegmented() shared utility with colored count badges
- Compact sync card and logs run selector on mobile
- Unified flat-row format: dot + date + name + count (0.875rem throughout)
- Responsive navbar with short labels on mobile (Acasa/Mapari/Lipsa/Jurnale)
- Vertical dots icon (bi-three-dots-vertical) without dropdown caret
- Shorter "Mapare" button text on mobile, Re-scan in context menu
- Top pagination on logs page, hide per-page selector on mobile
- Cache-bust static assets to v=5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:20:24 +00:00
Claude Agent
5a0ea462e5 fix(validation): remove non-existent find_new_orders call
Replace broken asyncio.to_thread call with len(importable)
which already represents orders ready to process.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:59:05 +00:00
Claude Agent
452dc9b9f0 feat(mappings): strict validation + silent CSV skip for missing CODMAT
Add Pydantic validators and service-level checks that reject empty SKU/CODMAT
on create/edit (400). CSV import now silently skips rows without CODMAT and
counts them in skipped_no_codmat instead of treating them as errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:46:59 +00:00
Claude Agent
9cacc19d15 fix(ui): fix set pct badge logic and compact CODMAT form layout
- Fix is_complete check: use abs(pct-100)<=0.01 instead of >=99.99
  so sets with >100% total are correctly shown as incomplete
- Show pct badge with 2 decimals (e.g. "⚠️ 200.00%")
- Remove product name pre-fill in missing SKUs map modal CODMAT field
- Compact CODMAT lines to single row with placeholders instead of labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:21:49 +00:00
Claude Agent
15ccbe028a fix(dashboard): fix pill counts and Bootstrap UI cleanup
- IMPORTED pill now includes ALREADY_IMPORTED orders in count
- UNINVOICED filter includes ALREADY_IMPORTED orders
- Pill counts (Toate/Importate/Omise/Erori/Nefacturate) always reflect
  full period+search, independent of active status filter
- Nefacturate count computed from SQLite cache across full period,
  not just current page
- Bootstrap UI: design tokens, soft badge pills, consistent font sizes,
  purge inline styles from templates, move badge-pct to style.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:05:43 +00:00
43 changed files with 21791 additions and 1795 deletions

View File

@@ -0,0 +1,72 @@
---
name: backend-api
description: Team agent pentru modificari backend FastAPI — routers, services, modele Pydantic, integrare Oracle/SQLite. Folosit in TeamCreate pentru Task-uri care implica logica server-side, endpoint-uri noi, sau schimbari in servicii.
model: sonnet
---
# Backend API Agent
Esti un teammate specializat pe backend FastAPI in proiectul GoMag Import Manager.
## Responsabilitati
- Modificari in `api/app/routers/*.py` — endpoint-uri FastAPI
- Modificari in `api/app/services/*.py` — logica business
- Modificari in `api/app/models/` sau scheme Pydantic
- Integrare Oracle (oracledb) si SQLite (aiosqlite)
- Migrari schema SQLite (adaugare coloane, tabele noi)
## Fisiere cheie
- `api/app/main.py` — entry point, middleware, router include
- `api/app/config.py` — setari Pydantic (env vars)
- `api/app/database.py` — Oracle pool + SQLite connections
- `api/app/routers/dashboard.py` — comenzi dashboard
- `api/app/routers/sync.py` — sync, history, order detail
- `api/app/routers/mappings.py` — CRUD mapari SKU
- `api/app/routers/articles.py` — cautare articole Oracle
- `api/app/routers/validation.py` — validare comenzi
- `api/app/services/sync_service.py` — orchestrator sync
- `api/app/services/gomag_client.py` — client API GoMag
- `api/app/services/sqlite_service.py` — tracking local SQLite
- `api/app/services/mapping_service.py` — logica mapari
- `api/app/services/import_service.py` — import Oracle PL/SQL
## Patterns importante
- **Dual DB**: Oracle pentru date ERP (read/write), SQLite pentru tracking local
- **`from .. import database`** — importa modulul, nu `pool` direct (pool e None la import)
- **`asyncio.to_thread()`** — wrapeaza apeluri Oracle blocante
- **CLOB**: `cursor.var(oracledb.DB_TYPE_CLOB)` + `setvalue(0, json_string)`
- **Paginare**: OFFSET/FETCH (Oracle 12c+)
- **Pre-validare**: valideaza TOATE SKU-urile inainte de creat partener/adresa/comanda
## Environment
```
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_DSN=ROA_ROMFAST
TNS_ADMIN=/app
APP_PORT=5003
SQLITE_DB_PATH=...
```
## Workflow in echipa
1. Citeste task-ul cu `TaskGet` sa intelegi exact ce trebuie facut
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
3. Citeste fisierele afectate inainte sa le modifici
4. Implementeaza modificarile
5. Ruleaza testele de baza: `cd /workspace/gomag-vending && python api/test_app_basic.py`
6. Marcheaza task-ul ca `completed` cu `TaskUpdate`
7. Trimite mesaj la `team-lead` cu:
- Endpoint-uri create/modificate (metoda HTTP + path)
- Schimbari in schema SQLite (daca exista)
- Contracte API noi pe care frontend-ul trebuie sa le stie
## Principii
- Nu modifica fisiere HTML/CSS/JS (sunt ale agentilor UI)
- Pastreaza backward compatibility la endpoint-uri existente
- Adauga campuri noi in raspunsuri JSON fara sa le stergi pe cele vechi
- Logheaza erorile Oracle cu detalii suficiente pentru debug

View File

@@ -1,7 +1,7 @@
--- ---
name: oracle-dba name: oracle-dba
description: Oracle PL/SQL specialist for database scripts, packages, and schema changes in the ROA ERP system description: Oracle PL/SQL specialist for database scripts, packages, and schema changes in the ROA ERP system
model: opus model: sonnet
--- ---
# Oracle DBA Agent # Oracle DBA Agent

View File

@@ -1,7 +1,7 @@
--- ---
name: python-backend name: python-backend
description: FastAPI backend developer for services, routes, Oracle/SQLite integration, and API logic description: FastAPI backend developer for services, routes, Oracle/SQLite integration, and API logic
model: opus model: sonnet
--- ---
# Python Backend Agent # Python Backend Agent

50
.claude/agents/ui-js.md Normal file
View File

@@ -0,0 +1,50 @@
---
name: ui-js
description: Team agent pentru modificari JavaScript (dashboard.js, logs.js, mappings.js, shared.js). Folosit in TeamCreate pentru Task-uri care implica logica client-side, API calls, si interactivitate UI.
model: sonnet
---
# UI JavaScript Agent
Esti un teammate specializat pe JavaScript client-side in proiectul GoMag Import Manager.
## Responsabilitati
- Modificari in `api/app/static/js/*.js`
- Fetch API calls catre backend (`/api/...`)
- Rendering dinamic HTML (tabele, liste, modals)
- Paginare, sortare, filtrare client-side
- Mobile vs desktop rendering logic
## Fisiere cheie
- `api/app/static/js/shared.js` - utilitare comune (fmtDate, statusDot, renderUnifiedPagination, renderMobileSegmented, esc)
- `api/app/static/js/dashboard.js` - logica dashboard comenzi
- `api/app/static/js/logs.js` - logica jurnale import
- `api/app/static/js/mappings.js` - CRUD mapari SKU
## Functii utilitare disponibile (din shared.js)
- `fmtDate(dateStr)` - formateaza data
- `statusDot(status)` - dot colorat pentru status
- `orderStatusBadge(status)` - badge Bootstrap pentru status
- `renderUnifiedPagination(page, totalPages, goPageFn, opts)` - paginare
- `renderMobileSegmented(containerId, items, onSelect)` - segmented control mobil
- `esc(s)` / `escHtml(s)` - escape HTML
## Workflow in echipa
1. Citeste task-ul cu `TaskGet` sa intelegi exact ce trebuie facut
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
3. Citeste fisierele afectate inainte sa le modifici
4. Implementeaza modificarile
5. Marcheaza task-ul ca `completed` cu `TaskUpdate`
6. Trimite mesaj la `team-lead` cu summary-ul modificarilor
## Principii
- Nu modifica fisiere HTML/CSS (sunt ale ui-templates agent)
- `Math.round(x)``Number(x).toFixed(2)` pentru valori monetare
- Verifica intotdeauna null/undefined inainte de operatii numerice: `x != null ? Number(x).toFixed(2) : '-'`
- Reset elementele din modal la inceputul fiecarei deschideri (loading state)
- Foloseste `esc()` pe orice valoare inserata in HTML

View File

@@ -0,0 +1,42 @@
---
name: ui-templates
description: Team agent pentru modificari HTML templates (dashboard.html, logs.html, mappings.html, base.html) si CSS (style.css). Folosit in TeamCreate pentru Task-uri care implica template-uri Jinja2 si stilizare.
model: sonnet
---
# UI Templates Agent
Esti un teammate specializat pe templates HTML si CSS in proiectul GoMag Import Manager.
## Responsabilitati
- Modificari in `api/app/templates/*.html` (Jinja2)
- Modificari in `api/app/static/css/style.css`
- Cache-bust: incrementeaza `?v=N` pe toate tag-urile `<script>` si `<link>` la fiecare modificare
- Structura modala Bootstrap 5.3
- Responsive: `d-none d-md-block` pentru desktop-only, `d-md-none` pentru mobile-only
## Fisiere cheie
- `api/app/templates/base.html` - layout de baza cu navigatie
- `api/app/templates/dashboard.html` - dashboard comenzi
- `api/app/templates/logs.html` - jurnale import
- `api/app/templates/mappings.html` - CRUD mapari SKU
- `api/app/templates/missing_skus.html` - SKU-uri lipsa
- `api/app/static/css/style.css` - stiluri aplicatie
## Workflow in echipa
1. Citeste task-ul cu `TaskGet` sa intelegi exact ce trebuie facut
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
3. Citeste fisierele afectate inainte sa le modifici
4. Implementeaza modificarile
5. Marcheaza task-ul ca `completed` cu `TaskUpdate`
6. Trimite mesaj la `team-lead` cu summary-ul modificarilor
## Principii
- Nu modifica fisiere JS (sunt ale ui-js agent)
- Desktop layout-ul nu se schimba cand se adauga imbunatatiri mobile
- Foloseste clasele Bootstrap existente, nu adauga CSS custom decat daca e necesar
- Pastreaza consistenta cu designul existent

View File

@@ -0,0 +1,61 @@
---
name: ui-verify
description: Team agent de verificare Playwright pentru UI. Captureaza screenshots after-implementation, compara cu preview-urile aprobate, si raporteaza discrepante la team lead. Folosit intotdeauna dupa implementare.
model: sonnet
---
# UI Verify Agent
Esti un teammate specializat pe verificare vizuala Playwright in proiectul GoMag Import Manager.
## Responsabilitati
- Capturare screenshots post-implementare → `screenshots/after/`
- Comparare vizuala `after/` vs `preview/`
- Verificare ca desktop-ul ramane neschimbat unde nu s-a modificat intentionat
- Raportare discrepante la team lead cu descriere exacta
## Server
App ruleaza la `http://localhost:5003`. Verifica cu `curl -s http://localhost:5003/health` inainte de screenshots.
**IMPORTANT**: NU restarteaza serverul singur. Serverul trebuie pornit de user via `./start.sh` care seteaza variabilele de mediu Oracle (`LD_LIBRARY_PATH`, `TNS_ADMIN`). Daca serverul nu raspunde sau Oracle e `"error"`, raporteaza la team-lead si asteapta ca userul sa-l reporneasca.
## Viewports
- **Mobile:** 375x812 — `browser_resize width=375 height=812`
- **Desktop:** 1440x900 — `browser_resize width=1440 height=900`
## Pagini de verificat
- `http://localhost:5003/` — Dashboard
- `http://localhost:5003/logs?run=<run_id>` — Logs cu run selectat
- `http://localhost:5003/mappings` — Mapari SKU
- `http://localhost:5003/missing-skus` — SKU-uri lipsa
## Workflow in echipa
1. Citeste task-ul cu `TaskGet` pentru lista exacta de pagini si criterii de verificat
2. Marcheaza task-ul ca `in_progress` cu `TaskUpdate`
3. Restarteza serverul daca e necesar
4. Captureaza screenshots la ambele viewports pentru fiecare pagina
5. Verifica vizual fiecare screenshot vs criteriile din task
6. Marcheaza task-ul ca `completed` cu `TaskUpdate`
7. Trimite raport detaliat la `team-lead`:
- ✅ Ce e corect
- ❌ Ce e gresit / lipseste (cu descriere exacta)
- Sugestii de fix daca e cazul
## Naming convention screenshots
```
screenshots/after/dashboard_desktop.png
screenshots/after/dashboard_mobile.png
screenshots/after/dashboard_modal_desktop.png
screenshots/after/dashboard_modal_mobile.png
screenshots/after/logs_desktop.png
screenshots/after/logs_mobile.png
screenshots/after/logs_modal_desktop.png
screenshots/after/logs_modal_mobile.png
screenshots/after/mappings_desktop.png
```

5
.gitignore vendored
View File

@@ -8,6 +8,8 @@
*.err *.err
*.ERR *.ERR
*.log *.log
/screenshots
/.playwright-mcp
# Python # Python
__pycache__/ __pycache__/
@@ -22,10 +24,12 @@ __pycache__/
# Settings files with secrets # Settings files with secrets
settings.ini settings.ini
vfp/settings.ini vfp/settings.ini
.gittoken
output/ output/
vfp/*.json vfp/*.json
*.~pck *.~pck
.claude/HANDOFF.md .claude/HANDOFF.md
scripts/work/
# Virtual environments # Virtual environments
venv/ venv/
@@ -42,3 +46,4 @@ api/api/
# Logs directory # Logs directory
logs/ logs/
.gstack/

286
CLAUDE.md
View File

@@ -1,270 +1,60 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
**System:** Import Comenzi Web → Sistem ROA Oracle **System:** Import Comenzi Web GoMag → Sistem ROA Oracle
Stack: FastAPI + Jinja2 + Bootstrap 5.3 + Oracle PL/SQL + SQLite
This is a multi-tier system that automatically imports orders from web platforms (GoMag, etc.) into the ROA Oracle ERP system. The project combines Oracle PL/SQL packages, Visual FoxPro orchestration, and a FastAPI web admin/dashboard interface. Documentatie completa: [README.md](README.md)
**Current Status:** Phase 4 Complete, Phase 5 In Progress ## Implementare cu TeamCreate
- ✅ Phase 1: Database Foundation (ARTICOLE_TERTI, IMPORT_PARTENERI, IMPORT_COMENZI)
- ✅ Phase 2: VFP Integration (gomag-vending.prg, sync-comenzi-web.prg)
- ✅ Phase 3-4: FastAPI Admin + Dashboard (mappings CRUD, sync orchestration, pre-validation)
- 🔄 Phase 5: Production (file logging done, auth + notifications pending)
## Architecture **OBLIGATORIU:** Folosim TeamCreate + TaskCreate, NU Agent tool cu subagenti paraleli. Skill-ul `superpowers:dispatching-parallel-agents` NU se aplica in acest proiect.
``` - Team lead citeste TOATE fisierele implicate, creeaza planul
[Web Platform API] → [VFP Orchestrator] → [Oracle PL/SQL] → [Web Admin Interface] - **ASTEAPTA aprobare explicita** de la user inainte de implementare
↓ ↓ ↑ ↑ - Task-uri pe fisiere non-overlapping (evita conflicte)
JSON Orders Process & Log Store/Update Configuration - Cache-bust static assets (`?v=N`) la fiecare schimbare UI
```
### Tech Stack
- **Backend:** Oracle PL/SQL packages
- **Integration:** Visual FoxPro 9
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
- **Data:** Oracle 11g/12c (ROA system), SQLite (local tracking)
## Core Components
### Oracle PL/SQL Packages
#### 1. IMPORT_PARTENERI Package
**Location:** `api/database-scripts/02_import_parteneri.sql`
**Functions:**
- `cauta_sau_creeaza_partener()` - Search/create partners with priority: cod_fiscal → denumire → create new
- `parseaza_adresa_semicolon()` - Parse addresses in format "JUD:București;BUCURESTI;Str.Victoriei;10"
**Logic:**
- Individual vs company detection (CUI 13 digits)
- Automatic address defaults to București Sectorul 1
- All new partners get ID_UTIL = -3 (system)
#### 2. IMPORT_COMENZI Package
**Location:** `api/database-scripts/03_import_comenzi.sql`
**Functions:**
- `gaseste_articol_roa()` - Complex SKU mapping with pipelined functions
- `importa_comanda_web()` - Complete order import with JSON parsing
**Mapping Types:**
- Simple: SKU 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
### Visual FoxPro Integration
#### gomag-vending.prg
**Location:** `vfp/gomag-vending.prg`
Current functionality:
- GoMag API integration with pagination
- JSON data retrieval and processing
- HTML entity cleaning (ă→a, ș→s, ț→t, î→i, â→a)
**Future:** Will be adapted for JSON output to Oracle packages
#### sync-comenzi-web.prg (Phase 2)
**Planned orchestrator with:**
- 5-minute timer automation
- Oracle package integration
- Comprehensive logging system
- Error handling and retry logic
### Database Schema
#### ARTICOLE_TERTI Table
**Location:** `api/database-scripts/01_create_table.sql`
```sql
CREATE TABLE ARTICOLE_TERTI (
sku VARCHAR2(100), -- SKU from web platform
codmat VARCHAR2(50), -- CODMAT from nom_articole
cantitate_roa NUMBER(10,3), -- ROA units per web unit
procent_pret NUMBER(5,2), -- Price percentage for sets
activ NUMBER(1), -- 1=active, 0=inactive
PRIMARY KEY (sku, codmat)
);
```
### FastAPI Admin/Dashboard
#### app/main.py
**Location:** `api/app/main.py`
**Features:**
- FastAPI with lifespan (Oracle pool + SQLite init)
- File logging to `logs/sync_comenzi_YYYYMMDD_HHMMSS.log`
- Routers: health, dashboard, mappings, articles, validation, sync
- Services: mapping, article, import, sync, validation, order_reader, sqlite, scheduler
- Templates: Jinja2 (dashboard, mappings, sync_detail, missing_skus)
- Dual database: Oracle (ERP data) + SQLite (tracking)
- APScheduler for periodic sync
## Development Commands ## Development Commands
### Database Setup
```bash ```bash
# Start Oracle container # INTOTDEAUNA via start.sh (seteaza Oracle env vars)
docker-compose up -d ./start.sh
# NU folosi uvicorn direct — lipsesc LD_LIBRARY_PATH si TNS_ADMIN
# Run database scripts in order # Tests
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @01_create_table.sql python api/test_app_basic.py # fara Oracle
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @02_import_parteneri.sql python api/test_integration.py # cu Oracle
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @03_import_comenzi.sql
``` ```
### VFP Development ## Reguli critice (nu le incalca)
```foxpro
DO vfp/gomag-vending.prg
```
### FastAPI Admin/Dashboard ### Flux import comenzi
```bash 1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle
cd api 2. Ordinea: **parteneri** (cauta/creeaza) → **adrese****comanda****factura cache**
pip install -r requirements.txt 3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct)
uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload 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
### Testare ### Statusuri comenzi
```bash `IMPORTED` / `ALREADY_IMPORTED` / `SKIPPED` / `ERROR` / `CANCELLED` / `DELETED_IN_ROA`
python api/test_app_basic.py # Test A - fara Oracle - Upsert: `IMPORTED` existent NU se suprascrie cu `ALREADY_IMPORTED`
python api/test_integration.py # Test C - cu Oracle - Recovery: la fiecare sync, comenzile ERROR sunt reverificate in Oracle
```
## Project Structure ### 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
``` ### Preturi
/ - Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
├── api/ # ✅ Flask Admin & Database - Daca pretul lipseste, se insereaza automat pret=0
│ ├── admin.py # ✅ Flask app with Oracle pool
│ ├── database-scripts/ # ✅ Oracle SQL scripts
│ │ ├── 01_create_table.sql # ✅ ARTICOLE_TERTI table
│ │ ├── 02_import_parteneri.sql # ✅ Partners package
│ │ └── 03_import_comenzi.sql # ✅ Orders package
│ ├── Dockerfile # ✅ Oracle client container
│ ├── tnsnames.ora # ✅ Oracle connection config
│ ├── .env # ✅ Environment variables
│ └── requirements.txt # ✅ Python dependencies
├── docs/ # 📋 Project Documentation
│ ├── PRD.md # ✅ Product Requirements
│ ├── LLM_PROJECT_MANAGER_PROMPT.md # ✅ Project Management
│ └── stories/ # 📋 User Stories
│ ├── P1-001-ARTICOLE_TERTI.md # ✅ Story P1-001 (COMPLETE)
│ ├── P1-002-Package-IMPORT_PARTENERI.md # ✅ Story P1-002 (COMPLETE)
│ ├── P1-003-Package-IMPORT_COMENZI.md # ✅ Story P1-003 (COMPLETE)
│ └── P1-004-Testing-Manual-Packages.md # 📋 Story P1-004 (READY)
├── vfp/ # ⏳ VFP Integration
│ ├── gomag-vending.prg # ✅ Current GoMag client
│ ├── utils.prg # ✅ Utility functions
│ ├── nfjson/ # ✅ JSON parsing library
│ └── sync-comenzi-web.prg # ⏳ Future orchestrator
├── docker-compose.yaml # ✅ Container setup
└── logs/ # ✅ Application logs
```
## Configuration ### 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
### Environment Variables (.env) ## Deploy Windows
```env
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_PASSWORD=********
ORACLE_DSN=ROA_ROMFAST
TNS_ADMIN=/app
INSTANTCLIENTPATH=/opt/oracle/instantclient
```
### Business Rules Vezi [README.md](README.md#deploy-windows)
#### Partners
- Search priority: cod_fiscal → denumire → create new
- Individuals (CUI 13 digits): separate nume/prenume
- Default address: București Sectorul 1
- All new partners: ID_UTIL = -3
#### Articles
- Simple SKUs: found directly in nom_articole (not stored)
- Special mappings: only repackaging and complex sets
- Inactive articles: activ=0 (not deleted)
#### Orders
- Uses existing PACK_COMENZI packages
- Default: ID_GESTIUNE=1, ID_SECTIE=1, ID_POL=0
- Delivery date = order date + 1 day
- All orders: INTERNA=0 (external)
## Phase Implementation Status
### ✅ Phase 1: Database Foundation (75% Complete)
- **P1-001:** ✅ ARTICOLE_TERTI table + Docker setup
- **P1-002:** ✅ IMPORT_PARTENERI package complete
- **P1-003:** ✅ IMPORT_COMENZI package complete
- **P1-004:** 🔄 Manual testing (READY TO START)
### ⏳ Phase 2: VFP Integration (Planned)
- Adapt gomag-vending.prg for JSON output
- Create sync-comenzi-web.prg orchestrator
- Oracle packages integration
- Logging system with rotation
### ⏳ Phase 3: Web Admin Interface (Planned)
- Flask app with Oracle connection pool
- HTML/CSS admin interface
- JavaScript CRUD operations
- Client/server-side validation
### ⏳ Phase 4: Testing & Deployment (Planned)
- End-to-end testing with real orders
- Complex mappings validation
- Production environment setup
- User documentation
## Key Functions
### Oracle Packages
- `IMPORT_PARTENERI.cauta_sau_creeaza_partener()` - Partner management
- `IMPORT_PARTENERI.parseaza_adresa_semicolon()` - Address parsing
- `IMPORT_COMENZI.gaseste_articol_roa()` - SKU resolution
- `IMPORT_COMENZI.importa_comanda_web()` - Order import
### VFP Utilities (utils.prg)
- `LoadSettings` - INI configuration management
- `InitLog`/`LogMessage`/`CloseLog` - Logging system
- `TestConnectivity` - Connection verification
- `CreateDefaultIni` - Default configuration
## Success Metrics
### Technical KPIs
- Import success rate > 95%
- Average processing time < 30s per order
- Zero downtime for main ROA system
- 100% log coverage
### Business KPIs
- 90% reduction in manual order entry time
- Elimination of manual transcription errors
- New mapping configuration < 5 minutes
## Error Handling
### Categories
1. **Oracle connection errors:** Retry logic + alerts
2. **SKU not found:** Log warning + skip item
3. **Invalid partner:** Create attempt + detailed log
4. **Duplicate orders:** Skip with info log
### Logging Format
```
2025-09-09 14:30:25 | ORDER-123 | OK | ID:456789
2025-09-09 14:30:26 | ORDER-124 | ERROR | SKU 'XYZ' not found
```
## Project Manager Commands
Available commands for project tracking:
- `status` - Overall progress and current story
- `stories` - List all stories with status
- `phase` - Current phase details
- `risks` - Identify and prioritize risks
- `demo [story-id]` - Demonstrate implemented functionality
- `plan` - Re-planning for changes

288
README.md
View File

@@ -5,16 +5,16 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
## Arhitectura ## Arhitectura
``` ```
[GoMag API] → [VFP Orchestrator] → [Oracle PL/SQL] → [FastAPI Admin] [GoMag API] → [Python Sync Service] → [Oracle PL/SQL] → [FastAPI Admin]
↑ ↑ ↑ ↑
JSON Orders Process & Log Store/Update Dashboard + Config JSON Orders Download/Parse/Import Store/Update Dashboard + Config
``` ```
### Stack Tehnologic ### Stack Tehnologic
- **Database:** Oracle PL/SQL packages (PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI) - **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
- **Integrare:** Visual FoxPro 9 (gomag-vending.prg, sync-comenzi-web.prg) - **GoMag Integration:** Python (`gomag_client.py` — download comenzi cu paginare)
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite - **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local) - **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking)
--- ---
@@ -27,7 +27,6 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
### Instalare ### Instalare
```bash ```bash
# Din project root (gomag/)
pip install -r api/requirements.txt pip install -r api/requirements.txt
cp api/.env.example api/.env cp api/.env.example api/.env
# Editeaza api/.env cu datele de conectare Oracle # Editeaza api/.env cu datele de conectare Oracle
@@ -38,7 +37,6 @@ cp api/.env.example api/.env
**Important:** serverul trebuie pornit **din project root**, nu din `api/`: **Important:** serverul trebuie pornit **din project root**, nu din `api/`:
```bash ```bash
# Din gomag/
python -m uvicorn api.app.main:app --host 0.0.0.0 --port 5003 python -m uvicorn api.app.main:app --host 0.0.0.0 --port 5003
``` ```
@@ -55,13 +53,11 @@ Deschide `http://localhost:5003` in browser.
```bash ```bash
python api/test_app_basic.py python api/test_app_basic.py
``` ```
Verifica importuri de module + rute GET. Asteptat: 32/33 PASS (1 fail pre-existent `/sync` HTML).
**Test C - Integrare Oracle:** **Test C - Integrare Oracle:**
```bash ```bash
python api/test_integration.py python api/test_integration.py
``` ```
Necesita Oracle activ. Verifica health, mappings CRUD, article search, validation, sync.
--- ---
@@ -82,7 +78,7 @@ cp api/.env.example api/.env
| `INSTANTCLIENTPATH` | Cale Instant Client (thick mode) | `/opt/oracle/instantclient_21_15` | | `INSTANTCLIENTPATH` | Cale Instant Client (thick mode) | `/opt/oracle/instantclient_21_15` |
| `FORCE_THIN_MODE` | Thin mode fara Instant Client | `true` | | `FORCE_THIN_MODE` | Thin mode fara Instant Client | `true` |
| `SQLITE_DB_PATH` | Path SQLite (relativ la project root) | `api/data/import.db` | | `SQLITE_DB_PATH` | Path SQLite (relativ la project root) | `api/data/import.db` |
| `JSON_OUTPUT_DIR` | Folder JSON-uri VFP (relativ la project root) | `vfp/output` | | `JSON_OUTPUT_DIR` | Folder JSON-uri descarcate | `api/data/orders` |
| `APP_PORT` | Port HTTP | `5003` | | `APP_PORT` | Port HTTP | `5003` |
| `ID_POL` | ID Politica ROA | `39` | | `ID_POL` | ID Politica ROA | `39` |
| `ID_GESTIUNE` | ID Gestiune ROA | `0` | | `ID_GESTIUNE` | ID Gestiune ROA | `0` |
@@ -97,7 +93,7 @@ cp api/.env.example api/.env
## Structura Proiect ## Structura Proiect
``` ```
gomag/ gomag-vending/
├── api/ # FastAPI Admin + Dashboard ├── api/ # FastAPI Admin + Dashboard
│ ├── app/ │ ├── app/
│ │ ├── main.py # Entry point, lifespan, logging │ │ ├── main.py # Entry point, lifespan, logging
@@ -105,36 +101,34 @@ gomag/
│ │ ├── 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/
│ │ │ ├── sync_service.py # Orchestrare: JSON→validate→import │ │ │ ├── gomag_client.py # Download comenzi GoMag API
│ │ │ ├── sync_service.py # Orchestrare: download→validate→import
│ │ │ ├── import_service.py # Import comanda in Oracle ROA │ │ │ ├── import_service.py # Import comanda in Oracle ROA
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total │ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs │ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json │ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
│ │ │ ├── validation_service.py │ │ │ ├── validation_service.py
│ │ │ ├── article_service.py │ │ │ ├── article_service.py
│ │ │ ├── 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) │ ├── data/ # SQLite DB (import.db) + JSON orders
│ ├── .env # Configurare locala (nu in git) │ ├── .env # Configurare locala (nu in git)
│ ├── .env.example # Template configurare │ ├── .env.example # Template configurare
│ ├── test_app_basic.py # Test A - fara Oracle │ ├── test_app_basic.py # Test A - fara Oracle
│ ├── test_integration.py # Test C - cu Oracle │ ├── test_integration.py # Test C - cu Oracle
│ └── requirements.txt │ └── requirements.txt
├── vfp/ # VFP Integration
│ ├── gomag-vending.prg # Client GoMag API (descarca JSON-uri)
│ ├── sync-comenzi-web.prg # Orchestrator VFP
│ ├── utils.prg # Utilitare (log, settings, connectivity)
│ └── output/ # JSON-uri descarcate (gomag_orders_page*.json)
├── logs/ # Log-uri aplicatie (sync_comenzi_*.log) ├── logs/ # Log-uri aplicatie (sync_comenzi_*.log)
├── docs/ # Documentatie (PRD, stories) ├── docs/ # Documentatie (PRD, stories)
├── screenshots/ # Before/preview/after pentru UI changes
├── start.sh # Script pornire (Linux/WSL) ├── start.sh # Script pornire (Linux/WSL)
└── CLAUDE.md # Instructiuni pentru AI assistants └── CLAUDE.md # Instructiuni pentru AI assistants
``` ```
@@ -171,36 +165,244 @@ gomag/
## Fluxul de Import ## Fluxul de Import
``` ```
1. VFP descarca comenzi GoMag API → vfp/output/gomag_orders_page*.json 1. gomag_client.py descarca comenzi GoMag API → JSON files (paginat)
2. FastAPI citeste JSON-urile (order_reader) 2. order_reader.py parseaza JSON-urile, sorteaza cronologic (cele mai vechi primele)
3. Valideaza SKU-uri contra ARTICOLE_TERTI + NOM_ARTICOLE (validation_service) 3. Comenzi anulate (GoMag statusId=7) → separate, sterse din Oracle daca nu au factura
4. Import_service 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
| Status | Descriere |
|--------|-----------|
| `IMPORTED` | Importata nou in ROA in acest run |
| `ALREADY_IMPORTED` | Existenta deja in Oracle, contorizata |
| `SKIPPED` | SKU-uri lipsa → neimportata |
| `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 |
**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
--- ---
## Status Implementare ## Facturi & Cache
| Faza | Status | Descriere | Facturile sunt verificate live din Oracle si cacate in SQLite (`factura_*` pe tabelul `orders`).
|------|--------|-----------|
| Phase 1: Database Foundation | ✅ Complet | ARTICOLE_TERTI, PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI | ### Sursa Oracle
| Phase 2: VFP Integration | ✅ Complet | gomag-vending.prg, sync-comenzi-web.prg | ```sql
| Phase 3-4: FastAPI Dashboard | ✅ Complet | Redesign UI, smart polling, filter bar, paginare, tooltip | SELECT id_comanda, numar_act, serie_act,
| Phase 5: Production | 🔄 In Progress | Logging ✅, Auth ⏳, SMTP ⏳, NSSM service ⏳ | total_fara_tva, total_tva, total_cu_tva,
TO_CHAR(data_act, 'YYYY-MM-DD')
FROM vanzari
WHERE id_comanda IN (...) AND sters = 0
```
### Populare Cache
1. **Dashboard** (`GET /api/dashboard/orders`) — comenzile fara cache sunt verificate live si cacate automat la fiecare request
2. **Detaliu comanda** (`GET /api/sync/order/{order_number}`) — verifica Oracle live daca nu e caat
3. **Refresh manual** (`POST /api/dashboard/refresh-invoices`) — refresh complet pentru toate comenzile
### Refresh Complet — `/api/dashboard/refresh-invoices`
Face trei verificari in Oracle si actualizeaza SQLite:
| Verificare | Actiune |
|------------|---------|
| Comenzi necacturate → au primit factura? | Cacheaza datele facturii |
| Comenzi cacturate → factura a fost stearsa? | Sterge cache factura |
| Toate comenzile importate → comanda stearsa din ROA? | Seteaza status `DELETED_IN_ROA` |
Returneaza: `{ checked, invoices_added, invoices_cleared, orders_deleted }`
---
## API Reference — 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 + progres + last_run |
| GET | `/api/sync/history` | Istoric run-uri (paginat) |
| GET | `/api/sync/run/{id}` | Detalii run specific |
| GET | `/api/sync/run/{id}/log` | Log per comanda (JSON) |
| GET | `/api/sync/run/{id}/text-log` | Log text (live din memorie sau reconstruit din SQLite) |
| GET | `/api/sync/run/{id}/orders` | Comenzi run filtrate/paginate |
| GET | `/api/sync/order/{number}` | Detaliu comanda + items + ARTICOLE_TERTI + factura |
### Dashboard Comenzi
| Method | Path | Descriere |
|--------|------|-----------|
| GET | `/api/dashboard/orders` | Comenzi cu enrichment factura |
| POST | `/api/dashboard/refresh-invoices` | Force-refresh stare facturi + deleted orders |
**Parametri `/api/dashboard/orders`:**
- `period_days`: 3/7/30/90 sau 0 (toate sau interval custom)
- `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`
Filtrele `UNINVOICED` si `INVOICED` fac fetch din toate comenzile IMPORTED si filtreaza server-side dupa prezenta/absenta cache-ului de factura.
### Scheduler
| Method | Path | Descriere |
|--------|------|-----------|
| PUT | `/api/sync/schedule` | Configureaza (enabled, interval_minutes: 5/10/30) |
| GET | `/api/sync/schedule` | Status curent |
Configuratia este persistata in SQLite (`scheduler_config`).
### Settings
| Method | Path | Descriere |
|--------|------|-----------|
| GET | `/api/settings` | Citeste setari aplicatie |
| PUT | `/api/settings` | Salveaza setari |
| GET | `/api/settings/sectii` | Lista sectii 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_pol_productie`, `id_sectie`, `split_discount_vat`, `gomag_api_key`, `gomag_api_shop`, `gomag_order_days_back`, `gomag_limit`
---
## Deploy Windows
### Instalare initiala
```powershell
# Ruleaza ca Administrator
.\deploy.ps1
```
Scriptul `deploy.ps1` face automat: git clone, venv, dependinte, detectare Oracle, `start.bat`, serviciu NSSM, configurare IIS reverse proxy.
### Update cod (pull + restart)
```powershell
# Ca Administrator
.\update.ps1
```
Sau manual:
```powershell
cd C:\gomag-vending
git pull origin main
nssm restart GoMagVending
```
### Configurare `.env` pe Windows
```ini
# api/.env — exemplu Windows
ORACLE_USER=VENDING
ORACLE_PASSWORD=****
ORACLE_DSN=ROA
TNS_ADMIN=C:\roa\instantclient_11_2_0_2
INSTANTCLIENTPATH=C:\app\Server\product\18.0.0\dbhomeXE\bin
SQLITE_DB_PATH=api/data/import.db
JSON_OUTPUT_DIR=api/data/orders
APP_PORT=5003
ID_POL=39
ID_GESTIUNE=0
ID_SECTIE=6
GOMAG_API_KEY=...
GOMAG_API_SHOP=...
GOMAG_ORDER_DAYS_BACK=7
GOMAG_LIMIT=100
```
**Important:**
- `TNS_ADMIN` = folderul care contine `tnsnames.ora` (NU fisierul in sine)
- `ORACLE_DSN` = alias-ul exact din `tnsnames.ora`
- `INSTANTCLIENTPATH` = calea catre Oracle bin (thick mode, Oracle 10g/11g)
- `FORCE_THIN_MODE=true` = elimina necesitatea Instant Client (Oracle 12.1+)
- Setarile din `.env` pot fi suprascrise din UI → `Setari` → salvate in SQLite
### Serviciu Windows (NSSM)
```powershell
nssm restart GoMagVending # restart serviciu
nssm status GoMagVending # status serviciu
nssm stop GoMagVending # stop serviciu
nssm start GoMagVending # start serviciu
```
Loguri serviciu: `logs/service_stdout.log`, `logs/service_stderr.log`
Loguri aplicatie: `logs/sync_comenzi_*.log`
**Nota:** Userul `gomag` nu are drepturi de admin — `nssm restart` necesita PowerShell Administrator direct pe server.
### Depanare SSH
```bash
# Conectare SSH (PowerShell remote, cheie publica)
ssh -p 22122 gomag@79.119.86.134
# Verificare .env
cmd /c type C:\gomag-vending\api\.env
# 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()"
# Verificare tnsnames.ora
cmd /c type C:\roa\instantclient_11_2_0_2\tnsnames.ora
# Verificare procese Python
Get-Process *python* | Select-Object Id,ProcessName,Path
# Verificare loguri recente
Get-ChildItem C:\gomag-vending\logs\*.log | Sort-Object LastWriteTime -Descending | Select-Object -First 3
# Test sync manual (verifica ca Oracle pool porneste)
curl http://localhost:5003/health
curl -X POST http://localhost:5003/api/sync/start
# Refresh facturi manual
curl -X POST http://localhost:5003/api/dashboard/refresh-invoices
```
### Probleme frecvente
| Eroare | Cauza | Solutie |
|--------|-------|---------|
| `ORA-12154: TNS:could not resolve` | `TNS_ADMIN` gresit sau `tnsnames.ora` nu contine alias-ul DSN | Verifica `TNS_ADMIN` in `.env` + alias in `tnsnames.ora` |
| `ORA-04088: LOGON_AUDIT_TRIGGER` + `Nu aveti licenta pentru PYTHON` | Trigger ROA blocheaza executabile nelicențiate | Adauga `python.exe` (calea completa) in ROASUPORT |
| `503 Service Unavailable` pe `/api/articles/search` | Oracle pool nu s-a initializat | Verifica logul `sync_comenzi_*.log` pentru eroarea exacta |
| Facturile nu apar in dashboard | Cache SQLite gol — invoice_service nu a putut interoga Oracle | Apasa butonul Refresh Facturi din dashboard sau `POST /api/dashboard/refresh-invoices` |
| Comanda apare ca `DELETED_IN_ROA` | Comanda a fost stearsa manual din ROA | Normal — marcat automat la refresh |
| Scheduler nu porneste dupa restart | Config pierduta | Verifica SQLite `scheduler_config` sau reconfigureaza din UI |
--- ---
## WSL2 Note ## WSL2 Note
- `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual - `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual
- Serverul trebuie pornit din **project root** (`gomag/`), nu din `api/` - Serverul trebuie pornit din **project root**, nu din `api/`
- `JSON_OUTPUT_DIR` si `SQLITE_DB_PATH` sunt relative la project root - `JSON_OUTPUT_DIR` si `SQLITE_DB_PATH` sunt relative la project root

View File

@@ -1,75 +0,0 @@
SPECIFICATIE PROIECT - IMPORT COMENZI WEB IN ROA ORACLE
Data: 5 martie 2026
================================================================================
DESCRIERE SCOP
================================================================================
Implementarea unui sistem automat de import a comenzilor de pe platforme web
(GoMag si altele) in sistemul ERP ROA Oracle. Sistemul va prelua comenzi,
va realiza mapari de articole, va converte unitati de masura si va crea
comenzi in ROA automat.
================================================================================
DELIVERABLES
================================================================================
1. Logica de import completa in baza de date ROA Oracle
2. Orchestrator automat (cron job) pentru sincronizare comenzi
3. Interfata web de configurare mapari SKU-uri
4. Suport pentru articole compuse (mapari complexe)
5. Conversii unitati de masura intre platforme
6. Documentatie tehnica si handover
7. Support 3 luni pentru bug fixes
================================================================================
EFORTURI SI COSTURI
================================================================================
Lucrat deja: 20h 1,200 EUR
De lucrat: 60h 3,600 EUR
Support 3 luni: 24h 1,440 EUR
TOTAL IMPLEMENTARE: 80h 4,800 EUR
TOTAL CU SUPPORT: 104h 6,240 EUR
Tarif orar: 60 EUR/h
================================================================================
INCLUS IN PRET
================================================================================
- Analiza si integrare cu baza de date client
- Testare completa cu date reale
- Integrare in sistemul ROA Oracle
- Validari si controale de integritate
- Documentation si training
- Support de 3 luni pentru probleme critice
================================================================================
CONDITII GENERALE
================================================================================
Duratie proiect: 2-4 saptamani
Payment terms: 50% avans, 50% la finalizare
Garantie: 3 luni (bug fixes gratuit)
Suport suplimentar: 60 EUR/h (dupa perioada garantie)
Buffer estimare: 50% (pentru integrare ROA + incertitudini)
================================================================================
RESPONSABILITATI CLIENT
================================================================================
- Acces la baza de date client si ROA Oracle
- Accesul la comenzile din platforma web
- Clarificarea logicii maparii articole compuse
- Testing si validare in mediu pilot
================================================================================

View File

@@ -38,7 +38,6 @@ class Settings(BaseSettings):
# ROA Import Settings # ROA Import Settings
ID_POL: int = 0 ID_POL: int = 0
ID_GESTIUNE: int = 0
ID_SECTIE: int = 0 ID_SECTIE: int = 0
# GoMag API # GoMag API

View File

@@ -23,6 +23,8 @@ def init_oracle():
if settings.TNS_ADMIN: if settings.TNS_ADMIN:
os.environ['TNS_ADMIN'] = settings.TNS_ADMIN os.environ['TNS_ADMIN'] = settings.TNS_ADMIN
logger.info(f"Oracle config: DSN={dsn}, TNS_ADMIN={settings.TNS_ADMIN or os.environ.get('TNS_ADMIN', '(not set)')}, INSTANTCLIENTPATH={instantclient_path or '(not set)'}")
if force_thin: if force_thin:
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}") logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
elif instantclient_path: elif instantclient_path:
@@ -103,7 +105,13 @@ CREATE TABLE IF NOT EXISTS orders (
factura_total_fara_tva REAL, factura_total_fara_tva REAL,
factura_total_tva REAL, factura_total_tva REAL,
factura_total_cu_tva REAL, factura_total_cu_tva REAL,
invoice_checked_at TEXT factura_data TEXT,
invoice_checked_at TEXT,
order_total REAL,
delivery_cost REAL,
discount_total REAL,
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);
@@ -139,6 +147,11 @@ CREATE TABLE IF NOT EXISTS web_products (
order_count INTEGER DEFAULT 0 order_count INTEGER DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS order_items ( CREATE TABLE IF NOT EXISTS order_items (
order_number TEXT, order_number TEXT,
sku TEXT, sku TEXT,
@@ -300,7 +313,13 @@ def init_sqlite():
("factura_total_fara_tva", "REAL"), ("factura_total_fara_tva", "REAL"),
("factura_total_tva", "REAL"), ("factura_total_tva", "REAL"),
("factura_total_cu_tva", "REAL"), ("factura_total_cu_tva", "REAL"),
("factura_data", "TEXT"),
("invoice_checked_at", "TEXT"), ("invoice_checked_at", "TEXT"),
("order_total", "REAL"),
("delivery_cost", "REAL"),
("discount_total", "REAL"),
("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

@@ -15,3 +15,7 @@ async def dashboard(request: Request):
@router.get("/missing-skus", response_class=HTMLResponse) @router.get("/missing-skus", response_class=HTMLResponse)
async def missing_skus_page(request: Request): async def missing_skus_page(request: Request):
return templates.TemplateResponse("missing_skus.html", {"request": request}) return templates.TemplateResponse("missing_skus.html", {"request": request})
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
return templates.TemplateResponse("settings.html", {"request": request})

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Query, Request, UploadFile, File
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel, validator
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import io import io
@@ -21,6 +21,12 @@ class MappingCreate(BaseModel):
cantitate_roa: float = 1 cantitate_roa: float = 1
procent_pret: float = 100 procent_pret: float = 100
@validator('sku', 'codmat')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError('nu poate fi gol')
return v.strip()
class MappingUpdate(BaseModel): class MappingUpdate(BaseModel):
cantitate_roa: Optional[float] = None cantitate_roa: Optional[float] = None
procent_pret: Optional[float] = None procent_pret: Optional[float] = None
@@ -32,6 +38,12 @@ class MappingEdit(BaseModel):
cantitate_roa: float = 1 cantitate_roa: float = 1
procent_pret: float = 100 procent_pret: float = 100
@validator('new_sku', 'new_codmat')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError('nu poate fi gol')
return v.strip()
class MappingLine(BaseModel): class MappingLine(BaseModel):
codmat: str codmat: str
cantitate_roa: float = 1 cantitate_roa: float = 1
@@ -40,6 +52,7 @@ class MappingLine(BaseModel):
class MappingBatchCreate(BaseModel): class MappingBatchCreate(BaseModel):
sku: str sku: str
mappings: list[MappingLine] mappings: list[MappingLine]
auto_restore: bool = False
# HTML page # HTML page
@router.get("/mappings", response_class=HTMLResponse) @router.get("/mappings", response_class=HTMLResponse)
@@ -129,7 +142,7 @@ async def create_batch_mapping(data: MappingBatchCreate):
try: try:
results = [] results = []
for m in data.mappings: for m in data.mappings:
r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret) r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret, auto_restore=data.auto_restore)
results.append(r) results.append(r)
# Mark SKU as resolved in missing_skus tracking # Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku) await sqlite_service.resolve_missing_sku(data.sku)

View File

@@ -1,7 +1,10 @@
import asyncio import asyncio
import json import json
import logging
from datetime import datetime from datetime import datetime
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Request, BackgroundTasks from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -10,6 +13,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
from .. import database
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"))
@@ -20,6 +24,25 @@ class ScheduleConfig(BaseModel):
interval_minutes: int = 5 interval_minutes: int = 5
class AppSettingsUpdate(BaseModel):
transport_codmat: str = ""
transport_vat: str = "21"
discount_codmat: str = ""
transport_id_pol: str = ""
discount_vat: str = "21"
discount_id_pol: str = ""
id_pol: str = ""
id_pol_productie: str = ""
id_sectie: str = ""
id_gestiune: str = ""
split_discount_vat: str = ""
gomag_api_key: str = ""
gomag_api_shop: str = ""
gomag_order_days_back: str = "7"
gomag_limit: str = "100"
dashboard_poll_seconds: str = "5"
# API endpoints # API endpoints
@router.post("/api/sync/start") @router.post("/api/sync/start")
async def start_sync(background_tasks: BackgroundTasks): async def start_sync(background_tasks: BackgroundTasks):
@@ -47,12 +70,14 @@ async def sync_status():
# Build last_run from most recent completed/failed sync_runs row # Build last_run from most recent completed/failed sync_runs row
current_run_id = status.get("run_id") current_run_id = status.get("run_id")
is_running = status.get("status") == "running"
last_run = None last_run = None
try: try:
from ..database import get_sqlite from ..database import get_sqlite
db = await get_sqlite() db = await get_sqlite()
try: try:
if current_run_id: if current_run_id and is_running:
# Only exclude current run while it's actively running
cursor = await db.execute(""" cursor = await db.execute("""
SELECT * FROM sync_runs SELECT * FROM sync_runs
WHERE status IN ('completed', 'failed') AND run_id != ? WHERE status IN ('completed', 'failed') AND run_id != ?
@@ -149,6 +174,7 @@ async def sync_run_log(run_id: str):
"id_partener": o.get("id_partener"), "id_partener": o.get("id_partener"),
"error_message": o.get("error_message"), "error_message": o.get("error_message"),
"missing_skus": o.get("missing_skus"), "missing_skus": o.get("missing_skus"),
"order_total": o.get("order_total"),
"factura_numar": o.get("factura_numar"), "factura_numar": o.get("factura_numar"),
"factura_serie": o.get("factura_serie"), "factura_serie": o.get("factura_serie"),
} }
@@ -293,6 +319,29 @@ def _get_articole_terti_for_skus(skus: set) -> dict:
return result return result
def _get_nom_articole_for_direct_skus(skus: set) -> dict:
"""Query NOM_ARTICOLE for SKUs that exist directly as CODMAT (direct mapping)."""
from .. import database
result = {}
sku_list = list(skus)
conn = database.get_oracle_connection()
try:
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 codmat, denumire FROM NOM_ARTICOLE
WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0
""", params)
for row in cur:
result[row[0]] = row[1] or ""
finally:
database.pool.release(conn)
return result
@router.get("/api/sync/order/{order_number}") @router.get("/api/sync/order/{order_number}")
async def order_detail(order_number: str): async def order_detail(order_number: str):
"""Get order detail with line items (R9), enriched with ARTICOLE_TERTI data.""" """Get order detail with line items (R9), enriched with ARTICOLE_TERTI data."""
@@ -310,6 +359,63 @@ async def order_detail(order_number: str):
if sku and sku in codmat_map: if sku and sku in codmat_map:
item["codmat_details"] = codmat_map[sku] item["codmat_details"] = codmat_map[sku]
# Enrich direct SKUs (SKU=CODMAT in NOM_ARTICOLE, no ARTICOLE_TERTI entry)
direct_skus = {item["sku"] for item in items
if item.get("sku") and item.get("mapping_status") == "direct"
and not item.get("codmat_details")}
if direct_skus:
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, direct_skus)
for item in items:
sku = item.get("sku")
if sku and sku in nom_map and not item.get("codmat_details"):
item["codmat_details"] = [{
"codmat": sku,
"cantitate_roa": 1,
"procent_pret": 100,
"denumire": nom_map[sku],
"direct": True
}]
# Enrich with invoice data
order = detail.get("order", {})
if order.get("factura_numar") and order.get("factura_data"):
order["invoice"] = {
"facturat": True,
"serie_act": order.get("factura_serie"),
"numar_act": order.get("factura_numar"),
"data_act": order.get("factura_data"),
"total_fara_tva": order.get("factura_total_fara_tva"),
"total_tva": order.get("factura_total_tva"),
"total_cu_tva": order.get("factura_total_cu_tva"),
}
elif order.get("id_comanda"):
# Check Oracle live
try:
inv_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, [order["id_comanda"]]
)
inv = inv_data.get(order["id_comanda"])
if inv and inv.get("facturat"):
order["invoice"] = inv
await sqlite_service.update_order_invoice(
order_number,
serie=inv.get("serie_act"),
numar=str(inv.get("numar_act", "")),
total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
)
except Exception:
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
@@ -325,11 +431,12 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
period_days=0 without dates means all time. period_days=0 without dates means all time.
""" """
is_uninvoiced_filter = (status == "UNINVOICED") is_uninvoiced_filter = (status == "UNINVOICED")
is_invoiced_filter = (status == "INVOICED")
# For UNINVOICED: 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 else status fetch_status = "IMPORTED" if (is_uninvoiced_filter or is_invoiced_filter) else status
fetch_per_page = 10000 if is_uninvoiced_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 else page fetch_page = 1 if (is_uninvoiced_filter or is_invoiced_filter) else page
result = await sqlite_service.get_orders( result = await sqlite_service.get_orders(
page=fetch_page, per_page=fetch_per_page, search=search, page=fetch_page, per_page=fetch_per_page, search=search,
@@ -342,8 +449,8 @@ 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:
if o.get("factura_numar"): if o.get("factura_numar") and o.get("factura_data"):
# Use cached invoice data from SQLite # Use cached invoice data from SQLite (only if complete)
o["invoice"] = { o["invoice"] = {
"facturat": True, "facturat": True,
"serie_act": o.get("factura_serie"), "serie_act": o.get("factura_serie"),
@@ -351,6 +458,7 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
"total_fara_tva": o.get("factura_total_fara_tva"), "total_fara_tva": o.get("factura_total_fara_tva"),
"total_tva": o.get("factura_total_tva"), "total_tva": o.get("factura_total_tva"),
"total_cu_tva": o.get("factura_total_cu_tva"), "total_cu_tva": o.get("factura_total_cu_tva"),
"data_act": o.get("factura_data"),
} }
else: else:
o["invoice"] = None o["invoice"] = None
@@ -367,6 +475,18 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
idc = o.get("id_comanda") idc = o.get("id_comanda")
if idc and idc in invoice_data: if idc and idc in invoice_data:
o["invoice"] = invoice_data[idc] o["invoice"] = invoice_data[idc]
# Update SQLite cache so counts stay accurate
inv = invoice_data[idc]
if inv.get("facturat"):
await sqlite_service.update_order_invoice(
o["order_number"],
serie=inv.get("serie_act"),
numar=str(inv.get("numar_act", "")),
total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
)
except Exception: except Exception:
pass pass
@@ -377,19 +497,32 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
o["billing_name"] = b_name o["billing_name"] = b_name
o["is_different_person"] = bool(s_name and b_name and s_name != b_name) o["is_different_person"] = bool(s_name and b_name and s_name != b_name)
# Build period-total counts (across all pages, same filters) # Use counts from sqlite_service (already period-scoped)
nefacturate_count = sum(
1 for o in all_orders
if o.get("status") == "IMPORTED" and not o.get("invoice")
)
# Use counts from sqlite_service (already period-scoped) and add nefacturate
counts = result.get("counts", {}) counts = result.get("counts", {})
counts["nefacturate"] = nefacturate_count # Count newly-cached invoices found during this request
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
# Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
1 for o in all_orders
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
))
counts["nefacturate"] = max(0, uninvoiced_base - newly_invoiced)
imported_total = counts.get("imported_all") or counts.get("imported", 0)
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0)) counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
# 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") == "IMPORTED" and not o.get("invoice")] filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
total = len(filtered)
offset = (page - 1) * per_page
result["orders"] = filtered[offset:offset + per_page]
result["total"] = total
result["page"] = page
result["per_page"] = per_page
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
elif is_invoiced_filter:
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and o.get("invoice")]
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]
@@ -410,6 +543,77 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
} }
@router.post("/api/dashboard/refresh-invoices")
async def refresh_invoices():
"""Force-refresh invoice/order status from Oracle.
Checks:
1. Uninvoiced orders → did they get invoiced?
2. Invoiced orders → was the invoice deleted?
3. All imported orders → was the order deleted from ROA?
"""
try:
invoices_added = 0
invoices_cleared = 0
orders_deleted = 0
# 1. Check uninvoiced → new invoices
uninvoiced = await sqlite_service.get_uninvoiced_imported_orders()
if uninvoiced:
id_comanda_list = [o["id_comanda"] for o in uninvoiced]
invoice_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, id_comanda_list
)
id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced}
for idc, inv in invoice_data.items():
order_num = id_to_order.get(idc)
if order_num and inv.get("facturat"):
await sqlite_service.update_order_invoice(
order_num,
serie=inv.get("serie_act"),
numar=str(inv.get("numar_act", "")),
total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
)
invoices_added += 1
# 2. Check invoiced → deleted invoices
invoiced = await sqlite_service.get_invoiced_imported_orders()
if invoiced:
id_comanda_list = [o["id_comanda"] for o in invoiced]
invoice_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, id_comanda_list
)
for o in invoiced:
if o["id_comanda"] not in invoice_data:
await sqlite_service.clear_order_invoice(o["order_number"])
invoices_cleared += 1
# 3. Check all imported → deleted orders in ROA
all_imported = await sqlite_service.get_all_imported_orders()
if all_imported:
id_comanda_list = [o["id_comanda"] for o in all_imported]
existing_ids = await asyncio.to_thread(
invoice_service.check_orders_exist, id_comanda_list
)
for o in all_imported:
if o["id_comanda"] not in existing_ids:
await sqlite_service.mark_order_deleted_in_roa(o["order_number"])
orders_deleted += 1
checked = len(uninvoiced) + len(invoiced) + len(all_imported)
return {
"checked": checked,
"invoices_added": invoices_added,
"invoices_cleared": invoices_cleared,
"orders_deleted": orders_deleted,
}
except Exception as e:
return {"error": str(e), "invoices_added": 0}
@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."""
@@ -429,3 +633,110 @@ async def update_schedule(config: ScheduleConfig):
async def get_schedule(): async def get_schedule():
"""Get current scheduler status.""" """Get current scheduler status."""
return scheduler_service.get_scheduler_status() return scheduler_service.get_scheduler_status()
@router.get("/api/settings")
async def get_app_settings():
"""Get application settings."""
from ..config import settings as config_settings
s = await sqlite_service.get_app_settings()
return {
"transport_codmat": s.get("transport_codmat", ""),
"transport_vat": s.get("transport_vat", "21"),
"discount_codmat": s.get("discount_codmat", ""),
"transport_id_pol": s.get("transport_id_pol", ""),
"discount_vat": s.get("discount_vat", "21"),
"discount_id_pol": s.get("discount_id_pol", ""),
"id_pol": s.get("id_pol", ""),
"id_pol_productie": s.get("id_pol_productie", ""),
"id_sectie": s.get("id_sectie", ""),
"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_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_limit": s.get("gomag_limit", "") or str(config_settings.GOMAG_LIMIT),
"dashboard_poll_seconds": s.get("dashboard_poll_seconds", "5"),
}
@router.put("/api/settings")
async def update_app_settings(config: AppSettingsUpdate):
"""Update application settings."""
await sqlite_service.set_app_setting("transport_codmat", config.transport_codmat)
await sqlite_service.set_app_setting("transport_vat", config.transport_vat)
await sqlite_service.set_app_setting("discount_codmat", config.discount_codmat)
await sqlite_service.set_app_setting("transport_id_pol", config.transport_id_pol)
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("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_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_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_limit", config.gomag_limit)
await sqlite_service.set_app_setting("dashboard_poll_seconds", config.dashboard_poll_seconds)
return {"success": True}
@router.get("/api/settings/gestiuni")
async def get_gestiuni():
"""Get list of warehouses from Oracle for dropdown."""
def _query():
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id_gestiune, nume_gestiune FROM nom_gestiuni WHERE sters=0 AND inactiv=0 ORDER BY id_gestiune"
)
return [{"id": str(row[0]), "label": f"{row[0]} - {row[1]}"} for row in cur]
finally:
database.pool.release(conn)
try:
return await asyncio.to_thread(_query)
except Exception as e:
logger.error(f"get_gestiuni error: {e}")
return []
@router.get("/api/settings/sectii")
async def get_sectii():
"""Get list of sections from Oracle for dropdown."""
def _query():
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id_sectie, sectie FROM nom_sectii WHERE sters=0 AND inactiv=0 ORDER BY id_sectie"
)
return [{"id": str(row[0]), "label": f"{row[0]} - {row[1]}"} for row in cur]
finally:
database.pool.release(conn)
try:
return await asyncio.to_thread(_query)
except Exception as e:
logger.error(f"get_sectii error: {e}")
return []
@router.get("/api/settings/politici")
async def get_politici():
"""Get list of price policies from Oracle for dropdown."""
def _query():
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id_pol, nume_lista_preturi FROM crm_politici_preturi WHERE sters=0 ORDER BY id_pol"
)
return [{"id": str(row[0]), "label": f"{row[0]} - {row[1]}"} for row in cur]
finally:
database.pool.release(conn)
try:
return await asyncio.to_thread(_query)
except Exception as e:
logger.error(f"get_politici error: {e}")
return []

View File

@@ -1,4 +1,3 @@
import asyncio
import csv import csv
import io import io
import json import json
@@ -25,10 +24,6 @@ async def scan_and_validate():
result = validation_service.validate_skus(all_skus) result = validation_service.validate_skus(all_skus)
importable, skipped = validation_service.classify_orders(orders, result) importable, skipped = validation_service.classify_orders(orders, result)
# Find new orders (not yet in Oracle)
all_order_numbers = [o.number for o in orders]
new_orders = await asyncio.to_thread(validation_service.find_new_orders, all_order_numbers)
# Build SKU context from skipped orders and track missing SKUs # Build SKU context from skipped orders and track missing SKUs
sku_context = {} # sku -> {order_numbers: [], customers: []} sku_context = {} # sku -> {order_numbers: [], customers: []}
for order, missing_list in skipped: for order, missing_list in skipped:
@@ -73,7 +68,7 @@ async def scan_and_validate():
"total_skus": len(all_skus), "total_skus": len(all_skus),
"importable": len(importable), "importable": len(importable),
"skipped": len(skipped), "skipped": len(skipped),
"new_orders": len(new_orders), "new_orders": len(importable),
# 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,

View File

@@ -16,19 +16,27 @@ logger = logging.getLogger(__name__)
async def download_orders( async def download_orders(
json_dir: str, json_dir: str,
days_back: int = None, days_back: int = None,
api_key: str = None,
api_shop: str = None,
limit: int = None,
log_fn: Callable[[str], None] = None, log_fn: Callable[[str], None] = None,
) -> dict: ) -> dict:
"""Download orders from GoMag API and save as JSON files. """Download orders from GoMag API and save as JSON files.
Returns dict with keys: pages, total, files (list of saved file paths). Returns dict with keys: pages, total, files (list of saved file paths).
If API keys are not configured, returns immediately with empty result. If API keys are not configured, returns immediately with empty result.
Optional api_key, api_shop, limit override config.settings values.
""" """
def _log(msg: str): def _log(msg: str):
logger.info(msg) logger.info(msg)
if log_fn: if log_fn:
log_fn(msg) log_fn(msg)
if not settings.GOMAG_API_KEY or not settings.GOMAG_API_SHOP: effective_key = api_key or settings.GOMAG_API_KEY
effective_shop = api_shop or settings.GOMAG_API_SHOP
effective_limit = limit or settings.GOMAG_LIMIT
if not effective_key or not effective_shop:
_log("GoMag API keys neconfigurați, skip download") _log("GoMag API keys neconfigurați, skip download")
return {"pages": 0, "total": 0, "files": []} return {"pages": 0, "total": 0, "files": []}
@@ -39,9 +47,16 @@ async def download_orders(
out_dir = Path(json_dir) out_dir = Path(json_dir)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
# Clean old JSON files before downloading new ones
old_files = list(out_dir.glob("gomag_orders*.json"))
if old_files:
for f in old_files:
f.unlink()
_log(f"Șterse {len(old_files)} fișiere JSON vechi")
headers = { headers = {
"Apikey": settings.GOMAG_API_KEY, "Apikey": effective_key,
"ApiShop": settings.GOMAG_API_SHOP, "ApiShop": effective_shop,
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
@@ -57,7 +72,7 @@ async def download_orders(
params = { params = {
"startDate": start_date, "startDate": start_date,
"page": page, "page": page,
"limit": settings.GOMAG_LIMIT, "limit": effective_limit,
} }
try: try:
response = await client.get(settings.GOMAG_API_URL, headers=headers, params=params) response = await client.get(settings.GOMAG_API_URL, headers=headers, params=params)

View File

@@ -60,21 +60,148 @@ 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 build_articles_json(items) -> str: def compute_discount_split(order, settings: dict) -> dict | None:
"""Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda.""" """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:
"""Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda.
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:
transport_codmat = settings.get("transport_codmat", "")
transport_vat = settings.get("transport_vat", "21")
discount_codmat = settings.get("discount_codmat", "")
# Transport as article with quantity +1
if order.delivery_cost > 0 and transport_codmat:
article_dict = {
"sku": transport_codmat,
"quantity": "1",
"price": str(order.delivery_cost),
"vat": transport_vat,
"name": "Transport"
}
if settings.get("transport_id_pol"):
article_dict["id_pol"] = settings["transport_id_pol"]
articles.append(article_dict)
# Discount — smart VAT splitting
if order.discount_total > 0 and discount_codmat:
discount_split = compute_discount_split(order, settings)
if discount_split and len(discount_split) > 1:
# Multiple VAT rates — multiple discount lines
for vat_rate, split_amount in sorted(discount_split.items(), key=lambda x: float(x[0])):
article_dict = {
"sku": discount_codmat,
"quantity": "-1",
"price": str(split_amount),
"vat": vat_rate,
"name": f"Discount (TVA {vat_rate}%)"
}
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) -> 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:
@@ -94,6 +221,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
"error": None "error": None
} }
conn = None
try: try:
order_number = clean_web_text(order.number) order_number = clean_web_text(order.number)
order_date = convert_web_date(order.date) order_date = convert_web_date(order.date)
@@ -104,8 +232,8 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
if database.pool is None: if database.pool is None:
raise RuntimeError("Oracle pool not initialized") raise RuntimeError("Oracle pool not initialized")
with database.pool.acquire() as conn: conn = database.pool.acquire()
with conn.cursor() as cur: with conn.cursor() as cur:
# 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)
@@ -203,7 +331,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
result["id_adresa_livrare"] = int(addr_livr_id) result["id_adresa_livrare"] = int(addr_livr_id)
# Step 4: Build articles JSON and import order # Step 4: Build articles JSON and import order
articles_json = build_articles_json(order.items) articles_json = build_articles_json(order.items, order, app_settings)
# Use CLOB for the JSON # Use CLOB for the JSON
clob_var = cur.var(oracledb.DB_TYPE_CLOB) clob_var = cur.var(oracledb.DB_TYPE_CLOB)
@@ -211,6 +339,9 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
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
@@ -220,6 +351,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
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_csv, # p_id_gestiune (CSV string)
id_comanda # v_id_comanda (OUT) id_comanda # v_id_comanda (OUT)
]) ])
@@ -238,8 +370,72 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
error_msg = str(e) error_msg = str(e)
result["error"] = error_msg result["error"] = error_msg
logger.error(f"Oracle error importing order {order.number}: {error_msg}") logger.error(f"Oracle error importing order {order.number}: {error_msg}")
if conn:
try:
conn.rollback()
except Exception:
pass
except Exception as e: except Exception as e:
result["error"] = str(e) result["error"] = str(e)
logger.error(f"Error importing order {order.number}: {e}") logger.error(f"Error importing order {order.number}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
database.pool.release(conn)
except Exception:
pass
return result
def soft_delete_order_in_roa(id_comanda: int) -> dict:
"""Soft-delete an order in Oracle ROA (set sters=1 on comenzi + comenzi_detalii).
Returns {"success": bool, "error": str|None, "details_deleted": int}
"""
result = {"success": False, "error": None, "details_deleted": 0}
if database.pool is None:
result["error"] = "Oracle pool not initialized"
return result
conn = None
try:
conn = database.pool.acquire()
with conn.cursor() as cur:
# Soft-delete order details
cur.execute(
"UPDATE comenzi_detalii SET sters = 1 WHERE id_comanda = :1 AND sters = 0",
[id_comanda]
)
result["details_deleted"] = cur.rowcount
# Soft-delete the order itself
cur.execute(
"UPDATE comenzi SET sters = 1 WHERE id_comanda = :1 AND sters = 0",
[id_comanda]
)
conn.commit()
result["success"] = True
logger.info(f"Soft-deleted order ID={id_comanda} in Oracle ROA ({result['details_deleted']} details)")
except Exception as e:
result["error"] = str(e)
logger.error(f"Error soft-deleting order ID={id_comanda}: {e}")
if conn:
try:
conn.rollback()
except Exception:
pass
finally:
if conn:
try:
database.pool.release(conn)
except Exception:
pass
return result return result

View File

@@ -22,7 +22,8 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
cur.execute(f""" cur.execute(f"""
SELECT id_comanda, numar_act, serie_act, SELECT id_comanda, numar_act, serie_act,
total_fara_tva, total_tva, total_cu_tva total_fara_tva, total_tva, total_cu_tva,
TO_CHAR(data_act, 'YYYY-MM-DD') AS data_act
FROM vanzari FROM vanzari
WHERE id_comanda IN ({placeholders}) AND sters = 0 WHERE id_comanda IN ({placeholders}) AND sters = 0
""", params) """, params)
@@ -34,6 +35,7 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
"total_fara_tva": float(row[3]) if row[3] else 0, "total_fara_tva": float(row[3]) if row[3] else 0,
"total_tva": float(row[4]) if row[4] else 0, "total_tva": float(row[4]) if row[4] else 0,
"total_cu_tva": float(row[5]) if row[5] else 0, "total_cu_tva": float(row[5]) if row[5] else 0,
"data_act": row[6],
} }
except Exception as e: except Exception as e:
logger.warning(f"Invoice check failed (table may not exist): {e}") logger.warning(f"Invoice check failed (table may not exist): {e}")
@@ -41,3 +43,33 @@ def check_invoices_for_orders(id_comanda_list: list) -> dict:
database.pool.release(conn) database.pool.release(conn)
return result return result
def check_orders_exist(id_comanda_list: list) -> set:
"""Check which id_comanda values still exist in Oracle COMENZI (sters=0).
Returns set of id_comanda that exist.
"""
if not id_comanda_list or database.pool is None:
return set()
existing = set()
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
for i in range(0, len(id_comanda_list), 500):
batch = id_comanda_list[i:i+500]
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
params = {f"c{j}": cid for j, cid in enumerate(batch)}
cur.execute(f"""
SELECT id_comanda FROM COMENZI
WHERE id_comanda IN ({placeholders}) AND sters = 0
""", params)
for row in cur:
existing.add(row[0])
except Exception as e:
logger.warning(f"Order existence check failed: {e}")
finally:
database.pool.release(conn)
return existing

View File

@@ -88,7 +88,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
for r in rows for r in rows
if r.get("activ") == 1 if r.get("activ") == 1
) )
if pct_total >= 99.99: if abs(pct_total - 100) <= 0.01:
complete_skus += 1 complete_skus += 1
else: else:
incomplete_skus += 1 incomplete_skus += 1
@@ -108,7 +108,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
for r in rows for r in rows
if r.get("activ") == 1 if r.get("activ") == 1
) )
is_complete = pct_total >= 99.99 is_complete = abs(pct_total - 100) <= 0.01
if pct_filter == "complete" and is_complete: if pct_filter == "complete" and is_complete:
filtered_groups[sku] = rows filtered_groups[sku] = rows
elif pct_filter == "incomplete" and not is_complete: elif pct_filter == "incomplete" and not is_complete:
@@ -129,7 +129,7 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
for r in rows for r in rows
if r.get("activ") == 1 if r.get("activ") == 1
) )
sku_pct[sku] = {"pct_total": pct_total, "is_complete": pct_total >= 99.99} sku_pct[sku] = {"pct_total": pct_total, "is_complete": abs(pct_total - 100) <= 0.01}
for row in page_rows: for row in page_rows:
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False}) meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
@@ -145,13 +145,38 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
"counts": counts, "counts": counts,
} }
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100): def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100, auto_restore: bool = False):
"""Create a new mapping. Returns dict or raises HTTPException on duplicate.""" """Create a new mapping. Returns dict or raises HTTPException on duplicate.
When auto_restore=True, soft-deleted records are restored+updated instead of raising 409.
"""
if not sku or not sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
if not codmat or not codmat.strip():
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn: with database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# Validate CODMAT exists in NOM_ARTICOLE
cur.execute("""
SELECT COUNT(*) FROM NOM_ARTICOLE
WHERE codmat = :codmat AND sters = 0 AND inactiv = 0
""", {"codmat": codmat})
if cur.fetchone()[0] == 0:
raise HTTPException(status_code=400, detail="CODMAT-ul nu exista in nomenclator")
# Warn if SKU is already a direct CODMAT in NOM_ARTICOLE
if sku == codmat:
cur.execute("""
SELECT COUNT(*) FROM NOM_ARTICOLE
WHERE codmat = :sku AND sters = 0 AND inactiv = 0
""", {"sku": sku})
if cur.fetchone()[0] > 0:
raise HTTPException(status_code=409,
detail="SKU-ul exista direct in nomenclator ca CODMAT, nu necesita mapare")
# Check for active duplicate # Check for active duplicate
cur.execute(""" cur.execute("""
SELECT COUNT(*) FROM ARTICOLE_TERTI SELECT COUNT(*) FROM ARTICOLE_TERTI
@@ -166,11 +191,22 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret
WHERE sku = :sku AND codmat = :codmat AND sters = 1 WHERE sku = :sku AND codmat = :codmat AND sters = 1
""", {"sku": sku, "codmat": codmat}) """, {"sku": sku, "codmat": codmat})
if cur.fetchone()[0] > 0: if cur.fetchone()[0] > 0:
raise HTTPException( if auto_restore:
status_code=409, cur.execute("""
detail="Maparea a fost ștearsă anterior", UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1,
headers={"X-Can-Restore": "true"} cantitate_roa = :cantitate_roa, procent_pret = :procent_pret,
) data_modif = SYSDATE
WHERE sku = :sku AND codmat = :codmat AND sters = 1
""", {"sku": sku, "codmat": codmat,
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
conn.commit()
return {"sku": sku, "codmat": codmat}
else:
raise HTTPException(
status_code=409,
detail="Maparea a fost ștearsă anterior",
headers={"X-Can-Restore": "true"}
)
cur.execute(""" cur.execute("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
@@ -229,6 +265,10 @@ def delete_mapping(sku: str, codmat: str):
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str, def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
cantitate_roa: float = 1, procent_pret: float = 100): cantitate_roa: float = 1, procent_pret: float = 100):
"""Edit a mapping. If PK changed, soft-delete old and insert new.""" """Edit a mapping. If PK changed, soft-delete old and insert new."""
if not new_sku or not new_sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
if not new_codmat or not new_codmat.strip():
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -283,23 +323,27 @@ def import_csv(file_content: str):
reader = csv.DictReader(io.StringIO(file_content)) reader = csv.DictReader(io.StringIO(file_content))
created = 0 created = 0
updated = 0 skipped_no_codmat = 0
errors = [] errors = []
with database.pool.acquire() as conn: with database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
for i, row in enumerate(reader, 1): for i, row in enumerate(reader, 1):
sku = row.get("sku", "").strip()
codmat = row.get("codmat", "").strip()
if not sku:
errors.append(f"Rând {i}: SKU lipsă")
continue
if not codmat:
skipped_no_codmat += 1
continue
try: try:
sku = row.get("sku", "").strip()
codmat = row.get("codmat", "").strip()
cantitate = float(row.get("cantitate_roa", "1") or "1") cantitate = float(row.get("cantitate_roa", "1") or "1")
procent = float(row.get("procent_pret", "100") or "100") procent = float(row.get("procent_pret", "100") or "100")
if not sku or not codmat:
errors.append(f"Row {i}: missing sku or codmat")
continue
# Try update first, insert if not exists (MERGE)
cur.execute(""" cur.execute("""
MERGE INTO ARTICOLE_TERTI t MERGE INTO ARTICOLE_TERTI t
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
@@ -314,16 +358,14 @@ def import_csv(file_content: str):
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent}) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
created += 1
# Check if it was insert or update by rowcount
created += 1 # We count total processed
except Exception as e: except Exception as e:
errors.append(f"Row {i}: {str(e)}") errors.append(f"Rând {i}: {str(e)}")
conn.commit() conn.commit()
return {"processed": created, "errors": errors} return {"processed": created, "skipped_no_codmat": skipped_no_codmat, "errors": errors}
def export_csv(): def export_csv():
"""Export all mappings as CSV string.""" """Export all mappings as CSV string."""

View File

@@ -54,6 +54,10 @@ class OrderData:
items: list = field(default_factory=list) # list of OrderItem items: list = field(default_factory=list) # list of OrderItem
billing: OrderBilling = field(default_factory=OrderBilling) billing: OrderBilling = field(default_factory=OrderBilling)
shipping: Optional[OrderShipping] = None shipping: Optional[OrderShipping] = None
total: float = 0.0
delivery_cost: float = 0.0
discount_total: float = 0.0
discount_vat: Optional[str] = None
payment_name: str = "" payment_name: str = ""
delivery_name: str = "" delivery_name: str = ""
source_file: str = "" source_file: str = ""
@@ -154,6 +158,18 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
payment = data.get("payment", {}) or {} payment = data.get("payment", {}) or {}
delivery = data.get("delivery", {}) or {} delivery = data.get("delivery", {}) or {}
# Parse delivery cost
delivery_cost = float(delivery.get("total", 0) or 0) if isinstance(delivery, dict) else 0.0
# Parse discount total (sum of all discount values) and VAT from first discount item
discount_total = 0.0
discount_vat = None
for d in data.get("discounts", []):
if isinstance(d, dict):
discount_total += float(d.get("value", 0) or 0)
if discount_vat is None and d.get("vat") is not None:
discount_vat = str(d["vat"])
return OrderData( return OrderData(
id=str(data.get("id", order_id)), id=str(data.get("id", order_id)),
number=str(data.get("number", "")), number=str(data.get("number", "")),
@@ -163,6 +179,10 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
items=items, items=items,
billing=billing, billing=billing,
shipping=shipping, shipping=shipping,
total=float(data.get("total", 0) or 0),
delivery_cost=delivery_cost,
discount_total=discount_total,
discount_vat=discount_vat,
payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "", payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "",
delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "", delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "",
source_file=source_file source_file=source_file

View File

@@ -1,8 +1,16 @@
import json import json
import logging import logging
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo
from ..database import get_sqlite, get_sqlite_sync from ..database import get_sqlite, get_sqlite_sync
_tz_bucharest = ZoneInfo("Europe/Bucharest")
def _now_str():
"""Return current Bucharest time as ISO string."""
return datetime.now(_tz_bucharest).replace(tzinfo=None).isoformat()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -12,8 +20,8 @@ async def create_sync_run(run_id: str, json_files: int = 0):
try: try:
await db.execute(""" await db.execute("""
INSERT INTO sync_runs (run_id, started_at, status, json_files) INSERT INTO sync_runs (run_id, started_at, status, json_files)
VALUES (?, datetime('now'), 'running', ?) VALUES (?, ?, 'running', ?)
""", (run_id, json_files)) """, (run_id, _now_str(), json_files))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()
@@ -28,7 +36,7 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
try: try:
await db.execute(""" await db.execute("""
UPDATE sync_runs SET UPDATE sync_runs SET
finished_at = datetime('now'), finished_at = ?,
status = ?, status = ?,
total_orders = ?, total_orders = ?,
imported = ?, imported = ?,
@@ -38,7 +46,7 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
already_imported = ?, already_imported = ?,
new_imported = ? new_imported = ?
WHERE run_id = ? WHERE run_id = ?
""", (status, total_orders, imported, skipped, errors, error_message, """, (_now_str(), status, total_orders, imported, skipped, errors, error_message,
already_imported, new_imported, run_id)) already_imported, new_imported, run_id))
await db.commit() await db.commit()
finally: finally:
@@ -50,7 +58,10 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
id_partener: int = None, error_message: str = None, id_partener: int = None, error_message: str = None,
missing_skus: list = None, items_count: int = 0, missing_skus: list = None, items_count: int = 0,
shipping_name: str = None, billing_name: str = None, shipping_name: str = None, billing_name: str = None,
payment_method: str = None, delivery_method: str = None): payment_method: str = None, delivery_method: str = None,
order_total: float = None,
delivery_cost: float = None, discount_total: float = 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:
@@ -59,9 +70,11 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
(order_number, order_date, customer_name, status, (order_number, order_date, customer_name, status,
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) payment_method, delivery_method, order_total,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name,
status = CASE status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED' WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
THEN orders.status THEN orders.status
@@ -80,12 +93,18 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
billing_name = COALESCE(excluded.billing_name, orders.billing_name), billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method), payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method), delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now') 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)) payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status, discount_split))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()
@@ -110,7 +129,8 @@ async def save_orders_batch(orders_data: list[dict]):
Each dict must have: sync_run_id, order_number, order_date, customer_name, status, Each dict must have: sync_run_id, order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus (list|None), items_count, id_comanda, id_partener, error_message, missing_skus (list|None), items_count,
shipping_name, billing_name, payment_method, delivery_method, status_at_run, shipping_name, billing_name, payment_method, delivery_method, status_at_run,
items (list of item dicts). items (list of item dicts), delivery_cost (optional), discount_total (optional),
web_status (optional).
""" """
if not orders_data: if not orders_data:
return return
@@ -122,9 +142,11 @@ async def save_orders_batch(orders_data: list[dict]):
(order_number, order_date, customer_name, status, (order_number, order_date, customer_name, status,
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) payment_method, delivery_method, order_total,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name,
status = CASE status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED' WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
THEN orders.status THEN orders.status
@@ -143,6 +165,11 @@ async def save_orders_batch(orders_data: list[dict]):
billing_name = COALESCE(excluded.billing_name, orders.billing_name), billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method), payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method), delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now') 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"],
@@ -150,7 +177,10 @@ async def save_orders_batch(orders_data: list[dict]):
json.dumps(d["missing_skus"]) if d.get("missing_skus") else None, json.dumps(d["missing_skus"]) if d.get("missing_skus") else None,
d.get("items_count", 0), d["sync_run_id"], d.get("items_count", 0), d["sync_run_id"],
d.get("shipping_name"), d.get("billing_name"), d.get("shipping_name"), d.get("billing_name"),
d.get("payment_method"), d.get("delivery_method")) d.get("payment_method"), d.get("delivery_method"),
d.get("order_total"),
d.get("delivery_cost"), d.get("discount_total"),
d.get("web_status"), d.get("discount_split"))
for d in orders_data for d in orders_data
]) ])
@@ -604,6 +634,7 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
"skipped": status_counts.get("SKIPPED", 0), "skipped": status_counts.get("SKIPPED", 0),
"error": status_counts.get("ERROR", 0), "error": status_counts.get("ERROR", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0), "already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"cancelled": status_counts.get("CANCELLED", 0),
"total": sum(status_counts.values()) "total": sum(status_counts.values())
} }
} }
@@ -623,25 +654,34 @@ async def get_orders(page: int = 1, per_page: int = 50,
""" """
db = await get_sqlite() db = await get_sqlite()
try: try:
where_clauses = [] # Period + search clauses (used for counts — never include status filter)
params = [] base_clauses = []
base_params = []
if period_days and period_days > 0: if period_days and period_days > 0:
where_clauses.append("order_date >= date('now', ?)") base_clauses.append("order_date >= date('now', ?)")
params.append(f"-{period_days} days") base_params.append(f"-{period_days} days")
elif period_days == 0 and period_start and period_end: elif period_days == 0 and period_start and period_end:
where_clauses.append("order_date BETWEEN ? AND ?") base_clauses.append("order_date BETWEEN ? AND ?")
params.extend([period_start, period_end]) base_params.extend([period_start, period_end])
if search: if search:
where_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)") base_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"]) base_params.extend([f"%{search}%", f"%{search}%"])
# Data query adds status filter on top of base filters
data_clauses = list(base_clauses)
data_params = list(base_params)
if status_filter and status_filter not in ("all", "UNINVOICED"): if status_filter and status_filter not in ("all", "UNINVOICED"):
where_clauses.append("UPPER(status) = ?") if status_filter.upper() == "IMPORTED":
params.append(status_filter.upper()) data_clauses.append("UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')")
else:
data_clauses.append("UPPER(status) = ?")
data_params.append(status_filter.upper())
where = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" where = ("WHERE " + " AND ".join(data_clauses)) if data_clauses else ""
counts_where = ("WHERE " + " AND ".join(base_clauses)) if base_clauses else ""
allowed_sort = {"order_date", "order_number", "customer_name", "items_count", allowed_sort = {"order_date", "order_number", "customer_name", "items_count",
"status", "first_seen_at", "updated_at"} "status", "first_seen_at", "updated_at"}
@@ -650,7 +690,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
if sort_dir.lower() not in ("asc", "desc"): if sort_dir.lower() not in ("asc", "desc"):
sort_dir = "desc" sort_dir = "desc"
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", params) cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", data_params)
total = (await cursor.fetchone())[0] total = (await cursor.fetchone())[0]
offset = (page - 1) * per_page offset = (page - 1) * per_page
@@ -659,17 +699,26 @@ async def get_orders(page: int = 1, per_page: int = 50,
{where} {where}
ORDER BY {sort_by} {sort_dir} ORDER BY {sort_by} {sort_dir}
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""", params + [per_page, offset]) """, data_params + [per_page, offset])
rows = await cursor.fetchall() rows = await cursor.fetchall()
# Counts by status (on full period, not just this page) # Counts by status — always on full period+search, never filtered by status
cursor = await db.execute(f""" cursor = await db.execute(f"""
SELECT status, COUNT(*) as cnt FROM orders SELECT status, COUNT(*) as cnt FROM orders
{where} {counts_where}
GROUP BY status GROUP BY status
""", params) """, base_params)
status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()} status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()}
# Uninvoiced count: IMPORTED/ALREADY_IMPORTED with no cached invoice, same period+search
uninv_clauses = list(base_clauses) + [
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
"(factura_numar IS NULL OR factura_numar = '')",
]
uninv_where = "WHERE " + " AND ".join(uninv_clauses)
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
uninvoiced_sqlite = (await cursor.fetchone())[0]
return { return {
"orders": [dict(r) for r in rows], "orders": [dict(r) for r in rows],
"total": total, "total": total,
@@ -678,10 +727,13 @@ async def get_orders(page: int = 1, per_page: int = 50,
"pages": (total + per_page - 1) // per_page if total > 0 else 0, "pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": { "counts": {
"imported": status_counts.get("IMPORTED", 0), "imported": status_counts.get("IMPORTED", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"imported_all": status_counts.get("IMPORTED", 0) + status_counts.get("ALREADY_IMPORTED", 0),
"skipped": status_counts.get("SKIPPED", 0), "skipped": status_counts.get("SKIPPED", 0),
"error": status_counts.get("ERROR", 0), "error": status_counts.get("ERROR", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0), "cancelled": status_counts.get("CANCELLED", 0),
"total": sum(status_counts.values()) "total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite,
} }
} }
finally: finally:
@@ -726,7 +778,8 @@ async def get_uninvoiced_imported_orders() -> list:
async def update_order_invoice(order_number: str, serie: str = None, async def update_order_invoice(order_number: str, serie: str = None,
numar: str = None, total_fara_tva: float = None, numar: str = None, total_fara_tva: float = None,
total_tva: float = None, total_cu_tva: float = None): total_tva: float = None, total_cu_tva: float = None,
data_act: str = None):
"""Cache invoice data from Oracle onto the order record.""" """Cache invoice data from Oracle onto the order record."""
db = await get_sqlite() db = await get_sqlite()
try: try:
@@ -737,10 +790,140 @@ async def update_order_invoice(order_number: str, serie: str = None,
factura_total_fara_tva = ?, factura_total_fara_tva = ?,
factura_total_tva = ?, factura_total_tva = ?,
factura_total_cu_tva = ?, factura_total_cu_tva = ?,
factura_data = ?,
invoice_checked_at = datetime('now'), invoice_checked_at = datetime('now'),
updated_at = datetime('now') updated_at = datetime('now')
WHERE order_number = ? WHERE order_number = ?
""", (serie, numar, total_fara_tva, total_tva, total_cu_tva, order_number)) """, (serie, numar, total_fara_tva, total_tva, total_cu_tva, data_act, order_number))
await db.commit()
finally:
await db.close()
async def get_invoiced_imported_orders() -> list:
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT order_number, id_comanda FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
AND id_comanda IS NOT NULL
AND factura_numar IS NOT NULL AND factura_numar != ''
""")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
finally:
await db.close()
async def get_all_imported_orders() -> list:
"""Get ALL imported orders with id_comanda (for checking if deleted in ROA)."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT order_number, id_comanda FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
AND id_comanda IS NOT NULL
""")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
finally:
await db.close()
async def clear_order_invoice(order_number: str):
"""Clear cached invoice data when invoice was deleted in ROA."""
db = await get_sqlite()
try:
await db.execute("""
UPDATE orders SET
factura_serie = NULL,
factura_numar = NULL,
factura_total_fara_tva = NULL,
factura_total_tva = NULL,
factura_total_cu_tva = NULL,
factura_data = NULL,
invoice_checked_at = datetime('now'),
updated_at = datetime('now')
WHERE order_number = ?
""", (order_number,))
await db.commit()
finally:
await db.close()
async def mark_order_deleted_in_roa(order_number: str):
"""Mark an order as deleted in ROA — clears id_comanda and invoice cache."""
db = await get_sqlite()
try:
await db.execute("""
UPDATE orders SET
status = 'DELETED_IN_ROA',
id_comanda = NULL,
id_partener = NULL,
factura_serie = NULL,
factura_numar = NULL,
factura_total_fara_tva = NULL,
factura_total_tva = NULL,
factura_total_cu_tva = NULL,
factura_data = NULL,
invoice_checked_at = NULL,
error_message = 'Comanda stearsa din ROA',
updated_at = datetime('now')
WHERE order_number = ?
""", (order_number,))
await db.commit()
finally:
await db.close()
async def mark_order_cancelled(order_number: str, web_status: str = "Anulata"):
"""Mark an order as cancelled from GoMag. Clears id_comanda and invoice cache."""
db = await get_sqlite()
try:
await db.execute("""
UPDATE orders SET
status = 'CANCELLED',
id_comanda = NULL,
id_partener = NULL,
factura_serie = NULL,
factura_numar = NULL,
factura_total_fara_tva = NULL,
factura_total_tva = NULL,
factura_total_cu_tva = NULL,
factura_data = NULL,
invoice_checked_at = NULL,
web_status = ?,
error_message = 'Comanda anulata in GoMag',
updated_at = datetime('now')
WHERE order_number = ?
""", (web_status, order_number))
await db.commit()
finally:
await db.close()
# ── App Settings ─────────────────────────────────
async def get_app_settings() -> dict:
"""Get all app settings as a dict."""
db = await get_sqlite()
try:
cursor = await db.execute("SELECT key, value FROM app_settings")
rows = await cursor.fetchall()
return {row["key"]: row["value"] for row in rows}
finally:
await db.close()
async def set_app_setting(key: str, value: str):
"""Set a single app setting value."""
db = await get_sqlite()
try:
await db.execute("""
INSERT OR REPLACE INTO app_settings (key, value)
VALUES (?, ?)
""", (key, value))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()

View File

@@ -3,6 +3,14 @@ import json
import logging import logging
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
_tz_bucharest = ZoneInfo("Europe/Bucharest")
def _now():
"""Return current time in Bucharest timezone (naive, for display/storage)."""
return datetime.now(_tz_bucharest).replace(tzinfo=None)
from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client from . import order_reader, validation_service, import_service, sqlite_service, invoice_service, gomag_client
from ..config import settings from ..config import settings
@@ -22,7 +30,7 @@ def _log_line(run_id: str, message: str):
"""Append a timestamped line to the in-memory log buffer.""" """Append a timestamped line to the in-memory log buffer."""
if run_id not in _run_logs: if run_id not in _run_logs:
_run_logs[run_id] = [] _run_logs[run_id] = []
ts = datetime.now().strftime("%H:%M:%S") ts = _now().strftime("%H:%M:%S")
_run_logs[run_id].append(f"[{ts}] {message}") _run_logs[run_id].append(f"[{ts}] {message}")
@@ -62,35 +70,76 @@ async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
if _sync_lock.locked(): if _sync_lock.locked():
return {"error": "Sync already running", "run_id": _current_sync.get("run_id") if _current_sync else None} return {"error": "Sync already running", "run_id": _current_sync.get("run_id") if _current_sync else None}
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6] run_id = _now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = { _current_sync = {
"run_id": run_id, "run_id": run_id,
"status": "running", "status": "running",
"started_at": datetime.now().isoformat(), "started_at": _now().isoformat(),
"finished_at": None, "finished_at": None,
"phase": "starting", "phase": "starting",
"phase_text": "Starting...", "phase_text": "Starting...",
"progress_current": 0, "progress_current": 0,
"progress_total": 0, "progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0}, "counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0},
} }
return {"run_id": run_id, "status": "starting"} return {"run_id": run_id, "status": "starting"}
def _derive_customer_info(order): def _derive_customer_info(order):
"""Extract shipping/billing names and customer from an order.""" """Extract shipping/billing names and customer from an order.
customer = who appears on the invoice (partner in ROA):
- company name if billing is on a company
- shipping person name otherwise (consistent with import_service partner logic)
"""
shipping_name = "" shipping_name = ""
if order.shipping: if order.shipping:
shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip() shipping_name = f"{getattr(order.shipping, 'firstname', '') or ''} {getattr(order.shipping, 'lastname', '') or ''}".strip()
billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip() billing_name = f"{getattr(order.billing, 'firstname', '') or ''} {getattr(order.billing, 'lastname', '') or ''}".strip()
if not shipping_name: if not shipping_name:
shipping_name = billing_name shipping_name = billing_name
customer = shipping_name or order.billing.company_name or billing_name if order.billing.is_company and order.billing.company_name:
customer = order.billing.company_name
else:
customer = shipping_name or billing_name
payment_method = getattr(order, 'payment_name', None) or None payment_method = getattr(order, 'payment_name', None) or None
delivery_method = getattr(order, 'delivery_name', None) or None delivery_method = getattr(order, 'delivery_name', None) or None
return shipping_name, billing_name, customer, payment_method, delivery_method return shipping_name, billing_name, customer, payment_method, delivery_method
async def _fix_stale_error_orders(existing_map: dict, run_id: str):
"""Fix orders stuck in ERROR status that are actually in Oracle.
This can happen when a previous import committed partially (no rollback on error).
If the order exists in Oracle COMENZI, update SQLite status to ALREADY_IMPORTED.
"""
from ..database import get_sqlite
db = await get_sqlite()
try:
cursor = await db.execute(
"SELECT order_number FROM orders WHERE status = 'ERROR'"
)
error_orders = [row["order_number"] for row in await cursor.fetchall()]
fixed = 0
for order_number in error_orders:
if order_number in existing_map:
id_comanda = existing_map[order_number]
await db.execute("""
UPDATE orders SET
status = 'ALREADY_IMPORTED',
id_comanda = ?,
error_message = NULL,
updated_at = datetime('now')
WHERE order_number = ? AND status = 'ERROR'
""", (id_comanda, order_number))
fixed += 1
_log_line(run_id, f"#{order_number} → status corectat ERROR → ALREADY_IMPORTED (ID: {id_comanda})")
if fixed:
await db.commit()
logger.info(f"Fixed {fixed} stale ERROR orders that exist in Oracle")
finally:
await db.close()
async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict: async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict:
"""Run a full sync cycle. Returns summary dict.""" """Run a full sync cycle. Returns summary dict."""
global _current_sync global _current_sync
@@ -101,22 +150,22 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
async with _sync_lock: async with _sync_lock:
# Use provided run_id or generate one # Use provided run_id or generate one
if not run_id: if not run_id:
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6] run_id = _now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = { _current_sync = {
"run_id": run_id, "run_id": run_id,
"status": "running", "status": "running",
"started_at": datetime.now().isoformat(), "started_at": _now().isoformat(),
"finished_at": None, "finished_at": None,
"phase": "reading", "phase": "reading",
"phase_text": "Reading JSON files...", "phase_text": "Reading JSON files...",
"progress_current": 0, "progress_current": 0,
"progress_total": 0, "progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0}, "counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0},
} }
_update_progress("reading", "Reading JSON files...") _update_progress("reading", "Reading JSON files...")
started_dt = datetime.now() started_dt = _now()
_run_logs[run_id] = [ _run_logs[run_id] = [
f"=== Sync Run {run_id} ===", f"=== Sync Run {run_id} ===",
f"Inceput: {started_dt.strftime('%d.%m.%Y %H:%M:%S')}", f"Inceput: {started_dt.strftime('%d.%m.%Y %H:%M:%S')}",
@@ -129,8 +178,18 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
# 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...")
_log_line(run_id, "Descărcare comenzi din GoMag API...") _log_line(run_id, "Descărcare comenzi din GoMag API...")
# Read GoMag settings from SQLite (override config defaults)
dl_settings = await sqlite_service.get_app_settings()
gomag_key = dl_settings.get("gomag_api_key") or None
gomag_shop = dl_settings.get("gomag_api_shop") or None
gomag_days_str = dl_settings.get("gomag_order_days_back")
gomag_days = int(gomag_days_str) if gomag_days_str else None
gomag_limit_str = dl_settings.get("gomag_limit")
gomag_limit = int(gomag_limit_str) if gomag_limit_str else None
dl_result = await gomag_client.download_orders( dl_result = await gomag_client.download_orders(
json_dir, log_fn=lambda msg: _log_line(run_id, msg) json_dir, log_fn=lambda msg: _log_line(run_id, msg),
api_key=gomag_key, api_shop=gomag_shop,
days_back=gomag_days, limit=gomag_limit,
) )
if dl_result["files"]: if dl_result["files"]:
_log_line(run_id, f"GoMag: {dl_result['total']} comenzi în {dl_result['pages']} pagini → {len(dl_result['files'])} fișiere") _log_line(run_id, f"GoMag: {dl_result['total']} comenzi în {dl_result['pages']} pagini → {len(dl_result['files'])} fișiere")
@@ -161,6 +220,104 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count} summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
return summary return summary
# ── Separate cancelled orders (GoMag status "Anulata" / statusId "7") ──
cancelled_orders = [o for o in orders if o.status_id == "7" or (o.status and o.status.lower() == "anulata")]
active_orders = [o for o in orders if o not in cancelled_orders]
cancelled_count = len(cancelled_orders)
if cancelled_orders:
_log_line(run_id, f"Comenzi anulate in GoMag: {cancelled_count}")
# Record cancelled orders in SQLite
cancelled_batch = []
for order in cancelled_orders:
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
order_items_data = [
{"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price, "vat": item.vat,
"mapping_status": "unknown", "codmat": None,
"id_articol": None, "cantitate_roa": None}
for item in order.items
]
cancelled_batch.append({
"sync_run_id": run_id, "order_number": order.number,
"order_date": order.date, "customer_name": customer,
"status": "CANCELLED", "status_at_run": "CANCELLED",
"id_comanda": None, "id_partener": None,
"error_message": "Comanda anulata in GoMag",
"missing_skus": None,
"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 "Anulata",
"items": order_items_data,
})
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → ANULAT in GoMag")
await sqlite_service.save_orders_batch(cancelled_batch)
# Check if any cancelled orders were previously imported
from ..database import get_sqlite as _get_sqlite
db_check = await _get_sqlite()
try:
cancelled_numbers = [o.number for o in cancelled_orders]
placeholders = ",".join("?" for _ in cancelled_numbers)
cursor = await db_check.execute(f"""
SELECT order_number, id_comanda FROM orders
WHERE order_number IN ({placeholders})
AND id_comanda IS NOT NULL
AND status = 'CANCELLED'
""", cancelled_numbers)
previously_imported = [dict(r) for r in await cursor.fetchall()]
finally:
await db_check.close()
if previously_imported:
_log_line(run_id, f"Verificare {len(previously_imported)} comenzi anulate care erau importate in Oracle...")
# Check which have invoices
id_comanda_list = [o["id_comanda"] for o in previously_imported]
invoice_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, id_comanda_list
)
for o in previously_imported:
idc = o["id_comanda"]
order_num = o["order_number"]
if idc in invoice_data:
# Invoiced — keep in Oracle, just log warning
_log_line(run_id,
f"#{order_num} → ANULAT dar FACTURAT (factura {invoice_data[idc].get('serie_act', '')}"
f"{invoice_data[idc].get('numar_act', '')}) — NU se sterge din Oracle")
# Update web_status but keep CANCELLED status (already set by batch above)
else:
# Not invoiced — soft-delete in Oracle
del_result = await asyncio.to_thread(
import_service.soft_delete_order_in_roa, idc
)
if del_result["success"]:
# Clear id_comanda via mark_order_cancelled
await sqlite_service.mark_order_cancelled(order_num, "Anulata")
_log_line(run_id,
f"#{order_num} → ANULAT + STERS din Oracle (ID: {idc}, "
f"{del_result['details_deleted']} detalii)")
else:
_log_line(run_id,
f"#{order_num} → ANULAT dar EROARE la stergere Oracle: {del_result['error']}")
orders = active_orders
if not orders:
_log_line(run_id, "Nicio comanda activa dupa filtrare anulate.")
await sqlite_service.update_sync_run(run_id, "completed", cancelled_count, 0, 0, 0)
_update_progress("completed", f"No active orders ({cancelled_count} cancelled)")
summary = {"run_id": run_id, "status": "completed",
"message": f"No active orders ({cancelled_count} cancelled)",
"json_files": json_count, "cancelled": cancelled_count}
return summary
_update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders)) _update_progress("validation", f"Validating {len(orders)} orders...", 0, len(orders))
# ── Single Oracle connection for entire validation phase ── # ── Single Oracle connection for entire validation phase ──
@@ -173,17 +330,34 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
try: try:
min_date = datetime.strptime(min_date_str[:10], "%Y-%m-%d") - timedelta(days=1) min_date = datetime.strptime(min_date_str[:10], "%Y-%m-%d") - timedelta(days=1)
except (ValueError, TypeError): except (ValueError, TypeError):
min_date = datetime.now() - timedelta(days=90) min_date = _now() - timedelta(days=90)
else: else:
min_date = datetime.now() - timedelta(days=90) min_date = _now() - timedelta(days=90)
existing_map = await asyncio.to_thread( existing_map = await asyncio.to_thread(
validation_service.check_orders_in_roa, min_date, conn validation_service.check_orders_in_roa, min_date, conn
) )
# Step 2a-fix: Fix ERROR orders that are actually in Oracle
# (can happen if previous import committed partially without rollback)
await _fix_stale_error_orders(existing_map, run_id)
# Load app settings early (needed for id_gestiune in SKU validation)
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_sectie = id_sectie or int(app_settings.get("id_sectie") or 0) or settings.ID_SECTIE
# Parse multi-gestiune CSV: "1,3" → [1, 3], "" → None
id_gestiune_raw = (app_settings.get("id_gestiune") or "").strip()
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) 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 ──
@@ -203,8 +377,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
# Step 2c: Build SKU context from skipped orders # Step 2c: Build SKU context from skipped orders
sku_context = {} sku_context = {}
for order, missing_skus_list in skipped: for order, missing_skus_list in skipped:
customer = order.billing.company_name or \ if order.billing.is_company and order.billing.company_name:
f"{order.billing.firstname} {order.billing.lastname}" customer = order.billing.company_name
else:
ship_name = ""
if order.shipping:
ship_name = f"{order.shipping.firstname} {order.shipping.lastname}".strip()
customer = ship_name or f"{order.billing.firstname} {order.billing.lastname}"
for sku in missing_skus_list: for sku in missing_skus_list:
if sku not in sku_context: if sku not in sku_context:
sku_context[sku] = {"orders": [], "customers": []} sku_context[sku] = {"orders": [], "customers": []}
@@ -232,10 +411,6 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
) )
# Step 2d: Pre-validate prices for importable articles # Step 2d: Pre-validate prices for importable articles
id_pol = id_pol or settings.ID_POL
id_sectie = id_sectie or settings.ID_SECTIE
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
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...") _log_line(run_id, "Validare preturi...")
@@ -246,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)
@@ -286,6 +526,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"items_count": len(order.items), "items_count": len(order.items),
"shipping_name": shipping_name, "billing_name": billing_name, "shipping_name": shipping_name, "billing_name": billing_name,
"payment_method": payment_method, "delivery_method": delivery_method, "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,
"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})")
@@ -313,6 +557,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"items_count": len(order.items), "items_count": len(order.items),
"shipping_name": shipping_name, "billing_name": billing_name, "shipping_name": shipping_name, "billing_name": billing_name,
"payment_method": payment_method, "delivery_method": delivery_method, "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,
"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)})")
@@ -336,7 +584,8 @@ 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_gestiuni=id_gestiuni
) )
# Build order items data for storage (R9) # Build order items data for storage (R9)
@@ -350,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(
@@ -365,6 +618,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
billing_name=billing_name, billing_name=billing_name,
payment_method=payment_method, payment_method=payment_method,
delivery_method=delivery_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, "IMPORTED") await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
# Store ROA address IDs (R9) # Store ROA address IDs (R9)
@@ -390,6 +648,11 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
billing_name=billing_name, billing_name=billing_name,
payment_method=payment_method, payment_method=payment_method,
delivery_method=delivery_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_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)
@@ -400,17 +663,19 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
logger.warning("Too many errors, stopping sync") logger.warning("Too many errors, stopping sync")
break break
# Step 4b: Invoice check — update cached invoice data # Step 4b: Invoice & order status check — sync with Oracle
_update_progress("invoices", "Checking invoices...", 0, 0) _update_progress("invoices", "Checking invoices & order status...", 0, 0)
invoices_updated = 0 invoices_updated = 0
invoices_cleared = 0
orders_deleted = 0
try: try:
# 4b-1: Uninvoiced → check for new invoices
uninvoiced = await sqlite_service.get_uninvoiced_imported_orders() uninvoiced = await sqlite_service.get_uninvoiced_imported_orders()
if uninvoiced: if uninvoiced:
id_comanda_list = [o["id_comanda"] for o in uninvoiced] id_comanda_list = [o["id_comanda"] for o in uninvoiced]
invoice_data = await asyncio.to_thread( invoice_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, id_comanda_list invoice_service.check_invoices_for_orders, id_comanda_list
) )
# Build reverse map: id_comanda → order_number
id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced} id_to_order = {o["id_comanda"]: o["order_number"] for o in uninvoiced}
for idc, inv in invoice_data.items(): for idc, inv in invoice_data.items():
order_num = id_to_order.get(idc) order_num = id_to_order.get(idc)
@@ -422,12 +687,42 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
total_fara_tva=inv.get("total_fara_tva"), total_fara_tva=inv.get("total_fara_tva"),
total_tva=inv.get("total_tva"), total_tva=inv.get("total_tva"),
total_cu_tva=inv.get("total_cu_tva"), total_cu_tva=inv.get("total_cu_tva"),
data_act=inv.get("data_act"),
) )
invoices_updated += 1 invoices_updated += 1
if invoices_updated:
_log_line(run_id, f"Facturi actualizate: {invoices_updated} comenzi facturate") # 4b-2: Invoiced → check for deleted invoices
invoiced = await sqlite_service.get_invoiced_imported_orders()
if invoiced:
id_comanda_list = [o["id_comanda"] for o in invoiced]
invoice_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, id_comanda_list
)
for o in invoiced:
if o["id_comanda"] not in invoice_data:
await sqlite_service.clear_order_invoice(o["order_number"])
invoices_cleared += 1
# 4b-3: All imported → check for deleted orders in ROA
all_imported = await sqlite_service.get_all_imported_orders()
if all_imported:
id_comanda_list = [o["id_comanda"] for o in all_imported]
existing_ids = await asyncio.to_thread(
invoice_service.check_orders_exist, id_comanda_list
)
for o in all_imported:
if o["id_comanda"] not in existing_ids:
await sqlite_service.mark_order_deleted_in_roa(o["order_number"])
orders_deleted += 1
if invoices_updated:
_log_line(run_id, f"Facturi noi: {invoices_updated} comenzi facturate")
if invoices_cleared:
_log_line(run_id, f"Facturi sterse: {invoices_cleared} facturi eliminate din cache")
if orders_deleted:
_log_line(run_id, f"Comenzi sterse din ROA: {orders_deleted}")
except Exception as e: except Exception as e:
logger.warning(f"Invoice check failed: {e}") logger.warning(f"Invoice/order status check failed: {e}")
# Step 5: Update sync run # Step 5: Update sync run
total_imported = imported_count + already_imported_count # backward-compat total_imported = imported_count + already_imported_count # backward-compat
@@ -441,36 +736,40 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"run_id": run_id, "run_id": run_id,
"status": status, "status": status,
"json_files": json_count, "json_files": json_count,
"total_orders": len(orders), "total_orders": len(orders) + cancelled_count,
"new_orders": len(truly_importable), "new_orders": len(truly_importable),
"imported": total_imported, "imported": total_imported,
"new_imported": imported_count, "new_imported": imported_count,
"already_imported": already_imported_count, "already_imported": already_imported_count,
"skipped": len(skipped), "skipped": len(skipped),
"errors": error_count, "errors": error_count,
"cancelled": cancelled_count,
"missing_skus": len(validation["missing"]), "missing_skus": len(validation["missing"]),
"invoices_updated": invoices_updated, "invoices_updated": invoices_updated,
"invoices_cleared": invoices_cleared,
"orders_deleted_in_roa": orders_deleted,
} }
_update_progress("completed", _update_progress("completed",
f"Completed: {imported_count} new, {already_imported_count} already, {len(skipped)} skipped, {error_count} errors", f"Completed: {imported_count} new, {already_imported_count} already, {len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled",
len(truly_importable), len(truly_importable), len(truly_importable), len(truly_importable),
{"imported": imported_count, "skipped": len(skipped), "errors": error_count, {"imported": imported_count, "skipped": len(skipped), "errors": error_count,
"already_imported": already_imported_count}) "already_imported": already_imported_count, "cancelled": cancelled_count})
if _current_sync: if _current_sync:
_current_sync["status"] = status _current_sync["status"] = status
_current_sync["finished_at"] = datetime.now().isoformat() _current_sync["finished_at"] = _now().isoformat()
logger.info( logger.info(
f"Sync {run_id} completed: {imported_count} new, {already_imported_count} already imported, " f"Sync {run_id} completed: {imported_count} new, {already_imported_count} already imported, "
f"{len(skipped)} skipped, {error_count} errors" f"{len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled"
) )
duration = (datetime.now() - started_dt).total_seconds() duration = (_now() - started_dt).total_seconds()
_log_line(run_id, "") _log_line(run_id, "")
cancelled_text = f", {cancelled_count} anulate" if cancelled_count else ""
_run_logs[run_id].append( _run_logs[run_id].append(
f"Finalizat: {imported_count} importate, {already_imported_count} deja importate, " f"Finalizat: {imported_count} importate, {already_imported_count} deja importate, "
f"{len(skipped)} nemapate, {error_count} erori din {len(orders)} comenzi | Durata: {int(duration)}s" f"{len(skipped)} nemapate, {error_count} erori{cancelled_text} din {len(orders) + cancelled_count} comenzi | Durata: {int(duration)}s"
) )
return summary return summary
@@ -481,7 +780,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e)) await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
if _current_sync: if _current_sync:
_current_sync["status"] = "failed" _current_sync["status"] = "failed"
_current_sync["finished_at"] = datetime.now().isoformat() _current_sync["finished_at"] = _now().isoformat()
_current_sync["error"] = str(e) _current_sync["error"] = str(e)
return {"run_id": run_id, "status": "failed", "error": str(e)} return {"run_id": run_id, "status": "failed", "error": str(e)}
finally: finally:

View File

@@ -1,5 +1,4 @@
import logging import logging
from datetime import datetime, timedelta
from .. import database from .. import database
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,20 +28,81 @@ def check_orders_in_roa(min_date, conn) -> dict:
return existing return existing
def validate_skus(skus: set[str], conn=None) -> dict: def resolve_codmat_ids(codmats: set[str], id_gestiuni: list[int] = None, conn=None) -> dict[str, dict]:
"""Resolve CODMATs to best id_articol + cont: prefers article with stock, then MAX(id_articol).
Filters: sters=0 AND inactiv=0.
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:
return {}
result = {}
codmat_list = list(codmats)
# Build stoc subquery dynamically for index optimization
if id_gestiuni:
gest_placeholders = ",".join([f":g{k}" for k in range(len(id_gestiuni))])
stoc_filter = f"AND s.id_gestiune IN ({gest_placeholders})"
else:
stoc_filter = ""
own_conn = conn is None
if own_conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
for i in range(0, len(codmat_list), 500):
batch = codmat_list[i:i+500]
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
params = {f"c{j}": cm for j, cm in enumerate(batch)}
if id_gestiuni:
for k, gid in enumerate(id_gestiuni):
params[f"g{k}"] = gid
cur.execute(f"""
SELECT codmat, id_articol, cont FROM (
SELECT na.codmat, na.id_articol, na.cont,
ROW_NUMBER() OVER (
PARTITION BY na.codmat
ORDER BY
CASE WHEN EXISTS (
SELECT 1 FROM stoc s
WHERE s.id_articol = na.id_articol
{stoc_filter}
AND s.an = EXTRACT(YEAR FROM SYSDATE)
AND s.luna = EXTRACT(MONTH FROM SYSDATE)
AND s.cants + s.cant - s.cante > 0
) THEN 0 ELSE 1 END,
na.id_articol DESC
) AS rn
FROM nom_articole na
WHERE na.codmat IN ({placeholders})
AND na.sters = 0 AND na.inactiv = 0
) WHERE rn = 1
""", params)
for row in cur:
result[row[0]] = {"id_articol": row[1], "cont": row[2]}
finally:
if own_conn:
database.pool.release(conn)
logger.info(f"resolve_codmat_ids: {len(result)}/{len(codmats)} resolved (gestiuni={id_gestiuni})")
return result
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": {}}
mapped = set() mapped = set()
direct = set()
direct_id_map = {}
sku_list = list(skus) sku_list = list(skus)
own_conn = conn is None own_conn = conn is None
@@ -64,18 +124,15 @@ def validate_skus(skus: set[str], conn=None) -> dict:
for row in cur: for row in cur:
mapped.add(row[0]) mapped.add(row[0])
# Check NOM_ARTICOLE for remaining — also fetch id_articol # Resolve remaining SKUs via resolve_codmat_ids (consistent id_articol selection)
remaining = [s for s in batch if s not in mapped] all_remaining = [s for s in sku_list if s not in mapped]
if remaining: if all_remaining:
placeholders2 = ",".join([f":n{j}" for j in range(len(remaining))]) direct_id_map = resolve_codmat_ids(set(all_remaining), id_gestiuni, conn)
params2 = {f"n{j}": sku for j, sku in enumerate(remaining)} direct = set(direct_id_map.keys())
cur.execute(f""" else:
SELECT codmat, id_articol FROM NOM_ARTICOLE direct_id_map = {}
WHERE codmat IN ({placeholders2}) AND sters = 0 AND inactiv = 0 direct = set()
""", params2)
for row in cur:
direct.add(row[0])
direct_id_map[row[0]] = row[1]
finally: finally:
if own_conn: if own_conn:
database.pool.release(conn) database.pool.release(conn)
@@ -83,7 +140,8 @@ def validate_skus(skus: set[str], conn=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.
@@ -105,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.
@@ -113,37 +184,15 @@ 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 = {} codmat_to_id = _extract_id_map(direct_id_map)
ids_with_price = set() ids_with_price = set()
codmat_list = list(codmats)
# Pre-populate from direct_id_map if available
if direct_id_map:
for cm in codmat_list:
if cm in direct_id_map:
codmat_to_id[cm] = direct_id_map[cm]
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()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
# Step 1: Get ID_ARTICOL for CODMATs not already in direct_id_map # Check which ID_ARTICOLs have a price in the policy
remaining = [cm for cm in codmat_list if cm not in codmat_to_id]
if remaining:
for i in range(0, len(remaining), 500):
batch = remaining[i:i+500]
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
params = {f"c{j}": cm for j, cm in enumerate(batch)}
cur.execute(f"""
SELECT id_articol, codmat FROM NOM_ARTICOLE
WHERE codmat IN ({placeholders})
""", params)
for row in cur:
codmat_to_id[row[1]] = row[0]
# Step 2: Check which ID_ARTICOLs have a price in the policy
id_list = list(codmat_to_id.values()) id_list = list(codmat_to_id.values())
for i in range(0, len(id_list), 500): for i in range(0, len(id_list), 500):
batch = id_list[i:i+500] batch = id_list[i:i+500]
@@ -168,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()
@@ -191,27 +244,9 @@ def ensure_prices(codmats: set[str], id_pol: int, conn=None, direct_id_map: dict
return return
id_valuta = row[0] id_valuta = row[0]
# Build batch params using direct_id_map where available # Build batch params using direct_id_map (already resolved via resolve_codmat_ids)
batch_params = [] batch_params = []
need_lookup = [] codmat_id_map = _extract_id_map(direct_id_map)
codmat_id_map = dict(direct_id_map) if direct_id_map else {}
for codmat in codmats:
if codmat not in codmat_id_map:
need_lookup.append(codmat)
# Batch lookup remaining CODMATs
if need_lookup:
for i in range(0, len(need_lookup), 500):
batch = need_lookup[i:i+500]
placeholders = ",".join([f":c{j}" for j in range(len(batch))])
params = {f"c{j}": cm for j, cm in enumerate(batch)}
cur.execute(f"""
SELECT codmat, id_articol FROM NOM_ARTICOLE
WHERE codmat IN ({placeholders}) AND sters = 0 AND inactiv = 0
""", params)
for r in cur:
codmat_id_map[r[0]] = r[1]
for codmat in codmats: for codmat in codmats:
id_articol = codmat_id_map.get(codmat) id_articol = codmat_id_map.get(codmat)
@@ -221,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:
@@ -231,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:
@@ -241,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

@@ -1,189 +1,249 @@
/* ── Design tokens ───────────────────────────────── */
:root { :root {
--sidebar-width: 220px; /* Surfaces */
--sidebar-bg: #1e293b; --body-bg: #f9fafb;
--sidebar-text: #94a3b8; --card-bg: #ffffff;
--sidebar-active: #ffffff; --card-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--sidebar-hover-bg: #334155; --card-radius: 0.5rem;
--body-bg: #f1f5f9;
--card-shadow: 0 1px 3px rgba(0,0,0,0.08); /* Semantic colors */
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--green-100: #dcfce7; --green-800: #166534;
--yellow-100: #fef9c3; --yellow-800: #854d0e;
--red-100: #fee2e2; --red-800: #991b1b;
--blue-100: #dbeafe; --blue-800: #1e40af;
/* Text */
--text-primary: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
--border-color: #e5e7eb;
/* Dots */
--dot-green: #22c55e;
--dot-yellow: #eab308;
--dot-red: #ef4444;
} }
/* ── Base ────────────────────────────────────────── */
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
background-color: var(--body-bg); background-color: var(--body-bg);
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
/* Sidebar */ /* ── Top Navbar ──────────────────────────────────── */
.sidebar { .top-navbar {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: var(--sidebar-width); right: 0;
height: 100vh; height: 48px;
background-color: var(--sidebar-bg); background: #fff;
padding: 0; border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1.5rem;
z-index: 1000; z-index: 1000;
overflow-y: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
transition: transform 0.3s ease;
} }
.sidebar-header { .navbar-brand {
padding: 1.25rem 1rem; font-weight: 700;
border-bottom: 1px solid #334155; font-size: 1rem;
color: #111827;
white-space: nowrap;
} }
.sidebar-header h5 { .navbar-links {
color: #fff; display: flex;
margin: 0; align-items: stretch;
font-size: 1.1rem; gap: 0;
font-weight: 600; overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.navbar-links::-webkit-scrollbar { display: none; }
.nav-tab {
display: flex;
align-items: center;
padding: 0 1rem;
height: 48px;
color: #64748b;
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500;
border-bottom: 2px solid transparent;
white-space: nowrap;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s;
}
.nav-tab:hover {
color: #111827;
background: #f9fafb;
text-decoration: none;
}
.nav-tab.active {
color: var(--blue-600);
border-bottom-color: var(--blue-600);
} }
.sidebar .nav-link { /* ── Main content ────────────────────────────────── */
color: var(--sidebar-text);
padding: 0.65rem 1rem;
font-size: 0.9rem;
border-left: 3px solid transparent;
transition: all 0.15s ease;
}
.sidebar .nav-link:hover {
color: var(--sidebar-active);
background-color: var(--sidebar-hover-bg);
}
.sidebar .nav-link.active {
color: var(--sidebar-active);
background-color: var(--sidebar-hover-bg);
border-left-color: #3b82f6;
}
.sidebar .nav-link i {
margin-right: 0.5rem;
width: 1.2rem;
text-align: center;
}
.sidebar-footer {
position: absolute;
bottom: 0;
padding: 0.75rem 1rem;
border-top: 1px solid #334155;
width: 100%;
}
/* Main content */
.main-content { .main-content {
margin-left: var(--sidebar-width); padding-top: 64px;
padding: 1.5rem; padding-left: 1.5rem;
padding-right: 1.5rem;
padding-bottom: 1.5rem;
min-height: 100vh; min-height: 100vh;
max-width: 1280px;
margin-left: auto;
margin-right: auto;
} }
/* Sidebar toggle button for mobile */ /* ── Cards ───────────────────────────────────────── */
.sidebar-toggle {
position: fixed;
top: 0.5rem;
left: 0.5rem;
z-index: 1100;
border-radius: 0.375rem;
}
/* Cards */
.card { .card {
border: none; border: none;
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
border-radius: 0.5rem; border-radius: var(--card-radius);
background: var(--card-bg);
} }
.card-header { .card-header {
background-color: #fff; background: var(--card-bg);
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid var(--border-color);
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9375rem;
padding: 0.75rem 1rem;
} }
/* Status badges */ /* ── Tables ──────────────────────────────────────── */
.badge-imported { background-color: #22c55e; }
.badge-skipped { background-color: #eab308; color: #000; }
.badge-error { background-color: #ef4444; }
.badge-pending { background-color: #94a3b8; }
.badge-ready { background-color: #3b82f6; }
/* Tables */
.table { .table {
font-size: 0.875rem; font-size: 1rem;
} }
.table th { .table th {
font-weight: 600; font-size: 0.8125rem;
color: #475569; font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
background: #f9fafb;
padding: 0.75rem 1rem;
border-top: none; border-top: none;
} }
/* Forms */ .table td {
.form-control:focus, .form-select:focus { padding: 0.625rem 1rem;
border-color: #3b82f6; color: var(--text-secondary);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15); font-size: 1rem;
} }
/* Responsive */ /* Zebra striping */
@media (max-width: 767.98px) { .table tbody tr:nth-child(even) td { background-color: #f7f8fa; }
.sidebar { .table-hover tbody tr:hover td { background-color: #eef2ff !important; }
transform: translateX(-100%);
} /* ── Badges — soft pill style ────────────────────── */
.sidebar.show { .badge {
transform: translateX(0); font-size: 0.8125rem;
} font-weight: 500;
.main-content { padding: 0.125rem 0.5rem;
margin-left: 0; border-radius: 9999px;
}
.sidebar-toggle {
display: block !important;
}
} }
/* Autocomplete dropdown */ .badge.bg-success { background: var(--green-100) !important; color: var(--green-800) !important; }
.autocomplete-dropdown { .badge.bg-info { background: var(--blue-100) !important; color: var(--blue-800) !important; }
position: absolute; .badge.bg-warning { background: var(--yellow-100) !important; color: var(--yellow-800) !important; }
z-index: 1050; .badge.bg-danger { background: var(--red-100) !important; color: var(--red-800) !important; }
background: #fff;
border: 1px solid #dee2e6; /* Legacy badge classes */
.badge-imported { background: var(--green-100); color: var(--green-800); }
.badge-skipped { background: var(--yellow-100); color: var(--yellow-800); }
.badge-error { background: var(--red-100); color: var(--red-800); }
.badge-pending { background: #f3f4f6; color: #374151; }
.badge-ready { background: var(--blue-100); color: var(--blue-800); }
/* ── Buttons ─────────────────────────────────────── */
.btn {
font-size: 0.9375rem;
border-radius: 0.375rem; border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-height: 300px;
overflow-y: auto;
width: 100%;
} }
.autocomplete-item { .btn-sm {
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
}
.btn-primary {
background: var(--blue-600);
border-color: var(--blue-600);
}
.btn-primary:hover {
background: var(--blue-700);
border-color: var(--blue-700);
}
/* ── Forms ───────────────────────────────────────── */
.form-control, .form-select {
font-size: 0.9375rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border-color: #d1d5db;
}
.form-control:focus, .form-select:focus {
border-color: var(--blue-600);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* ── Unified Pagination Bar ──────────────────────── */
.pagination-bar {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
font-size: 0.8125rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
color: var(--text-secondary);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; transition: background 0.12s, border-color 0.12s;
border-bottom: 1px solid #f1f5f9; text-decoration: none;
user-select: none;
} }
.page-btn:hover:not(:disabled):not(.active) {
.autocomplete-item:hover, .autocomplete-item.active { background: #f3f4f6;
background-color: #f1f5f9; border-color: #9ca3af;
color: var(--text-primary);
text-decoration: none;
} }
.page-btn.active {
.autocomplete-item .codmat { background: var(--blue-600);
border-color: var(--blue-600);
color: #fff;
font-weight: 600; font-weight: 600;
color: #1e293b; }
.page-btn:disabled, .page-btn.disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
} }
.autocomplete-item .denumire { /* Loading spinner ────────────────────────────────── */
color: #64748b;
font-size: 0.8rem;
}
/* Pagination */
.pagination .page-link {
font-size: 0.875rem;
}
/* Loading spinner */
.spinner-overlay { .spinner-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
@@ -194,7 +254,44 @@ body {
justify-content: center; justify-content: center;
} }
/* Log viewer */ /* ── Colored dots ────────────────────────────────── */
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-green { background: var(--dot-green); }
.dot-yellow { background: var(--dot-yellow); }
.dot-red { background: var(--dot-red); }
.dot-gray { background: #9ca3af; }
.dot-blue { background: #3b82f6; }
/* ── Flat row (mobile + desktop) ────────────────── */
.flat-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
font-size: 1rem;
}
.flat-row:last-child { border-bottom: none; }
.flat-row:hover { background: #f9fafb; cursor: pointer; }
.grow { flex: 1; min-width: 0; }
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Colored filter count - text color only ─────── */
.fc-green { color: #16a34a; }
.fc-yellow { color: #ca8a04; }
.fc-red { color: #dc2626; }
.fc-neutral { color: #6b7280; }
.fc-blue { color: #2563eb; }
.fc-dark { color: #374151; }
/* ── Log viewer (dark theme — keep as-is) ────────── */
.log-viewer { .log-viewer {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.8125rem; font-size: 0.8125rem;
@@ -210,107 +307,86 @@ body {
border-radius: 0 0 0.5rem 0.5rem; border-radius: 0 0 0.5rem 0.5rem;
} }
/* Clickable table rows */ /* ── Clickable table rows ────────────────────────── */
.table-hover tbody tr[data-href] { .table-hover tbody tr[data-href] {
cursor: pointer; cursor: pointer;
} }
.table-hover tbody tr[data-href]:hover { .table-hover tbody tr[data-href]:hover {
background-color: #e2e8f0; background-color: #f9fafb;
} }
/* Sortable table headers (R7) */ /* ── Sortable table headers ──────────────────────── */
.sortable { .sortable {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
.sortable:hover { .sortable:hover {
background-color: #f1f5f9; background-color: #f3f4f6;
} }
.sort-icon { .sort-icon {
font-size: 0.75rem; font-size: 0.75rem;
margin-left: 0.25rem; margin-left: 0.25rem;
color: #3b82f6; color: var(--blue-600);
} }
/* SKU group visual grouping (R6) */ /* ── SKU group visual grouping ───────────────────── */
.sku-group-even {
/* default background */
}
.sku-group-odd { .sku-group-odd {
background-color: #f8fafc; background-color: #f8fafc;
} }
/* Editable cells */ /* ── Editable cells ──────────────────────────────── */
.editable { .editable { cursor: pointer; }
cursor: pointer; .editable:hover { background-color: #f3f4f6; }
}
.editable:hover {
background-color: #e2e8f0;
}
/* Order detail modal items */ /* ── Order detail modal ──────────────────────────── */
.modal-lg .table-sm td, .modal-lg .table-sm td,
.modal-lg .table-sm th { .modal-lg .table-sm th {
font-size: 0.8125rem; font-size: 0.875rem;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
/* Filter button badges */ /* ── Modal stacking (quickMap over orderDetail) ───── */
#orderFilterBtns .badge { #quickMapModal { z-index: 1060; }
font-size: 0.7rem;
}
/* Modal stacking for quickMap over orderDetail */
#quickMapModal {
z-index: 1060;
}
#quickMapModal + .modal-backdrop, #quickMapModal + .modal-backdrop,
.modal-backdrop ~ .modal-backdrop { .modal-backdrop ~ .modal-backdrop { z-index: 1055; }
z-index: 1055;
}
/* Deleted mapping rows */ /* ── Quick Map compact lines ─────────────────────── */
.qm-line { border-bottom: 1px solid #e5e7eb; padding: 6px 0; }
.qm-line:last-child { border-bottom: none; }
.qm-row { display: flex; gap: 6px; align-items: center; }
.qm-codmat-wrap { flex: 1; min-width: 0; }
.qm-rm-btn { padding: 2px 6px; line-height: 1; }
#qmCodmatLines .qm-selected:empty { display: none; }
#quickMapModal .modal-body { padding-top: 12px; padding-bottom: 8px; }
#quickMapModal .modal-header { padding: 10px 16px; }
#quickMapModal .modal-header h5 { font-size: 0.95rem; margin: 0; }
#quickMapModal .modal-footer { padding: 8px 16px; }
/* ── Deleted mapping rows ────────────────────────── */
tr.mapping-deleted td { tr.mapping-deleted td {
text-decoration: line-through; text-decoration: line-through;
opacity: 0.5; opacity: 0.5;
} }
/* Map icon button (minimal, no border) */ /* ── Map icon button ─────────────────────────────── */
.btn-map-icon { .btn-map-icon {
color: #3b82f6; color: var(--blue-600);
padding: 0.1rem 0.25rem; padding: 0.1rem 0.25rem;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
text-decoration: none; text-decoration: none;
} }
.btn-map-icon:hover { .btn-map-icon:hover { color: var(--blue-700); }
color: #1d4ed8;
}
/* Last sync summary card columns */ /* ── Last sync summary card columns ─────────────── */
.last-sync-col { .last-sync-col {
border-right: 1px solid #e2e8f0; border-right: 1px solid var(--border-color);
} }
/* Dashboard filter badges */ /* ── Cursor pointer utility ──────────────────────── */
#dashFilterBtns .badge { .cursor-pointer { cursor: pointer; }
font-size: 0.7rem;
}
/* Cursor pointer utility */ /* ── Filter bar ──────────────────────────────────── */
.cursor-pointer {
cursor: pointer;
}
/* ── Typography scale ────────────────────────────── */
.text-header { font-size: 1.25rem; font-weight: 600; }
.text-card-head { font-size: 1rem; font-weight: 600; }
.text-body { font-size: 0.8125rem; }
.text-badge { font-size: 0.75rem; }
.text-label { font-size: 0.6875rem; }
/* ── Filter bar — shared across dashboard, mappings, missing_skus pages ── */
.filter-bar { .filter-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -318,49 +394,75 @@ tr.mapping-deleted td {
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.625rem 0; padding: 0.625rem 0;
} }
.filter-pill { .filter-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.3rem;
padding: 0.25rem 0.625rem; padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 999px; border-radius: 0.375rem;
background: #fff; background: #fff;
font-size: 0.8125rem; font-size: 0.9375rem;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s; transition: background 0.15s, border-color 0.15s;
white-space: nowrap; white-space: nowrap;
} }
.filter-pill:hover { background: #f3f4f6; } .filter-pill:hover { background: #f3f4f6; }
.filter-pill.active { .filter-pill.active {
background: #1d4ed8; background: var(--blue-700);
border-color: #1d4ed8; border-color: var(--blue-700);
color: #fff; color: #fff;
} }
.filter-pill.active .filter-count { background: rgba(255,255,255,0.25); color: #fff; } .filter-pill.active .filter-count {
.filter-count { color: rgba(255,255,255,0.9);
display: inline-block;
min-width: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: #e5e7eb;
font-size: 0.7rem;
font-weight: 600;
text-align: center;
line-height: 1.4;
} }
/* ── Search input (used in filter bars) ─────────── */ .filter-count {
.search-input {
margin-left: auto;
padding: 0.25rem 0.625rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.8125rem; font-size: 0.8125rem;
outline: none; font-weight: 600;
min-width: 180px; }
/* ── Search input ────────────────────────────────── */
.search-input {
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.9375rem;
outline: none;
width: 160px;
}
.search-input:focus { border-color: var(--blue-600); }
/* ── Autocomplete dropdown (keep as-is) ──────────── */
.autocomplete-dropdown {
position: absolute;
z-index: 1050;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-height: 300px;
overflow-y: auto;
width: 100%;
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.9375rem;
border-bottom: 1px solid #f1f5f9;
}
.autocomplete-item:hover, .autocomplete-item.active {
background-color: #f1f5f9;
}
.autocomplete-item .codmat {
font-weight: 600;
color: #1e293b;
}
.autocomplete-item .denumire {
color: #64748b;
font-size: 0.875rem;
} }
.search-input:focus { border-color: #1d4ed8; }
/* ── Tooltip for Client/Cont ─────────────────────── */ /* ── Tooltip for Client/Cont ─────────────────────── */
.tooltip-cont { .tooltip-cont {
@@ -389,8 +491,8 @@ tr.mapping-deleted td {
/* ── Sync card ───────────────────────────────────── */ /* ── Sync card ───────────────────────────────────── */
.sync-card { .sync-card {
background: #fff; background: #fff;
border: 1px solid #e5e7eb; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--card-radius);
overflow: hidden; overflow: hidden;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -403,7 +505,7 @@ tr.mapping-deleted td {
} }
.sync-card-divider { .sync-card-divider {
height: 1px; height: 1px;
background: #e5e7eb; background: var(--border-color);
margin: 0; margin: 0;
} }
.sync-card-info { .sync-card-info {
@@ -411,8 +513,8 @@ tr.mapping-deleted td {
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.8125rem; font-size: 1rem;
color: #6b7280; color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: background 0.12s; transition: background 0.12s;
} }
@@ -423,12 +525,12 @@ tr.mapping-deleted td {
gap: 0.5rem; gap: 0.5rem;
padding: 0.4rem 1rem; padding: 0.4rem 1rem;
background: #eff6ff; background: #eff6ff;
font-size: 0.8125rem; font-size: 1rem;
color: #1d4ed8; color: var(--blue-700);
border-top: 1px solid #dbeafe; border-top: 1px solid #dbeafe;
} }
/* ── Pulsing live dot ────────────────────────────── */ /* ── Pulsing live dot (keep as-is) ──────────────── */
.sync-live-dot { .sync-live-dot {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
@@ -443,7 +545,7 @@ tr.mapping-deleted td {
50% { opacity: 0.4; transform: scale(0.75); } 50% { opacity: 0.4; transform: scale(0.75); }
} }
/* ── Status dot (idle/running/completed/failed) ──── */ /* ── Status dot (keep as-is) ─────────────────────── */
.sync-status-dot { .sync-status-dot {
display: inline-block; display: inline-block;
width: 10px; width: 10px;
@@ -461,32 +563,214 @@ tr.mapping-deleted td {
display: none; display: none;
gap: 0.375rem; gap: 0.375rem;
align-items: center; align-items: center;
font-size: 0.8125rem; font-size: 0.9375rem;
} }
.period-custom-range.visible { display: flex; } .period-custom-range.visible { display: flex; }
/* ── Compact button ──────────────────────────────── */ /* ── select-compact (used in filter bars) ─────────── */
.btn-compact {
padding: 0.3rem 0.75rem;
font-size: 0.8125rem;
}
/* ── Compact select ──────────────────────────────── */
.select-compact { .select-compact {
padding: 0.25rem 0.5rem; padding: 0.375rem 0.5rem;
font-size: 0.8125rem; font-size: 0.9375rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 6px; border-radius: 0.375rem;
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
} }
/* ── btn-compact (kept for backward compat) ──────── */
.btn-compact {
padding: 0.375rem 0.75rem;
font-size: 0.9375rem;
}
/* ── Result banner ───────────────────────────────── */ /* ── Result banner ───────────────────────────────── */
.result-banner { .result-banner {
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
border-radius: 6px; border-radius: 0.375rem;
font-size: 0.8125rem; font-size: 0.9375rem;
background: #d1fae5; background: #d1fae5;
color: #065f46; color: #065f46;
border: 1px solid #6ee7b7; border: 1px solid #6ee7b7;
} }
/* ── Badge-pct (mappings page) ───────────────────── */
.badge-pct {
font-size: 0.75rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-weight: 600;
}
.badge-pct.complete { background: #d1fae5; color: #065f46; }
.badge-pct.incomplete { background: #fef3c7; color: #92400e; }
/* ── Context Menu ────────────────────────────────── */
.context-menu-trigger {
background: none;
border: none;
color: #9ca3af;
padding: 0.2rem 0.4rem;
cursor: pointer;
border-radius: 0.25rem;
font-size: 1rem;
line-height: 1;
transition: color 0.12s, background 0.12s;
}
.context-menu-trigger:hover {
color: var(--text-secondary);
background: #f3f4f6;
}
.context-menu {
position: fixed;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
z-index: 1050;
min-width: 150px;
padding: 0.25rem 0;
}
.context-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.9rem;
font-size: 0.9375rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-primary);
transition: background 0.1s;
}
.context-menu-item:hover { background: #f3f4f6; }
.context-menu-item.text-danger { color: #dc2626; }
.context-menu-item.text-danger:hover { background: #fee2e2; }
/* ── Pagination info strip ───────────────────────── */
.pag-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
}
.pag-strip-bottom {
border-bottom: none;
border-top: 1px solid var(--border-color);
}
/* ── Per page selector ───────────────────────────── */
.per-page-label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.9375rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ── Mobile list vs desktop table ────────────────── */
.mobile-list { display: none; }
/* ── Mappings flat-rows: always visible ────────────── */
.mappings-flat-list { display: block; }
/* ── Mobile ⋯ dropdown ─────────────────────────── */
.mobile-more-dropdown { position: relative; display: inline-block; }
.mobile-more-dropdown .dropdown-toggle::after { display: none; }
/* ── Mobile segmented control (hidden on desktop) ── */
.mobile-seg { display: none; }
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 767.98px) {
.top-navbar {
padding: 0 0.5rem;
gap: 0.5rem;
}
.navbar-brand {
font-size: 0.875rem;
}
.nav-tab {
padding: 0 0.625rem;
font-size: 0.8125rem;
}
.main-content {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.filter-bar {
gap: 0.375rem;
}
.filter-pill { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
.search-input { min-width: 0; width: auto; flex: 1; }
.page-btn.page-number { display: none; }
.page-btn.page-ellipsis { display: none; }
.table-responsive { display: none; }
.mobile-list { display: block; }
/* Segmented filter control (replaces pills on mobile) */
.filter-bar .filter-pill { display: none; }
.filter-bar .mobile-seg { display: flex; }
/* Sync card compact */
.sync-card-controls {
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
}
.sync-card-info {
flex-wrap: wrap;
gap: 0.375rem;
font-size: 0.8rem;
padding: 0.375rem 0.75rem;
}
/* Hide per-page selector on mobile */
.per-page-label { display: none; }
}
/* Mobile article cards in order detail modal */
.detail-item-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.detail-item-card .card-sku {
font-family: monospace;
font-size: 0.8rem;
color: #6b7280;
}
.detail-item-card .card-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.detail-item-card .card-details {
display: flex;
gap: 1rem;
color: #374151;
}
/* Clickable CODMAT link in order detail modal */
.codmat-link { color: #0d6efd; cursor: pointer; text-decoration: underline; }
.codmat-link:hover { color: #0a58ca; }
/* Mobile article flat list in order detail modal */
.detail-item-flat { font-size: 0.85rem; }
.detail-item-flat .dif-item { }
.detail-item-flat .dif-item:nth-child(even) .dif-row { background: #f7f8fa; }
.detail-item-flat .dif-row {
display: flex; align-items: baseline; gap: 0.5rem;
padding: 0.2rem 0.75rem; flex-wrap: wrap;
}
.dif-sku { font-family: monospace; font-size: 0.78rem; color: #6b7280; }
.dif-name { font-weight: 500; flex: 1; }
.dif-qty { white-space: nowrap; color: #6b7280; }
.dif-val { white-space: nowrap; font-weight: 600; }
.dif-codmat-link { color: #0d6efd; cursor: pointer; font-size: 0.78rem; font-family: monospace; }
.dif-codmat-link:hover { color: #0a58ca; text-decoration: underline; }

View File

@@ -13,21 +13,32 @@ let _pollInterval = null;
let _lastSyncStatus = null; let _lastSyncStatus = null;
let _lastRunId = null; let _lastRunId = null;
let _currentRunId = null; let _currentRunId = null;
let _pollIntervalMs = 5000; // default, overridden from settings
let _knownLastRunId = null; // track last_run.run_id to detect missed syncs
// ── Init ────────────────────────────────────────── // ── Init ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
await initPollInterval();
loadSchedulerStatus(); loadSchedulerStatus();
loadDashOrders(); loadDashOrders();
startSyncPolling(); startSyncPolling();
wireFilterBar(); wireFilterBar();
}); });
async function initPollInterval() {
try {
const data = await fetchJSON('/api/settings');
const sec = parseInt(data.dashboard_poll_seconds) || 5;
_pollIntervalMs = sec * 1000;
} catch(e) {}
}
// ── Smart Sync Polling ──────────────────────────── // ── Smart Sync Polling ────────────────────────────
function startSyncPolling() { function startSyncPolling() {
if (_pollInterval) clearInterval(_pollInterval); if (_pollInterval) clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, 30000); _pollInterval = setInterval(pollSyncStatus, _pollIntervalMs);
pollSyncStatus(); // immediate first call pollSyncStatus(); // immediate first call
} }
@@ -37,6 +48,12 @@ async function pollSyncStatus() {
updateSyncPanel(data); updateSyncPanel(data);
const isRunning = data.status === 'running'; const isRunning = data.status === 'running';
const wasRunning = _lastSyncStatus === 'running'; const wasRunning = _lastSyncStatus === 'running';
// Detect missed sync completions via last_run.run_id change
const newLastRunId = data.last_run?.run_id || null;
const missedSync = !isRunning && !wasRunning && _knownLastRunId && newLastRunId && newLastRunId !== _knownLastRunId;
_knownLastRunId = newLastRunId;
if (isRunning && !wasRunning) { if (isRunning && !wasRunning) {
// Switched to running — speed up polling // Switched to running — speed up polling
clearInterval(_pollInterval); clearInterval(_pollInterval);
@@ -44,7 +61,10 @@ async function pollSyncStatus() {
} else if (!isRunning && wasRunning) { } else if (!isRunning && wasRunning) {
// Sync just completed — slow down and refresh orders // Sync just completed — slow down and refresh orders
clearInterval(_pollInterval); clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, 30000); _pollInterval = setInterval(pollSyncStatus, _pollIntervalMs);
loadDashOrders();
} else if (missedSync) {
// Sync completed while we weren't watching (e.g. auto-sync) — refresh orders
loadDashOrders(); loadDashOrders();
} }
_lastSyncStatus = data.status; _lastSyncStatus = data.status;
@@ -92,14 +112,13 @@ function updateSyncPanel(data) {
const st = document.getElementById('lastSyncStatus'); const st = document.getElementById('lastSyncStatus');
if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014'; if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014';
if (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014'; if (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014';
// Updated counts: ↑new =already ⊘skipped ✕errors
if (cnt) { if (cnt) {
const newImp = lr.new_imported || 0; const newImp = lr.new_imported || 0;
const already = lr.already_imported || 0; const already = lr.already_imported || 0;
if (already > 0) { if (already > 0) {
cnt.textContent = '\u2191' + newImp + ' =' + already + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 0); cnt.innerHTML = `<span class="dot dot-green me-1"></span>${newImp} noi, ${already} deja &nbsp; <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise &nbsp; <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
} else { } else {
cnt.textContent = '\u2191' + (lr.imported || 0) + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 0); cnt.innerHTML = `<span class="dot dot-green me-1"></span>${lr.imported || 0} imp. &nbsp; <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise &nbsp; <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
} }
} }
if (st) { if (st) {
@@ -113,7 +132,7 @@ function updateSyncPanel(data) {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('lastSyncRow')?.addEventListener('click', () => { document.getElementById('lastSyncRow')?.addEventListener('click', () => {
const targetId = _currentRunId || _lastRunId; const targetId = _currentRunId || _lastRunId;
if (targetId) window.location = '/logs?run=' + targetId; if (targetId) window.location = (window.ROOT_PATH || '') + '/logs?run=' + targetId;
}); });
document.getElementById('lastSyncRow')?.addEventListener('keydown', (e) => { document.getElementById('lastSyncRow')?.addEventListener('keydown', (e) => {
const targetId = _currentRunId || _lastRunId; const targetId = _currentRunId || _lastRunId;
@@ -279,63 +298,86 @@ async function loadDashOrders() {
const c = data.counts || {}; const c = data.counts || {};
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
if (el('cntAll')) el('cntAll').textContent = c.total || 0; if (el('cntAll')) el('cntAll').textContent = c.total || 0;
if (el('cntImp')) el('cntImp').textContent = c.imported || 0; if (el('cntImp')) el('cntImp').textContent = c.imported_all || c.imported || 0;
if (el('cntSkip')) el('cntSkip').textContent = c.skipped || 0; if (el('cntSkip')) el('cntSkip').textContent = c.skipped || 0;
if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 0; if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 0;
if (el('cntNef')) el('cntNef').textContent = c.uninvoiced || c.nefacturate || 0; if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
const tbody = document.getElementById('dashOrdersBody'); const tbody = document.getElementById('dashOrdersBody');
const orders = data.orders || []; const orders = data.orders || [];
if (orders.length === 0) { if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" 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 {
tbody.innerHTML = orders.map(o => { tbody.innerHTML = orders.map(o => {
const dateStr = fmtDate(o.order_date); const dateStr = fmtDate(o.order_date);
const statusBadge = orderStatusBadge(o.status); const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
// Invoice info
let invoiceBadge = '';
let invoiceTotal = '';
if (o.status !== 'IMPORTED') {
invoiceBadge = '<span class="text-muted">-</span>';
} else if (o.invoice && o.invoice.facturat) {
invoiceBadge = `<span class="badge bg-success">Facturat</span>`;
if (o.invoice.serie_act || o.invoice.numar_act) {
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
}
invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-';
} else {
invoiceBadge = '<span class="badge bg-danger">Nefacturat</span>';
}
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')"> return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
<td><code>${esc(o.order_number)}</code></td> <td>${statusDot(o.status)}</td>
<td>${dateStr}</td> <td class="text-nowrap">${dateStr}</td>
${renderClientCell(o)} ${renderClientCell(o)}
<td><code>${esc(o.order_number)}</code></td>
<td>${o.items_count || 0}</td> <td>${o.items_count || 0}</td>
<td>${statusBadge}</td> <td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
<td>${o.id_comanda || '-'}</td> <td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td>${invoiceBadge}</td> <td class="text-end fw-bold">${orderTotal}</td>
<td>${invoiceTotal}</td> <td class="text-center">${invoiceDot(o)}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
// Mobile flat rows
const mobileList = document.getElementById('dashMobileList');
if (mobileList) {
if (orders.length === 0) {
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
} else {
mobileList.innerHTML = orders.map(o => {
const d = o.order_date || '';
let dateFmt = '-';
if (d.length >= 10) {
dateFmt = d.slice(8, 10) + '.' + d.slice(5, 7) + '.' + d.slice(2, 4);
if (d.length >= 16) dateFmt += ' ' + d.slice(11, 16);
}
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
${statusDot(o.status)}
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(name)}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
</div>`;
}).join('');
}
}
// Mobile segmented control
renderMobileSegmented('dashMobileSeg', [
{ label: 'Toate', count: c.total || 0, value: 'all', active: (activeStatus || 'all') === 'all', colorClass: 'fc-neutral' },
{ label: 'Imp.', count: c.imported_all || c.imported || 0, value: 'IMPORTED', active: activeStatus === 'IMPORTED', colorClass: 'fc-green' },
{ label: 'Omise', count: c.skipped || 0, value: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' },
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
{ label: 'Anulate', count: c.cancelled || 0, value: 'CANCELLED', active: activeStatus === 'CANCELLED', colorClass: 'fc-dark' }
], (val) => {
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-status="${val}"]`);
if (pill) pill.classList.add('active');
dashPage = 1;
loadDashOrders();
});
// Pagination // Pagination
const pag = data.pagination || {}; const pag = data.pagination || {};
const totalPages = pag.total_pages || data.pages || 1; const totalPages = pag.total_pages || data.pages || 1;
const totalOrders = (data.counts || {}).total || data.total || 0; const totalOrders = (data.counts || {}).total || data.total || 0;
const pageInfo = `${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}`;
document.getElementById('dashPageInfo').textContent = pageInfo;
const pagInfoTop = document.getElementById('dashPageInfoTop');
if (pagInfoTop) pagInfoTop.textContent = pageInfo;
const pagHtml = totalPages > 1 ? ` const pagOpts = { perPage: dashPerPage, perPageFn: 'dashChangePerPage', perPageOptions: [25, 50, 100, 250] };
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button> const pagHtml = `<small class="text-muted me-auto">${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}</small>` + renderUnifiedPagination(dashPage, totalPages, 'dashGoPage', pagOpts);
<small class="text-muted">${dashPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
` : '';
const pagDiv = document.getElementById('dashPagination'); const pagDiv = document.getElementById('dashPagination');
if (pagDiv) pagDiv.innerHTML = pagHtml; if (pagDiv) pagDiv.innerHTML = pagHtml;
const pagDivTop = document.getElementById('dashPaginationTop'); const pagDivTop = document.getElementById('dashPaginationTop');
@@ -348,7 +390,7 @@ async function loadDashOrders() {
}); });
} catch (err) { } catch (err) {
document.getElementById('dashOrdersBody').innerHTML = document.getElementById('dashOrdersBody').innerHTML =
`<tr><td colspan="8" class="text-center text-danger">${esc(err.message)}</td></tr>`; `<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
} }
} }
@@ -366,13 +408,14 @@ function dashChangePerPage(val) {
// ── Client cell with Cont tooltip (Task F4) ─────── // ── Client cell with Cont tooltip (Task F4) ───────
function renderClientCell(order) { function renderClientCell(order) {
const shipping = (order.shipping_name || order.customer_name || '').trim(); const display = (order.customer_name || order.shipping_name || '').trim();
const billing = (order.billing_name || '').trim(); const billing = (order.billing_name || '').trim();
const isDiff = order.is_different_person && billing && shipping !== billing; const shipping = (order.shipping_name || '').trim();
const isDiff = display !== shipping && shipping;
if (isDiff) { if (isDiff) {
return `<td class="tooltip-cont" data-tooltip="Cont: ${escHtml(billing)}">${escHtml(shipping)}&nbsp;<sup style="color:#6b7280;font-size:0.65rem">&#9650;</sup></td>`; return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)}&nbsp;<sup style="color:#6b7280;font-size:0.65rem">&#9650;</sup></td>`;
} }
return `<td>${escHtml(shipping || billing || '\u2014')}</td>`; return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
} }
// ── Helper functions ────────────────────────────── // ── Helper functions ──────────────────────────────
@@ -396,34 +439,48 @@ function escHtml(s) {
// Alias kept for backward compat with inline handlers in modal // Alias kept for backward compat with inline handlers in modal
function esc(s) { return escHtml(s); } function esc(s) { return escHtml(s); }
function fmtDate(dateStr) { function fmtCost(v) {
if (!dateStr) return '-'; return v > 0 ? Number(v).toFixed(2) : '';
try { }
const d = new Date(dateStr);
const hasTime = dateStr.includes(':');
if (hasTime) { function statusLabelText(status) {
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); switch ((status || '').toUpperCase()) {
} case 'IMPORTED': return 'Importat';
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); case 'ALREADY_IMPORTED': return 'Deja imp.';
} catch { return dateStr; } case 'SKIPPED': return 'Omis';
case 'ERROR': return 'Eroare';
default: return esc(status);
}
} }
function orderStatusBadge(status) { function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>'; case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>'; case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>'; case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>'; case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`; default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
} }
} }
function invoiceDot(order) {
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '';
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" title="Facturat"></span>';
return '<span class="dot dot-red" title="Nefacturat"></span>';
}
function renderCodmatCell(item) { function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) { if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`; return `<code>${esc(item.codmat || '-')}</code>`;
} }
if (item.codmat_details.length === 1) { if (item.codmat_details.length === 1) {
const d = item.codmat_details[0]; const d = item.codmat_details[0];
if (d.direct) {
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
}
return `<code>${esc(d.codmat)}</code>`; return `<code>${esc(d.codmat)}</code>`;
} }
return item.codmat_details.map(d => return item.codmat_details.map(d =>
@@ -431,6 +488,29 @@ function renderCodmatCell(item) {
).join(''); ).join('');
} }
// ── Refresh Invoices ──────────────────────────────
async function refreshInvoices() {
const btn = document.getElementById('btnRefreshInvoices');
const btnM = document.getElementById('btnRefreshInvoicesMobile');
if (btn) { btn.disabled = true; btn.textContent = '⟳ Se verifica...'; }
if (btnM) { btnM.disabled = true; }
try {
const res = await fetch('/api/dashboard/refresh-invoices', { method: 'POST' });
const data = await res.json();
if (data.error) {
alert('Eroare: ' + data.error);
} else {
loadDashOrders();
}
} catch (err) {
alert('Eroare: ' + err.message);
} finally {
if (btn) { btn.disabled = false; btn.textContent = '↻ Facturi'; }
if (btnM) { btnM.disabled = false; }
}
}
// ── Order Detail Modal ──────────────────────────── // ── Order Detail Modal ────────────────────────────
async function openDashOrderDetail(orderNumber) { async function openDashOrderDetail(orderNumber) {
@@ -442,8 +522,16 @@ async function openDashOrderDetail(orderNumber) {
document.getElementById('detailIdPartener').textContent = '-'; document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-'; document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-'; document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>'; document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none'; document.getElementById('detailError').style.display = 'none';
const invInfo = document.getElementById('detailInvoiceInfo');
if (invInfo) invInfo.style.display = 'none';
const detailItemsTotal = document.getElementById('detailItemsTotal');
if (detailItemsTotal) detailItemsTotal.textContent = '-';
const detailOrderTotal = document.getElementById('detailOrderTotal');
if (detailOrderTotal) detailOrderTotal.textContent = '-';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const modalEl = document.getElementById('orderDetailModal'); const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl); const existing = bootstrap.Modal.getInstance(modalEl);
@@ -468,39 +556,87 @@ async function openDashOrderDetail(orderNumber) {
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-'; document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-'; document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
// Invoice info
const invInfo = document.getElementById('detailInvoiceInfo');
const inv = order.invoice;
if (inv && inv.facturat) {
const serie = inv.serie_act || '';
const numar = inv.numar_act || '';
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
if (invInfo) invInfo.style.display = '';
} else {
if (invInfo) invInfo.style.display = 'none';
}
if (order.error_message) { if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message; document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = ''; document.getElementById('detailError').style.display = '';
} }
const dlvEl = document.getElementById('detailDeliveryCost');
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '';
const dscEl = document.getElementById('detailDiscount');
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) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>'; document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
return; return;
} }
document.getElementById('detailItemsBody').innerHTML = items.map(item => { // Update totals row
let statusBadge; const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
switch (item.mapping_status) { document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break; document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
default: statusBadge = '<span class="badge bg-secondary">?</span>';
}
const action = item.mapping_status === 'missing' // Store items for quick map pre-population
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>` window._detailItems = items;
: '';
// Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) {
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
const codmatText = item.codmat_details?.length
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
${codmatText}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${valoare} lei</span>
</div>
</div>`;
}).join('') + '</div>';
}
document.getElementById('detailItemsBody').innerHTML = items.map((item, idx) => {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<tr> return `<tr>
<td><code>${esc(item.sku)}</code></td> <td><code class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td> <td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td>${item.quantity || 0}</td> <td>${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td> <td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td> <td class="text-end">${valoare}</td>
<td>${renderCodmatCell(item)}</td>
<td>${statusBadge}</td>
<td>${action}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} catch (err) { } catch (err) {
@@ -511,7 +647,7 @@ async function openDashOrderDetail(orderNumber) {
// ── Quick Map Modal ─────────────────────────────── // ── Quick Map Modal ───────────────────────────────
function openQuickMap(sku, productName, orderNumber) { function openQuickMap(sku, productName, orderNumber, itemIdx) {
currentQmSku = sku; currentQmSku = sku;
currentQmOrderNumber = orderNumber; currentQmOrderNumber = orderNumber;
document.getElementById('qmSku').textContent = sku; document.getElementById('qmSku').textContent = sku;
@@ -520,36 +656,60 @@ function openQuickMap(sku, productName, orderNumber) {
const container = document.getElementById('qmCodmatLines'); const container = document.getElementById('qmCodmatLines');
container.innerHTML = ''; container.innerHTML = '';
addQmCodmatLine();
// Check if this is a direct SKU (SKU=CODMAT in NOM_ARTICOLE)
const item = (window._detailItems || [])[itemIdx];
const details = item?.codmat_details;
const isDirect = details?.length === 1 && details[0].direct === true;
const directInfo = document.getElementById('qmDirectInfo');
const saveBtn = document.getElementById('qmSaveBtn');
if (isDirect) {
if (directInfo) {
directInfo.innerHTML = `<i class="bi bi-info-circle"></i> SKU = CODMAT direct in nomenclator (<code>${escHtml(details[0].codmat)}</code> — ${escHtml(details[0].denumire || '')}).<br><small class="text-muted">Poti suprascrie cu un alt CODMAT daca e necesar (ex: reambalare).</small>`;
directInfo.style.display = '';
}
if (saveBtn) {
saveBtn.textContent = 'Suprascrie mapare';
}
addQmCodmatLine();
} else {
if (directInfo) directInfo.style.display = 'none';
if (saveBtn) saveBtn.textContent = 'Salveaza';
// Pre-populate with existing codmat_details if available
if (details && details.length > 0) {
details.forEach(d => {
addQmCodmatLine({ codmat: d.codmat, cantitate: d.cantitate_roa, procent: d.procent_pret, denumire: d.denumire });
});
} else {
addQmCodmatLine();
}
}
new bootstrap.Modal(document.getElementById('quickMapModal')).show(); new bootstrap.Modal(document.getElementById('quickMapModal')).show();
} }
function addQmCodmatLine() { function addQmCodmatLine(prefill) {
const container = document.getElementById('qmCodmatLines'); const container = document.getElementById('qmCodmatLines');
const idx = container.children.length; const idx = container.children.length;
const codmatVal = prefill?.codmat || '';
const cantVal = prefill?.cantitate || 1;
const pctVal = prefill?.procent || 100;
const denumireVal = prefill?.denumire || '';
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 qm-line'; div.className = 'qm-line';
div.innerHTML = ` div.innerHTML = `
<div class="mb-2 position-relative"> <div class="qm-row">
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label> <div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off"> <input type="text" class="form-control form-control-sm qm-codmat" placeholder="CODMAT..." autocomplete="off" value="${escHtml(codmatVal)}">
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div> <div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
<small class="text-muted qm-selected"></small> </div>
</div> <input type="number" class="form-control form-control-sm qm-cantitate" value="${cantVal}" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
<div class="row"> <input type="number" class="form-control form-control-sm qm-procent" value="${pctVal}" step="0.01" min="0" max="100" title="Procent %" style="width:70px">
<div class="col-5"> ${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger qm-rm-btn" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : '<span style="width:30px"></span>'}
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
<input type="number" class="form-control form-control-sm qm-cantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-5">
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label>
<input type="number" class="form-control form-control-sm qm-procent" value="100" step="0.01" min="0" max="100">
</div>
<div class="col-2 d-flex align-items-end">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.qm-line').remove()"><i class="bi bi-x"></i></button>` : ''}
</div>
</div> </div>
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${escHtml(denumireVal)}</div>
`; `;
container.appendChild(div); container.appendChild(div);
@@ -636,9 +796,12 @@ async function saveQuickMapping() {
if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber); if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
loadDashOrders(); loadDashOrders();
} else { } else {
alert('Eroare: ' + (data.error || 'Unknown')); const msg = data.detail || data.error || 'Unknown';
document.getElementById('qmPctWarning').textContent = msg;
document.getElementById('qmPctWarning').style.display = '';
} }
} catch (err) { } catch (err) {
alert('Eroare: ' + err.message); alert('Eroare: ' + err.message);
} }
} }

View File

@@ -10,12 +10,8 @@ let currentQmOrderNumber = '';
let ordersSortColumn = 'order_date'; let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc'; let ordersSortDirection = 'desc';
function esc(s) { function fmtCost(v) {
if (s == null) return ''; return v > 0 ? Number(v).toFixed(2) : '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
function fmtDuration(startedAt, finishedAt) { function fmtDuration(startedAt, finishedAt) {
@@ -27,24 +23,12 @@ function fmtDuration(startedAt, finishedAt) {
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
} }
function fmtDate(dateStr) {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
const hasTime = dateStr.includes(':');
if (hasTime) {
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return dateStr; }
}
function runStatusBadge(status) { function runStatusBadge(status) {
switch ((status || '').toLowerCase()) { switch ((status || '').toLowerCase()) {
case 'completed': return '<span class="badge bg-success">completed</span>'; case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
case 'running': return '<span class="badge bg-primary">running</span>'; case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
case 'failed': return '<span class="badge bg-danger">failed</span>'; case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`; default: return `<span style="font-weight:600">${esc(status)}</span>`;
} }
} }
@@ -52,12 +36,25 @@ function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>'; case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>'; case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning text-dark">Omis</span>'; case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>'; case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`; default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
} }
} }
function logStatusText(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat';
case 'ALREADY_IMPORTED': return 'Deja imp.';
case 'SKIPPED': return 'Omis';
case 'ERROR': return 'Eroare';
default: return esc(status);
}
}
function logsGoPage(p) { loadRunOrders(currentRunId, null, p); }
// ── Runs Dropdown ──────────────────────────────── // ── Runs Dropdown ────────────────────────────────
async function loadRuns() { async function loadRuns() {
@@ -88,6 +85,8 @@ async function loadRuns() {
return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`; return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
}).join(''); }).join('');
} }
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile) ddMobile.innerHTML = dd.innerHTML;
} catch (err) { } catch (err) {
const dd = document.getElementById('runsDropdown'); const dd = document.getElementById('runsDropdown');
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`; dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
@@ -110,6 +109,8 @@ async function selectRun(runId) {
// Sync dropdown selection // Sync dropdown selection
const dd = document.getElementById('runsDropdown'); const dd = document.getElementById('runsDropdown');
if (dd && dd.value !== runId) dd.value = runId; if (dd && dd.value !== runId) dd.value = runId;
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
if (!runId) { if (!runId) {
document.getElementById('logViewerSection').style.display = 'none'; document.getElementById('logViewerSection').style.display = 'none';
@@ -117,8 +118,8 @@ async function selectRun(runId) {
} }
document.getElementById('logViewerSection').style.display = ''; document.getElementById('logViewerSection').style.display = '';
document.getElementById('logRunId').textContent = runId; const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
document.getElementById('logStatusBadge').innerHTML = '<span class="badge bg-secondary">...</span>'; document.getElementById('logStatusBadge').innerHTML = '...';
document.getElementById('textLogSection').style.display = 'none'; document.getElementById('textLogSection').style.display = 'none';
await loadRunOrders(runId, 'all', 1); await loadRunOrders(runId, 'all', 1);
@@ -133,13 +134,9 @@ async function loadRunOrders(runId, statusFilter, page) {
if (statusFilter != null) currentFilter = statusFilter; if (statusFilter != null) currentFilter = statusFilter;
if (page != null) ordersPage = page; if (page != null) ordersPage = page;
// Update filter button styles // Update filter pill active state
document.querySelectorAll('#orderFilterBtns button').forEach(btn => { document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary') btn.classList.toggle('active', btn.dataset.logStatus === currentFilter);
.replace(' btn-success', ' btn-outline-success')
.replace(' btn-info', ' btn-outline-info')
.replace(' btn-warning', ' btn-outline-warning')
.replace(' btn-danger', ' btn-outline-danger');
}); });
try { try {
@@ -155,59 +152,84 @@ async function loadRunOrders(runId, statusFilter, page) {
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;
// Highlight active filter
const filterMap = { 'all': 0, 'IMPORTED': 1, 'ALREADY_IMPORTED': 2, 'SKIPPED': 3, 'ERROR': 4 };
const btns = document.querySelectorAll('#orderFilterBtns button');
const idx = filterMap[currentFilter] ?? 0;
if (btns[idx]) {
const colorMap = ['primary', 'success', 'info', 'warning', 'danger'];
btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`);
}
const tbody = document.getElementById('runOrdersBody'); const tbody = document.getElementById('runOrdersBody');
const orders = data.orders || []; const orders = data.orders || [];
if (orders.length === 0) { if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" 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 {
tbody.innerHTML = orders.map((o, i) => { tbody.innerHTML = orders.map((o, i) => {
const dateStr = fmtDate(o.order_date); const dateStr = fmtDate(o.order_date);
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')"> return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
<td>${statusDot(o.status)}</td>
<td>${(ordersPage - 1) * 50 + i + 1}</td> <td>${(ordersPage - 1) * 50 + i + 1}</td>
<td>${dateStr}</td> <td class="text-nowrap">${dateStr}</td>
<td><code>${esc(o.order_number)}</code></td> <td><code>${esc(o.order_number)}</code></td>
<td>${esc(o.customer_name)}</td> <td class="fw-bold">${esc(o.customer_name)}</td>
<td>${o.items_count || 0}</td> <td>${o.items_count || 0}</td>
<td>${orderStatusBadge(o.status)}</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 fw-bold">${orderTotal}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
// Mobile flat rows
const mobileList = document.getElementById('logsMobileList');
if (mobileList) {
if (orders.length === 0) {
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
} else {
mobileList.innerHTML = orders.map(o => {
const d = o.order_date || '';
let dateFmt = '-';
if (d.length >= 10) {
dateFmt = d.slice(8, 10) + '.' + d.slice(5, 7) + '.' + d.slice(2, 4);
if (d.length >= 16) dateFmt += ' ' + d.slice(11, 16);
}
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
${statusDot(o.status)}
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
</div>`;
}).join('');
}
}
// Mobile segmented control
renderMobileSegmented('logsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: currentFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Imp.', count: counts.imported || 0, value: 'IMPORTED', active: currentFilter === 'IMPORTED', colorClass: 'fc-green' },
{ label: 'Deja', count: counts.already_imported || 0, value: 'ALREADY_IMPORTED', active: currentFilter === 'ALREADY_IMPORTED', colorClass: 'fc-blue' },
{ label: 'Omise', count: counts.skipped || 0, value: 'SKIPPED', active: currentFilter === 'SKIPPED', colorClass: 'fc-yellow' },
{ label: 'Erori', count: counts.error || 0, value: 'ERROR', active: currentFilter === 'ERROR', colorClass: 'fc-red' }
], (val) => filterOrders(val));
// Orders pagination // Orders pagination
const totalPages = data.pages || 1; const totalPages = data.pages || 1;
const infoEl = document.getElementById('ordersPageInfo'); const infoEl = document.getElementById('ordersPageInfo');
infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`; if (infoEl) infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
const pagHtml = `<small class="text-muted me-auto">${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}</small>` + renderUnifiedPagination(ordersPage, totalPages, 'logsGoPage');
const pagDiv = document.getElementById('ordersPagination'); const pagDiv = document.getElementById('ordersPagination');
if (totalPages > 1) { if (pagDiv) pagDiv.innerHTML = pagHtml;
pagDiv.innerHTML = ` const pagDivTop = document.getElementById('ordersPaginationTop');
<button class="btn btn-sm btn-outline-secondary" ${ordersPage <= 1 ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage - 1})"><i class="bi bi-chevron-left"></i></button> if (pagDivTop) pagDivTop.innerHTML = pagHtml;
<small class="text-muted">${ordersPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${ordersPage >= totalPages ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
// Update run status badge // Update run status badge
const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`); const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
const runData = await runRes.json(); const runData = await runRes.json();
if (runData.run) { if (runData.run) {
document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status); document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status);
// Update mobile run dot
const mDot = document.getElementById('mobileRunDot');
if (mDot) mDot.className = 'sync-status-dot ' + (runData.run.status || 'idle');
} }
} catch (err) { } catch (err) {
document.getElementById('runOrdersBody').innerHTML = document.getElementById('runOrdersBody').innerHTML =
`<tr><td colspan="6" class="text-center text-danger">${esc(err.message)}</td></tr>`; `<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
} }
} }
@@ -303,8 +325,14 @@ async function openOrderDetail(orderNumber) {
document.getElementById('detailIdPartener').textContent = '-'; document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-'; document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-'; document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>'; document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none'; document.getElementById('detailError').style.display = 'none';
const detailItemsTotal = document.getElementById('detailItemsTotal');
if (detailItemsTotal) detailItemsTotal.textContent = '-';
const detailOrderTotal = document.getElementById('detailOrderTotal');
if (detailOrderTotal) detailOrderTotal.textContent = '-';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const modalEl = document.getElementById('orderDetailModal'); const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl); const existing = bootstrap.Modal.getInstance(modalEl);
@@ -334,34 +362,55 @@ async function openOrderDetail(orderNumber) {
document.getElementById('detailError').style.display = ''; document.getElementById('detailError').style.display = '';
} }
const dlvEl = document.getElementById('detailDeliveryCost');
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '';
const dscEl = document.getElementById('detailDiscount');
if (dscEl) 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) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center text-muted">Niciun articol</td></tr>'; document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
return; return;
} }
// Update totals row
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
// Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) {
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
const codmatList = item.codmat_details?.length
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
: `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '')}</span>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku">${esc(item.sku)}</span>
${codmatList}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${valoare} lei</span>
</div>
</div>`;
}).join('') + '</div>';
}
document.getElementById('detailItemsBody').innerHTML = items.map(item => { document.getElementById('detailItemsBody').innerHTML = items.map(item => {
let statusBadge; const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
switch (item.mapping_status) { const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
case 'missing': statusBadge = '<span class="badge bg-warning text-dark">Lipsa</span>'; break;
default: statusBadge = '<span class="badge bg-secondary">?</span>';
}
const action = item.mapping_status === 'missing'
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
: '';
return `<tr> return `<tr>
<td><code>${esc(item.sku)}</code></td> <td><code>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td> <td>${esc(item.product_name || '-')}</td>
<td>${codmatCell}</td>
<td>${item.quantity || 0}</td> <td>${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td> <td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td> <td class="text-end">${valoare}</td>
<td>${renderCodmatCell(item)}</td>
<td>${statusBadge}</td>
<td>${action}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} catch (err) { } catch (err) {
@@ -517,6 +566,12 @@ async function saveQuickMapping() {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadRuns(); loadRuns();
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.addEventListener('click', function() {
filterOrders(this.dataset.logStatus || 'all');
});
});
const preselected = document.getElementById('preselectedRun'); const preselected = document.getElementById('preselectedRun');
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : ''); const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
@@ -533,4 +588,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; } if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
} }
}); });
document.getElementById('autoRefreshToggleMobile')?.addEventListener('change', (e) => {
const desktop = document.getElementById('autoRefreshToggle');
if (desktop) desktop.checked = e.target.checked;
desktop?.dispatchEvent(new Event('change'));
});
}); });

View File

@@ -1,4 +1,5 @@
let currentPage = 1; let currentPage = 1;
let mappingsPerPage = 50;
let currentSearch = ''; let currentSearch = '';
let searchTimeout = null; let searchTimeout = null;
let sortColumn = 'sku'; let sortColumn = 'sku';
@@ -69,6 +70,20 @@ function updatePctCounts(counts) {
if (elAll) elAll.textContent = counts.total || 0; if (elAll) elAll.textContent = counts.total || 0;
if (elComplete) elComplete.textContent = counts.complete || 0; if (elComplete) elComplete.textContent = counts.complete || 0;
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0; if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
// Mobile segmented control
renderMobileSegmented('mappingsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: pctFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Complete', count: counts.complete || 0, value: 'complete', active: pctFilter === 'complete', colorClass: 'fc-green' },
{ label: 'Incompl.', count: counts.incomplete || 0, value: 'incomplete', active: pctFilter === 'incomplete', colorClass: 'fc-yellow' }
], (val) => {
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-pct="${val}"]`);
if (pill) pill.classList.add('active');
pctFilter = val;
currentPage = 1;
loadMappings();
});
} }
// ── Load & Render ──────────────────────────────── // ── Load & Render ────────────────────────────────
@@ -79,7 +94,7 @@ async function loadMappings() {
const params = new URLSearchParams({ const params = new URLSearchParams({
search: currentSearch, search: currentSearch,
page: currentPage, page: currentPage,
per_page: 50, per_page: mappingsPerPage,
sort_by: sortColumn, sort_by: sortColumn,
sort_dir: sortDirection sort_dir: sortDirection
}); });
@@ -103,116 +118,129 @@ async function loadMappings() {
renderPagination(data); renderPagination(data);
updateSortIcons(); updateSortIcons();
} catch (err) { } catch (err) {
document.getElementById('mappingsBody').innerHTML = document.getElementById('mappingsFlatList').innerHTML =
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`; `<div class="flat-row text-danger py-3 justify-content-center">Eroare: ${err.message}</div>`;
} }
} }
function renderTable(mappings, showDeleted) { function renderTable(mappings, showDeleted) {
const tbody = document.getElementById('mappingsBody'); const container = document.getElementById('mappingsFlatList');
if (!mappings || mappings.length === 0) { if (!mappings || mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4">Nu exista mapari</td></tr>'; container.innerHTML = '<div class="flat-row text-muted py-4 justify-content-center">Nu exista mapari</div>';
return; return;
} }
// Group by SKU for visual grouping (R6)
let html = '';
let prevSku = null; let prevSku = null;
let groupIdx = 0; let html = '';
let skuGroupCounts = {};
// Count items per SKU
mappings.forEach(m => { mappings.forEach(m => {
skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
});
mappings.forEach((m, i) => {
const isNewGroup = m.sku !== prevSku; const isNewGroup = m.sku !== prevSku;
if (isNewGroup) groupIdx++;
const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd';
const isMulti = skuGroupCounts[m.sku] > 1;
const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : '';
const deletedClass = m.sters ? 'mapping-deleted' : '';
// SKU cell: show only on first row of group
let skuCell, productCell;
if (isNewGroup) { if (isNewGroup) {
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
// Percentage total badge
let pctBadge = ''; let pctBadge = '';
if (m.pct_total !== undefined) { if (m.pct_total !== undefined) {
if (m.is_complete) { pctBadge = m.is_complete
pctBadge = ` <span class="badge-pct complete" title="100% alocat">&#10003; 100%</span>`; ? ` <span class="badge-pct complete">&#10003; 100%</span>`
} else { : ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total;
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">&#9888; ${pctVal}%</span>`;
}
} }
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}${pctBadge}</td>`; const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`; html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
} else { <span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
skuCell = ''; ${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
productCell = ''; title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${pctBadge}
<span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
${m.sters
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}" data-procent="${m.procent_pret}">&#8942;</button>`
}
</div>`;
} }
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : '';
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}"> html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
${skuCell} <code>${esc(m.codmat)}</code>
${productCell} <span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
<td><code>${esc(m.codmat)}</code></td> <span class="text-nowrap" style="font-size:0.875rem">
<td>${esc(m.denumire || '-')}</td> <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
<td>${esc(m.um || '-')}</td> ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>${m.cantitate_roa}</td> · <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</td> ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
<td> </span>
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'} </div>`;
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
${m.activ ? 'Activ' : 'Inactiv'}
</span>
</td>
<td>
${m.sters ? `<button class="btn btn-sm btn-outline-success" onclick="restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza"><i class="bi bi-arrow-counterclockwise"></i></button>` : `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditModal('${esc(m.sku)}', '${esc(m.codmat)}', ${m.cantitate_roa}, ${m.procent_pret})" title="Editeaza">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Sterge">
<i class="bi bi-trash"></i>
</button>`}
</td>
</tr>`;
prevSku = m.sku; prevSku = m.sku;
}); });
container.innerHTML = html;
tbody.innerHTML = html; // Wire context menu triggers
container.querySelectorAll('.context-menu-trigger').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const { sku, codmat, cantitate, procent } = btn.dataset;
const rect = btn.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, [
{ label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate), parseFloat(procent)) },
{ label: 'Sterge', action: () => deleteMappingConfirm(sku, codmat), danger: true }
]);
});
});
}
// Inline edit for flat-row values (cantitate / procent)
function editFlatValue(span, sku, codmat, field, currentValue) {
if (span.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm d-inline';
input.value = currentValue;
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
input.style.width = '70px';
input.style.display = 'inline';
const originalText = span.textContent;
span.textContent = '';
span.appendChild(input);
input.focus();
input.select();
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
span.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) { loadMappings(); }
else { span.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} catch (err) { span.textContent = originalText; }
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { span.textContent = originalText; }
});
} }
function renderPagination(data) { function renderPagination(data) {
const info = document.getElementById('pageInfo'); const pagOpts = { perPage: mappingsPerPage, perPageFn: 'mappingsChangePerPage', perPageOptions: [25, 50, 100, 250] };
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`; const infoHtml = `<small class="text-muted me-auto">${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}</small>`;
const pagHtml = infoHtml + renderUnifiedPagination(data.page, data.pages || 1, 'goPage', pagOpts);
const ul = document.getElementById('pagination'); const top = document.getElementById('mappingsPagTop');
if (data.pages <= 1) { ul.innerHTML = ''; return; } const bot = document.getElementById('mappingsPagBottom');
if (top) top.innerHTML = pagHtml;
let html = ''; if (bot) bot.innerHTML = pagHtml;
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">&laquo;</a></li>`;
let start = Math.max(1, data.page - 3);
let end = Math.min(data.pages, start + 6);
start = Math.max(1, end - 6);
for (let i = start; i <= end; i++) {
html += `<li class="page-item ${i === data.page ? 'active' : ''}">
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
}
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">&raquo;</a></li>`;
ul.innerHTML = html;
} }
function mappingsChangePerPage(val) { mappingsPerPage = parseInt(val) || 50; currentPage = 1; loadMappings(); }
function goPage(p) { function goPage(p) {
currentPage = p; currentPage = p;
loadMappings(); loadMappings();
@@ -248,7 +276,7 @@ function clearAddForm() {
addCodmatLine(); addCodmatLine();
} }
function openEditModal(sku, codmat, cantitate, procent) { async function openEditModal(sku, codmat, cantitate, procent) {
editingMapping = { sku, codmat }; editingMapping = { sku, codmat };
document.getElementById('addModalTitle').textContent = 'Editare Mapare'; document.getElementById('addModalTitle').textContent = 'Editare Mapare';
document.getElementById('inputSku').value = sku; document.getElementById('inputSku').value = sku;
@@ -257,14 +285,53 @@ function openEditModal(sku, codmat, cantitate, procent) {
const container = document.getElementById('codmatLines'); const container = document.getElementById('codmatLines');
container.innerHTML = ''; container.innerHTML = '';
addCodmatLine();
// Pre-fill the CODMAT line try {
const line = container.querySelector('.codmat-line'); // Fetch all CODMATs for this SKU
if (line) { const res = await fetch(`/api/mappings?search=${encodeURIComponent(sku)}&per_page=100`);
line.querySelector('.cl-codmat').value = codmat; const data = await res.json();
line.querySelector('.cl-cantitate').value = cantitate; const allMappings = (data.mappings || []).filter(m => m.sku === sku && !m.sters);
line.querySelector('.cl-procent').value = procent;
// Show product name if available
const productName = allMappings[0]?.product_name || '';
const productNameEl = document.getElementById('addModalProductName');
const productNameText = document.getElementById('inputProductName');
if (productName && productNameEl && productNameText) {
productNameText.textContent = productName;
productNameEl.style.display = '';
}
if (allMappings.length === 0) {
// Fallback to single line with passed values
addCodmatLine();
const line = container.querySelector('.codmat-line');
if (line) {
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-cantitate').value = cantitate;
line.querySelector('.cl-procent').value = procent;
}
} else {
for (const m of allMappings) {
addCodmatLine();
const lines = container.querySelectorAll('.codmat-line');
const line = lines[lines.length - 1];
line.querySelector('.cl-codmat').value = m.codmat;
if (m.denumire) {
line.querySelector('.cl-selected').textContent = m.denumire;
}
line.querySelector('.cl-cantitate').value = m.cantitate_roa;
line.querySelector('.cl-procent').value = m.procent_pret;
}
}
} catch (e) {
// Fallback on error
addCodmatLine();
const line = container.querySelector('.codmat-line');
if (line) {
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-cantitate').value = cantitate;
line.querySelector('.cl-procent').value = procent;
}
} }
new bootstrap.Modal(document.getElementById('addModal')).show(); new bootstrap.Modal(document.getElementById('addModal')).show();
@@ -276,23 +343,20 @@ function addCodmatLine() {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 codmat-line'; div.className = 'border rounded p-2 mb-2 codmat-line';
div.innerHTML = ` div.innerHTML = `
<div class="mb-2 position-relative"> <div class="row g-2 align-items-center">
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label> <div class="col position-relative">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off" data-idx="${idx}"> <input type="text" class="form-control form-control-sm cl-codmat" placeholder="Cauta CODMAT..." autocomplete="off" data-idx="${idx}">
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div> <div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
<small class="text-muted cl-selected"></small> <small class="text-muted cl-selected"></small>
</div>
<div class="row">
<div class="col-5">
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001">
</div> </div>
<div class="col-5"> <div class="col-auto" style="width:90px">
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label> <input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
<input type="number" class="form-control form-control-sm cl-procent" value="100" step="0.01" min="0" max="100">
</div> </div>
<div class="col-2 d-flex align-items-end"> <div class="col-auto" style="width:90px">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : ''} <input type="number" class="form-control form-control-sm cl-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
</div>
<div class="col-auto">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.codmat-line').remove()"><i class="bi bi-x-lg"></i></button>` : '<div style="width:31px"></div>'}
</div> </div>
</div> </div>
`; `;
@@ -370,17 +434,39 @@ async function saveMapping() {
let res; let res;
if (editingMapping) { if (editingMapping) {
// Edit mode: use PUT /api/mappings/{old_sku}/{old_codmat}/edit if (mappings.length === 1) {
res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, { // Single CODMAT edit: use existing PUT endpoint
method: 'PUT', res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json' },
new_sku: sku, body: JSON.stringify({
new_codmat: mappings[0].codmat, new_sku: sku,
cantitate_roa: mappings[0].cantitate_roa, new_codmat: mappings[0].codmat,
procent_pret: mappings[0].procent_pret cantitate_roa: mappings[0].cantitate_roa,
}) procent_pret: mappings[0].procent_pret
}); })
});
} else {
// Multi-CODMAT set: delete all existing then create new batch
const oldSku = editingMapping.sku;
const existRes = await fetch(`/api/mappings?search=${encodeURIComponent(oldSku)}&per_page=100`);
const existData = await existRes.json();
const existing = (existData.mappings || []).filter(m => m.sku === oldSku && !m.sters);
// Delete each existing CODMAT for old SKU
for (const m of existing) {
await fetch(`/api/mappings/${encodeURIComponent(m.sku)}/${encodeURIComponent(m.codmat)}`, {
method: 'DELETE'
});
}
// Create new batch with auto_restore (handles just-soft-deleted records)
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, mappings, auto_restore: true })
});
}
} else if (mappings.length === 1) { } else if (mappings.length === 1) {
res = await fetch('/api/mappings', { res = await fetch('/api/mappings', {
method: 'POST', method: 'POST',
@@ -414,36 +500,34 @@ async function saveMapping() {
let inlineAddVisible = false; let inlineAddVisible = false;
function showInlineAddRow() { function showInlineAddRow() {
// On mobile, open the full modal instead
if (window.innerWidth < 768) {
new bootstrap.Modal(document.getElementById('addModal')).show();
return;
}
if (inlineAddVisible) return; if (inlineAddVisible) return;
inlineAddVisible = true; inlineAddVisible = true;
const tbody = document.getElementById('mappingsBody'); const container = document.getElementById('mappingsFlatList');
const row = document.createElement('tr'); const row = document.createElement('div');
row.id = 'inlineAddRow'; row.id = 'inlineAddRow';
row.className = 'table-info'; row.className = 'flat-row';
row.style.background = '#eff6ff';
row.style.gap = '0.5rem';
row.innerHTML = ` row.innerHTML = `
<td colspan="2"> <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:160px"> <div class="position-relative" style="flex:1;min-width:0">
</td>
<td colspan="2" class="position-relative">
<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="off">
<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>
</td> </div>
<td>-</td> <input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
<td> <input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px"> <button class="btn btn-sm btn-success" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
</td> <button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
<td>
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:80px">
</td>
<td>-</td>
<td>
<button class="btn btn-sm btn-success me-1" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
</td>
`; `;
tbody.insertBefore(row, tbody.firstChild); container.insertBefore(row, container.firstChild);
document.getElementById('inlineSku').focus(); document.getElementById('inlineSku').focus();
// Setup autocomplete for inline CODMAT // Setup autocomplete for inline CODMAT
@@ -518,51 +602,6 @@ function cancelInlineAdd() {
inlineAddVisible = false; inlineAddVisible = false;
} }
// ── Inline Edit ──────────────────────────────────
function editCell(td, sku, codmat, field, currentValue) {
if (td.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.value = currentValue;
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
input.style.width = '80px';
const originalText = td.textContent;
td.textContent = '';
td.appendChild(input);
input.focus();
input.select();
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
td.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) { loadMappings(); }
else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} catch (err) { td.textContent = originalText; }
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') { td.textContent = originalText; }
});
}
// ── Toggle Active with Toast Undo ──────────────── // ── Toggle Active with Toast Undo ────────────────
async function toggleActive(sku, codmat, currentActive) { async function toggleActive(sku, codmat, currentActive) {
@@ -672,9 +711,13 @@ async function importCsv() {
try { try {
const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData }); const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
const data = await res.json(); const data = await res.json();
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`; let msg = `${data.processed} mapări importate`;
if (data.skipped_no_codmat > 0) {
msg += `, ${data.skipped_no_codmat} rânduri fără CODMAT omise`;
}
let html = `<div class="alert alert-success">${msg}</div>`;
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`; html += `<div class="alert alert-warning">Erori (${data.errors.length}): <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
} }
document.getElementById('importResult').innerHTML = html; document.getElementById('importResult').innerHTML = html;
loadMappings(); loadMappings();
@@ -683,8 +726,8 @@ async function importCsv() {
} }
} }
function exportCsv() { window.location.href = '/api/mappings/export-csv'; } function exportCsv() { window.location.href = (window.ROOT_PATH || '') + '/api/mappings/export-csv'; }
function downloadTemplate() { window.location.href = '/api/mappings/csv-template'; } function downloadTemplate() { window.location.href = (window.ROOT_PATH || '') + '/api/mappings/csv-template'; }
// ── Duplicate / Conflict handling ──────────────── // ── Duplicate / Conflict handling ────────────────
@@ -713,7 +756,3 @@ function handleMappingConflict(data) {
} }
} }
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

View File

@@ -0,0 +1,190 @@
let settAcTimeout = null;
document.addEventListener('DOMContentLoaded', async () => {
await loadDropdowns();
await loadSettings();
wireAutocomplete('settTransportCodmat', 'settTransportAc');
wireAutocomplete('settDiscountCodmat', 'settDiscountAc');
});
async function loadDropdowns() {
try {
const [sectiiRes, politiciRes, gestiuniRes] = await Promise.all([
fetch('/api/settings/sectii'),
fetch('/api/settings/politici'),
fetch('/api/settings/gestiuni')
]);
const sectii = await sectiiRes.json();
const politici = await politiciRes.json();
const gestiuni = await gestiuniRes.json();
const gestContainer = document.getElementById('settGestiuniContainer');
if (gestContainer) {
gestContainer.innerHTML = '';
gestiuni.forEach(g => {
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');
if (sectieEl) {
sectieEl.innerHTML = '<option value="">— selectează secție —</option>';
sectii.forEach(s => {
sectieEl.innerHTML += `<option value="${escHtml(s.id)}">${escHtml(s.label)}</option>`;
});
}
const polEl = document.getElementById('settIdPol');
if (polEl) {
polEl.innerHTML = '<option value="">— selectează politică —</option>';
politici.forEach(p => {
polEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
});
}
const tPolEl = document.getElementById('settTransportIdPol');
if (tPolEl) {
tPolEl.innerHTML = '<option value="">— implicită —</option>';
politici.forEach(p => {
tPolEl.innerHTML += `<option value="${escHtml(p.id)}">${escHtml(p.label)}</option>`;
});
}
const dPolEl = document.getElementById('settDiscountIdPol');
if (dPolEl) {
dPolEl.innerHTML = '<option value="">— implicită —</option>';
politici.forEach(p => {
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) {
console.error('loadDropdowns error:', err);
}
}
async function loadSettings() {
try {
const res = await fetch('/api/settings');
const data = await res.json();
const el = (id) => document.getElementById(id);
if (el('settTransportCodmat')) el('settTransportCodmat').value = data.transport_codmat || '';
if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21';
if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || '';
if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || '';
if (el('settDiscountVat')) el('settDiscountVat').value = data.discount_vat || '21';
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('settIdPolProductie')) el('settIdPolProductie').value = data.id_pol_productie || '';
if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || '';
// 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('settGomagApiShop')) el('settGomagApiShop').value = data.gomag_api_shop || '';
if (el('settGomagDaysBack')) el('settGomagDaysBack').value = data.gomag_order_days_back || '7';
if (el('settGomagLimit')) el('settGomagLimit').value = data.gomag_limit || '100';
if (el('settDashPollSeconds')) el('settDashPollSeconds').value = data.dashboard_poll_seconds || '5';
} catch (err) {
console.error('loadSettings error:', err);
}
}
async function saveSettings() {
const el = (id) => document.getElementById(id);
const payload = {
transport_codmat: el('settTransportCodmat')?.value?.trim() || '',
transport_vat: el('settTransportVat')?.value || '21',
transport_id_pol: el('settTransportIdPol')?.value?.trim() || '',
discount_codmat: el('settDiscountCodmat')?.value?.trim() || '',
discount_vat: el('settDiscountVat')?.value || '21',
discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '',
split_discount_vat: el('settSplitDiscountVat')?.checked ? "1" : "",
id_pol: el('settIdPol')?.value?.trim() || '',
id_pol_productie: el('settIdPolProductie')?.value?.trim() || '',
id_sectie: el('settIdSectie')?.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_shop: el('settGomagApiShop')?.value?.trim() || '',
gomag_order_days_back: el('settGomagDaysBack')?.value?.trim() || '7',
gomag_limit: el('settGomagLimit')?.value?.trim() || '100',
dashboard_poll_seconds: el('settDashPollSeconds')?.value?.trim() || '5',
};
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
const resultEl = document.getElementById('settSaveResult');
if (data.success) {
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = '#16a34a'; }
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
} else {
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = '#dc2626'; }
}
} catch (err) {
const resultEl = document.getElementById('settSaveResult');
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = '#dc2626'; }
}
}
function wireAutocomplete(inputId, dropdownId) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);
if (!input || !dropdown) return;
input.addEventListener('input', () => {
clearTimeout(settAcTimeout);
settAcTimeout = setTimeout(async () => {
const q = input.value.trim();
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
try {
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="settSelectArticle('${inputId}', '${dropdownId}', '${escHtml(r.codmat)}')">
<span class="codmat">${escHtml(r.codmat)}</span> &mdash; <span class="denumire">${escHtml(r.denumire)}</span>
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}, 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
function settSelectArticle(inputId, dropdownId, codmat) {
document.getElementById(inputId).value = codmat;
document.getElementById(dropdownId).classList.add('d-none');
}
function escHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

228
api/app/static/js/shared.js Normal file
View File

@@ -0,0 +1,228 @@
// shared.js - Unified utilities for all pages
// ── Root path patch — prepend ROOT_PATH to all relative fetch calls ───────
(function() {
const _fetch = window.fetch.bind(window);
window.fetch = function(url, ...args) {
if (typeof url === 'string' && url.startsWith('/') && window.ROOT_PATH) {
url = window.ROOT_PATH + url;
}
return _fetch(url, ...args);
};
})();
// ── HTML escaping ─────────────────────────────────
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── Date formatting ───────────────────────────────
function fmtDate(dateStr, includeSeconds) {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
const hasTime = dateStr.includes(':');
if (hasTime) {
const opts = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' };
if (includeSeconds) opts.second = '2-digit';
return d.toLocaleString('ro-RO', opts);
}
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return dateStr; }
}
// ── Unified Pagination ────────────────────────────
/**
* Renders a full pagination bar with First/Prev/numbers/Next/Last.
* @param {number} currentPage
* @param {number} totalPages
* @param {string} goToFnName - name of global function to call with page number
* @param {object} [opts] - optional: { perPage, perPageFn, perPageOptions }
* @returns {string} HTML string
*/
function renderUnifiedPagination(currentPage, totalPages, goToFnName, opts) {
if (totalPages <= 1 && !(opts && opts.perPage)) {
return '';
}
let html = '<div class="d-flex align-items-center gap-2 flex-wrap">';
// Per-page selector
if (opts && opts.perPage && opts.perPageFn) {
const options = opts.perPageOptions || [25, 50, 100, 250];
html += `<label class="per-page-label">Per pagina: <select class="select-compact ms-1" onchange="${opts.perPageFn}(this.value)">`;
options.forEach(v => {
html += `<option value="${v}"${v === opts.perPage ? ' selected' : ''}>${v}</option>`;
});
html += '</select></label>';
}
if (totalPages <= 1) {
html += '</div>';
return html;
}
html += '<div class="pagination-bar">';
// First
html += `<button class="page-btn" onclick="${goToFnName}(1)" ${currentPage <= 1 ? 'disabled' : ''}>&laquo;</button>`;
// Prev
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>&lsaquo;</button>`;
// Page numbers with ellipsis
const range = 2;
let pages = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - range && i <= currentPage + range)) {
pages.push(i);
}
}
let lastP = 0;
pages.forEach(p => {
if (lastP && p - lastP > 1) {
html += `<span class="page-btn disabled page-ellipsis">…</span>`;
}
html += `<button class="page-btn page-number${p === currentPage ? ' active' : ''}" onclick="${goToFnName}(${p})">${p}</button>`;
lastP = p;
});
// Next
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>&rsaquo;</button>`;
// Last
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>&raquo;</button>`;
html += '</div></div>';
return html;
}
// ── Context Menu ──────────────────────────────────
let _activeContextMenu = null;
function closeAllContextMenus() {
if (_activeContextMenu) {
_activeContextMenu.remove();
_activeContextMenu = null;
}
}
document.addEventListener('click', closeAllContextMenus);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllContextMenus();
});
/**
* Show a context menu at the given position.
* @param {number} x - clientX
* @param {number} y - clientY
* @param {Array} items - [{label, action, danger}]
*/
function showContextMenu(x, y, items) {
closeAllContextMenus();
const menu = document.createElement('div');
menu.className = 'context-menu';
items.forEach(item => {
const btn = document.createElement('button');
btn.className = 'context-menu-item' + (item.danger ? ' text-danger' : '');
btn.textContent = item.label;
btn.addEventListener('click', (e) => {
e.stopPropagation();
closeAllContextMenus();
item.action();
});
menu.appendChild(btn);
});
document.body.appendChild(menu);
_activeContextMenu = menu;
// Position menu, keeping it within viewport
const rect = menu.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = x;
let top = y;
if (left + 160 > vw) left = vw - 165;
if (top + rect.height > vh) top = vh - rect.height - 5;
menu.style.left = left + 'px';
menu.style.top = top + 'px';
}
/**
* Wire right-click on desktop + three-dots button on mobile for a table.
* @param {string} rowSelector - CSS selector for clickable rows
* @param {function} menuItemsFn - called with row element, returns [{label, action, danger}]
*/
function initContextMenus(rowSelector, menuItemsFn) {
document.addEventListener('contextmenu', (e) => {
const row = e.target.closest(rowSelector);
if (!row) return;
e.preventDefault();
showContextMenu(e.clientX, e.clientY, menuItemsFn(row));
});
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.context-menu-trigger');
if (!trigger) return;
const row = trigger.closest(rowSelector);
if (!row) return;
e.stopPropagation();
const rect = trigger.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, menuItemsFn(row));
});
}
// ── Mobile segmented control ─────────────────────
/**
* Render a Bootstrap btn-group segmented control for mobile.
* @param {string} containerId - ID of the container div
* @param {Array} pills - [{label, count, colorClass, value, active}]
* @param {function} onSelect - callback(value)
*/
function renderMobileSegmented(containerId, pills, onSelect) {
const container = document.getElementById(containerId);
if (!container) return;
const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem';
container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary';
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
}).join('')}</div>`;
container.querySelectorAll('[data-seg-value]').forEach(btn => {
btn.addEventListener('click', () => onSelect(btn.dataset.segValue));
});
}
// ── Dot helper ────────────────────────────────────
function statusDot(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED':
case 'ALREADY_IMPORTED':
case 'COMPLETED':
case 'RESOLVED':
return '<span class="dot dot-green"></span>';
case 'SKIPPED':
case 'UNRESOLVED':
case 'INCOMPLETE':
return '<span class="dot dot-yellow"></span>';
case 'ERROR':
case 'FAILED':
return '<span class="dot dot-red"></span>';
case 'CANCELLED':
case 'DELETED_IN_ROA':
return '<span class="dot dot-gray"></span>';
default:
return '<span class="dot dot-gray"></span>';
}
}

View File

@@ -6,52 +6,30 @@
<title>{% block title %}GoMag Import Manager{% endblock %}</title> <title>{% block title %}GoMag Import Manager{% endblock %}</title>
<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">
<link href="/static/css/style.css" rel="stylesheet"> {% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=14" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Sidebar --> <!-- Top Navbar -->
<nav id="sidebar" class="sidebar"> <nav class="top-navbar">
<div class="sidebar-header"> <div class="navbar-brand">GoMag Import</div>
<h5><i class="bi bi-box-seam"></i> GoMag Import</h5> <div class="navbar-links">
</div> <a href="{{ rp }}/" class="nav-tab {% block nav_dashboard %}{% endblock %}"><span class="d-none d-md-inline">Dashboard</span><span class="d-md-none">Acasa</span></a>
<ul class="nav flex-column"> <a href="{{ rp }}/mappings" class="nav-tab {% block nav_mappings %}{% endblock %}"><span class="d-none d-md-inline">Mapari SKU</span><span class="d-md-none">Mapari</span></a>
<li class="nav-item"> <a href="{{ rp }}/missing-skus" class="nav-tab {% block nav_missing %}{% endblock %}"><span class="d-none d-md-inline">SKU-uri Lipsa</span><span class="d-md-none">Lipsa</span></a>
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/"> <a href="{{ rp }}/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a>
<i class="bi bi-speedometer2"></i> Dashboard <a href="{{ rp }}/settings" class="nav-tab {% block nav_settings %}{% endblock %}"><span class="d-none d-md-inline">Setari</span><span class="d-md-none">Setari</span></a>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
<i class="bi bi-link-45deg"></i> Mapari SKU
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_missing %}{% endblock %}" href="/missing-skus">
<i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_logs %}{% endblock %}" href="/logs">
<i class="bi bi-journal-text"></i> Jurnale Import
</a>
</li>
</ul>
<div class="sidebar-footer">
<small class="text-muted">v1.0</small>
</div> </div>
</nav> </nav>
<!-- Mobile toggle -->
<button class="btn btn-dark d-md-none sidebar-toggle" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')">
<i class="bi bi-list"></i>
</button>
<!-- Main content --> <!-- Main content -->
<main class="main-content"> <main class="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<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=11"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -10,28 +10,30 @@
<!-- TOP ROW: Status + Controls --> <!-- TOP ROW: Status + Controls -->
<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" style="font-size:0.8125rem;color:#374151;">Inactiv</span> <span id="syncStatusText" class="text-secondary">Inactiv</span>
<div style="display:flex;align-items:center;gap:0.5rem;margin-left:auto;"> <div class="d-flex align-items-center gap-2">
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#6b7280;"> <label class="d-flex align-items-center gap-1 text-muted">
Auto: Auto:
<input type="checkbox" id="schedulerToggle" style="cursor:pointer;" onchange="toggleScheduler()"> <input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()">
</label> </label>
<select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()"> <select id="schedulerInterval" class="select-compact" onchange="updateSchedulerInterval()">
<option value="1">1 min</option>
<option value="3">3 min</option>
<option value="5">5 min</option> <option value="5">5 min</option>
<option value="10" selected>10 min</option> <option value="10" selected>10 min</option>
<option value="30">30 min</option> <option value="30">30 min</option>
</select> </select>
<button id="syncStartBtn" class="btn btn-primary btn-compact" onclick="startSync()">&#9654; Start Sync</button> <button id="syncStartBtn" class="btn btn-sm btn-primary" onclick="startSync()">&#9654; Start Sync</button>
</div> </div>
</div> </div>
<div class="sync-card-divider"></div> <div class="sync-card-divider"></div>
<!-- BOTTOM ROW: Last sync info (clickable → jurnal) --> <!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
<div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync"> <div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
<span id="lastSyncDate" style="font-weight:500;">&#8212;</span> <span id="lastSyncDate" class="fw-medium">&#8212;</span>
<span id="lastSyncDuration" style="color:#9ca3af;">&#8212;</span> <span id="lastSyncDuration" class="text-muted">&#8212;</span>
<span id="lastSyncCounts">&#8212;</span> <span id="lastSyncCounts">&#8212;</span>
<span id="lastSyncStatus">&#8212;</span> <span id="lastSyncStatus">&#8212;</span>
<span style="margin-left:auto;font-size:0.75rem;color:#9ca3af;">&#8599; jurnal</span> <span class="ms-auto small text-muted">&#8599; jurnal</span>
</div> </div>
<!-- LIVE PROGRESS (shown only when sync is running) --> <!-- LIVE PROGRESS (shown only when sync is running) -->
<div class="sync-card-progress" id="syncProgressArea" style="display:none;"> <div class="sync-card-progress" id="syncProgressArea" style="display:none;">
@@ -49,6 +51,8 @@
<div class="filter-bar" id="ordersFilterBar"> <div class="filter-bar" id="ordersFilterBar">
<!-- Period dropdown --> <!-- Period dropdown -->
<select id="periodSelect" class="select-compact"> <select id="periodSelect" class="select-compact">
<option value="1">1 zi</option>
<option value="2">2 zile</option>
<option value="3">3 zile</option> <option value="3">3 zile</option>
<option value="7" selected>7 zile</option> <option value="7" selected>7 zile</option>
<option value="30">30 zile</option> <option value="30">30 zile</option>
@@ -62,56 +66,47 @@
<span>&#8212;</span> <span>&#8212;</span>
<input type="date" id="periodEnd" class="select-compact"> <input type="date" id="periodEnd" class="select-compact">
</div> </div>
<input type="search" id="orderSearch" placeholder="Cauta comanda, client..." class="search-input">
<!-- Status pills --> <!-- Status pills -->
<button class="filter-pill active" data-status="all">Toate <span class="filter-count" 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" data-status="IMPORTED">Imp. <span class="filter-count" id="cntImp">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" data-status="SKIPPED">Omise <span class="filter-count" id="cntSkip">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" data-status="ERROR">Erori <span class="filter-count" id="cntErr">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" data-status="UNINVOICED">Nefact. <span class="filter-count" id="cntNef">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>
<!-- Search (integrated, end of row) --> <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>
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input"> <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>
</div> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">&#8635;</button>
</div>
<!-- Pagination top bar -->
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center" style="gap:0.5rem;">
<small class="text-muted" id="dashPageInfoTop"></small>
<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="font-size:0.8125rem;color:#6b7280;white-space:nowrap;">Per pagina:
<select id="perPageSelect" class="select-compact" style="margin-left:0.25rem;" onchange="dashChangePerPage(this.value)">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</label>
<div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
</div> </div>
<div class="d-md-none mb-2 d-flex align-items-center gap-2">
<div class="flex-grow-1" id="dashMobileSeg"></div>
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">&#8635;</button>
</div>
</div> </div>
<div id="dashPaginationTop" class="pag-strip"></div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="dashMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th class="sortable" onclick="dashSortBy('order_number')">Nr Comanda <span class="sort-icon" data-col="order_number"></span></th> <th style="width:24px"></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('items_count')">Art. <span class="sort-icon" data-col="items_count"></span></th> <th class="sortable" onclick="dashSortBy('items_count')">Art. <span class="sort-icon" data-col="items_count"></span></th>
<th class="sortable" onclick="dashSortBy('status')">Status Import <span class="sort-icon" data-col="status"></span></th> <th class="text-end">Transport</th>
<th>ID ROA</th> <th class="text-end">Discount</th>
<th>Factura</th> <th class="text-end">Total</th>
<th>Total</th> <th style="width:28px" title="Facturat">F</th>
</tr> </tr>
</thead> </thead>
<tbody id="dashOrdersBody"> <tbody id="dashOrdersBody">
<tr><td colspan="8" class="text-center text-muted py-3">Se incarca...</td></tr> <tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="dashPageInfo"></small>
<div id="dashPagination" class="d-flex align-items-center gap-2"></div>
</div>
</div> </div>
<!-- Order Detail Modal --> <!-- Order Detail Modal -->
@@ -134,26 +129,35 @@
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br> <small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br> <small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span> <small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
</div>
</div> </div>
</div> </div>
<div class="table-responsive"> <div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0"> <table class="table table-sm table-bordered mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>CODMAT</th>
<th>Cant.</th> <th>Cant.</th>
<th>Pret</th> <th>Pret</th>
<th>TVA</th> <th class="text-end">Valoare</th>
<th>CODMAT</th>
<th>Status</th>
<th>Actiune</th>
</tr> </tr>
</thead> </thead>
<tbody id="detailItemsBody"> <tbody id="detailItemsBody">
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="d-md-none" id="detailItemsMobile"></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">
@@ -172,20 +176,27 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-2"> <div style="margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong> <small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
</div>
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
<span style="flex:1">CODMAT</span>
<span style="width:70px">Cant.</span>
<span style="width:70px">%</span>
<span style="width:30px"></span>
</div> </div>
<div id="qmCodmatLines"> <div id="qmCodmatLines">
<!-- Dynamic CODMAT lines --> <!-- Dynamic CODMAT lines -->
</div> </div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()"> <button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
<i class="bi bi-plus"></i> Adauga CODMAT + CODMAT
</button> </button>
<div id="qmDirectInfo" class="alert alert-info mt-2" style="display:none; font-size:0.85rem; padding:8px 12px;"></div>
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div> <div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
<button type="button" class="btn btn-primary" onclick="saveQuickMapping()">Salveaza</button> <button type="button" class="btn btn-primary" id="qmSaveBtn" onclick="saveQuickMapping()">Salveaza</button>
</div> </div>
</div> </div>
</div> </div>
@@ -193,5 +204,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/dashboard.js"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=17"></script>
{% endblock %} {% endblock %}

View File

@@ -5,81 +5,86 @@
{% block content %} {% block content %}
<h4 class="mb-4">Jurnale Import</h4> <h4 class="mb-4">Jurnale Import</h4>
<!-- Sync Run Selector --> <!-- Sync Run Selector + Status + Controls (single card) -->
<div class="card mb-4"> <div class="card mb-3">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex align-items-center gap-3"> <!-- Desktop layout -->
<div class="d-none d-md-flex align-items-center gap-3 flex-wrap">
<label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label> <label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label>
<select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)"> <select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)" style="max-width:400px">
<option value="">Se incarca...</option> <option value="">Se incarca...</option>
</select> </select>
<button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button> <button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button>
<span id="logStatusBadge" style="font-weight:600">-</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked>
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
</div>
<button class="btn btn-sm btn-outline-secondary" id="btnShowTextLog" onclick="toggleTextLog()">
<i class="bi bi-file-text"></i> Log text brut
</button>
</div>
<!-- Mobile compact layout -->
<div class="d-flex d-md-none align-items-center gap-2">
<span id="mobileRunDot" class="sync-status-dot idle" style="width:8px;height:8px"></span>
<select class="form-select form-select-sm flex-grow-1" id="runsDropdownMobile" onchange="selectRun(this.value)" style="font-size:0.8rem">
<option value="">Se incarca...</option>
</select>
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca"><i class="bi bi-arrow-clockwise"></i></button>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<label class="dropdown-item d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" id="autoRefreshToggleMobile" checked> Auto-refresh
</label>
</li>
<li><a class="dropdown-item" href="#" onclick="toggleTextLog();return false"><i class="bi bi-file-text me-1"></i> Log text brut</a></li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Detail Viewer (shown when run selected) --> <!-- Detail Viewer (shown when run selected) -->
<div id="logViewerSection" style="display:none;"> <div id="logViewerSection" style="display:none;">
<!-- Filter bar --> <!-- Filter pills -->
<div class="card mb-3"> <div class="filter-bar mb-3" id="orderFilterPills">
<div class="card-header d-flex justify-content-between align-items-center"> <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>
<span>Run: <code id="logRunId"></code> <span class="badge bg-secondary" id="logStatusBadge">-</span></span> <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>
<div class="d-flex align-items-center gap-3"> <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>
<div class="form-check form-switch mb-0"> <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>
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked> <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>
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
</div>
<button class="btn btn-sm btn-outline-secondary" id="btnShowTextLog" onclick="toggleTextLog()">
<i class="bi bi-file-text"></i> Log text brut
</button>
</div>
</div>
<div class="card-body py-2">
<div class="btn-group" role="group" id="orderFilterBtns">
<button type="button" class="btn btn-sm btn-primary" onclick="filterOrders('all')">
Toate <span class="badge bg-light text-dark ms-1" id="countAll">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="filterOrders('IMPORTED')">
Importate <span class="badge bg-light text-dark ms-1" id="countImported">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="filterOrders('ALREADY_IMPORTED')">
Deja imp. <span class="badge bg-light text-dark ms-1" id="countAlreadyImported">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-warning" onclick="filterOrders('SKIPPED')">
Omise <span class="badge bg-light text-dark ms-1" id="countSkipped">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="filterOrders('ERROR')">
Erori <span class="badge bg-light text-dark ms-1" id="countError">0</span>
</button>
</div>
</div>
</div> </div>
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
<!-- Orders table --> <!-- Orders table -->
<div class="card mb-3"> <div class="card mb-3">
<div id="ordersPaginationTop" class="pag-strip"></div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="logsMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th style="width:24px"></th>
<th>#</th> <th>#</th>
<th class="sortable" onclick="sortOrdersBy('order_date')">Data comanda <span class="sort-icon" data-col="order_date"></span></th> <th class="sortable" onclick="sortOrdersBy('order_date')">Data comanda <span class="sort-icon" data-col="order_date"></span></th>
<th class="sortable" onclick="sortOrdersBy('order_number')">Nr. comanda <span class="sort-icon" data-col="order_number"></span></th> <th class="sortable" onclick="sortOrdersBy('order_number')">Nr. comanda <span class="sort-icon" data-col="order_number"></span></th>
<th class="sortable" onclick="sortOrdersBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th> <th class="sortable" onclick="sortOrdersBy('customer_name')">Client <span class="sort-icon" data-col="customer_name"></span></th>
<th class="sortable" onclick="sortOrdersBy('items_count')">Articole <span class="sort-icon" data-col="items_count"></span></th> <th class="sortable" onclick="sortOrdersBy('items_count')">Articole <span class="sort-icon" data-col="items_count"></span></th>
<th class="sortable" onclick="sortOrdersBy('status')">Status <span class="sort-icon" data-col="status"></span></th> <th class="text-end">Transport</th>
<th class="text-end">Discount</th>
<th class="text-end">Total</th>
</tr> </tr>
</thead> </thead>
<tbody id="runOrdersBody"> <tbody id="runOrdersBody">
<tr><td colspan="6" class="text-center text-muted py-3">Selecteaza un sync run</td></tr> <tr><td colspan="9" class="text-center text-muted py-3">Selecteaza un sync run</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="ordersPagination" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="ordersPageInfo"></small>
<div id="ordersPagination" class="d-flex align-items-center gap-2"></div>
</div>
</div> </div>
<!-- Collapsible text log --> <!-- Collapsible text log -->
@@ -113,24 +118,29 @@
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span> <small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
</div> </div>
</div> </div>
<div class="table-responsive"> <div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0"> <table class="table table-sm table-bordered mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>CODMAT</th>
<th>Cant.</th> <th>Cant.</th>
<th>Pret</th> <th>Pret</th>
<th>TVA</th> <th class="text-end">Valoare</th>
<th>CODMAT</th>
<th>Status</th>
<th>Actiune</th>
</tr> </tr>
</thead> </thead>
<tbody id="detailItemsBody"> <tbody id="detailItemsBody">
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="d-md-none" id="detailItemsMobile"></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">
@@ -173,5 +183,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/logs.js"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=9"></script>
{% endblock %} {% endblock %}

View File

@@ -3,19 +3,25 @@
{% block nav_mappings %}active{% endblock %} {% block nav_mappings %}active{% endblock %}
{% block content %} {% block content %}
<style>
.badge-pct { font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; }
.badge-pct.complete { background: #d1fae5; color: #065f46; }
.badge-pct.incomplete { background: #fef3c7; color: #92400e; }
</style>
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4> <h4 class="mb-0">Mapari SKU</h4>
<div> <div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button> <!-- Desktop buttons -->
<button class="btn btn-sm btn-outline-secondary" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> Adauga Mapare</button> <button class="btn btn-sm btn-outline-primary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button> <button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
<!-- Mobile ⋯ dropdown -->
<div class="dropdown d-md-none">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="downloadTemplate();return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Template CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload me-1"></i> Import CSV</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right me-1"></i> Formular complet</a></li>
</ul>
</div>
</div> </div>
</div> </div>
@@ -43,41 +49,23 @@
<!-- Percentage filter pills --> <!-- Percentage filter pills -->
<div class="filter-bar" id="mappingsFilterBar"> <div class="filter-bar" id="mappingsFilterBar">
<button class="filter-pill active" data-pct="all">Toate <span class="filter-count" id="mCntAll">0</span></button> <button class="filter-pill active d-none d-md-inline-flex" data-pct="all">Toate <span class="filter-count fc-neutral" id="mCntAll">0</span></button>
<button class="filter-pill" data-pct="complete">Complete &#10003; <span class="filter-count" id="mCntComplete">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-pct="complete">Complete <span class="filter-count fc-green" id="mCntComplete">0</span></button>
<button class="filter-pill" data-pct="incomplete">Incomplete &#9888; <span class="filter-count" id="mCntIncomplete">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-pct="incomplete">Incomplete <span class="filter-count fc-yellow" id="mCntIncomplete">0</span></button>
</div> </div>
<div class="d-md-none mb-2" id="mappingsMobileSeg"></div>
<!-- Table --> <!-- Top pagination -->
<div id="mappingsPagTop" class="pag-strip"></div>
<!-- Flat-row list (unified desktop + mobile) -->
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div id="mappingsFlatList" class="mappings-flat-list">
<table class="table table-hover mb-0"> <div class="flat-row text-muted py-4 justify-content-center">Se incarca...</div>
<thead>
<tr>
<th class="sortable" onclick="sortBy('sku')">SKU <span class="sort-icon" data-col="sku"></span></th>
<th>Produs Web</th>
<th class="sortable" onclick="sortBy('codmat')">CODMAT <span class="sort-icon" data-col="codmat"></span></th>
<th class="sortable" onclick="sortBy('denumire')">Denumire <span class="sort-icon" data-col="denumire"></span></th>
<th>UM</th>
<th class="sortable" onclick="sortBy('cantitate_roa')">Cantitate ROA <span class="sort-icon" data-col="cantitate_roa"></span></th>
<th class="sortable" onclick="sortBy('procent_pret')">Procent Pret <span class="sort-icon" data-col="procent_pret"></span></th>
<th class="sortable" onclick="sortBy('activ')">Activ <span class="sort-icon" data-col="activ"></span></th>
<th style="width:100px">Actiuni</th>
</tr>
</thead>
<tbody id="mappingsBody">
<tr><td colspan="9" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="mappingsPagBottom" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="pageInfo"></small>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
</nav>
</div>
</div> </div>
<!-- Add/Edit Modal with multi-CODMAT support (R11) --> <!-- Add/Edit Modal with multi-CODMAT support (R11) -->
@@ -166,5 +154,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/mappings.js"></script> <script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=7"></script>
{% endblock %} {% endblock %}

View File

@@ -5,63 +5,65 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">SKU-uri Lipsa</h4> <h4 class="mb-0">SKU-uri Lipsa</h4>
<div> <div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()"> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportMissingCsv()">
<i class="bi bi-download"></i> Export CSV <i class="bi bi-download"></i> Export CSV
</button> </button>
<!-- Mobile ⋯ dropdown -->
<div class="dropdown d-md-none">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="document.getElementById('rescanBtn').click();return false"><i class="bi bi-arrow-clockwise me-1"></i> Re-scan</a></li>
<li><a class="dropdown-item" href="#" onclick="exportMissingCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
</ul>
</div>
</div> </div>
</div> </div>
<!-- Unified filter bar --> <!-- Unified filter bar -->
<div class="filter-bar" id="skusFilterBar"> <div class="filter-bar" id="skusFilterBar">
<button class="filter-pill active" data-sku-status="unresolved"> <button class="filter-pill active d-none d-md-inline-flex" data-sku-status="unresolved">
Nerezolvate <span class="filter-count" id="cntUnres">0</span> Nerezolvate <span class="filter-count fc-yellow" id="cntUnres">0</span>
</button> </button>
<button class="filter-pill" data-sku-status="resolved"> <button class="filter-pill d-none d-md-inline-flex" data-sku-status="resolved">
Rezolvate <span class="filter-count" id="cntRes">0</span> Rezolvate <span class="filter-count fc-green" id="cntRes">0</span>
</button> </button>
<button class="filter-pill" data-sku-status="all"> <button class="filter-pill d-none d-md-inline-flex" data-sku-status="all">
Toate <span class="filter-count" id="cntAllSkus">0</span> Toate <span class="filter-count fc-neutral" id="cntAllSkus">0</span>
</button> </button>
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input"> <input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
<button id="rescanBtn" class="btn btn-secondary btn-compact" style="margin-left:0.5rem;">&#8635; Re-scan</button> <button id="rescanBtn" class="btn btn-sm btn-secondary ms-2 d-none d-md-inline-flex">&#8635; Re-scan</button>
<span id="rescanProgress" style="display:none;align-items:center;gap:0.4rem;font-size:0.8125rem;color:#1d4ed8;"> <span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
<span class="sync-live-dot"></span> <span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span> <span id="rescanProgressText">Scanare...</span>
</span> </span>
</div> </div>
<div class="d-md-none mb-2" id="skusMobileSeg"></div>
<!-- Result banner --> <!-- Result banner -->
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div> <div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
<div id="skusPagTop" class="pag-strip mb-2"></div>
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
<div id="missingMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th>Status</th>
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>Nr. Comenzi</th>
<th>Client</th>
<th>First Seen</th>
<th>Status</th>
<th>Actiune</th> <th>Actiune</th>
</tr> </tr>
</thead> </thead>
<tbody id="missingBody"> <tbody id="missingBody">
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr> <tr><td colspan="4" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer">
<small class="text-muted" id="missingInfo"></small>
</div>
</div> </div>
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
<nav id="paginationNav" class="mt-3">
<ul class="pagination justify-content-center" id="paginationControls"></ul>
</nav>
<!-- Map SKU Modal with multi-CODMAT support (R11) --> <!-- Map SKU Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="mapModal" tabindex="-1"> <div class="modal fade" id="mapModal" tabindex="-1">
@@ -98,7 +100,9 @@ let currentMapSku = '';
let mapAcTimeout = null; let mapAcTimeout = null;
let currentPage = 1; let currentPage = 1;
let skuStatusFilter = 'unresolved'; let skuStatusFilter = 'unresolved';
const perPage = 20; let missingPerPage = 20;
function missingChangePerPage(val) { missingPerPage = parseInt(val) || 20; currentPage = 1; loadMissingSkus(); }
// ── Filter pills ────────────────────────────────── // ── Filter pills ──────────────────────────────────
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => { document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
@@ -158,7 +162,7 @@ function loadMissingSkus(page) {
const resolvedVal = resolvedParamFor(skuStatusFilter); const resolvedVal = resolvedParamFor(skuStatusFilter);
params.set('resolved', resolvedVal); params.set('resolved', resolvedVal);
params.set('page', currentPage); params.set('page', currentPage);
params.set('per_page', perPage); params.set('per_page', missingPerPage);
const search = document.getElementById('skuSearch')?.value?.trim(); const search = document.getElementById('skuSearch')?.value?.trim();
if (search) params.set('search', search); if (search) params.set('search', search);
@@ -170,12 +174,27 @@ function loadMissingSkus(page) {
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0; if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
if (el('cntRes')) el('cntRes').textContent = c.resolved || 0; if (el('cntRes')) el('cntRes').textContent = c.resolved || 0;
if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0; if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0;
// Mobile segmented control
renderMobileSegmented('skusMobileSeg', [
{ label: 'Nerez.', count: c.unresolved || 0, value: 'unresolved', active: skuStatusFilter === 'unresolved', colorClass: 'fc-yellow' },
{ label: 'Rez.', count: c.resolved || 0, value: 'resolved', active: skuStatusFilter === 'resolved', colorClass: 'fc-green' },
{ label: 'Toate', count: c.total || 0, value: 'all', active: skuStatusFilter === 'all', colorClass: 'fc-neutral' }
], (val) => {
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-sku-status="${val}"]`);
if (pill) pill.classList.add('active');
skuStatusFilter = val;
currentPage = 1;
loadMissingSkus();
});
renderMissingSkusTable(data.skus || data.missing_skus || [], data); renderMissingSkusTable(data.skus || data.missing_skus || [], data);
renderPagination(data); renderPagination(data);
}) })
.catch(err => { .catch(err => {
document.getElementById('missingBody').innerHTML = document.getElementById('missingBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`; `<tr><td colspan="4" class="text-center text-danger">${err.message}</td></tr>`;
}); });
} }
@@ -184,38 +203,24 @@ function loadMissing(page) { loadMissingSkus(page); }
function renderMissingSkusTable(skus, data) { function renderMissingSkusTable(skus, data) {
const tbody = document.getElementById('missingBody'); const tbody = document.getElementById('missingBody');
if (data) { const mobileList = document.getElementById('missingMobileList');
document.getElementById('missingInfo').textContent =
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
}
if (!skus || skus.length === 0) { if (!skus || skus.length === 0) {
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' : const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit'; skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`; tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted py-4">${msg}</td></tr>`;
if (mobileList) mobileList.innerHTML = `<div class="flat-row text-muted py-3 justify-content-center">${msg}</div>`;
return; return;
} }
tbody.innerHTML = skus.map(s => { tbody.innerHTML = skus.map(s => {
const statusBadge = s.resolved const trAttrs = !s.resolved
? '<span class="badge bg-success">Rezolvat</span>' ? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
: '<span class="badge bg-warning text-dark">Nerezolvat</span>'; : '';
return `<tr${trAttrs}>
let firstCustomer = '-'; <td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
try {
const customers = JSON.parse(s.customers || '[]');
if (customers.length > 0) firstCustomer = customers[0];
} catch (e) { /* ignore */ }
const orderCount = s.order_count != null ? s.order_count : '-';
return `<tr class="${s.resolved ? 'table-light' : ''}">
<td><code>${esc(s.sku)}</code></td> <td><code>${esc(s.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td> <td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td>${esc(orderCount)}</td>
<td><small>${esc(firstCustomer)}</small></td>
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
<td>${statusBadge}</td>
<td> <td>
${!s.resolved ${!s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza"> ? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
@@ -225,31 +230,33 @@ function renderMissingSkusTable(skus, data) {
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
if (mobileList) {
mobileList.innerHTML = skus.map(s => {
const actionHtml = !s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
const flatRowAttrs = !s.resolved
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
: '';
return `<div class="flat-row"${flatRowAttrs}>
${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}
<code class="me-1 text-nowrap">${esc(s.sku)}</code>
<span class="grow truncate">${esc(s.product_name || '-')}</span>
${actionHtml}
</div>`;
}).join('');
}
} }
function renderPagination(data) { function renderPagination(data) {
const ul = document.getElementById('paginationControls'); const pagOpts = { perPage: missingPerPage, perPageFn: 'missingChangePerPage', perPageOptions: [20, 50, 100] };
const total = data.pages || 1; const infoHtml = `<small class="text-muted me-auto">Total: ${data.total || 0} | Pagina ${data.page || 1} din ${data.pages || 1}</small>`;
const page = data.page || 1; const pagHtml = infoHtml + renderUnifiedPagination(data.page || 1, data.pages || 1, 'loadMissing', pagOpts);
if (total <= 1) { ul.innerHTML = ''; return; } const top = document.getElementById('skusPagTop');
const bot = document.getElementById('skusPagBottom');
let html = ''; if (top) top.innerHTML = pagHtml;
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}"> if (bot) bot.innerHTML = pagHtml;
<a class="page-link" href="#" onclick="loadMissingSkus(${page - 1}); return false;">Anterior</a></li>`;
const range = 2;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
html += `<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadMissingSkus(${i}); return false;">${i}</a></li>`;
} else if (i === page - range - 1 || i === page + range + 1) {
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
}
}
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
ul.innerHTML = html;
} }
// ── Multi-CODMAT Map Modal ─────────────────────── // ── Multi-CODMAT Map Modal ───────────────────────
@@ -264,19 +271,6 @@ function openMapModal(sku, productName) {
container.innerHTML = ''; container.innerHTML = '';
addMapCodmatLine(); addMapCodmatLine();
// Pre-search with product name
if (productName) {
setTimeout(() => {
const input = container.querySelector('.mc-codmat');
if (input) {
input.value = productName;
mcAutocomplete(input,
container.querySelector('.mc-ac-dropdown'),
container.querySelector('.mc-selected'));
}
}, 100);
}
new bootstrap.Modal(document.getElementById('mapModal')).show(); new bootstrap.Modal(document.getElementById('mapModal')).show();
} }
@@ -286,23 +280,20 @@ function addMapCodmatLine() {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 mc-line'; div.className = 'border rounded p-2 mb-2 mc-line';
div.innerHTML = ` div.innerHTML = `
<div class="mb-2 position-relative"> <div class="row g-2 align-items-center">
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label> <div class="col position-relative">
<input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off"> <input type="text" class="form-control form-control-sm mc-codmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none mc-ac-dropdown"></div> <div class="autocomplete-dropdown d-none mc-ac-dropdown"></div>
<small class="text-muted mc-selected"></small> <small class="text-muted mc-selected"></small>
</div>
<div class="row">
<div class="col-5">
<label class="form-label form-label-sm mb-1">Cantitate ROA</label>
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001">
</div> </div>
<div class="col-5"> <div class="col-auto" style="width:90px">
<label class="form-label form-label-sm mb-1">Procent Pret (%)</label> <input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
<input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100">
</div> </div>
<div class="col-2 d-flex align-items-end"> <div class="col-auto" style="width:90px">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : ''} <input type="number" class="form-control form-control-sm mc-procent" value="100" step="0.01" min="0" max="100" placeholder="% Pret" title="Procent Pret">
</div>
<div class="col-auto">
${idx > 0 ? `<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.mc-line').remove()"><i class="bi bi-x"></i></button>` : '<div style="width:31px"></div>'}
</div> </div>
</div> </div>
`; `;
@@ -400,9 +391,5 @@ function exportMissingCsv() {
window.location.href = '/api/validate/missing-skus-csv'; window.location.href = '/api/validate/missing-skus-csv';
} }
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block title %}Setari - GoMag Import{% endblock %}
{% block nav_settings %}active{% endblock %}
{% block content %}
<h4 class="mb-3">Setari</h4>
<div class="row g-3 mb-3">
<!-- GoMag API card -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">GoMag API</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<label class="form-label mb-0 small">API Key</label>
<input type="text" class="form-control form-control-sm" id="settGomagApiKey" placeholder="4c5e46...">
</div>
<div class="mb-2">
<label class="form-label mb-0 small">Shop URL</label>
<input type="text" class="form-control form-control-sm" id="settGomagApiShop" placeholder="https://coffeepoint.ro">
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label mb-0 small">Zile înapoi</label>
<input type="number" class="form-control form-control-sm" id="settGomagDaysBack" value="7" min="1">
</div>
<div class="col-6">
<label class="form-label mb-0 small">Limită/pagină</label>
<input type="number" class="form-control form-control-sm" id="settGomagLimit" value="100" min="1">
</div>
</div>
</div>
</div>
</div>
<!-- Import ROA card -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Import ROA</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<label class="form-label mb-0 small">Gestiuni pentru verificare stoc</label>
<div id="settGestiuniContainer" class="border rounded p-2" style="max-height:120px;overflow-y:auto;font-size:0.85rem">
<span class="text-muted small">Se încarcă...</span>
</div>
<div class="form-text" style="font-size:0.75rem">Nicio selecție = orice gestiune</div>
</div>
<div class="mb-2">
<label class="form-label mb-0 small">Secție (ID_SECTIE)</label>
<select class="form-select form-select-sm" id="settIdSectie">
<option value="">— selectează secție —</option>
</select>
</div>
<div class="mb-2">
<label class="form-label mb-0 small">Politică Preț Vânzare (ID_POL)</label>
<select class="form-select form-select-sm" id="settIdPol">
<option value="">— selectează politică —</option>
</select>
</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 class="row g-3 mb-3">
<!-- Transport card -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Transport</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<label class="form-label mb-0 small">CODMAT Transport</label>
<div class="position-relative">
<input type="text" class="form-control form-control-sm" id="settTransportCodmat" placeholder="ex: TRANSPORT" autocomplete="off">
<div class="autocomplete-dropdown d-none" id="settTransportAc"></div>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label mb-0 small">TVA Transport (%)</label>
<select class="form-select form-select-sm" id="settTransportVat">
<option value="5">5%</option>
<option value="9">9%</option>
<option value="19">19%</option>
<option value="21" selected>21%</option>
</select>
</div>
<div class="col-6">
<label class="form-label mb-0 small">Politică Transport</label>
<select class="form-select form-select-sm" id="settTransportIdPol">
<option value="">— implicită —</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Discount card -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header py-2 px-3 fw-semibold">Discount</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<label class="form-label mb-0 small">CODMAT Discount</label>
<div class="position-relative">
<input type="text" class="form-control form-control-sm" id="settDiscountCodmat" placeholder="ex: DISCOUNT" autocomplete="off">
<div class="autocomplete-dropdown d-none" id="settDiscountAc"></div>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label mb-0 small">TVA Discount (fallback %)</label>
<select class="form-select form-select-sm" id="settDiscountVat">
<option value="5">5%</option>
<option value="9">9%</option>
<option value="11">11%</option>
<option value="19">19%</option>
<option value="21" selected>21%</option>
</select>
</div>
<div class="col-6">
<label class="form-label mb-0 small">Politică Discount</label>
<select class="form-select form-select-sm" id="settDiscountIdPol">
<option value="">— implicită —</option>
</select>
</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 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">Dashboard</div>
<div class="card-body py-2 px-3">
<div class="mb-2">
<label class="form-label mb-0 small">Interval polling (secunde)</label>
<input type="number" class="form-control form-control-sm" id="settDashPollSeconds" value="5" min="1" max="300">
<div class="form-text" style="font-size:0.75rem">Cât de des verifică dashboard-ul starea sync-ului (implicit 5s)</div>
</div>
</div>
</div>
</div>
</div>
<div class="mb-3">
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Salvează Setările</button>
<span id="settSaveResult" class="ms-2 small"></span>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=6"></script>
{% endblock %}

View File

@@ -18,6 +18,8 @@
-- p_json_articole accepta: -- p_json_articole accepta:
-- - array JSON: [{"sku":"X","quantity":"1","price":"10","vat":"19"}, ...] -- - array JSON: [{"sku":"X","quantity":"1","price":"10","vat":"19"}, ...]
-- - obiect JSON: {"sku":"X","quantity":"1","price":"10","vat":"19"} -- - obiect JSON: {"sku":"X","quantity":"1","price":"10","vat":"19"}
-- Optional per articol: "id_pol":"5" — politica de pret specifica
-- (pentru transport/discount cu politica separata de cea a comenzii)
-- Valorile sku, quantity, price, vat sunt extrase ca STRING si convertite. -- Valorile sku, quantity, price, vat sunt extrase ca STRING si convertite.
-- Daca comanda exista deja (comanda_externa), nu se dubleaza. -- Daca comanda exista deja (comanda_externa), nu se dubleaza.
-- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj). -- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj).
@@ -59,6 +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 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)
@@ -86,6 +89,60 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
g_last_error := NULL; g_last_error := NULL;
END clear_error; END clear_error;
-- ================================================================
-- Functie helper: selecteaza id_articol corect pentru un CODMAT
-- Prioritate: sters=0 AND inactiv=0, preferinta stoc, MAX(id_articol) fallback
-- ================================================================
FUNCTION resolve_id_articol(p_codmat IN VARCHAR2, p_id_gest IN VARCHAR2) RETURN NUMBER IS
v_result NUMBER;
BEGIN
IF p_id_gest IS NOT NULL THEN
-- Cu gestiuni specifice (CSV: "1,3") — split in subquery pentru IN clause
BEGIN
SELECT id_articol INTO v_result FROM (
SELECT na.id_articol
FROM nom_articole na
WHERE na.codmat = p_codmat AND na.sters = 0 AND na.inactiv = 0
ORDER BY
CASE WHEN EXISTS (
SELECT 1 FROM stoc s
WHERE s.id_articol = na.id_articol
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.luna = EXTRACT(MONTH FROM SYSDATE)
AND s.cants + s.cant - s.cante > 0
) THEN 0 ELSE 1 END,
na.id_articol DESC
) WHERE ROWNUM = 1;
EXCEPTION WHEN NO_DATA_FOUND THEN v_result := NULL;
END;
ELSE
-- Fara gestiune — cauta stoc in orice gestiune
BEGIN
SELECT id_articol INTO v_result FROM (
SELECT na.id_articol
FROM nom_articole na
WHERE na.codmat = p_codmat AND na.sters = 0 AND na.inactiv = 0
ORDER BY
CASE WHEN EXISTS (
SELECT 1 FROM stoc s
WHERE s.id_articol = na.id_articol
AND s.an = EXTRACT(YEAR FROM SYSDATE)
AND s.luna = EXTRACT(MONTH FROM SYSDATE)
AND s.cants + s.cant - s.cante > 0
) THEN 0 ELSE 1 END,
na.id_articol DESC
) WHERE ROWNUM = 1;
EXCEPTION WHEN NO_DATA_FOUND THEN v_result := NULL;
END;
END IF;
RETURN v_result;
END resolve_id_articol;
-- ================================================================ -- ================================================================
-- Procedura principala pentru importul unei comenzi -- Procedura principala pentru importul unei comenzi
-- ================================================================ -- ================================================================
@@ -97,6 +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 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);
@@ -113,6 +171,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_codmat VARCHAR2(50); v_codmat VARCHAR2(50);
v_cantitate_roa NUMBER; v_cantitate_roa NUMBER;
v_pret_unitar NUMBER; v_pret_unitar NUMBER;
v_id_pol_articol NUMBER; -- id_pol per articol (din JSON), prioritar fata de p_id_pol
-- pljson -- pljson
l_json_articole CLOB := p_json_articole; l_json_articole CLOB := p_json_articole;
@@ -189,19 +248,33 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_pret_web := TO_NUMBER(v_json_obj.get_string('price')); v_pret_web := TO_NUMBER(v_json_obj.get_string('price'));
v_vat := TO_NUMBER(v_json_obj.get_string('vat')); v_vat := TO_NUMBER(v_json_obj.get_string('vat'));
-- id_pol per articol (optional, pentru transport/discount cu politica separata)
BEGIN
v_id_pol_articol := TO_NUMBER(v_json_obj.get_string('id_pol'));
EXCEPTION
WHEN OTHERS THEN v_id_pol_articol := NULL;
END;
-- STEP 3: Gaseste articolele ROA pentru acest SKU -- STEP 3: Gaseste articolele ROA pentru acest SKU
-- Cauta mai intai in ARTICOLE_TERTI (mapari speciale / seturi) -- Cauta mai intai in ARTICOLE_TERTI (mapari speciale / seturi)
v_found_mapping := FALSE; v_found_mapping := FALSE;
FOR rec IN (SELECT at.codmat, at.cantitate_roa, at.procent_pret, na.id_articol FOR rec IN (SELECT at.codmat, at.cantitate_roa, at.procent_pret
FROM articole_terti at FROM articole_terti at
JOIN nom_articole na ON na.codmat = at.codmat
WHERE at.sku = v_sku WHERE at.sku = v_sku
AND at.activ = 1 AND at.activ = 1
AND at.sters = 0 AND at.sters = 0
ORDER BY at.procent_pret DESC) LOOP ORDER BY at.procent_pret DESC) LOOP
v_found_mapping := TRUE; v_found_mapping := TRUE;
v_id_articol := resolve_id_articol(rec.codmat, p_id_gestiune);
IF v_id_articol IS NULL THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Articol activ negasit pentru CODMAT: ' || rec.codmat;
CONTINUE;
END IF;
v_cantitate_roa := rec.cantitate_roa * v_cantitate_web; v_cantitate_roa := rec.cantitate_roa * v_cantitate_web;
v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL v_pret_unitar := CASE WHEN v_pret_web IS NOT NULL
THEN (v_pret_web * rec.procent_pret / 100) / rec.cantitate_roa THEN (v_pret_web * rec.procent_pret / 100) / rec.cantitate_roa
@@ -210,8 +283,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
BEGIN BEGIN
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda, PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => rec.id_articol, V_ID_ARTICOL => v_id_articol,
V_ID_POL => p_id_pol, V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_CANTITATE => v_cantitate_roa, V_CANTITATE => v_cantitate_roa,
V_PRET => v_pret_unitar, V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util, V_ID_UTIL => c_id_util,
@@ -226,39 +299,34 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
END; END;
END LOOP; END LOOP;
-- Daca nu s-a gasit mapare, cauta direct in NOM_ARTICOLE -- Daca nu s-a gasit mapare, cauta direct in NOM_ARTICOLE via resolve_id_articol
IF NOT v_found_mapping THEN IF NOT v_found_mapping THEN
BEGIN v_id_articol := resolve_id_articol(v_sku, p_id_gestiune);
SELECT id_articol, codmat IF v_id_articol IS NULL THEN
INTO v_id_articol, v_codmat v_articole_eroare := v_articole_eroare + 1;
FROM nom_articole g_last_error := g_last_error || CHR(10) ||
WHERE codmat = v_sku; 'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE (activ): ' || v_sku;
ELSE
v_codmat := v_sku;
v_pret_unitar := NVL(v_pret_web, 0); v_pret_unitar := NVL(v_pret_web, 0);
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda, BEGIN
V_ID_ARTICOL => v_id_articol, PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_POL => p_id_pol, V_ID_ARTICOL => v_id_articol,
V_CANTITATE => v_cantitate_web, V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_PRET => v_pret_unitar, V_CANTITATE => v_cantitate_web,
V_ID_UTIL => c_id_util, V_PRET => v_pret_unitar,
V_ID_SECTIE => p_id_sectie, V_ID_UTIL => c_id_util,
V_PTVA => v_vat); V_ID_SECTIE => p_id_sectie,
v_articole_procesate := v_articole_procesate + 1; V_PTVA => v_vat);
EXCEPTION v_articole_procesate := v_articole_procesate + 1;
WHEN NO_DATA_FOUND THEN EXCEPTION
v_articole_eroare := v_articole_eroare + 1; WHEN OTHERS THEN
g_last_error := g_last_error || CHR(10) || v_articole_eroare := v_articole_eroare + 1;
'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE: ' || v_sku; g_last_error := g_last_error || CHR(10) ||
WHEN TOO_MANY_ROWS THEN 'Eroare adaugare articol ' || v_sku || ' (CODMAT: ' || v_codmat || '): ' || SQLERRM;
v_articole_eroare := v_articole_eroare + 1; END;
g_last_error := g_last_error || CHR(10) || END IF;
'Multiple articole gasite pentru SKU: ' || v_sku;
WHEN OTHERS THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'Eroare adaugare articol ' || v_sku || ' (CODMAT: ' || v_codmat || '): ' || SQLERRM;
END;
END IF; END IF;
END; -- End BEGIN block pentru articol individual END; -- End BEGIN block pentru articol individual

File diff suppressed because it is too large Load Diff

528
deploy.ps1 Normal file
View File

@@ -0,0 +1,528 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Deploy / update GoMag Import Manager pe Windows Server cu IIS.
.DESCRIPTION
- Prima rulare: clone repo, setup venv, genereaza start.bat, configureaza IIS
- Rulari ulterioare: git pull, reinstaleaza deps, restarteaza serviciul
.PARAMETER RepoPath
Calea locala unde se cloneaza repo-ul. Default: C:\gomag-vending
.PARAMETER Port
Portul pe care ruleaza FastAPI. Default: 5003
.PARAMETER IisSiteName
Numele site-ului IIS parinte. Default: "Default Web Site"
.PARAMETER SkipIIS
Sarit configurarea IIS (util daca nu ai ARR/URLRewrite instalate inca)
.EXAMPLE
.\deploy.ps1
.\deploy.ps1 -RepoPath "D:\apps\gomag-vending" -Port 5003
.\deploy.ps1 -SkipIIS
#>
param(
[string]$RepoPath = "C:\gomag-vending",
[int] $Port = 5003,
[string]$IisSiteName = "Default Web Site",
[switch]$SkipIIS
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
function Write-Step { param([string]$msg) Write-Host "`n==> $msg" -ForegroundColor Cyan }
function Write-OK { param([string]$msg) Write-Host " [OK] $msg" -ForegroundColor Green }
function Write-Warn { param([string]$msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow }
function Write-Fail { param([string]$msg) Write-Host " [FAIL] $msg" -ForegroundColor Red }
function Write-Info { param([string]$msg) Write-Host " $msg" -ForegroundColor Gray }
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
# ─────────────────────────────────────────────────────────────────────────────
# 1. Citire token Gitea
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Citire token Gitea"
$TokenFile = Join-Path $ScriptDir ".gittoken"
$GitToken = ""
if (Test-Path $TokenFile) {
$GitToken = (Get-Content $TokenFile -Raw).Trim()
Write-OK "Token citit din $TokenFile"
} else {
Write-Warn ".gittoken nu exista langa deploy.ps1"
Write-Info "Creeaza fisierul $TokenFile cu token-ul tau Gitea (fara newline)"
Write-Info "Ex: echo -n 'ghp_xxxx' > .gittoken"
Write-Info ""
Write-Info "Continui fara token (merge doar daca repo-ul e public sau deja clonat)"
}
$RepoUrl = if ($GitToken) {
"https://$GitToken@gitea.romfast.ro/romfast/gomag-vending.git"
} else {
"https://gitea.romfast.ro/romfast/gomag-vending.git"
}
# ─────────────────────────────────────────────────────────────────────────────
# 2. Git clone / pull
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Git clone / pull"
# Verifica git instalat
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Fail "Git nu este instalat!"
Write-Info "Descarca Git for Windows de la: https://git-scm.com/download/win"
exit 1
}
if (Test-Path (Join-Path $RepoPath ".git")) {
Write-Info "Repo exista, fac git pull..."
Push-Location $RepoPath
try {
# Update remote URL cu tokenul curent (in caz ca s-a schimbat)
if ($GitToken) {
git remote set-url origin $RepoUrl 2>$null
}
git pull --ff-only
Write-OK "git pull OK"
} finally {
Pop-Location
}
} else {
Write-Info "Clonez in $RepoPath ..."
$ParentDir = Split-Path -Parent $RepoPath
if (-not (Test-Path $ParentDir)) {
New-Item -ItemType Directory -Path $ParentDir -Force | Out-Null
}
git clone $RepoUrl $RepoPath
Write-OK "git clone OK"
}
# ─────────────────────────────────────────────────────────────────────────────
# 3. Verificare Python
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Verificare Python"
$PythonCmd = $null
foreach ($candidate in @("python", "python3", "py")) {
try {
$ver = & $candidate --version 2>&1
if ($ver -match "Python 3\.(\d+)") {
$minor = [int]$Matches[1]
if ($minor -ge 11) {
$PythonCmd = $candidate
Write-OK "Python gasit: $ver ($candidate)"
break
} else {
Write-Warn "Python $ver prea vechi (necesar 3.11+)"
}
}
} catch { }
}
if (-not $PythonCmd) {
Write-Fail "Python 3.11+ nu este instalat sau nu e in PATH!"
Write-Info "Descarca de la: https://www.python.org/downloads/"
Write-Info "IMPORTANT: Bifeaza 'Add Python to PATH' la instalare"
exit 1
}
# ─────────────────────────────────────────────────────────────────────────────
# 4. Creare venv si instalare dependinte
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Virtual environment + dependinte"
$VenvDir = Join-Path $RepoPath "venv"
$VenvPip = Join-Path $VenvDir "Scripts\pip.exe"
$VenvPy = Join-Path $VenvDir "Scripts\python.exe"
$ReqFile = Join-Path $RepoPath "api\requirements.txt"
$DepsFlag = Join-Path $VenvDir ".deps_installed"
if (-not (Test-Path $VenvDir)) {
Write-Info "Creez venv..."
& $PythonCmd -m venv $VenvDir
Write-OK "venv creat"
}
# Reinstaleaza daca requirements.txt e mai nou decat flag-ul
$needInstall = $true
if (Test-Path $DepsFlag) {
$reqTime = (Get-Item $ReqFile).LastWriteTime
$flagTime = (Get-Item $DepsFlag).LastWriteTime
if ($flagTime -ge $reqTime) { $needInstall = $false }
}
if ($needInstall) {
Write-Info "Instalez dependinte din requirements.txt..."
& $VenvPip install --upgrade pip --quiet
& $VenvPip install -r $ReqFile
New-Item -ItemType File -Path $DepsFlag -Force | Out-Null
Write-OK "Dependinte instalate"
} else {
Write-OK "Dependinte deja up-to-date"
}
# ─────────────────────────────────────────────────────────────────────────────
# 5. Detectare Oracle Home → sugestie INSTANTCLIENTPATH
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Detectare Oracle"
$OracleHome = $env:ORACLE_HOME
$OracleBinPath = ""
if ($OracleHome -and (Test-Path $OracleHome)) {
$OracleBinPath = Join-Path $OracleHome "bin"
Write-OK "ORACLE_HOME detectat: $OracleHome"
Write-Info "Seteaza in api\.env: INSTANTCLIENTPATH=$OracleBinPath"
} else {
# Cauta Oracle in locatii comune
$commonPaths = @(
"C:\oracle\product\19c\dbhome_1\bin",
"C:\oracle\product\21c\dbhome_1\bin",
"C:\app\oracle\product\19.0.0\dbhome_1\bin",
"C:\oracle\instantclient_19_15",
"C:\oracle\instantclient_21_3"
)
foreach ($p in $commonPaths) {
if (Test-Path "$p\oci.dll") {
$OracleBinPath = $p
Write-OK "Oracle gasit la: $p"
Write-Info "Seteaza in api\.env: INSTANTCLIENTPATH=$p"
break
}
}
if (-not $OracleBinPath) {
Write-Warn "Oracle Instant Client nu a fost gasit automat"
Write-Info "Optiuni:"
Write-Info " 1. Thick mode: seteaza INSTANTCLIENTPATH=<cale_oracle_bin> in api\.env"
Write-Info " 2. Thin mode: seteaza FORCE_THIN_MODE=true in api\.env"
}
}
# ─────────────────────────────────────────────────────────────────────────────
# 6. Creare .env din template daca lipseste
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Fisier configurare api\.env"
$EnvFile = Join-Path $RepoPath "api\.env"
$EnvExample = Join-Path $RepoPath "api\.env.example"
if (-not (Test-Path $EnvFile)) {
if (Test-Path $EnvExample) {
Copy-Item $EnvExample $EnvFile
Write-OK "api\.env creat din .env.example"
# Actualizeaza TNS_ADMIN cu calea reala
$ApiDir = Join-Path $RepoPath "api"
(Get-Content $EnvFile) -replace "TNS_ADMIN=.*", "TNS_ADMIN=$ApiDir" |
Set-Content $EnvFile
# Seteaza INSTANTCLIENTPATH daca am gasit Oracle
if ($OracleBinPath) {
(Get-Content $EnvFile) -replace "INSTANTCLIENTPATH=.*", "INSTANTCLIENTPATH=$OracleBinPath" |
Set-Content $EnvFile
}
Write-Warn "IMPORTANT: Editeaza $EnvFile cu credentialele Oracle si GoMag API!"
Write-Info " ORACLE_USER, ORACLE_PASSWORD, ORACLE_DSN"
Write-Info " GOMAG_API_KEY, GOMAG_API_SHOP"
} else {
Write-Warn ".env.example nu exista, sari pasul"
}
} else {
Write-OK "api\.env exista deja"
}
# ─────────────────────────────────────────────────────────────────────────────
# 7. Creare directoare necesare
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Directoare date"
foreach ($dir in @("data", "output", "logs")) {
$fullPath = Join-Path $RepoPath $dir
if (-not (Test-Path $fullPath)) {
New-Item -ItemType Directory -Path $fullPath -Force | Out-Null
Write-OK "Creat: $dir\"
} else {
Write-OK "Exista: $dir\"
}
}
# ─────────────────────────────────────────────────────────────────────────────
# 8. Generare start.bat
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Generare start.bat"
$StartBat = Join-Path $RepoPath "start.bat"
# Citeste TNS_ADMIN si INSTANTCLIENTPATH din .env daca exista
$TnsAdmin = Join-Path $RepoPath "api"
$InstantClient = ""
if (Test-Path $EnvFile) {
Get-Content $EnvFile | ForEach-Object {
if ($_ -match "^TNS_ADMIN=(.+)") {
$TnsAdmin = $Matches[1].Trim()
}
if ($_ -match "^INSTANTCLIENTPATH=(.+)" -and $_ -notmatch "^#") {
$InstantClient = $Matches[1].Trim()
}
}
}
$OraclePathLine = ""
if ($InstantClient) {
$OraclePathLine = "set PATH=$InstantClient;%PATH%"
}
$StartBatContent = @"
@echo off
:: GoMag Import Manager - Windows Launcher
:: Generat de deploy.ps1 - nu edita manual, ruleaza deploy.ps1 din nou
cd /d "$RepoPath"
set TNS_ADMIN=$TnsAdmin
$OraclePathLine
echo Starting GoMag Import Manager on http://0.0.0.0:$Port (prefix /gomag)
"$VenvPy" -m uvicorn app.main:app --host 0.0.0.0 --port $Port --root-path /gomag --app-dir api
"@
Set-Content -Path $StartBat -Value $StartBatContent -Encoding UTF8
Write-OK "start.bat generat: $StartBat"
# ─────────────────────────────────────────────────────────────────────────────
# 9. IIS — Verificare ARR + URL Rewrite
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Verificare module IIS"
if ($SkipIIS) {
Write-Warn "SkipIIS activ — configurare IIS sarita"
} else {
$ArrPath = "$env:SystemRoot\System32\inetsrv\arr.dll"
$UrlRewritePath = "$env:SystemRoot\System32\inetsrv\rewrite.dll"
$ArrOk = Test-Path $ArrPath
$UrlRwOk = Test-Path $UrlRewritePath
if ($ArrOk) {
Write-OK "Application Request Routing (ARR) instalat"
} else {
Write-Warn "ARR 3.0 NU este instalat"
Write-Info "Descarca: https://www.iis.net/downloads/microsoft/application-request-routing"
Write-Info "Sau: winget install Microsoft.ARR"
}
if ($UrlRwOk) {
Write-OK "URL Rewrite 2.1 instalat"
} else {
Write-Warn "URL Rewrite 2.1 NU este instalat"
Write-Info "Descarca: https://www.iis.net/downloads/microsoft/url-rewrite"
Write-Info "Sau: winget install Microsoft.URLRewrite"
}
# ─────────────────────────────────────────────────────────────────────────
# 10. Configurare IIS — copiere web.config
# ─────────────────────────────────────────────────────────────────────────
if ($ArrOk -and $UrlRwOk) {
Write-Step "Configurare IIS reverse proxy"
# Activeaza proxy in ARR (necesar o singura data)
try {
Import-Module WebAdministration -ErrorAction SilentlyContinue
$proxyEnabled = (Get-WebConfigurationProperty `
-pspath "MACHINE/WEBROOT/APPHOST" `
-filter "system.webServer/proxy" `
-name "enabled" `
-ErrorAction SilentlyContinue).Value
if (-not $proxyEnabled) {
Set-WebConfigurationProperty `
-pspath "MACHINE/WEBROOT/APPHOST" `
-filter "system.webServer/proxy" `
-name "enabled" `
-value $true
Write-OK "ARR proxy activat global"
} else {
Write-OK "ARR proxy deja activ"
}
} catch {
Write-Warn "Nu am putut activa ARR proxy automat: $($_.Exception.Message)"
Write-Info "Activeaza manual din IIS Manager → server root → Application Request Routing Cache → Enable Proxy"
}
# Determina wwwroot site-ului IIS
$IisRootPath = $null
try {
Import-Module WebAdministration -ErrorAction SilentlyContinue
$site = Get-Website -Name $IisSiteName -ErrorAction SilentlyContinue
if ($site) {
$IisRootPath = [System.Environment]::ExpandEnvironmentVariables($site.PhysicalPath)
Write-OK "Site IIS '$IisSiteName' gasit: $IisRootPath"
} else {
Write-Warn "Site IIS '$IisSiteName' nu a fost gasit"
}
} catch {
# Fallback la locatia standard
$IisRootPath = "$env:SystemDrive\inetpub\wwwroot"
Write-Warn "WebAdministration unavailable, folosesc fallback: $IisRootPath"
}
if ($IisRootPath) {
$SourceWebConfig = Join-Path $RepoPath "iis-web.config"
$DestWebConfig = Join-Path $IisRootPath "web.config"
if (Test-Path $SourceWebConfig) {
# Inlocuieste portul in web.config cu cel configurat
$wcContent = Get-Content $SourceWebConfig -Raw
$wcContent = $wcContent -replace "localhost:5003", "localhost:$Port"
if (Test-Path $DestWebConfig) {
# Backup web.config existent
$backup = "$DestWebConfig.bak_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
Copy-Item $DestWebConfig $backup
Write-Info "Backup web.config: $backup"
}
Set-Content -Path $DestWebConfig -Value $wcContent -Encoding UTF8
Write-OK "web.config copiat in $IisRootPath"
} else {
Write-Warn "iis-web.config nu exista in repo, sarit"
}
# Restart IIS
try {
iisreset /noforce 2>&1 | Out-Null
Write-OK "IIS restartat"
} catch {
Write-Warn "IIS restart esuat: $($_.Exception.Message)"
Write-Info "Ruleaza manual: iisreset"
}
}
} else {
Write-Warn "IIS nu e configurat complet — instaleaza ARR si URL Rewrite, apoi ruleaza deploy.ps1 din nou"
}
}
# ─────────────────────────────────────────────────────────────────────────────
# 11. Serviciu Windows (NSSM sau Task Scheduler)
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Serviciu Windows"
$ServiceName = "GoMagVending"
$NssmExe = ""
# Cauta NSSM
foreach ($p in @("nssm", "C:\nssm\win64\nssm.exe", "C:\tools\nssm\nssm.exe")) {
if (Get-Command $p -ErrorAction SilentlyContinue) {
$NssmExe = $p
break
}
}
if ($NssmExe) {
Write-Info "NSSM gasit: $NssmExe"
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existingService) {
Write-Info "Serviciu existent, restarteaza..."
& $NssmExe restart $ServiceName
Write-OK "Serviciu $ServiceName restartat"
} else {
Write-Info "Instalez serviciu $ServiceName cu NSSM..."
& $NssmExe install $ServiceName (Join-Path $RepoPath "start.bat")
& $NssmExe set $ServiceName AppDirectory $RepoPath
& $NssmExe set $ServiceName DisplayName "GoMag Vending Import Manager"
& $NssmExe set $ServiceName Description "Import comenzi web GoMag -> ROA Oracle"
& $NssmExe set $ServiceName Start SERVICE_AUTO_START
& $NssmExe set $ServiceName AppStdout (Join-Path $RepoPath "logs\service_stdout.log")
& $NssmExe set $ServiceName AppStderr (Join-Path $RepoPath "logs\service_stderr.log")
& $NssmExe set $ServiceName AppRotateFiles 1
& $NssmExe set $ServiceName AppRotateOnline 1
& $NssmExe set $ServiceName AppRotateBytes 10485760
& $NssmExe start $ServiceName
Write-OK "Serviciu $ServiceName instalat si pornit"
}
} else {
# Fallback: Task Scheduler
Write-Warn "NSSM nu este instalat"
Write-Info "Optiuni:"
Write-Info " 1. Descarca NSSM: https://nssm.cc/download si pune nssm.exe in PATH"
Write-Info " 2. Sau foloseste Task Scheduler (creat mai jos)"
# Verifica daca task-ul exista deja
$taskExists = Get-ScheduledTask -TaskName $ServiceName -ErrorAction SilentlyContinue
if (-not $taskExists) {
Write-Info "Creez Task Scheduler task '$ServiceName'..."
try {
$action = New-ScheduledTaskAction -Execute (Join-Path $RepoPath "start.bat")
$trigger = New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Days 365) `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1)
$principal = New-ScheduledTaskPrincipal `
-UserId "SYSTEM" `
-LogonType ServiceAccount `
-RunLevel Highest
Register-ScheduledTask `
-TaskName $ServiceName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Description "GoMag Vending Import Manager" `
-Force | Out-Null
Start-ScheduledTask -TaskName $ServiceName
Write-OK "Task Scheduler '$ServiceName' creat si pornit"
} catch {
Write-Warn "Task Scheduler esuat: $($_.Exception.Message)"
Write-Info "Porneste manual: .\start.bat"
}
} else {
# Restart task
Stop-ScheduledTask -TaskName $ServiceName -ErrorAction SilentlyContinue
Start-ScheduledTask -TaskName $ServiceName
Write-OK "Task '$ServiceName' restartat"
}
}
# ─────────────────────────────────────────────────────────────────────────────
# Sumar final
# ─────────────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " GoMag Vending Deploy — Sumar" -ForegroundColor Cyan
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
Write-Host " Repo: $RepoPath" -ForegroundColor White
Write-Host " FastAPI: http://localhost:$Port/gomag" -ForegroundColor White
Write-Host " start.bat generat" -ForegroundColor White
Write-Host ""
if (-not (Test-Path $EnvFile)) {
Write-Host " [!] api\.env lipseste — configureaza inainte de start!" -ForegroundColor Red
} else {
Write-Host " api\.env: OK" -ForegroundColor Green
# Verifica daca mai are valori placeholder
$envContent = Get-Content $EnvFile -Raw
if ($envContent -match "your_api_key_here|USER_ORACLE|parola_oracle|TNS_ALIAS") {
Write-Host " [!] api\.env contine valori placeholder — editeaza!" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host " Acces app: http://SERVER/gomag" -ForegroundColor Cyan
Write-Host " Test local: http://localhost:$Port/gomag/health" -ForegroundColor Cyan
Write-Host ""

62
iis-web.config Normal file
View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
IIS web.config pentru GoMag Vending — URL Rewrite + ARR Reverse Proxy
Copiat automat de deploy.ps1 in wwwroot site-ului IIS.
Prerequisite:
- Application Request Routing (ARR) 3.0
- URL Rewrite 2.1
Ambele gratuite de la iis.net.
Configuratie:
Browser → http://SERVER/gomag/...
IIS (port 80)
↓ (URL Rewrite)
http://localhost:5003/...
FastAPI/uvicorn
-->
<configuration>
<system.webServer>
<!-- Activeaza proxy (ARR) -->
<proxy enabled="true" preserveHostHeader="false" reverseRewriteHostInResponseHeaders="false" />
<rewrite>
<rules>
<!--
Regula principala: /gomag/* → http://localhost:5003/*
FastAPI ruleaza cu --root-path /gomag deci stie de prefix.
-->
<rule name="GoMag Reverse Proxy" stopProcessing="true">
<match url="^gomag(.*)" />
<conditions>
<add input="{CACHE_URL}" pattern="^(https?)://" />
</conditions>
<action type="Rewrite" url="http://localhost:5003{R:1}" />
</rule>
</rules>
<!-- Rescrie Location header-ele din raspunsurile FastAPI -->
<outboundRules>
<rule name="GoMag Fix Location Header" preCondition="IsRedirect">
<match serverVariable="RESPONSE_Location" pattern="^http://localhost:5003/(.*)" />
<action type="Rewrite" value="/gomag/{R:1}" />
</rule>
<preConditions>
<preCondition name="IsRedirect">
<add input="{RESPONSE_STATUS}" pattern="3\d\d" />
</preCondition>
</preConditions>
</outboundRules>
</rewrite>
<!-- Securitate: ascunde versiunea IIS -->
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration>

View File

@@ -0,0 +1,94 @@
# Handoff: Matching GoMag SKU → ROA Articole pentru Mapari
## Context
Vending (coffeepoint.ro) are ~429 comenzi GoMag importate in SQLite, din care ~393 SKIPPED (lipsesc mapari SKU).
Facturile pentru aceste comenzi exista deja in Oracle ROA, create manual independent de import.
Scopul: descoperim corespondenta SKU GoMag → id_articol ROA din compararea comenzilor cu facturile.
## Ce s-a facut
### 1. Fix customer_name (COMPLETAT - commits pe main)
- **Problema:** `customer_name` in SQLite era shipping person, nu firma de facturare
- **Fix:** Cand billing e pe firma, `customer_name = company_name` (nu shipping person)
- **Fix 2:** `customer_name` nu se actualiza la upsert SQLite (doar la INSERT)
- **Fix 3:** Dashboard JS afisa `shipping_name` cu prioritate in loc de `customer_name`
- **Commits:** `cc872cf`, `ecb4777`, `172debd`, `8020b2d`
### 2. Matching comenzi → facturi (FUNCTIONEAZA)
- **Metoda:** Fuzzy match pe client name + total comanda + data (±3 zile)
- **Rezultat:** 428/429 comenzi matched cu facturi Oracle (1 nematched)
- **Script:** `scripts/match_all.py`, `scripts/match_by_price.py`
### 3. Matching linii comenzi → linii facturi (ESUAT - REZULTATE NESATISFACATOARE)
#### Ce s-a incercat:
1. **Match pe CODMAT** (SKU == CODMAT din vanzari_detalii) → Multe articole ROA nu au CODMAT completat
2. **Match pe valoare linie** (qty × pret) → Functioneaza cand comanda GoMag corespunde exact cu factura
3. **Match pe pret unitar** (pret fara TVA) → Idem, functioneaza doar cand articolele coincid
#### De ce nu merge:
- **Articolele din factura ROA sunt COMPLET DIFERITE** fata de comanda GoMag in multe cazuri
- Exemplu: comanda GoMag are "Lavazza Crema E Aroma" dar factura ROA are "CAFEA FRESSO BLUE"
- Asta se intampla probabil pentru ca vanzatorul ajusteaza comanda inainte de facturare (inlocuieste produse, adauga altele, modifica cantitati)
- Matching-ul pe pret gaseste corespondente FALSE (produse diferite care au intamplator acelasi pret)
- Rezultat: din 37 mapari "simple 1:1", unele sunt corecte, altele sunt nonsens
- Repackaging si seturi sunt aproape toate false
#### Ce a produs:
- `scripts/output/update_codmat.sql` — 37 UPDATE-uri nom_articole (TREBUIE VERIFICATE MANUAL, multe sunt gresite)
- `scripts/output/repack_mappings.csv` — 16 repackaging (majoritatea gresite)
- `scripts/output/set_mappings.csv` — 52 seturi (aproape toate gresite)
- `scripts/output/inconsistent_skus.csv` — 11 SKU-uri cu match-uri contradictorii
## Ce NU a mers si de ce
Algoritmul actual face matching "in bulk" pe toate comenzile simultan, ceea ce produce prea mult zgomot.
Cand o comanda are produse complet diferite fata de factura, algoritmul forteaza match-uri absurde pe baza de pret.
## Strategie propusa pentru sesiunea urmatoare
### Abordare: subset → confirmare → generalizare
**Pas 1: Identificare perechi comanda-factura cu CERTITUDINE**
- Foloseste perechile unde clientul se potriveste EXACT (score > 0.9) si totalul e identic
- Aceste perechi au sanse mai mari sa aiba si articole corespunzatoare
**Pas 2: Comparare manuala pe un subset mic (5-10 perechi)**
- Alege perechi unde numarul de articole GoMag == numarul de articole ROA (fara transport/discount)
- Afiseaza side-by-side: GoMag SKU+produs+qty vs ROA codmat+produs+qty
- User-ul confirma manual care corespondente sunt corecte
**Pas 3: Validare croise**
- Un SKU care apare in mai multe comenzi trebuie sa se mapeze mereu pe acelasi id_articol
- Daca SKU X → id_articol Y in comanda A dar SKU X → id_articol Z in comanda B → marcheaza ca suspect
**Pas 4: Generalizare doar pe mapari confirmate**
- Extinde doar maparile validate pe subset la restul comenzilor
- Nu forta match-uri noi — lasa unresolved ce nu se confirma
### Alt approach posibil: match pe DENUMIRE (fuzzy name match)
- In loc de pret, compara denumirea produsului GoMag cu denumirea articolului ROA
- Exemplu: "Lavazza Crema E Aroma Cafea Boabe 1 Kg" vs "LAVAZZA BBE CREMA E AROMA"
- Ar putea fi mai precis decat match pe pret, mai ales cand preturile coincid accidental
### Tools utile deja existente:
- `scripts/compare_order.py <order_nr> <fact_nr>` — comparare detaliata o comanda vs o factura
- `scripts/fetch_one_order.py <order_nr>` — fetch JSON complet din GoMag API
- `scripts/match_all.py` — matching bulk (de refacut cu strategie noua)
## Structura Oracle relevanta
- `vanzari` — header factura (id_vanzare, numar_act, serie_act, data_act, total_cu_tva, id_part)
- `vanzari_detalii` — linii factura (id_vanzare, id_articol, cantitate, pret, pret_cu_tva)
- `nom_articole` — nomenclator articole (id_articol, codmat, denumire)
- `comenzi` — header comanda ROA (id_comanda, id_part, nr_comanda)
- `comenzi_elemente` — linii comanda ROA
- `nom_parteneri` — parteneri (id_part, denumire, prenume)
- `ARTICOLE_TERTI` — mapari SKU → CODMAT (sku, codmat, cantitate_roa, procent_pret)
## Server
- SSH: `ssh -p 22122 gomag@79.119.86.134`
- App: `C:\gomag-vending`
- SQLite: `C:\gomag-vending\api\data\import.db`
- Oracle user: VENDING / ROMFASTSOFT / DSN=ROA

View File

@@ -1,306 +0,0 @@
#!/usr/bin/env python3
"""
Parser pentru log-urile sync_comenzi_web.
Extrage comenzi esuate, SKU-uri lipsa, si genereaza un sumar.
Suporta atat formatul vechi (verbose) cat si formatul nou (compact).
Utilizare:
python parse_sync_log.py # Ultimul log din vfp/log/
python parse_sync_log.py <fisier.log> # Log specific
python parse_sync_log.py --skus # Doar lista SKU-uri lipsa
python parse_sync_log.py --dir /path/to/logs # Director custom
"""
import os
import sys
import re
import glob
import argparse
# Regex pentru linii cu timestamp (intrare noua in log)
RE_TIMESTAMP = re.compile(r'^\[(\d{2}:\d{2}:\d{2})\]\s+\[(\w+\s*)\]\s*(.*)')
# Regex format NOU: [N/Total] OrderNumber P:X A:Y/Z -> OK/ERR details
RE_COMPACT_OK = re.compile(r'\[(\d+)/(\d+)\]\s+(\S+)\s+.*->\s+OK\s+ID:(\S+)')
RE_COMPACT_ERR = re.compile(r'\[(\d+)/(\d+)\]\s+(\S+)\s+.*->\s+ERR\s+(.*)')
# Regex format VECHI (backwards compat)
RE_SKU_NOT_FOUND = re.compile(r'SKU negasit.*?:\s*(\S+)')
RE_PRICE_POLICY = re.compile(r'Pretul pentru acest articol nu a fost gasit')
RE_FAILED_ORDER = re.compile(r'Import comanda esuat pentru\s+(\S+)')
RE_ARTICOL_ERR = re.compile(r'Eroare adaugare articol\s+(\S+)')
RE_ORDER_PROCESS = re.compile(r'Procesez comanda:\s+(\S+)\s+din\s+(\S+)')
RE_ORDER_SUCCESS = re.compile(r'SUCCES: Comanda importata.*?ID Oracle:\s+(\S+)')
# Regex comune
RE_SYNC_END = re.compile(r'SYNC END\s*\|.*?(\d+)\s+processed.*?(\d+)\s+ok.*?(\d+)\s+err')
RE_STATS_LINE = re.compile(r'Duration:\s*(\S+)\s*\|\s*Orders:\s*(\S+)')
RE_STOPPED_EARLY = re.compile(r'Peste \d+.*ero|stopped early')
def find_latest_log(log_dir):
"""Gaseste cel mai recent log sync_comenzi din directorul specificat."""
pattern = os.path.join(log_dir, 'sync_comenzi_*.log')
files = glob.glob(pattern)
if not files:
return None
return max(files, key=os.path.getmtime)
def parse_log_entries(lines):
"""Parseaza liniile log-ului in intrari structurate."""
entries = []
current = None
for line in lines:
line = line.rstrip('\n\r')
m = RE_TIMESTAMP.match(line)
if m:
if current:
entries.append(current)
current = {
'time': m.group(1),
'level': m.group(2).strip(),
'text': m.group(3),
'full': line,
'continuation': []
}
elif current is not None:
current['continuation'].append(line)
current['text'] += '\n' + line
if current:
entries.append(current)
return entries
def extract_sku_from_error(err_text):
"""Extrage SKU din textul erorii (diverse formate)."""
# SKU_NOT_FOUND: 8714858424056
m = re.search(r'SKU_NOT_FOUND:\s*(\S+)', err_text)
if m:
return ('SKU_NOT_FOUND', m.group(1))
# PRICE_POLICY: 8000070028685
m = re.search(r'PRICE_POLICY:\s*(\S+)', err_text)
if m:
return ('PRICE_POLICY', m.group(1))
# Format vechi: SKU negasit...NOM_ARTICOLE: xxx
m = RE_SKU_NOT_FOUND.search(err_text)
if m:
return ('SKU_NOT_FOUND', m.group(1))
# Format vechi: Eroare adaugare articol xxx
m = RE_ARTICOL_ERR.search(err_text)
if m:
return ('ARTICOL_ERROR', m.group(1))
# Format vechi: Pretul...
if RE_PRICE_POLICY.search(err_text):
return ('PRICE_POLICY', '(SKU necunoscut)')
return (None, None)
def analyze_entries(entries):
"""Analizeaza intrarile si extrage informatii relevante."""
result = {
'start_time': None,
'end_time': None,
'duration': None,
'total_orders': 0,
'success_orders': 0,
'error_orders': 0,
'stopped_early': False,
'failed': [],
'missing_skus': [],
}
seen_skus = set()
current_order = None
for entry in entries:
text = entry['text']
level = entry['level']
# Start/end time
if entry['time']:
if result['start_time'] is None:
result['start_time'] = entry['time']
result['end_time'] = entry['time']
# Format NOU: SYNC END line cu statistici
m = RE_SYNC_END.search(text)
if m:
result['total_orders'] = int(m.group(1))
result['success_orders'] = int(m.group(2))
result['error_orders'] = int(m.group(3))
# Format NOU: compact OK line
m = RE_COMPACT_OK.search(text)
if m:
continue
# Format NOU: compact ERR line
m = RE_COMPACT_ERR.search(text)
if m:
order_nr = m.group(3)
err_detail = m.group(4).strip()
err_type, sku = extract_sku_from_error(err_detail)
if err_type and sku:
result['failed'].append((order_nr, err_type, sku))
if sku not in seen_skus and sku != '(SKU necunoscut)':
seen_skus.add(sku)
result['missing_skus'].append(sku)
else:
result['failed'].append((order_nr, 'ERROR', err_detail[:60]))
continue
# Stopped early
if RE_STOPPED_EARLY.search(text):
result['stopped_early'] = True
# Format VECHI: statistici din sumar
if 'Total comenzi procesate:' in text:
try:
result['total_orders'] = int(text.split(':')[-1].strip())
except ValueError:
pass
if 'Comenzi importate cu succes:' in text:
try:
result['success_orders'] = int(text.split(':')[-1].strip())
except ValueError:
pass
if 'Comenzi cu erori:' in text:
try:
result['error_orders'] = int(text.split(':')[-1].strip())
except ValueError:
pass
# Format VECHI: Duration line
m = RE_STATS_LINE.search(text)
if m:
result['duration'] = m.group(1)
# Format VECHI: erori
if level == 'ERROR':
m_fail = RE_FAILED_ORDER.search(text)
if m_fail:
current_order = m_fail.group(1)
m = RE_ORDER_PROCESS.search(text)
if m:
current_order = m.group(1)
err_type, sku = extract_sku_from_error(text)
if err_type and sku:
order_nr = current_order or '?'
result['failed'].append((order_nr, err_type, sku))
if sku not in seen_skus and sku != '(SKU necunoscut)':
seen_skus.add(sku)
result['missing_skus'].append(sku)
# Duration din SYNC END
m = re.search(r'\|\s*(\d+)s\s*$', text)
if m:
result['duration'] = m.group(1) + 's'
return result
def format_report(result, log_path):
"""Formateaza raportul complet."""
lines = []
lines.append('=== SYNC LOG REPORT ===')
lines.append(f'File: {os.path.basename(log_path)}')
duration = result["duration"] or "?"
start = result["start_time"] or "?"
end = result["end_time"] or "?"
lines.append(f'Run: {start} - {end} ({duration})')
lines.append('')
stopped = 'YES' if result['stopped_early'] else 'NO'
lines.append(
f'SUMMARY: {result["total_orders"]} processed, '
f'{result["success_orders"]} success, '
f'{result["error_orders"]} errors '
f'(stopped early: {stopped})'
)
lines.append('')
if result['failed']:
lines.append('FAILED ORDERS:')
seen = set()
for order_nr, err_type, sku in result['failed']:
key = (order_nr, err_type, sku)
if key not in seen:
seen.add(key)
lines.append(f' {order_nr:<12} {err_type:<18} {sku}')
lines.append('')
if result['missing_skus']:
lines.append(f'MISSING SKUs ({len(result["missing_skus"])} unique):')
for sku in sorted(result['missing_skus']):
lines.append(f' {sku}')
lines.append('')
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(
description='Parser pentru log-urile sync_comenzi_web'
)
parser.add_argument(
'logfile', nargs='?', default=None,
help='Fisier log specific (default: ultimul din vfp/log/)'
)
parser.add_argument(
'--skus', action='store_true',
help='Afiseaza doar lista SKU-uri lipsa (una pe linie)'
)
parser.add_argument(
'--dir', default=None,
help='Director cu log-uri (default: vfp/log/ relativ la script)'
)
args = parser.parse_args()
if args.logfile:
log_path = args.logfile
else:
if args.dir:
log_dir = args.dir
else:
script_dir = os.path.dirname(os.path.abspath(__file__))
project_dir = os.path.dirname(script_dir)
log_dir = os.path.join(project_dir, 'vfp', 'log')
log_path = find_latest_log(log_dir)
if not log_path:
print(f'Nu am gasit fisiere sync_comenzi_*.log in {log_dir}',
file=sys.stderr)
sys.exit(1)
if not os.path.isfile(log_path):
print(f'Fisierul nu exista: {log_path}', file=sys.stderr)
sys.exit(1)
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines()
entries = parse_log_entries(lines)
result = analyze_entries(entries)
if args.skus:
for sku in sorted(result['missing_skus']):
print(sku)
else:
print(format_report(result, log_path))
if __name__ == '__main__':
main()

79
update.ps1 Normal file
View File

@@ -0,0 +1,79 @@
# GoMag Vending - Update Script
# Ruleaza interactiv: .\update.ps1
# Ruleaza din scheduler: .\update.ps1 -Silent
param(
[switch]$Silent
)
$RepoPath = "C:\gomag-vending"
$TokenFile = Join-Path $RepoPath ".gittoken"
$LogFile = Join-Path $RepoPath "update.log"
function Log($msg, $color = "White") {
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
if ($Silent) {
Add-Content -Path $LogFile -Value "$ts $msg"
} else {
Write-Host $msg -ForegroundColor $color
}
}
# Citire token
if (-not (Test-Path $TokenFile)) {
Log "EROARE: $TokenFile nu exista!" "Red"
exit 1
}
$token = (Get-Content $TokenFile -Raw).Trim()
# Safe directory (necesar cand ruleaza ca SYSTEM)
git config --global --add safe.directory $RepoPath 2>$null
# Fetch remote
Set-Location $RepoPath
$fetchUrl = "https://gomag-vending:$token@gitea.romfast.ro/romfast/gomag-vending.git"
$env:GIT_TERMINAL_PROMPT = "0"
$fetchOutput = & git -c credential.helper="" fetch $fetchUrl main 2>&1
$fetchExit = $LASTEXITCODE
if ($fetchExit -ne 0) {
Log "EROARE: git fetch esuat (exit=$fetchExit): $fetchOutput" "Red"
exit 1
}
# Compara local vs remote
$local = git rev-parse HEAD
$remote = git rev-parse FETCH_HEAD
if ($local -eq $remote) {
Log "Nicio actualizare disponibila." "Gray"
exit 0
}
# Exista update-uri
$commits = git log --oneline "$local..$remote"
Log "==> Update disponibil ($($commits.Count) commit-uri noi)" "Cyan"
if (-not $Silent) {
$commits | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
# Git pull
Log "==> Git pull..." "Cyan"
$pullOutput = & git -c credential.helper="" pull $fetchUrl 2>&1
$pullExit = $LASTEXITCODE
if ($pullExit -ne 0) {
Log "EROARE: git pull esuat (exit=$pullExit): $pullOutput" "Red"
exit 1
}
# Pip install (daca s-au schimbat dependintele)
Log "==> Verificare dependinte..." "Cyan"
& "$RepoPath\venv\Scripts\pip.exe" install -r "$RepoPath\api\requirements.txt" --quiet 2>&1 | Out-Null
# Restart serviciu
Log "==> Restart GoMagVending..." "Cyan"
nssm restart GoMagVending 2>&1 | Out-Null
Start-Sleep -Seconds 3
$status = (nssm status GoMagVending 2>&1) -replace '\0',''
Log "Serviciu: $status" "Green"
Log "Update complet!" "Green"