Compare commits

...

60 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
Claude Agent
b69b5e7104 feat(sync): add GoMag API client with Phase 0 auto-download
- New gomag_client.py service: async httpx client that downloads orders
  from GoMag API with full pagination and 1s rate-limit sleep
- config.py: add GOMAG_API_KEY, GOMAG_API_SHOP, GOMAG_ORDER_DAYS_BACK,
  GOMAG_LIMIT, GOMAG_API_URL settings
- sync_service.py: Phase 0 downloads fresh orders before reading JSONs;
  graceful skip if API keys not configured
- start.sh: auto-detect INSTANTCLIENTPATH from .env, fallback to thin mode
- .env.example: document GoMag API variables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 23:03:39 +00:00
2e65855fe2 feat(sync): already_imported tracking, invoice cache, path fixes, remove vfp
- Track already_imported/new_imported counts separately in sync_runs
  and surface them in status API + dashboard last-run card
- Cache invoice data in SQLite orders table (factura_* columns);
  dashboard falls back to Oracle only for uncached imported orders
- Resolve JSON_OUTPUT_DIR and SQLITE_DB_PATH relative to known
  anchored roots in config.py, independent of CWD (fixes WSL2 start)
- Use single Oracle connection for entire validation phase (perf)
- Batch upsert web_products instead of one-by-one
- Remove stale VFP scripts (replaced by gomag-vending.prg workflow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 00:15:37 +02:00
8681a92eec fix(logs): use status_at_run for per-run order counts and filtering
orders.status preserves IMPORTED over ALREADY_IMPORTED to avoid
overwriting historical data, so per-run journal views must use
sync_run_orders.status_at_run to show what actually happened in
that specific run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 00:12:10 +02:00
f52c504c2b chore: ignore SQLite auxiliary files (journal, wal, shm)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 18:02:08 +02:00
77a89f4b16 docs: update README and .env.example for project root start convention
Fix start command (python -m uvicorn api.app.main:app from project root),
correct JSON_OUTPUT_DIR path (vfp/output not ../vfp/output), document
all env variables with descriptions, add dashboard features overview,
business rules, and WSL2 notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:59:58 +02:00
5f8b9b6003 feat(dashboard): redesign UI with smart polling, unified sync card, filter bar
Replace SSE with smart polling (30s idle / 3s when running). Unify sync
panel into single two-row card with live progress text. Add unified filter
bar (period dropdown, status pills, search) with period-total counts.
Add Client/Cont tooltip for different shipping/billing persons. Add SKU
mappings pct_total badges + complete/incomplete filter + 409 duplicate
check. Add missing SKUs search + rescan progress UX. Migrate SQLite
orders schema (shipping_name, billing_name, payment_method,
delivery_method). Fix JSON_OUTPUT_DIR path for server running from
project root. Fix pagination controls showing top+bottom with per-page
selector (25/50/100/250).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:55:36 +02:00
82196b9dc0 feat(sqlite): refactor orders schema + dashboard period filter
Replace import_orders (insert-per-run) with orders table (one row per
order, upsert on conflict). Eliminates dedup CTE on every dashboard
query and prevents unbounded row growth at 4-500 orders/sync.

Key changes:
- orders table: PK order_number, upsert via ON CONFLICT DO UPDATE;
  COALESCE preserves id_comanda once set; times_skipped auto-increments
- sync_run_orders: lightweight junction (sync_run_id, order_number)
  replaces sync_run_id column on orders
- order_items: PK changed to (order_number, sku), INSERT OR IGNORE
- Auto-migration in init_sqlite(): import_orders → orders on first boot,
  old table renamed to import_orders_bak
- /api/dashboard/orders: period_days param (3/7/30/0=all, default 7)
- Dashboard: period selector buttons in orders card header
- start.sh: stop existing process on port 5003 before restart;
  remove --reload (broken on WSL2 /mnt/e/)
- Add invoice_service, E2E Playwright tests, Oracle package updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 16:18:57 +02:00
650e98539e feat(sync): add SSE live feed, unified logs page, fix Oracle connection
- Add SSE event bus in sync_service (subscribe/unsubscribe/_emit)
- Add GET /api/sync/stream SSE endpoint for real-time sync progress
- Rewrite logs.html: unified runs table + live feed + summary + filters
- Rewrite logs.js: SSE EventSource client, run selection, pagination
- Dashboard: clickable runs navigate to /logs?run=, sync started banner
- Remove "Import Comenzi" nav item, delete sync_detail.html
- Add error_message column to sync_runs table with migration
- Fix: export TNS_ADMIN as OS env var so oracledb finds tnsnames.ora
- Fix: use get_oracle_connection() instead of direct pool.acquire()
- Fix: CRM_POLITICI_PRET_ART INSERT to match actual table schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:08:09 +02:00
97699fa0e5 feat(dashboard): add logs page, pagination, quick mapping modal, price pre-validation
- Add /logs page with per-order sync run details, filters (Toate/Importate/Fara Mapare/Erori)
- Add price pre-validation (validate_prices + ensure_prices) to prevent ORA-20000 on direct articles
- Add find_new_orders() to detect orders not yet in Oracle COMENZI
- Extend missing_skus table with order context (order_count, order_numbers, customers)
- Add server-side pagination on /api/validate/missing-skus and /missing-skus page
- Replace confusing "Skip"/"Err" with "Fara Mapare"/"Erori" terminology
- Add inline mapping modal on dashboard (replaces navigation to /mappings)
- Add 2-row stat cards: orders (Comenzi Noi/Ready/Importate/Fara Mapare/Erori) + articles
- Add ID_POL/ID_GESTIUNE/ID_SECTIE to config.py and .env
- Update .gitignore (venv, *.db, api/api/, logs/)
- 33/33 unit tests pass, E2E verified with Playwright

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:59:08 +02:00
72 changed files with 27411 additions and 5159 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
description: Oracle PL/SQL specialist for database scripts, packages, and schema changes in the ROA ERP system
model: opus
model: sonnet
---
# Oracle DBA Agent

View File

@@ -1,7 +1,7 @@
---
name: python-backend
description: FastAPI backend developer for services, routes, Oracle/SQLite integration, and API logic
model: opus
model: sonnet
---
# 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
```

23
.gitignore vendored
View File

@@ -8,6 +8,8 @@
*.err
*.ERR
*.log
/screenshots
/.playwright-mcp
# Python
__pycache__/
@@ -22,7 +24,26 @@ __pycache__/
# Settings files with secrets
settings.ini
vfp/settings.ini
vfp/output/
.gittoken
output/
vfp/*.json
*.~pck
.claude/HANDOFF.md
scripts/work/
# Virtual environments
venv/
.venv/
# SQLite databases
*.db
*.db-journal
*.db-wal
*.db-shm
# Generated/duplicate directories
api/api/
# Logs directory
logs/
.gstack/

286
CLAUDE.md
View File

@@ -1,270 +1,60 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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
- ✅ 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)
## Implementare cu TeamCreate
## Architecture
**OBLIGATORIU:** Folosim TeamCreate + TaskCreate, NU Agent tool cu subagenti paraleli. Skill-ul `superpowers:dispatching-parallel-agents` NU se aplica in acest proiect.
```
[Web Platform API] → [VFP Orchestrator] → [Oracle PL/SQL] → [Web Admin Interface]
↓ ↓ ↑ ↑
JSON Orders Process & Log Store/Update Configuration
```
### 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
- Team lead citeste TOATE fisierele implicate, creeaza planul
- **ASTEAPTA aprobare explicita** de la user inainte de implementare
- Task-uri pe fisiere non-overlapping (evita conflicte)
- Cache-bust static assets (`?v=N`) la fiecare schimbare UI
## Development Commands
### Database Setup
```bash
# Start Oracle container
docker-compose up -d
# INTOTDEAUNA via start.sh (seteaza Oracle env vars)
./start.sh
# NU folosi uvicorn direct — lipsesc LD_LIBRARY_PATH si TNS_ADMIN
# Run database scripts in order
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @01_create_table.sql
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @02_import_parteneri.sql
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @03_import_comenzi.sql
# Tests
python api/test_app_basic.py # fara Oracle
python api/test_integration.py # cu Oracle
```
### VFP Development
```foxpro
DO vfp/gomag-vending.prg
```
## Reguli critice (nu le incalca)
### FastAPI Admin/Dashboard
```bash
cd api
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
```
### Flux import comenzi
1. Download GoMag API → JSON → parse → validate SKU-uri → import Oracle
2. Ordinea: **parteneri** (cauta/creeaza) → **adrese****comanda****factura cache**
3. SKU lookup: ARTICOLE_TERTI (mapped) are prioritate fata de NOM_ARTICOLE (direct)
4. Complex sets: un SKU → multiple CODMAT-uri cu `procent_pret` (trebuie sa fie sum=100%)
5. Comenzi anulate (GoMag statusId=7): verifica daca au factura inainte de stergere din Oracle
### Testare
```bash
python api/test_app_basic.py # Test A - fara Oracle
python api/test_integration.py # Test C - cu Oracle
```
### Statusuri comenzi
`IMPORTED` / `ALREADY_IMPORTED` / `SKIPPED` / `ERROR` / `CANCELLED` / `DELETED_IN_ROA`
- Upsert: `IMPORTED` existent NU se suprascrie cu `ALREADY_IMPORTED`
- 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
```
/
├── api/ # ✅ Flask Admin & Database
│ ├── 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
```
### Preturi
- Dual policy: articolele sunt rutate la `id_pol_vanzare` sau `id_pol_productie` pe baza contului contabil (341/345 = productie)
- Daca pretul lipseste, se insereaza automat pret=0
## 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)
```env
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_PASSWORD=********
ORACLE_DSN=ROA_ROMFAST
TNS_ADMIN=/app
INSTANTCLIENTPATH=/opt/oracle/instantclient
```
## Deploy Windows
### Business Rules
#### 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
Vezi [README.md](README.md#deploy-windows)

432
README.md
View File

@@ -5,29 +5,44 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
## Arhitectura
```
[GoMag API] → [VFP Orchestrator] → [Oracle PL/SQL] → [FastAPI Admin]
↑ ↑
JSON Orders Process & Log Store/Update Dashboard + Config
[GoMag API] → [Python Sync Service] → [Oracle PL/SQL] → [FastAPI Admin]
↑ ↑
JSON Orders Download/Parse/Import Store/Update Dashboard + Config
```
### Stack Tehnologic
- **Database:** Oracle PL/SQL packages (PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI)
- **Integrare:** Visual FoxPro 9 (gomag-vending.prg, sync-comenzi-web.prg)
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local)
- **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
- **GoMag Integration:** Python (`gomag_client.py` — download comenzi cu paginare)
- **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
- **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking)
---
## Quick Start
### Prerequisite
- Python 3.10+
- Oracle Instant Client (optional - suporta si thin mode)
- Oracle Instant Client 21.x (optional suporta si thin mode pentru Oracle 12.1+)
### Instalare
### Instalare si pornire
```bash
cd api
pip install -r requirements.txt
# Configureaza .env (vezi api/.env.example)
uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
pip install -r api/requirements.txt
cp api/.env.example api/.env
# Editeaza api/.env cu datele de conectare Oracle
```
### Pornire server
**Important:** serverul trebuie pornit **din project root**, nu din `api/`:
```bash
python -m uvicorn api.app.main:app --host 0.0.0.0 --port 5003
```
Sau folosind scriptul inclus:
```bash
./start.sh
```
Deschide `http://localhost:5003` in browser.
@@ -36,93 +51,358 @@ Deschide `http://localhost:5003` in browser.
**Test A - Basic (fara Oracle):**
```bash
cd api
python test_app_basic.py
python api/test_app_basic.py
```
Verifica 17 importuri de module + 13 rute GET. Asteptat: 30/30 PASS.
**Test C - Integrare Oracle:**
```bash
python api/test_integration.py
```
Necesita Oracle activ. Verifica health, mappings CRUD, article search, validation, sync. Asteptat: 9/9 PASS.
---
## Configurare (.env)
Copiaza `.env.example` si completeaza:
```bash
cp api/.env.example api/.env
```
| Variabila | Descriere | Exemplu |
|-----------|-----------|---------|
| `ORACLE_USER` | User Oracle | `MARIUSM_AUTO` |
| `ORACLE_PASSWORD` | Parola Oracle | `secret` |
| `ORACLE_DSN` | TNS alias | `ROA_CENTRAL` |
| `TNS_ADMIN` | Cale absoluta la tnsnames.ora | `/mnt/e/.../gomag/api` |
| `INSTANTCLIENTPATH` | Cale Instant Client (thick mode) | `/opt/oracle/instantclient_21_15` |
| `FORCE_THIN_MODE` | Thin mode fara Instant Client | `true` |
| `SQLITE_DB_PATH` | Path SQLite (relativ la project root) | `api/data/import.db` |
| `JSON_OUTPUT_DIR` | Folder JSON-uri descarcate | `api/data/orders` |
| `APP_PORT` | Port HTTP | `5003` |
| `ID_POL` | ID Politica ROA | `39` |
| `ID_GESTIUNE` | ID Gestiune ROA | `0` |
| `ID_SECTIE` | ID Sectie ROA | `6` |
**Nota Oracle mode:**
- **Thick mode** (Oracle 10g/11g): seteaza `INSTANTCLIENTPATH`
- **Thin mode** (Oracle 12.1+): seteaza `FORCE_THIN_MODE=true`, sterge `INSTANTCLIENTPATH`
---
## Structura Proiect
```
/
├── api/ # FastAPI Admin + Database
│ ├── app/ # Aplicatia FastAPI
gomag-vending/
├── api/ # FastAPI Admin + Dashboard
│ ├── app/
│ │ ├── main.py # Entry point, lifespan, logging
│ │ ├── config.py # Settings (pydantic-settings, .env)
│ │ ├── database.py # Oracle pool + SQLite init
│ │ ├── config.py # Settings (pydantic-settings + .env)
│ │ ├── database.py # Oracle pool + SQLite schema + migrari
│ │ ├── routers/ # Endpoint-uri HTTP
│ │ │ ├── health.py # /health, /api/health
│ │ │ ├── dashboard.py # / (dashboard HTML)
│ │ │ ├── health.py # GET /health
│ │ │ ├── dashboard.py # GET / (HTML) + /settings (HTML)
│ │ │ ├── mappings.py # /mappings, /api/mappings
│ │ │ ├── articles.py # /api/articles/search
│ │ │ ├── validation.py # /api/validate/*
│ │ │ └── sync.py # /api/sync/*
│ │ ├── services/ # Business logic
│ │ │ ├── mapping_service # CRUD ARTICOLE_TERTI
│ │ │ ├── article_service # Cautare NOM_ARTICOLE
│ │ │ ├── import_service # Import comanda in Oracle
│ │ │ ├── sync_service # Orchestrare: JSON→validate→import
│ │ │ ├── validation_service # Validare SKU-uri
│ │ │ ├── order_reader # Citire JSON-uri din vfp/output/
│ │ │ ├── sqlite_service # Tracking runs/orders/missing SKUs
│ │ │ ── scheduler_service # APScheduler timer
│ │ ├── templates/ # Jinja2 HTML (dashboard, mappings, etc.)
│ │ └── static/ # CSS + JS
│ ├── database-scripts/ # Oracle SQL scripts
│ │ │ └── sync.py # /api/sync/* + /api/dashboard/* + /api/settings
│ │ ├── services/
│ │ │ ├── gomag_client.py # Download comenzi GoMag API
│ │ │ ├── sync_service.py # Orchestrare: download→validate→import
│ │ │ ├── import_service.py # Import comanda in Oracle ROA
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
│ │ │ ├── validation_service.py
│ │ │ ── article_service.py
│ │ │ ├── invoice_service.py # Verificare facturi ROA
│ │ └── scheduler_service.py # APScheduler timer
│ ├── templates/ # Jinja2 (dashboard, mappings, missing_skus, logs, settings)
│ │ └── static/ # CSS (style.css) + JS (dashboard, logs, mappings, settings, shared)
│ ├── database-scripts/ # Oracle SQL (ARTICOLE_TERTI, packages)
│ ├── data/ # SQLite DB (import.db) + JSON orders
│ ├── .env # Configurare locala (nu in git)
│ ├── .env.example # Template configurare
│ ├── test_app_basic.py # Test A - fara Oracle
│ ├── test_integration.py # Test C - cu Oracle
│ └── requirements.txt # Python dependencies
├── vfp/ # VFP Integration
│ ├── gomag-vending.prg # Client GoMag API
├── sync-comenzi-web.prg # Orchestrator VFP
│ └── utils.prg # Utilitare VFP
── docs/ # Documentatie
│ ├── PRD.md # Product Requirements
│ └── stories/ # User Stories
└── logs/ # Log-uri aplicatie
│ └── requirements.txt
├── logs/ # Log-uri aplicatie (sync_comenzi_*.log)
├── docs/ # Documentatie (PRD, stories)
├── screenshots/ # Before/preview/after pentru UI changes
├── start.sh # Script pornire (Linux/WSL)
── CLAUDE.md # Instructiuni pentru AI assistants
```
## Configurare (.env)
---
```env
ORACLE_USER=MARIUSM_AUTO
ORACLE_PASSWORD=********
ORACLE_DSN=ROA_CENTRAL
FORCE_THIN_MODE=true # sau INSTANTCLIENTPATH=C:\oracle\instantclient
SQLITE_DB_PATH=data/import.db
## Dashboard Features
### Sync Panel
- Start sync manual sau scheduler automat (5/10/30 min)
- Progress live: `"Import 45/80: #CMD-1234 Ion Popescu"`
- Smart polling: 30s idle → 3s cand ruleaza → auto-refresh tabela
- Last sync clickabil → jurnal detaliat
### Comenzi
- Filtru perioada: 3z / 7z / 30z / 3 luni / toate / custom
- Status pills cu conturi totale pe perioada (nu per-pagina)
- Cautare integrata in bara de filtre
- Coloana Client cu tooltip `▲` cand persoana livrare ≠ facturare
- Paginare sus + jos, selector rezultate per pagina (25/50/100/250)
### Mapari SKU
- Badge `✓ 100%` / `⚠ 80%` per grup SKU
- Filtru Complete / Incomplete
- Verificare duplicat SKU-CODMAT (409 cu optiune de restaurare)
### SKU-uri Lipsa
- Cautare dupa SKU sau nume produs
- Filtru Nerezolvate / Rezolvate / Toate cu conturi
- Re-scan cu progress inline si banner rezultat
---
## Fluxul de Import
```
1. gomag_client.py descarca comenzi GoMag API → JSON files (paginat)
2. order_reader.py parseaza JSON-urile, sorteaza cronologic (cele mai vechi primele)
3. Comenzi anulate (GoMag statusId=7) → separate, sterse din Oracle daca nu au factura
4. validation_service.py valideaza SKU-uri: ARTICOLE_TERTI (mapped) → NOM_ARTICOLE (direct) → missing
5. Verificare existenta in Oracle (COMENZI by date range) → deja importate se sar
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
**Parteneri & Adrese:**
- Prioritate partener: daca exista **companie** in GoMag (billing.company_name) → firma (PJ, cod_fiscal + registru). Altfel → persoana fizica, cu **shipping name** ca nume partener
- Adresa livrare: intotdeauna din GoMag shipping
- 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
---
## Facturi & Cache
Facturile sunt verificate live din Oracle si cacate in SQLite (`factura_*` pe tabelul `orders`).
### Sursa Oracle
```sql
SELECT id_comanda, numar_act, serie_act,
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
LOG_LEVEL=INFO
JSON_OUTPUT_DIR=../vfp/output
ID_POL=39
ID_GESTIUNE=0
ID_SECTIE=6
GOMAG_API_KEY=...
GOMAG_API_SHOP=...
GOMAG_ORDER_DAYS_BACK=7
GOMAG_LIMIT=100
```
## Status Implementare
**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
### Phase 1: Database Foundation - COMPLET
- ARTICOLE_TERTI table + Docker setup
- PACK_IMPORT_PARTENERI package
- PACK_IMPORT_COMENZI package
### Serviciu Windows (NSSM)
### Phase 2: VFP Integration - COMPLET
- gomag-vending.prg (GoMag API client)
- sync-comenzi-web.prg (orchestrator cu logging)
```powershell
nssm restart GoMagVending # restart serviciu
nssm status GoMagVending # status serviciu
nssm stop GoMagVending # stop serviciu
nssm start GoMagVending # start serviciu
```
### Phase 3-4: FastAPI Admin + Dashboard - COMPLET
- Mappings CRUD + CSV import/export
- Article autocomplete (NOM_ARTICOLE)
- Pre-validation SKU-uri
- Import orchestration (JSON→Oracle)
- Dashboard cu stat cards, sync control, history
- Missing SKUs management page
- File logging (logs/sync_comenzi_*.log)
Loguri serviciu: `logs/service_stdout.log`, `logs/service_stderr.log`
Loguri aplicatie: `logs/sync_comenzi_*.log`
### Phase 5: Production - IN PROGRESS
- [x] File logging
- [ ] Email notifications (SMTP)
- [ ] HTTP Basic Auth
- [ ] NSSM Windows service
**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
- `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual
- Serverul trebuie pornit din **project root**, nu din `api/`
- `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

@@ -1,15 +1,86 @@
# Oracle Database Configuration
ORACLE_USER=YOUR_ORACLE_USERNAME
ORACLE_PASSWORD=YOUR_ORACLE_PASSWORD
ORACLE_DSN=YOUR_TNS_CONNECTION_NAME
TNS_ADMIN=/app
INSTANTCLIENTPATH=/opt/oracle/instantclient_21_1
# =============================================================================
# GoMag Import Manager - Configurare
# Copiaza in api/.env si completeaza cu datele reale
# =============================================================================
# Flask Configuration
FLASK_ENV=development
FLASK_DEBUG=1
PYTHONUNBUFFERED=1
# =============================================================================
# ORACLE MODE - Alege una din urmatoarele doua optiuni:
# =============================================================================
# Application Settings
APP_PORT=5000
LOG_LEVEL=DEBUG
# THICK MODE (Oracle 10g/11g/12.1+) - Recomandat pentru compatibilitate maxima
# Necesita Oracle Instant Client instalat
INSTANTCLIENTPATH=/opt/oracle/instantclient_21_15
# THIN MODE (Oracle 12.1+ only) - Fara Instant Client, mai simplu
# Comenteaza INSTANTCLIENTPATH de sus si decommenteaza urmatoarea linie:
# FORCE_THIN_MODE=true
# =============================================================================
# ORACLE - Credentiale baza de date
# =============================================================================
ORACLE_USER=USER_ORACLE
ORACLE_PASSWORD=parola_oracle
ORACLE_DSN=TNS_ALIAS
# Calea absoluta la directorul cu tnsnames.ora
# De obicei: directorul api/ al proiectului
TNS_ADMIN=/cale/absoluta/la/gomag/api
# =============================================================================
# APLICATIE
# =============================================================================
APP_PORT=5003
LOG_LEVEL=INFO
# =============================================================================
# CALE FISIERE
# Relative: JSON_OUTPUT_DIR la project root, SQLITE_DB_PATH la api/
# Se pot folosi si cai absolute
# =============================================================================
# JSON-uri comenzi GoMag
JSON_OUTPUT_DIR=output
# SQLite tracking DB
SQLITE_DB_PATH=data/import.db
# =============================================================================
# ROA - Setari import comenzi (din vfp/settings.ini sectiunea [ROA])
# =============================================================================
# Politica de pret
ID_POL=39
# Gestiune implicita
ID_GESTIUNE=0
# Sectie implicita
ID_SECTIE=6
# =============================================================================
# GoMag API
# =============================================================================
GOMAG_API_KEY=your_api_key_here
GOMAG_API_SHOP=https://yourstore.gomag.ro
GOMAG_ORDER_DAYS_BACK=7
GOMAG_LIMIT=100
# =============================================================================
# SMTP - Notificari email (optional)
# =============================================================================
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=email@exemplu.com
# SMTP_PASSWORD=parola_app
# SMTP_TO=destinatar@exemplu.com
# =============================================================================
# AUTH - HTTP Basic Auth pentru dashboard (optional)
# =============================================================================
# API_USERNAME=admin
# API_PASSWORD=parola_sigura

View File

@@ -1,7 +1,13 @@
from pydantic_settings import BaseSettings
from pydantic import model_validator
from pathlib import Path
import os
# Anchored paths - independent of CWD
_api_root = Path(__file__).resolve().parent.parent # .../gomag/api/
_project_root = _api_root.parent # .../gomag/
_env_path = _api_root / ".env"
class Settings(BaseSettings):
# Oracle
ORACLE_USER: str = "MARIUSM_AUTO"
@@ -12,12 +18,12 @@ class Settings(BaseSettings):
TNS_ADMIN: str = ""
# SQLite
SQLITE_DB_PATH: str = str(Path(__file__).parent.parent / "data" / "import.db")
SQLITE_DB_PATH: str = "data/import.db"
# App
APP_PORT: int = 5003
LOG_LEVEL: str = "INFO"
JSON_OUTPUT_DIR: str = ""
JSON_OUTPUT_DIR: str = "output"
# SMTP (optional)
SMTP_HOST: str = ""
@@ -30,6 +36,28 @@ class Settings(BaseSettings):
API_USERNAME: str = ""
API_PASSWORD: str = ""
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
# ROA Import Settings
ID_POL: int = 0
ID_SECTIE: int = 0
# GoMag API
GOMAG_API_KEY: str = ""
GOMAG_API_SHOP: str = ""
GOMAG_ORDER_DAYS_BACK: int = 7
GOMAG_LIMIT: int = 100
GOMAG_API_URL: str = "https://api.gomag.ro/api/v1/order/read/json"
@model_validator(mode="after")
def resolve_paths(self):
"""Resolve relative paths against known roots, independent of CWD."""
# SQLITE_DB_PATH: relative to api/ root
if self.SQLITE_DB_PATH and not os.path.isabs(self.SQLITE_DB_PATH):
self.SQLITE_DB_PATH = str(_api_root / self.SQLITE_DB_PATH)
# JSON_OUTPUT_DIR: relative to project root
if self.JSON_OUTPUT_DIR and not os.path.isabs(self.JSON_OUTPUT_DIR):
self.JSON_OUTPUT_DIR = str(_project_root / self.JSON_OUTPUT_DIR)
return self
model_config = {"env_file": str(_env_path), "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings()

View File

@@ -19,6 +19,12 @@ def init_oracle():
instantclient_path = settings.INSTANTCLIENTPATH
dsn = settings.ORACLE_DSN
# Ensure TNS_ADMIN is set as OS env var so oracledb can find tnsnames.ora
if 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:
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
elif instantclient_path:
@@ -68,22 +74,53 @@ CREATE TABLE IF NOT EXISTS sync_runs (
imported INTEGER DEFAULT 0,
skipped INTEGER DEFAULT 0,
errors INTEGER DEFAULT 0,
json_files INTEGER DEFAULT 0
json_files INTEGER DEFAULT 0,
error_message TEXT,
already_imported INTEGER DEFAULT 0,
new_imported INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS import_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_run_id TEXT REFERENCES sync_runs(run_id),
order_number TEXT,
order_date TEXT,
customer_name TEXT,
status TEXT,
id_comanda INTEGER,
id_partener INTEGER,
error_message TEXT,
missing_skus TEXT,
items_count INTEGER,
created_at TEXT DEFAULT (datetime('now'))
CREATE TABLE IF NOT EXISTS orders (
order_number TEXT PRIMARY KEY,
order_date TEXT,
customer_name TEXT,
status TEXT,
id_comanda INTEGER,
id_partener INTEGER,
id_adresa_facturare INTEGER,
id_adresa_livrare INTEGER,
error_message TEXT,
missing_skus TEXT,
items_count INTEGER,
times_skipped INTEGER DEFAULT 0,
first_seen_at TEXT DEFAULT (datetime('now')),
last_sync_run_id TEXT REFERENCES sync_runs(run_id),
updated_at TEXT DEFAULT (datetime('now')),
shipping_name TEXT,
billing_name TEXT,
payment_method TEXT,
delivery_method TEXT,
factura_serie TEXT,
factura_numar TEXT,
factura_total_fara_tva REAL,
factura_total_tva REAL,
factura_total_cu_tva REAL,
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_date ON orders(order_date);
CREATE TABLE IF NOT EXISTS sync_run_orders (
sync_run_id TEXT REFERENCES sync_runs(run_id),
order_number TEXT REFERENCES orders(order_number),
status_at_run TEXT,
PRIMARY KEY (sync_run_id, order_number)
);
CREATE TABLE IF NOT EXISTS missing_skus (
@@ -91,13 +128,45 @@ CREATE TABLE IF NOT EXISTS missing_skus (
product_name TEXT,
first_seen TEXT DEFAULT (datetime('now')),
resolved INTEGER DEFAULT 0,
resolved_at TEXT
resolved_at TEXT,
order_count INTEGER DEFAULT 0,
order_numbers TEXT,
customers TEXT
);
CREATE TABLE IF NOT EXISTS scheduler_config (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS web_products (
sku TEXT PRIMARY KEY,
product_name TEXT,
first_seen TEXT DEFAULT (datetime('now')),
last_seen TEXT DEFAULT (datetime('now')),
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 (
order_number TEXT,
sku TEXT,
product_name TEXT,
quantity REAL,
price REAL,
vat REAL,
mapping_status TEXT,
codmat TEXT,
id_articol INTEGER,
cantitate_roa REAL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (order_number, sku)
);
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number);
"""
_sqlite_db_path = None
@@ -114,7 +183,152 @@ def init_sqlite():
# Create tables synchronously
conn = sqlite3.connect(_sqlite_db_path)
# Check existing tables before running schema
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in cursor.fetchall()}
# Migration: import_orders → orders (one row per order)
if 'import_orders' in existing_tables and 'orders' not in existing_tables:
logger.info("Migrating import_orders → orders schema...")
conn.executescript("""
CREATE TABLE orders (
order_number TEXT PRIMARY KEY,
order_date TEXT,
customer_name TEXT,
status TEXT,
id_comanda INTEGER,
id_partener INTEGER,
id_adresa_facturare INTEGER,
id_adresa_livrare INTEGER,
error_message TEXT,
missing_skus TEXT,
items_count INTEGER,
times_skipped INTEGER DEFAULT 0,
first_seen_at TEXT DEFAULT (datetime('now')),
last_sync_run_id TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
CREATE TABLE sync_run_orders (
sync_run_id TEXT,
order_number TEXT,
status_at_run TEXT,
PRIMARY KEY (sync_run_id, order_number)
);
""")
# Copy latest record per order_number into orders
# Note: old import_orders didn't have address columns — those stay NULL
conn.execute("""
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus,
items_count, last_sync_run_id)
SELECT io.order_number, io.order_date, io.customer_name, io.status,
io.id_comanda, io.id_partener, io.error_message, io.missing_skus,
io.items_count, io.sync_run_id
FROM import_orders io
INNER JOIN (
SELECT order_number, MAX(id) as max_id
FROM import_orders
GROUP BY order_number
) latest ON io.id = latest.max_id
""")
# Populate sync_run_orders from all import_orders rows
conn.execute("""
INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run)
SELECT sync_run_id, order_number, status
FROM import_orders
WHERE sync_run_id IS NOT NULL
""")
# Migrate order_items: drop sync_run_id, change PK to (order_number, sku)
if 'order_items' in existing_tables:
conn.executescript("""
CREATE TABLE order_items_new (
order_number TEXT,
sku TEXT,
product_name TEXT,
quantity REAL,
price REAL,
vat REAL,
mapping_status TEXT,
codmat TEXT,
id_articol INTEGER,
cantitate_roa REAL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (order_number, sku)
);
INSERT OR IGNORE INTO order_items_new
(order_number, sku, product_name, quantity, price, vat,
mapping_status, codmat, id_articol, cantitate_roa, created_at)
SELECT order_number, sku, product_name, quantity, price, vat,
mapping_status, codmat, id_articol, cantitate_roa, created_at
FROM order_items;
DROP TABLE order_items;
ALTER TABLE order_items_new RENAME TO order_items;
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_number);
""")
# Rename old table instead of dropping (safety backup)
conn.execute("ALTER TABLE import_orders RENAME TO import_orders_bak")
conn.commit()
logger.info("Migration complete: import_orders → orders")
conn.executescript(SQLITE_SCHEMA)
# Migrate: add columns if missing (for existing databases)
try:
cursor = conn.execute("PRAGMA table_info(missing_skus)")
cols = {row[1] for row in cursor.fetchall()}
for col, typedef in [("order_count", "INTEGER DEFAULT 0"),
("order_numbers", "TEXT"),
("customers", "TEXT")]:
if col not in cols:
conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}")
logger.info(f"Migrated missing_skus: added column {col}")
# Migrate sync_runs: add columns
cursor = conn.execute("PRAGMA table_info(sync_runs)")
sync_cols = {row[1] for row in cursor.fetchall()}
if "error_message" not in sync_cols:
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
logger.info("Migrated sync_runs: added column error_message")
if "already_imported" not in sync_cols:
conn.execute("ALTER TABLE sync_runs ADD COLUMN already_imported INTEGER DEFAULT 0")
logger.info("Migrated sync_runs: added column already_imported")
if "new_imported" not in sync_cols:
conn.execute("ALTER TABLE sync_runs ADD COLUMN new_imported INTEGER DEFAULT 0")
logger.info("Migrated sync_runs: added column new_imported")
# Migrate orders: add shipping/billing/payment/delivery + invoice columns
cursor = conn.execute("PRAGMA table_info(orders)")
order_cols = {row[1] for row in cursor.fetchall()}
for col, typedef in [
("shipping_name", "TEXT"),
("billing_name", "TEXT"),
("payment_method", "TEXT"),
("delivery_method", "TEXT"),
("factura_serie", "TEXT"),
("factura_numar", "TEXT"),
("factura_total_fara_tva", "REAL"),
("factura_total_tva", "REAL"),
("factura_total_cu_tva", "REAL"),
("factura_data", "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:
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
logger.info(f"Migrated orders: added column {col}")
conn.commit()
except Exception as e:
logger.warning(f"Migration check failed: {e}")
conn.close()
logger.info(f"SQLite initialized: {_sqlite_db_path}")

View File

@@ -15,3 +15,7 @@ async def dashboard(request: Request):
@router.get("/missing-skus", response_class=HTMLResponse)
async def missing_skus_page(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

@@ -1,13 +1,17 @@
from fastapi import APIRouter, Query, Request, UploadFile, File
from fastapi.responses import StreamingResponse, HTMLResponse
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from fastapi import HTTPException
from pydantic import BaseModel, validator
from pathlib import Path
from typing import Optional
import io
from ..services import mapping_service, sqlite_service
import logging
logger = logging.getLogger(__name__)
router = APIRouter(tags=["mappings"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@@ -17,11 +21,39 @@ class MappingCreate(BaseModel):
cantitate_roa: float = 1
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):
cantitate_roa: Optional[float] = None
procent_pret: Optional[float] = None
activ: Optional[int] = None
class MappingEdit(BaseModel):
new_sku: str
new_codmat: str
cantitate_roa: float = 1
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):
codmat: str
cantitate_roa: float = 1
procent_pret: float = 100
class MappingBatchCreate(BaseModel):
sku: str
mappings: list[MappingLine]
auto_restore: bool = False
# HTML page
@router.get("/mappings", response_class=HTMLResponse)
async def mappings_page(request: Request):
@@ -29,8 +61,22 @@ async def mappings_page(request: Request):
# API endpoints
@router.get("/api/mappings")
def list_mappings(search: str = "", page: int = 1, per_page: int = 50):
return mapping_service.get_mappings(search=search, page=page, per_page=per_page)
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False, pct_filter: str = None):
result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
sort_by=sort_by, sort_dir=sort_dir,
show_deleted=show_deleted,
pct_filter=pct_filter)
# Merge product names from web_products (R4)
skus = list({m["sku"] for m in result.get("mappings", [])})
product_names = await sqlite_service.get_web_products_batch(skus)
for m in result.get("mappings", []):
m["product_name"] = product_names.get(m["sku"], "")
# Ensure counts key is always present
if "counts" not in result:
result["counts"] = {"total": 0, "complete": 0, "incomplete": 0}
return result
@router.post("/api/mappings")
async def create_mapping(data: MappingCreate):
@@ -39,6 +85,12 @@ async def create_mapping(data: MappingCreate):
# Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku)
return {"success": True, **result}
except HTTPException as e:
can_restore = e.headers.get("X-Can-Restore") == "true" if e.headers else False
resp: dict = {"error": e.detail}
if can_restore:
resp["can_restore"] = True
return JSONResponse(status_code=e.status_code, content=resp)
except Exception as e:
return {"success": False, "error": str(e)}
@@ -50,6 +102,15 @@ def update_mapping(sku: str, codmat: str, data: MappingUpdate):
except Exception as e:
return {"success": False, "error": str(e)}
@router.put("/api/mappings/{sku}/{codmat}/edit")
def edit_mapping(sku: str, codmat: str, data: MappingEdit):
try:
result = mapping_service.edit_mapping(sku, codmat, data.new_sku, data.new_codmat,
data.cantitate_roa, data.procent_pret)
return {"success": result}
except Exception as e:
return {"success": False, "error": str(e)}
@router.delete("/api/mappings/{sku}/{codmat}")
def delete_mapping(sku: str, codmat: str):
try:
@@ -58,6 +119,38 @@ def delete_mapping(sku: str, codmat: str):
except Exception as e:
return {"success": False, "error": str(e)}
@router.post("/api/mappings/{sku}/{codmat}/restore")
def restore_mapping(sku: str, codmat: str):
try:
restored = mapping_service.restore_mapping(sku, codmat)
return {"success": restored}
except Exception as e:
return {"success": False, "error": str(e)}
@router.post("/api/mappings/batch")
async def create_batch_mapping(data: MappingBatchCreate):
"""Create multiple (sku, codmat) rows for complex sets (R11)."""
if not data.mappings:
return {"success": False, "error": "No mappings provided"}
# Validate procent_pret sums to 100 for multi-line sets
if len(data.mappings) > 1:
total_pct = sum(m.procent_pret for m in data.mappings)
if abs(total_pct - 100) > 0.01:
return {"success": False, "error": f"Procent pret trebuie sa fie 100% (actual: {total_pct}%)"}
try:
results = []
for m in data.mappings:
r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, m.procent_pret, auto_restore=data.auto_restore)
results.append(r)
# Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku)
return {"success": True, "created": len(results)}
except Exception as e:
return {"success": False, "error": str(e)}
@router.post("/api/mappings/import-csv")
async def import_csv(file: UploadFile = File(...)):
content = await file.read()

View File

@@ -1,3 +1,10 @@
import asyncio
import json
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
@@ -5,7 +12,8 @@ from pydantic import BaseModel
from pathlib import Path
from typing import Optional
from ..services import sync_service, scheduler_service, sqlite_service
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
from .. import database
router = APIRouter(tags=["sync"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
@@ -16,27 +24,36 @@ class ScheduleConfig(BaseModel):
interval_minutes: int = 5
# HTML pages
@router.get("/sync", response_class=HTMLResponse)
async def sync_page(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request})
@router.get("/sync/run/{run_id}", response_class=HTMLResponse)
async def sync_detail_page(request: Request, run_id: str):
return templates.TemplateResponse("sync_detail.html", {"request": request, "run_id": run_id})
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
@router.post("/api/sync/start")
async def start_sync(background_tasks: BackgroundTasks):
"""Trigger a sync run in the background."""
status = await sync_service.get_sync_status()
if status.get("status") == "running":
return {"error": "Sync already running", "run_id": status.get("run_id")}
result = await sync_service.prepare_sync()
if result.get("error"):
return {"error": result["error"], "run_id": result.get("run_id")}
background_tasks.add_task(sync_service.run_sync)
return {"message": "Sync started"}
run_id = result["run_id"]
background_tasks.add_task(sync_service.run_sync, run_id=run_id)
return {"message": "Sync started", "run_id": run_id}
@router.post("/api/sync/stop")
@@ -48,10 +65,72 @@ async def stop_sync():
@router.get("/api/sync/status")
async def sync_status():
"""Get current sync status."""
"""Get current sync status with progress details and last_run info."""
status = await sync_service.get_sync_status()
stats = await sqlite_service.get_dashboard_stats()
return {**status, "stats": stats}
# Build last_run from most recent completed/failed sync_runs row
current_run_id = status.get("run_id")
is_running = status.get("status") == "running"
last_run = None
try:
from ..database import get_sqlite
db = await get_sqlite()
try:
if current_run_id and is_running:
# Only exclude current run while it's actively running
cursor = await db.execute("""
SELECT * FROM sync_runs
WHERE status IN ('completed', 'failed') AND run_id != ?
ORDER BY started_at DESC LIMIT 1
""", (current_run_id,))
else:
cursor = await db.execute("""
SELECT * FROM sync_runs
WHERE status IN ('completed', 'failed')
ORDER BY started_at DESC LIMIT 1
""")
row = await cursor.fetchone()
if row:
row_dict = dict(row)
duration_seconds = None
if row_dict.get("started_at") and row_dict.get("finished_at"):
try:
dt_start = datetime.fromisoformat(row_dict["started_at"])
dt_end = datetime.fromisoformat(row_dict["finished_at"])
duration_seconds = int((dt_end - dt_start).total_seconds())
except (ValueError, TypeError):
pass
last_run = {
"run_id": row_dict.get("run_id"),
"started_at": row_dict.get("started_at"),
"finished_at": row_dict.get("finished_at"),
"duration_seconds": duration_seconds,
"status": row_dict.get("status"),
"imported": row_dict.get("imported", 0),
"skipped": row_dict.get("skipped", 0),
"errors": row_dict.get("errors", 0),
"already_imported": row_dict.get("already_imported", 0),
"new_imported": row_dict.get("new_imported", 0),
}
finally:
await db.close()
except Exception:
pass
# Ensure all expected keys are present
result = {
"status": status.get("status", "idle"),
"run_id": status.get("run_id"),
"started_at": status.get("started_at"),
"finished_at": status.get("finished_at"),
"phase": status.get("phase"),
"phase_text": status.get("phase_text"),
"progress_current": status.get("progress_current", 0),
"progress_total": status.get("progress_total", 0),
"counts": status.get("counts", {"imported": 0, "skipped": 0, "errors": 0}),
"last_run": last_run,
}
return result
@router.get("/api/sync/history")
@@ -60,6 +139,11 @@ async def sync_history(page: int = 1, per_page: int = 20):
return await sqlite_service.get_sync_runs(page, per_page)
@router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request, run: str = None):
return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
@router.get("/api/sync/run/{run_id}")
async def sync_run_detail(run_id: str):
"""Get details for a specific sync run."""
@@ -69,6 +153,467 @@ async def sync_run_detail(run_id: str):
return detail
@router.get("/api/sync/run/{run_id}/log")
async def sync_run_log(run_id: str):
"""Get detailed log per order for a sync run."""
detail = await sqlite_service.get_sync_run_detail(run_id)
if not detail:
return {"error": "Run not found", "status_code": 404}
orders = detail.get("orders", [])
return {
"run_id": run_id,
"run": detail.get("run", {}),
"orders": [
{
"order_number": o.get("order_number"),
"order_date": o.get("order_date"),
"customer_name": o.get("customer_name"),
"items_count": o.get("items_count"),
"status": o.get("status"),
"id_comanda": o.get("id_comanda"),
"id_partener": o.get("id_partener"),
"error_message": o.get("error_message"),
"missing_skus": o.get("missing_skus"),
"order_total": o.get("order_total"),
"factura_numar": o.get("factura_numar"),
"factura_serie": o.get("factura_serie"),
}
for o in orders
]
}
def _format_text_log_from_detail(detail: dict) -> str:
"""Build a text log from SQLite stored data for completed runs."""
run = detail.get("run", {})
orders = detail.get("orders", [])
run_id = run.get("run_id", "?")
started = run.get("started_at", "")
lines = [f"=== Sync Run {run_id} ==="]
if started:
try:
dt = datetime.fromisoformat(started)
lines.append(f"Inceput: {dt.strftime('%d.%m.%Y %H:%M:%S')}")
except (ValueError, TypeError):
lines.append(f"Inceput: {started}")
lines.append("")
for o in orders:
status = (o.get("status") or "").upper()
number = o.get("order_number", "?")
customer = o.get("customer_name", "?")
order_date = o.get("order_date") or "?"
if status == "IMPORTED":
id_cmd = o.get("id_comanda", "?")
lines.append(f"#{number} [{order_date}] {customer} → IMPORTAT (ID: {id_cmd})")
elif status == "ALREADY_IMPORTED":
id_cmd = o.get("id_comanda", "?")
lines.append(f"#{number} [{order_date}] {customer} → DEJA IMPORTAT (ID: {id_cmd})")
elif status == "SKIPPED":
missing = o.get("missing_skus", "")
if isinstance(missing, str):
try:
missing = json.loads(missing)
except (json.JSONDecodeError, TypeError):
missing = [missing] if missing else []
skus_str = ", ".join(missing) if isinstance(missing, list) else str(missing)
lines.append(f"#{number} [{order_date}] {customer} → OMIS (lipsa: {skus_str})")
elif status == "ERROR":
err = o.get("error_message", "necunoscuta")
lines.append(f"#{number} [{order_date}] {customer} → EROARE: {err}")
# Summary line
lines.append("")
total = run.get("total_orders", 0)
imported = run.get("imported", 0)
skipped = run.get("skipped", 0)
errors = run.get("errors", 0)
duration_str = ""
finished = run.get("finished_at", "")
if started and finished:
try:
dt_start = datetime.fromisoformat(started)
dt_end = datetime.fromisoformat(finished)
secs = int((dt_end - dt_start).total_seconds())
duration_str = f" | Durata: {secs}s"
except (ValueError, TypeError):
pass
already = run.get("already_imported", 0)
new_imp = run.get("new_imported", 0)
if already:
lines.append(f"Finalizat: {new_imp} importate, {already} deja importate, {skipped} nemapate, {errors} erori din {total} comenzi{duration_str}")
else:
lines.append(f"Finalizat: {imported} importate, {skipped} nemapate, {errors} erori din {total} comenzi{duration_str}")
return "\n".join(lines)
@router.get("/api/sync/run/{run_id}/text-log")
async def sync_run_text_log(run_id: str):
"""Get text log for a sync run - live from memory or reconstructed from SQLite."""
# Check in-memory first (active/recent runs)
live_log = sync_service.get_run_text_log(run_id)
if live_log is not None:
status = "running"
current = await sync_service.get_sync_status()
if current.get("run_id") != run_id or current.get("status") != "running":
status = "completed"
return {"text": live_log, "status": status, "finished": status != "running"}
# Fall back to SQLite for historical runs
detail = await sqlite_service.get_sync_run_detail(run_id)
if not detail:
return {"error": "Run not found", "text": "", "status": "unknown", "finished": True}
run = detail.get("run", {})
text = _format_text_log_from_detail(detail)
status = run.get("status", "completed")
return {"text": text, "status": status, "finished": True}
@router.get("/api/sync/run/{run_id}/orders")
async def sync_run_orders(run_id: str, status: str = "all", page: int = 1, per_page: int = 50,
sort_by: str = "order_date", sort_dir: str = "desc"):
"""Get filtered, paginated orders for a sync run (R1)."""
return await sqlite_service.get_run_orders_filtered(run_id, status, page, per_page,
sort_by=sort_by, sort_dir=sort_dir)
def _get_articole_terti_for_skus(skus: set) -> dict:
"""Query ARTICOLE_TERTI for all active codmat/cantitate/procent per SKU."""
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 at.sku, at.codmat, at.cantitate_roa, at.procent_pret,
na.denumire
FROM ARTICOLE_TERTI at
LEFT 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
ORDER BY at.sku, at.codmat
""", params)
for row in cur:
sku = row[0]
if sku not in result:
result[sku] = []
result[sku].append({
"codmat": row[1],
"cantitate_roa": float(row[2]) if row[2] else 1,
"procent_pret": float(row[3]) if row[3] else 100,
"denumire": row[4] or ""
})
finally:
database.pool.release(conn)
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}")
async def order_detail(order_number: str):
"""Get order detail with line items (R9), enriched with ARTICOLE_TERTI data."""
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
return {"error": "Order not found"}
# Enrich items with ARTICOLE_TERTI mappings from Oracle
items = detail.get("items", [])
skus = {item["sku"] for item in items if item.get("sku")}
if skus:
codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
for item in items:
sku = item.get("sku")
if sku and sku in codmat_map:
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
@router.get("/api/dashboard/orders")
async def dashboard_orders(page: int = 1, per_page: int = 50,
search: str = "", status: str = "all",
sort_by: str = "order_date", sort_dir: str = "desc",
period_days: int = 7,
period_start: str = "", period_end: str = ""):
"""Get orders for dashboard, enriched with invoice data.
period_days=0 with period_start/period_end uses custom date range.
period_days=0 without dates means all time.
"""
is_uninvoiced_filter = (status == "UNINVOICED")
is_invoiced_filter = (status == "INVOICED")
# For UNINVOICED/INVOICED: fetch all IMPORTED orders, then filter post-invoice-check
fetch_status = "IMPORTED" if (is_uninvoiced_filter or is_invoiced_filter) else status
fetch_per_page = 10000 if (is_uninvoiced_filter or is_invoiced_filter) else per_page
fetch_page = 1 if (is_uninvoiced_filter or is_invoiced_filter) else page
result = await sqlite_service.get_orders(
page=fetch_page, per_page=fetch_per_page, search=search,
status_filter=fetch_status, sort_by=sort_by, sort_dir=sort_dir,
period_days=period_days,
period_start=period_start if period_days == 0 else "",
period_end=period_end if period_days == 0 else "",
)
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
all_orders = result["orders"]
for o in all_orders:
if o.get("factura_numar") and o.get("factura_data"):
# Use cached invoice data from SQLite (only if complete)
o["invoice"] = {
"facturat": True,
"serie_act": o.get("factura_serie"),
"numar_act": o.get("factura_numar"),
"total_fara_tva": o.get("factura_total_fara_tva"),
"total_tva": o.get("factura_total_tva"),
"total_cu_tva": o.get("factura_total_cu_tva"),
"data_act": o.get("factura_data"),
}
else:
o["invoice"] = None
# For orders without cached invoice, check Oracle (only uncached imported orders)
uncached_orders = [o for o in all_orders if o.get("id_comanda") and not o.get("invoice")]
if uncached_orders:
try:
id_comanda_list = [o["id_comanda"] for o in uncached_orders]
invoice_data = await asyncio.to_thread(
invoice_service.check_invoices_for_orders, id_comanda_list
)
for o in uncached_orders:
idc = o.get("id_comanda")
if idc and idc in invoice_data:
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:
pass
# Add shipping/billing name fields + is_different_person flag
s_name = o.get("shipping_name") or ""
b_name = o.get("billing_name") or ""
o["shipping_name"] = s_name
o["billing_name"] = b_name
o["is_different_person"] = bool(s_name and b_name and s_name != b_name)
# Use counts from sqlite_service (already period-scoped)
counts = result.get("counts", {})
# 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))
# For UNINVOICED filter: apply server-side filtering + pagination
if is_uninvoiced_filter:
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
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)
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
# Reshape response
return {
"orders": result["orders"],
"pagination": {
"page": result.get("page", page),
"per_page": result.get("per_page", per_page),
"total_pages": result.get("pages", 0),
},
"counts": counts,
}
@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")
async def update_schedule(config: ScheduleConfig):
"""Update scheduler configuration."""
@@ -88,3 +633,110 @@ async def update_schedule(config: ScheduleConfig):
async def get_schedule():
"""Get current 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,9 +1,10 @@
import csv
import io
from fastapi import APIRouter
import json
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from ..services import order_reader, validation_service
from ..services import order_reader, validation_service, sqlite_service
from ..database import get_sqlite
router = APIRouter(prefix="/api/validate", tags=["validation"])
@@ -14,33 +15,52 @@ async def scan_and_validate():
orders, json_count = order_reader.read_json_orders()
if not orders:
return {"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found"}
return {
"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found",
"total_skus_scanned": 0, "new_missing": 0, "auto_resolved": 0, "unchanged": 0,
}
all_skus = order_reader.get_all_skus(orders)
result = validation_service.validate_skus(all_skus)
importable, skipped = validation_service.classify_orders(orders, result)
# Track missing SKUs in SQLite
db = await get_sqlite()
try:
for sku in result["missing"]:
# Find product name from orders
product_name = ""
for order in orders:
for item in order.items:
if item.sku == sku:
product_name = item.name
break
if product_name:
break
# Build SKU context from skipped orders and track missing SKUs
sku_context = {} # sku -> {order_numbers: [], customers: []}
for order, missing_list in skipped:
customer = order.billing.company_name or f"{order.billing.firstname} {order.billing.lastname}"
for sku in missing_list:
if sku not in sku_context:
sku_context[sku] = {"order_numbers": [], "customers": []}
sku_context[sku]["order_numbers"].append(order.number)
if customer not in sku_context[sku]["customers"]:
sku_context[sku]["customers"].append(customer)
await db.execute("""
INSERT OR IGNORE INTO missing_skus (sku, product_name)
VALUES (?, ?)
""", (sku, product_name))
await db.commit()
finally:
await db.close()
new_missing = 0
for sku in result["missing"]:
# Find product name from orders
product_name = ""
for order in orders:
for item in order.items:
if item.sku == sku:
product_name = item.name
break
if product_name:
break
ctx = sku_context.get(sku, {})
tracked = await sqlite_service.track_missing_sku(
sku=sku,
product_name=product_name,
order_count=len(ctx.get("order_numbers", [])),
order_numbers=json.dumps(ctx.get("order_numbers", [])),
customers=json.dumps(ctx.get("customers", []))
)
if tracked:
new_missing += 1
total_skus_scanned = len(all_skus)
new_missing_count = len(result["missing"])
unchanged = total_skus_scanned - new_missing_count
return {
"json_files": json_count,
@@ -48,11 +68,20 @@ async def scan_and_validate():
"total_skus": len(all_skus),
"importable": len(importable),
"skipped": len(skipped),
"new_orders": len(importable),
# Fields consumed by the rescan progress banner in missing_skus.html
"total_skus_scanned": total_skus_scanned,
"new_missing": new_missing_count,
"auto_resolved": 0,
"unchanged": unchanged,
"skus": {
"mapped": len(result["mapped"]),
"direct": len(result["direct"]),
"missing": len(result["missing"]),
"missing_list": sorted(result["missing"])
"missing_list": sorted(result["missing"]),
"total_skus": len(all_skus),
"mapped_skus": len(result["mapped"]),
"direct_skus": len(result["direct"])
},
"skipped_orders": [
{
@@ -66,27 +95,43 @@ async def scan_and_validate():
}
@router.get("/missing-skus")
async def get_missing_skus():
"""Get all tracked missing SKUs."""
async def get_missing_skus(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
resolved: int = Query(0, ge=-1, le=1),
search: str = Query(None)
):
"""Get paginated missing SKUs. resolved=-1 means show all (R10).
Optional search filters by sku or product_name."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT sku, product_name, first_seen, resolved, resolved_at
FROM missing_skus
ORDER BY resolved ASC, first_seen DESC
""")
rows = await cursor.fetchall()
return {
"missing_skus": [dict(row) for row in rows],
"total": len(rows),
"unresolved": sum(1 for r in rows if not r["resolved"])
}
# Compute counts across ALL records (unfiltered by search)
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus WHERE resolved = 0")
unresolved_count = (await cursor.fetchone())[0]
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus WHERE resolved = 1")
resolved_count = (await cursor.fetchone())[0]
cursor = await db.execute("SELECT COUNT(*) FROM missing_skus")
total_count = (await cursor.fetchone())[0]
finally:
await db.close()
counts = {
"total": total_count,
"unresolved": unresolved_count,
"resolved": resolved_count,
}
result = await sqlite_service.get_missing_skus_paginated(page, per_page, resolved, search=search)
# Backward compat
result["unresolved"] = unresolved_count
result["counts"] = counts
# rename key for JS consistency
result["skus"] = result.get("missing_skus", [])
return result
@router.get("/missing-skus-csv")
async def export_missing_skus_csv():
"""Export missing SKUs as CSV."""
"""Export missing SKUs as CSV compatible with mapping import (R8)."""
db = await get_sqlite()
try:
cursor = await db.execute("""
@@ -100,9 +145,9 @@ async def export_missing_skus_csv():
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["sku", "product_name", "first_seen"])
writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret", "product_name"])
for row in rows:
writer.writerow([row["sku"], row["product_name"], row["first_seen"]])
writer.writerow([row["sku"], "", "", "", row["product_name"] or ""])
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8-sig")),

View File

@@ -15,10 +15,11 @@ def search_articles(query: str, limit: int = 20):
with database.pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT id_articol, codmat, denumire
SELECT id_articol, codmat, denumire, um
FROM nom_articole
WHERE (UPPER(codmat) LIKE UPPER(:q) || '%'
OR UPPER(denumire) LIKE '%' || UPPER(:q) || '%')
AND sters = 0 AND inactiv = 0
AND ROWNUM <= :lim
ORDER BY CASE WHEN UPPER(codmat) LIKE UPPER(:q) || '%' THEN 0 ELSE 1 END, codmat
""", {"q": query, "lim": limit})

View File

@@ -0,0 +1,103 @@
"""GoMag API client - downloads orders and saves them as JSON files."""
import asyncio
import json
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable
import httpx
from ..config import settings
logger = logging.getLogger(__name__)
async def download_orders(
json_dir: str,
days_back: int = None,
api_key: str = None,
api_shop: str = None,
limit: int = None,
log_fn: Callable[[str], None] = None,
) -> dict:
"""Download orders from GoMag API and save as JSON files.
Returns dict with keys: pages, total, files (list of saved file paths).
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):
logger.info(msg)
if log_fn:
log_fn(msg)
effective_key = api_key or settings.GOMAG_API_KEY
effective_shop = api_shop or settings.GOMAG_API_SHOP
effective_limit = limit or settings.GOMAG_LIMIT
if not effective_key or not effective_shop:
_log("GoMag API keys neconfigurați, skip download")
return {"pages": 0, "total": 0, "files": []}
if days_back is None:
days_back = settings.GOMAG_ORDER_DAYS_BACK
start_date = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
out_dir = Path(json_dir)
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 = {
"Apikey": effective_key,
"ApiShop": effective_shop,
"User-Agent": "Mozilla/5.0",
"Content-Type": "application/json",
}
saved_files = []
total_orders = 0
total_pages = 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
async with httpx.AsyncClient(timeout=30) as client:
page = 1
while page <= total_pages:
params = {
"startDate": start_date,
"page": page,
"limit": effective_limit,
}
try:
response = await client.get(settings.GOMAG_API_URL, headers=headers, params=params)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as e:
_log(f"GoMag API eroare pagina {page}: {e}")
break
except Exception as e:
_log(f"GoMag eroare neașteptată pagina {page}: {e}")
break
# Update totals from first page response
if page == 1:
total_orders = int(data.get("total", 0))
total_pages = int(data.get("pages", 1))
_log(f"GoMag: {total_orders} comenzi în {total_pages} pagini (startDate={start_date})")
filename = out_dir / f"gomag_orders_page{page}_{timestamp}.json"
filename.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
saved_files.append(str(filename))
_log(f"GoMag: pagina {page}/{total_pages} salvată → {filename.name}")
page += 1
if page <= total_pages:
await asyncio.sleep(1)
return {"pages": total_pages, "total": total_orders, "files": saved_files}

View File

@@ -44,9 +44,12 @@ def convert_web_date(date_str: str) -> datetime:
if not date_str:
return datetime.now()
try:
return datetime.strptime(date_str[:10], '%Y-%m-%d')
return datetime.strptime(date_str.strip(), '%Y-%m-%d %H:%M:%S')
except ValueError:
return datetime.now()
try:
return datetime.strptime(date_str.strip()[:10], '%Y-%m-%d')
except ValueError:
return datetime.now()
def format_address_for_oracle(address: str, city: str, region: str) -> str:
@@ -57,54 +60,198 @@ def format_address_for_oracle(address: str, city: str, region: str) -> str:
return f"JUD:{region_clean};{city_clean};{address_clean}"
def build_articles_json(items) -> str:
"""Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda."""
def compute_discount_split(order, settings: dict) -> dict | None:
"""Compute proportional discount split by VAT rate from order items.
Returns: {"11": 3.98, "21": 1.43} or None if split not applicable.
Only splits when split_discount_vat is enabled AND multiple VAT rates exist.
When single VAT rate: returns {actual_rate: total} (smarter than GoMag's fixed 21%).
"""
if not order or order.discount_total <= 0:
return None
split_enabled = settings.get("split_discount_vat") == "1"
# Calculate VAT distribution from order items (exclude zero-value)
vat_totals = {}
for item in order.items:
item_value = abs(item.price * item.quantity)
if item_value > 0:
vat_key = str(int(item.vat)) if item.vat == int(item.vat) else str(item.vat)
vat_totals[vat_key] = vat_totals.get(vat_key, 0) + item_value
if not vat_totals:
return None
grand_total = sum(vat_totals.values())
if grand_total <= 0:
return None
if len(vat_totals) == 1:
# Single VAT rate — use that rate (smarter than GoMag's fixed 21%)
actual_vat = list(vat_totals.keys())[0]
return {actual_vat: round(order.discount_total, 2)}
if not split_enabled:
return None
# Multiple VAT rates — split proportionally
result = {}
discount_remaining = order.discount_total
sorted_rates = sorted(vat_totals.keys(), key=lambda x: float(x))
for i, vat_rate in enumerate(sorted_rates):
if i == len(sorted_rates) - 1:
split_amount = round(discount_remaining, 2) # last gets remainder
else:
proportion = vat_totals[vat_rate] / grand_total
split_amount = round(order.discount_total * proportion, 2)
discount_remaining -= split_amount
if split_amount > 0:
result[vat_rate] = split_amount
return result if result else None
def build_articles_json(items, order=None, settings=None) -> str:
"""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 = []
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:
articles.append({
article_dict = {
"sku": item.sku,
"quantity": str(item.quantity),
"price": str(item.price),
"vat": str(item.vat),
"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)
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.
Returns dict with:
success: bool
id_comanda: int or None
id_partener: int or None
id_adresa_facturare: int or None
id_adresa_livrare: int or None
error: str or None
"""
result = {
"success": False,
"id_comanda": None,
"id_partener": None,
"id_adresa_facturare": None,
"id_adresa_livrare": None,
"error": None
}
conn = None
try:
order_number = clean_web_text(order.number)
order_date = convert_web_date(order.date)
logger.info(
f"Order {order.number}: raw date={order.date!r}"
f"parsed={order_date.strftime('%Y-%m-%d %H:%M:%S')}"
)
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Step 1: Process partner
if database.pool is None:
raise RuntimeError("Oracle pool not initialized")
conn = database.pool.acquire()
with conn.cursor() as cur:
# Step 1: Process partner — use shipping person data for name
id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
if order.billing.is_company:
denumire = clean_web_text(order.billing.company_name)
denumire = clean_web_text(order.billing.company_name).upper()
cod_fiscal = clean_web_text(order.billing.company_code) or None
registru = clean_web_text(order.billing.company_reg) or None
is_pj = 1
else:
denumire = clean_web_text(
f"{order.billing.firstname} {order.billing.lastname}"
)
# Use shipping person for partner name (person on shipping label)
if order.shipping and (order.shipping.lastname or order.shipping.firstname):
denumire = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).upper()
else:
denumire = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).upper()
cod_fiscal = None
registru = None
is_pj = 0
@@ -120,20 +267,31 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
result["id_partener"] = int(partner_id)
# Step 2: Process billing address
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
billing_addr = format_address_for_oracle(
order.billing.address, order.billing.city, order.billing.region
# Determine if billing and shipping are different persons
billing_name = clean_web_text(
f"{order.billing.lastname} {order.billing.firstname}"
).strip().upper()
shipping_name = ""
if order.shipping:
shipping_name = clean_web_text(
f"{order.shipping.lastname} {order.shipping.firstname}"
).strip().upper()
different_person = bool(
shipping_name and billing_name and shipping_name != billing_name
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, billing_addr,
order.billing.phone or "",
order.billing.email or "",
id_adresa_fact
])
addr_fact_id = id_adresa_fact.getvalue()
# Step 3: Process shipping address (if different)
# Step 2: Process shipping address (primary — person on shipping label)
# Use shipping person phone/email for partner contact
shipping_phone = ""
shipping_email = ""
if order.shipping:
shipping_phone = order.shipping.phone or ""
shipping_email = order.shipping.email or ""
if not shipping_phone:
shipping_phone = order.billing.phone or ""
if not shipping_email:
shipping_email = order.billing.email or ""
addr_livr_id = None
if order.shipping:
id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
@@ -143,14 +301,37 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, shipping_addr,
order.shipping.phone or "",
order.shipping.email or "",
shipping_phone,
shipping_email,
id_adresa_livr
])
addr_livr_id = id_adresa_livr.getvalue()
# Step 3: Process billing address
if different_person:
# Different person: use shipping address for BOTH billing and shipping in ROA
addr_fact_id = addr_livr_id
else:
# Same person: use billing address as-is
id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
billing_addr = format_address_for_oracle(
order.billing.address, order.billing.city, order.billing.region
)
cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
partner_id, billing_addr,
order.billing.phone or "",
order.billing.email or "",
id_adresa_fact
])
addr_fact_id = id_adresa_fact.getvalue()
if addr_fact_id is not None:
result["id_adresa_facturare"] = int(addr_fact_id)
if addr_livr_id is not None:
result["id_adresa_livrare"] = int(addr_livr_id)
# 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
clob_var = cur.var(oracledb.DB_TYPE_CLOB)
@@ -158,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)
# 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", [
order_number, # p_nr_comanda_ext
order_date, # p_data_comanda
@@ -167,6 +351,7 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
addr_fact_id, # p_id_adresa_facturare
id_pol, # p_id_pol
id_sectie, # p_id_sectie
id_gestiune_csv, # p_id_gestiune (CSV string)
id_comanda # v_id_comanda (OUT)
])
@@ -185,8 +370,72 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
error_msg = str(e)
result["error"] = 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:
result["error"] = str(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

View File

@@ -0,0 +1,75 @@
import logging
from .. import database
logger = logging.getLogger(__name__)
def check_invoices_for_orders(id_comanda_list: list) -> dict:
"""Check which orders have been invoiced in Oracle (vanzari table).
Returns {id_comanda: {facturat, numar_act, serie_act, total_fara_tva, total_tva, total_cu_tva}}
"""
if not id_comanda_list or database.pool is None:
return {}
result = {}
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, numar_act, serie_act,
total_fara_tva, total_tva, total_cu_tva,
TO_CHAR(data_act, 'YYYY-MM-DD') AS data_act
FROM vanzari
WHERE id_comanda IN ({placeholders}) AND sters = 0
""", params)
for row in cur:
result[row[0]] = {
"facturat": True,
"numar_act": row[1],
"serie_act": row[2],
"total_fara_tva": float(row[3]) if row[3] else 0,
"total_tva": float(row[4]) if row[4] else 0,
"total_cu_tva": float(row[5]) if row[5] else 0,
"data_act": row[6],
}
except Exception as e:
logger.warning(f"Invoice check failed (table may not exist): {e}")
finally:
database.pool.release(conn)
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

@@ -7,69 +7,210 @@ from .. import database
logger = logging.getLogger(__name__)
def get_mappings(search: str = "", page: int = 1, per_page: int = 50):
"""Get paginated mappings with optional search."""
def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False, pct_filter: str = None):
"""Get paginated mappings with optional search, sorting, and pct_filter.
pct_filter values:
'complete' only SKU groups where sum(procent_pret for active rows) == 100
'incomplete' only SKU groups where sum < 100
None / 'all' no filter
"""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
offset = (page - 1) * per_page
# Validate and resolve sort parameters
allowed_sort = {
"sku": "at.sku",
"codmat": "at.codmat",
"denumire": "na.denumire",
"um": "na.um",
"cantitate_roa": "at.cantitate_roa",
"procent_pret": "at.procent_pret",
"activ": "at.activ",
}
sort_col = allowed_sort.get(sort_by, "at.sku")
if sort_dir.lower() not in ("asc", "desc"):
sort_dir = "asc"
order_clause = f"{sort_col} {sort_dir}"
# Always add secondary sort to keep groups together
if sort_col not in ("at.sku",):
order_clause += ", at.sku"
order_clause += ", at.codmat"
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Build WHERE clause
where = ""
where_clauses = []
params = {}
if not show_deleted:
where_clauses.append("at.sters = 0")
if search:
where = """WHERE (UPPER(at.sku) LIKE '%' || UPPER(:search) || '%'
where_clauses.append("""(UPPER(at.sku) LIKE '%' || UPPER(:search) || '%'
OR UPPER(at.codmat) LIKE '%' || UPPER(:search) || '%'
OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')"""
OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')""")
params["search"] = search
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total
count_sql = f"""
SELECT COUNT(*) FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where}
"""
cur.execute(count_sql, params)
total = cur.fetchone()[0]
# Get page
# Fetch ALL matching rows (no pagination yet — we need to group by SKU first)
data_sql = f"""
SELECT at.sku, at.codmat, na.denumire, at.cantitate_roa,
at.procent_pret, at.activ,
SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa,
at.procent_pret, at.activ, at.sters,
TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare
FROM ARTICOLE_TERTI at
LEFT JOIN nom_articole na ON na.codmat = at.codmat
{where}
ORDER BY at.sku, at.codmat
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
ORDER BY {order_clause}
"""
params["offset"] = offset
params["per_page"] = per_page
cur.execute(data_sql, params)
columns = [col[0].lower() for col in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
all_rows = [dict(zip(columns, row)) for row in cur.fetchall()]
# Group by SKU and compute pct_total for each group
from collections import OrderedDict
groups = OrderedDict()
for row in all_rows:
sku = row["sku"]
if sku not in groups:
groups[sku] = []
groups[sku].append(row)
# Compute counts across ALL groups (before pct_filter)
total_skus = len(groups)
complete_skus = 0
incomplete_skus = 0
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
if abs(pct_total - 100) <= 0.01:
complete_skus += 1
else:
incomplete_skus += 1
counts = {
"total": total_skus,
"complete": complete_skus,
"incomplete": incomplete_skus,
}
# Apply pct_filter
if pct_filter in ("complete", "incomplete"):
filtered_groups = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
is_complete = abs(pct_total - 100) <= 0.01
if pct_filter == "complete" and is_complete:
filtered_groups[sku] = rows
elif pct_filter == "incomplete" and not is_complete:
filtered_groups[sku] = rows
groups = filtered_groups
# Flatten back to rows for pagination (paginate by raw row count)
filtered_rows = [row for rows in groups.values() for row in rows]
total = len(filtered_rows)
page_rows = filtered_rows[offset: offset + per_page]
# Attach pct_total and is_complete to each row for the renderer
# Re-compute per visible group
sku_pct = {}
for sku, rows in groups.items():
pct_total = sum(
(r["procent_pret"] or 0)
for r in rows
if r.get("activ") == 1
)
sku_pct[sku] = {"pct_total": pct_total, "is_complete": abs(pct_total - 100) <= 0.01}
for row in page_rows:
meta = sku_pct.get(row["sku"], {"pct_total": 0, "is_complete": False})
row["pct_total"] = meta["pct_total"]
row["is_complete"] = meta["is_complete"]
return {
"mappings": rows,
"mappings": page_rows,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": counts,
}
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
"""Create a new mapping."""
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.
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:
raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Validate CODMAT exists in NOM_ARTICOLE
cur.execute("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3)
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
cur.execute("""
SELECT COUNT(*) FROM ARTICOLE_TERTI
WHERE sku = :sku AND codmat = :codmat AND NVL(sters, 0) = 0
""", {"sku": sku, "codmat": codmat})
if cur.fetchone()[0] > 0:
raise HTTPException(status_code=409, detail="Maparea SKU-CODMAT există deja")
# Check for soft-deleted record that could be restored
cur.execute("""
SELECT COUNT(*) FROM ARTICOLE_TERTI
WHERE sku = :sku AND codmat = :codmat AND sters = 1
""", {"sku": sku, "codmat": codmat})
if cur.fetchone()[0] > 0:
if auto_restore:
cur.execute("""
UPDATE ARTICOLE_TERTI SET sters = 0, activ = 1,
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("""
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
conn.commit()
return {"sku": sku, "codmat": codmat}
@@ -108,8 +249,72 @@ def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_p
return cur.rowcount > 0
def delete_mapping(sku: str, codmat: str):
"""Soft delete (set activ=0)."""
return update_mapping(sku, codmat, activ=0)
"""Soft delete (set sters=1)."""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE
WHERE sku = :sku AND codmat = :codmat
""", {"sku": sku, "codmat": codmat})
conn.commit()
return cur.rowcount > 0
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
cantitate_roa: float = 1, procent_pret: float = 100):
"""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:
raise HTTPException(status_code=503, detail="Oracle unavailable")
if old_sku == new_sku and old_codmat == new_codmat:
# Simple update - only cantitate/procent changed
return update_mapping(new_sku, new_codmat, cantitate_roa, procent_pret)
else:
# PK changed: soft-delete old, upsert new (MERGE handles existing soft-deleted target)
with database.pool.acquire() as conn:
with conn.cursor() as cur:
# Mark old record as deleted
cur.execute("""
UPDATE ARTICOLE_TERTI SET sters = 1, data_modif = SYSDATE
WHERE sku = :sku AND codmat = :codmat
""", {"sku": old_sku, "codmat": old_codmat})
# Upsert new record (MERGE in case target PK exists as soft-deleted)
cur.execute("""
MERGE INTO ARTICOLE_TERTI t
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
ON (t.sku = s.sku AND t.codmat = s.codmat)
WHEN MATCHED THEN UPDATE SET
cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret,
activ = 1, sters = 0,
data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": new_sku, "codmat": new_codmat,
"cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
conn.commit()
return True
def restore_mapping(sku: str, codmat: str):
"""Restore a soft-deleted mapping (set sters=0)."""
if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable")
with database.pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE ARTICOLE_TERTI SET sters = 0, data_modif = SYSDATE
WHERE sku = :sku AND codmat = :codmat
""", {"sku": sku, "codmat": codmat})
conn.commit()
return cur.rowcount > 0
def import_csv(file_content: str):
"""Import mappings from CSV content. Returns summary."""
@@ -118,23 +323,27 @@ def import_csv(file_content: str):
reader = csv.DictReader(io.StringIO(file_content))
created = 0
updated = 0
skipped_no_codmat = 0
errors = []
with database.pool.acquire() as conn:
with conn.cursor() as cur:
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:
sku = row.get("sku", "").strip()
codmat = row.get("codmat", "").strip()
cantitate = float(row.get("cantitate_roa", "1") or "1")
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("""
MERGE INTO ARTICOLE_TERTI t
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
@@ -143,21 +352,20 @@ def import_csv(file_content: str):
cantitate_roa = :cantitate_roa,
procent_pret = :procent_pret,
activ = 1,
sters = 0,
data_modif = SYSDATE
WHEN NOT MATCHED THEN INSERT
(sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3)
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
# Check if it was insert or update by rowcount
created += 1 # We count total processed
created += 1
except Exception as e:
errors.append(f"Row {i}: {str(e)}")
errors.append(f"Rând {i}: {str(e)}")
conn.commit()
return {"processed": created, "errors": errors}
return {"processed": created, "skipped_no_codmat": skipped_no_codmat, "errors": errors}
def export_csv():
"""Export all mappings as CSV string."""
@@ -172,7 +380,7 @@ def export_csv():
with conn.cursor() as cur:
cur.execute("""
SELECT sku, codmat, cantitate_roa, procent_pret, activ
FROM ARTICOLE_TERTI ORDER BY sku, codmat
FROM ARTICOLE_TERTI WHERE sters = 0 ORDER BY sku, codmat
""")
for row in cur:
writer.writerow(row)

View File

@@ -54,6 +54,10 @@ class OrderData:
items: list = field(default_factory=list) # list of OrderItem
billing: OrderBilling = field(default_factory=OrderBilling)
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 = ""
delivery_name: 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 {}
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(
id=str(data.get("id", order_id)),
number=str(data.get("number", "")),
@@ -163,6 +179,10 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
items=items,
billing=billing,
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 "",
delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "",
source_file=source_file

View File

@@ -1,8 +1,16 @@
import json
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
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__)
@@ -12,61 +20,218 @@ async def create_sync_run(run_id: str, json_files: int = 0):
try:
await db.execute("""
INSERT INTO sync_runs (run_id, started_at, status, json_files)
VALUES (?, datetime('now'), 'running', ?)
""", (run_id, json_files))
VALUES (?, ?, 'running', ?)
""", (run_id, _now_str(), json_files))
await db.commit()
finally:
await db.close()
async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
imported: int = 0, skipped: int = 0, errors: int = 0):
imported: int = 0, skipped: int = 0, errors: int = 0,
error_message: str = None,
already_imported: int = 0, new_imported: int = 0):
"""Update sync run with results."""
db = await get_sqlite()
try:
await db.execute("""
UPDATE sync_runs SET
finished_at = datetime('now'),
finished_at = ?,
status = ?,
total_orders = ?,
imported = ?,
skipped = ?,
errors = ?
errors = ?,
error_message = ?,
already_imported = ?,
new_imported = ?
WHERE run_id = ?
""", (status, total_orders, imported, skipped, errors, run_id))
""", (_now_str(), status, total_orders, imported, skipped, errors, error_message,
already_imported, new_imported, run_id))
await db.commit()
finally:
await db.close()
async def add_import_order(sync_run_id: str, order_number: str, order_date: str,
customer_name: str, status: str, id_comanda: int = None,
id_partener: int = None, error_message: str = None,
missing_skus: list = None, items_count: int = 0):
"""Record an individual order import result."""
async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
customer_name: str, status: str, id_comanda: int = None,
id_partener: int = None, error_message: str = None,
missing_skus: list = None, items_count: int = 0,
shipping_name: str = None, billing_name: 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."""
db = await get_sqlite()
try:
await db.execute("""
INSERT INTO import_orders
(sync_run_id, order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (sync_run_id, order_number, order_date, customer_name, status,
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name,
status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
THEN orders.status
ELSE excluded.status
END,
error_message = excluded.error_message,
missing_skus = excluded.missing_skus,
items_count = excluded.items_count,
id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda),
id_partener = COALESCE(excluded.id_partener, orders.id_partener),
times_skipped = CASE WHEN excluded.status = 'SKIPPED'
THEN orders.times_skipped + 1
ELSE orders.times_skipped END,
last_sync_run_id = excluded.last_sync_run_id,
shipping_name = COALESCE(excluded.shipping_name, orders.shipping_name),
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now')
""", (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message,
json.dumps(missing_skus) if missing_skus else None, items_count))
json.dumps(missing_skus) if missing_skus else None,
items_count, sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status, discount_split))
await db.commit()
finally:
await db.close()
async def track_missing_sku(sku: str, product_name: str = ""):
"""Track a missing SKU."""
async def add_sync_run_order(sync_run_id: str, order_number: str, status_at_run: str):
"""Record that this run processed this order (junction table)."""
db = await get_sqlite()
try:
await db.execute("""
INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run)
VALUES (?, ?, ?)
""", (sync_run_id, order_number, status_at_run))
await db.commit()
finally:
await db.close()
async def save_orders_batch(orders_data: list[dict]):
"""Batch save a list of orders + their sync_run_orders + order_items in one transaction.
Each dict must have: sync_run_id, order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus (list|None), items_count,
shipping_name, billing_name, payment_method, delivery_method, status_at_run,
items (list of item dicts), delivery_cost (optional), discount_total (optional),
web_status (optional).
"""
if not orders_data:
return
db = await get_sqlite()
try:
# 1. Upsert orders
await db.executemany("""
INSERT INTO orders
(order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method, order_total,
delivery_cost, discount_total, web_status, discount_split)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET
customer_name = excluded.customer_name,
status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
THEN orders.status
ELSE excluded.status
END,
error_message = excluded.error_message,
missing_skus = excluded.missing_skus,
items_count = excluded.items_count,
id_comanda = COALESCE(excluded.id_comanda, orders.id_comanda),
id_partener = COALESCE(excluded.id_partener, orders.id_partener),
times_skipped = CASE WHEN excluded.status = 'SKIPPED'
THEN orders.times_skipped + 1
ELSE orders.times_skipped END,
last_sync_run_id = excluded.last_sync_run_id,
shipping_name = COALESCE(excluded.shipping_name, orders.shipping_name),
billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
delivery_cost = COALESCE(excluded.delivery_cost, orders.delivery_cost),
discount_total = COALESCE(excluded.discount_total, orders.discount_total),
web_status = COALESCE(excluded.web_status, orders.web_status),
discount_split = COALESCE(excluded.discount_split, orders.discount_split),
updated_at = datetime('now')
""", [
(d["order_number"], d["order_date"], d["customer_name"], d["status"],
d.get("id_comanda"), d.get("id_partener"), d.get("error_message"),
json.dumps(d["missing_skus"]) if d.get("missing_skus") else None,
d.get("items_count", 0), d["sync_run_id"],
d.get("shipping_name"), d.get("billing_name"),
d.get("payment_method"), d.get("delivery_method"),
d.get("order_total"),
d.get("delivery_cost"), d.get("discount_total"),
d.get("web_status"), d.get("discount_split"))
for d in orders_data
])
# 2. Sync run orders
await db.executemany("""
INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run)
VALUES (?, ?, ?)
""", [(d["sync_run_id"], d["order_number"], d["status_at_run"]) for d in orders_data])
# 3. Order items
all_items = []
for d in orders_data:
for item in d.get("items", []):
all_items.append((
d["order_number"],
item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("vat"),
item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa")
))
if all_items:
await db.executemany("""
INSERT OR IGNORE INTO order_items
(order_number, sku, product_name, quantity, price, vat,
mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", all_items)
await db.commit()
finally:
await db.close()
async def track_missing_sku(sku: str, product_name: str = "",
order_count: int = 0, order_numbers: str = None,
customers: str = None):
"""Track a missing SKU with order context."""
db = await get_sqlite()
try:
await db.execute("""
INSERT OR IGNORE INTO missing_skus (sku, product_name)
VALUES (?, ?)
""", (sku, product_name))
if order_count or order_numbers or customers:
await db.execute("""
UPDATE missing_skus SET
order_count = ?,
order_numbers = ?,
customers = ?
WHERE sku = ?
""", (order_count, order_numbers, customers, sku))
await db.commit()
finally:
await db.close()
@@ -85,6 +250,66 @@ async def resolve_missing_sku(sku: str):
await db.close()
async def get_missing_skus_paginated(page: int = 1, per_page: int = 20,
resolved: int = 0, search: str = None):
"""Get paginated missing SKUs. resolved=-1 means show all.
Optional search filters by sku or product_name (LIKE)."""
db = await get_sqlite()
try:
offset = (page - 1) * per_page
# Build WHERE clause parts
where_parts = []
params_count = []
params_data = []
if resolved != -1:
where_parts.append("resolved = ?")
params_count.append(resolved)
params_data.append(resolved)
if search:
like = f"%{search}%"
where_parts.append("(LOWER(sku) LIKE LOWER(?) OR LOWER(COALESCE(product_name,'')) LIKE LOWER(?))")
params_count.extend([like, like])
params_data.extend([like, like])
where_clause = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
order_clause = (
"ORDER BY resolved ASC, order_count DESC, first_seen DESC"
if resolved == -1
else "ORDER BY order_count DESC, first_seen DESC"
)
cursor = await db.execute(
f"SELECT COUNT(*) FROM missing_skus {where_clause}",
params_count
)
total = (await cursor.fetchone())[0]
cursor = await db.execute(f"""
SELECT sku, product_name, first_seen, resolved, resolved_at,
order_count, order_numbers, customers
FROM missing_skus
{where_clause}
{order_clause}
LIMIT ? OFFSET ?
""", params_data + [per_page, offset])
rows = await cursor.fetchall()
return {
"missing_skus": [dict(row) for row in rows],
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0
}
finally:
await db.close()
async def get_sync_runs(page: int = 1, per_page: int = 20):
"""Get paginated sync run history."""
db = await get_sqlite()
@@ -112,7 +337,7 @@ async def get_sync_runs(page: int = 1, per_page: int = 20):
async def get_sync_run_detail(run_id: str):
"""Get details for a specific sync run including its orders."""
"""Get details for a specific sync run including its orders via sync_run_orders."""
db = await get_sqlite()
try:
cursor = await db.execute(
@@ -123,9 +348,10 @@ async def get_sync_run_detail(run_id: str):
return None
cursor = await db.execute("""
SELECT * FROM import_orders
WHERE sync_run_id = ?
ORDER BY created_at
SELECT o.* FROM orders o
INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
WHERE sro.sync_run_id = ?
ORDER BY o.order_date
""", (run_id,))
orders = await cursor.fetchall()
@@ -141,31 +367,34 @@ async def get_dashboard_stats():
"""Get stats for the dashboard."""
db = await get_sqlite()
try:
# Total imported
cursor = await db.execute(
"SELECT COUNT(*) FROM import_orders WHERE status = 'IMPORTED'"
"SELECT COUNT(*) FROM orders WHERE status = 'IMPORTED'"
)
imported = (await cursor.fetchone())[0]
# Total skipped
cursor = await db.execute(
"SELECT COUNT(*) FROM import_orders WHERE status = 'SKIPPED'"
"SELECT COUNT(*) FROM orders WHERE status = 'SKIPPED'"
)
skipped = (await cursor.fetchone())[0]
# Total errors
cursor = await db.execute(
"SELECT COUNT(*) FROM import_orders WHERE status = 'ERROR'"
"SELECT COUNT(*) FROM orders WHERE status = 'ERROR'"
)
errors = (await cursor.fetchone())[0]
# Missing SKUs (unresolved)
cursor = await db.execute(
"SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
)
missing = (await cursor.fetchone())[0]
# Last sync run
cursor = await db.execute("SELECT COUNT(DISTINCT sku) FROM missing_skus")
total_missing_skus = (await cursor.fetchone())[0]
cursor = await db.execute(
"SELECT COUNT(DISTINCT sku) FROM missing_skus WHERE resolved = 0"
)
unresolved_skus = (await cursor.fetchone())[0]
cursor = await db.execute("""
SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1
""")
@@ -176,6 +405,8 @@ async def get_dashboard_stats():
"skipped": skipped,
"errors": errors,
"missing_skus": missing,
"total_tracked_skus": total_missing_skus,
"unresolved_skus": unresolved_skus,
"last_run": dict(last_run) if last_run else None
}
finally:
@@ -204,3 +435,495 @@ async def set_scheduler_config(key: str, value: str):
await db.commit()
finally:
await db.close()
# ── web_products ─────────────────────────────────
async def upsert_web_product(sku: str, product_name: str):
"""Insert or update a web product, incrementing order_count."""
db = await get_sqlite()
try:
await db.execute("""
INSERT INTO web_products (sku, product_name, order_count)
VALUES (?, ?, 1)
ON CONFLICT(sku) DO UPDATE SET
product_name = COALESCE(NULLIF(excluded.product_name, ''), web_products.product_name),
last_seen = datetime('now'),
order_count = web_products.order_count + 1
""", (sku, product_name))
await db.commit()
finally:
await db.close()
async def upsert_web_products_batch(items: list[tuple[str, str]]):
"""Batch upsert web products in a single transaction. items: list of (sku, product_name)."""
if not items:
return
db = await get_sqlite()
try:
await db.executemany("""
INSERT INTO web_products (sku, product_name, order_count)
VALUES (?, ?, 1)
ON CONFLICT(sku) DO UPDATE SET
product_name = COALESCE(NULLIF(excluded.product_name, ''), web_products.product_name),
last_seen = datetime('now'),
order_count = web_products.order_count + 1
""", items)
await db.commit()
finally:
await db.close()
async def get_web_product_name(sku: str) -> str:
"""Lookup product name by SKU."""
db = await get_sqlite()
try:
cursor = await db.execute(
"SELECT product_name FROM web_products WHERE sku = ?", (sku,)
)
row = await cursor.fetchone()
return row["product_name"] if row else ""
finally:
await db.close()
async def get_web_products_batch(skus: list) -> dict:
"""Batch lookup product names by SKU list. Returns {sku: product_name}."""
if not skus:
return {}
db = await get_sqlite()
try:
placeholders = ",".join("?" for _ in skus)
cursor = await db.execute(
f"SELECT sku, product_name FROM web_products WHERE sku IN ({placeholders})",
list(skus)
)
rows = await cursor.fetchall()
return {row["sku"]: row["product_name"] for row in rows}
finally:
await db.close()
# ── order_items ──────────────────────────────────
async def add_order_items(order_number: str, items: list):
"""Bulk insert order items. Uses INSERT OR IGNORE — PK is (order_number, sku)."""
if not items:
return
db = await get_sqlite()
try:
await db.executemany("""
INSERT OR IGNORE INTO order_items
(order_number, sku, product_name, quantity, price, vat,
mapping_status, codmat, id_articol, cantitate_roa)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", [
(order_number,
item.get("sku"), item.get("product_name"),
item.get("quantity"), item.get("price"), item.get("vat"),
item.get("mapping_status"), item.get("codmat"),
item.get("id_articol"), item.get("cantitate_roa"))
for item in items
])
await db.commit()
finally:
await db.close()
async def get_order_items(order_number: str) -> list:
"""Fetch items for one order."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT * FROM order_items
WHERE order_number = ?
ORDER BY sku
""", (order_number,))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
finally:
await db.close()
async def get_order_detail(order_number: str) -> dict:
"""Get full order detail: order metadata + items."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT * FROM orders WHERE order_number = ?
""", (order_number,))
order = await cursor.fetchone()
if not order:
return None
cursor = await db.execute("""
SELECT * FROM order_items WHERE order_number = ?
ORDER BY sku
""", (order_number,))
items = await cursor.fetchall()
return {
"order": dict(order),
"items": [dict(i) for i in items]
}
finally:
await db.close()
async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
page: int = 1, per_page: int = 50,
sort_by: str = "order_date", sort_dir: str = "asc"):
"""Get paginated orders for a run via sync_run_orders junction table."""
db = await get_sqlite()
try:
where = "WHERE sro.sync_run_id = ?"
params = [run_id]
if status_filter and status_filter != "all":
where += " AND UPPER(sro.status_at_run) = ?"
params.append(status_filter.upper())
allowed_sort = {"order_date", "order_number", "customer_name", "items_count",
"status", "first_seen_at", "updated_at"}
if sort_by not in allowed_sort:
sort_by = "order_date"
if sort_dir.lower() not in ("asc", "desc"):
sort_dir = "asc"
cursor = await db.execute(
f"SELECT COUNT(*) FROM orders o INNER JOIN sync_run_orders sro "
f"ON sro.order_number = o.order_number {where}", params
)
total = (await cursor.fetchone())[0]
offset = (page - 1) * per_page
cursor = await db.execute(f"""
SELECT o.*, sro.status_at_run AS run_status FROM orders o
INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
{where}
ORDER BY o.{sort_by} {sort_dir}
LIMIT ? OFFSET ?
""", params + [per_page, offset])
rows = await cursor.fetchall()
cursor = await db.execute("""
SELECT sro.status_at_run AS status, COUNT(*) as cnt
FROM orders o
INNER JOIN sync_run_orders sro ON sro.order_number = o.order_number
WHERE sro.sync_run_id = ?
GROUP BY sro.status_at_run
""", (run_id,))
status_counts = {row["status"]: row["cnt"] for row in await cursor.fetchall()}
# Use run_status (status_at_run) as the status field for each order row
order_rows = []
for r in rows:
d = dict(r)
d["status"] = d.pop("run_status", d.get("status"))
order_rows.append(d)
return {
"orders": order_rows,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": {
"imported": status_counts.get("IMPORTED", 0),
"skipped": status_counts.get("SKIPPED", 0),
"error": status_counts.get("ERROR", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"cancelled": status_counts.get("CANCELLED", 0),
"total": sum(status_counts.values())
}
}
finally:
await db.close()
async def get_orders(page: int = 1, per_page: int = 50,
search: str = "", status_filter: str = "all",
sort_by: str = "order_date", sort_dir: str = "desc",
period_days: int = 7,
period_start: str = "", period_end: str = ""):
"""Get orders with filters, sorting, and period.
period_days=0 with period_start/period_end uses custom date range.
period_days=0 without dates means all time.
"""
db = await get_sqlite()
try:
# Period + search clauses (used for counts — never include status filter)
base_clauses = []
base_params = []
if period_days and period_days > 0:
base_clauses.append("order_date >= date('now', ?)")
base_params.append(f"-{period_days} days")
elif period_days == 0 and period_start and period_end:
base_clauses.append("order_date BETWEEN ? AND ?")
base_params.extend([period_start, period_end])
if search:
base_clauses.append("(order_number LIKE ? OR customer_name LIKE ?)")
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.upper() == "IMPORTED":
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(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",
"status", "first_seen_at", "updated_at"}
if sort_by not in allowed_sort:
sort_by = "order_date"
if sort_dir.lower() not in ("asc", "desc"):
sort_dir = "desc"
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {where}", data_params)
total = (await cursor.fetchone())[0]
offset = (page - 1) * per_page
cursor = await db.execute(f"""
SELECT * FROM orders
{where}
ORDER BY {sort_by} {sort_dir}
LIMIT ? OFFSET ?
""", data_params + [per_page, offset])
rows = await cursor.fetchall()
# Counts by status — always on full period+search, never filtered by status
cursor = await db.execute(f"""
SELECT status, COUNT(*) as cnt FROM orders
{counts_where}
GROUP BY status
""", base_params)
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 {
"orders": [dict(r) for r in rows],
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
"counts": {
"imported": status_counts.get("IMPORTED", 0),
"already_imported": status_counts.get("ALREADY_IMPORTED", 0),
"imported_all": status_counts.get("IMPORTED", 0) + status_counts.get("ALREADY_IMPORTED", 0),
"skipped": status_counts.get("SKIPPED", 0),
"error": status_counts.get("ERROR", 0),
"cancelled": status_counts.get("CANCELLED", 0),
"total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite,
}
}
finally:
await db.close()
async def update_import_order_addresses(order_number: str,
id_adresa_facturare: int = None,
id_adresa_livrare: int = None):
"""Update ROA address IDs on an order record."""
db = await get_sqlite()
try:
await db.execute("""
UPDATE orders SET
id_adresa_facturare = ?,
id_adresa_livrare = ?,
updated_at = datetime('now')
WHERE order_number = ?
""", (id_adresa_facturare, id_adresa_livrare, order_number))
await db.commit()
finally:
await db.close()
# ── Invoice cache ────────────────────────────────
async def get_uninvoiced_imported_orders() -> list:
"""Get all imported orders that don't yet have invoice data cached."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT order_number, id_comanda FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
AND id_comanda IS NOT NULL
AND factura_numar IS NULL
""")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
finally:
await db.close()
async def update_order_invoice(order_number: str, serie: str = None,
numar: str = None, total_fara_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."""
db = await get_sqlite()
try:
await db.execute("""
UPDATE orders SET
factura_serie = ?,
factura_numar = ?,
factura_total_fara_tva = ?,
factura_total_tva = ?,
factura_total_cu_tva = ?,
factura_data = ?,
invoice_checked_at = datetime('now'),
updated_at = datetime('now')
WHERE 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()
finally:
await db.close()

View File

@@ -1,9 +1,20 @@
import asyncio
import json
import logging
import uuid
from datetime import datetime
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from . import order_reader, validation_service, import_service, sqlite_service
_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 ..config import settings
from .. import database
logger = logging.getLogger(__name__)
@@ -11,6 +22,38 @@ logger = logging.getLogger(__name__)
_sync_lock = asyncio.Lock()
_current_sync = None # dict with run_id, status, progress info
# In-memory text log buffer per run
_run_logs: dict[str, list[str]] = {}
def _log_line(run_id: str, message: str):
"""Append a timestamped line to the in-memory log buffer."""
if run_id not in _run_logs:
_run_logs[run_id] = []
ts = _now().strftime("%H:%M:%S")
_run_logs[run_id].append(f"[{ts}] {message}")
def get_run_text_log(run_id: str) -> str | None:
"""Return the accumulated text log for a run, or None if not found."""
lines = _run_logs.get(run_id)
if lines is None:
return None
return "\n".join(lines)
def _update_progress(phase: str, phase_text: str, current: int = 0, total: int = 0,
counts: dict = None):
"""Update _current_sync with progress details for polling."""
global _current_sync
if _current_sync is None:
return
_current_sync["phase"] = phase
_current_sync["phase_text"] = phase_text
_current_sync["progress_current"] = current
_current_sync["progress_total"] = total
_current_sync["counts"] = counts or {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0}
async def get_sync_status():
"""Get current sync status."""
@@ -19,7 +62,85 @@ async def get_sync_status():
return {"status": "idle"}
async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
"""Prepare a sync run - creates run_id and sets initial state.
Returns {"run_id": ..., "status": "starting"} or {"error": ...} if already running.
"""
global _current_sync
if _sync_lock.locked():
return {"error": "Sync already running", "run_id": _current_sync.get("run_id") if _current_sync else None}
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = {
"run_id": run_id,
"status": "running",
"started_at": _now().isoformat(),
"finished_at": None,
"phase": "starting",
"phase_text": "Starting...",
"progress_current": 0,
"progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0},
}
return {"run_id": run_id, "status": "starting"}
def _derive_customer_info(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 = ""
if order.shipping:
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()
if not shipping_name:
shipping_name = 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
delivery_method = getattr(order, 'delivery_name', None) or None
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:
"""Run a full sync cycle. Returns summary dict."""
global _current_sync
@@ -27,79 +148,464 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
return {"error": "Sync already running"}
async with _sync_lock:
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = {
"run_id": run_id,
"status": "running",
"started_at": datetime.now().isoformat(),
"progress": "Reading JSON files..."
}
# Use provided run_id or generate one
if not run_id:
run_id = _now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = {
"run_id": run_id,
"status": "running",
"started_at": _now().isoformat(),
"finished_at": None,
"phase": "reading",
"phase_text": "Reading JSON files...",
"progress_current": 0,
"progress_total": 0,
"counts": {"imported": 0, "skipped": 0, "errors": 0, "already_imported": 0, "cancelled": 0},
}
_update_progress("reading", "Reading JSON files...")
started_dt = _now()
_run_logs[run_id] = [
f"=== Sync Run {run_id} ===",
f"Inceput: {started_dt.strftime('%d.%m.%Y %H:%M:%S')}",
""
]
json_dir = settings.JSON_OUTPUT_DIR
try:
# Step 1: Read orders
# Phase 0: Download orders from GoMag API
_update_progress("downloading", "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(
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"]:
_log_line(run_id, f"GoMag: {dl_result['total']} comenzi în {dl_result['pages']} pagini → {len(dl_result['files'])} fișiere")
_update_progress("reading", "Citire fisiere JSON...")
_log_line(run_id, "Citire fisiere JSON...")
# Step 1: Read orders and sort chronologically (oldest first - R3)
orders, json_count = order_reader.read_json_orders()
orders.sort(key=lambda o: o.date or '')
await sqlite_service.create_sync_run(run_id, json_count)
_update_progress("reading", f"Found {len(orders)} orders in {json_count} files", 0, len(orders))
_log_line(run_id, f"Gasite {len(orders)} comenzi in {json_count} fisiere")
# Populate web_products catalog from all orders (R4)
web_product_items = [
(item.sku, item.name)
for order in orders
for item in order.items
if item.sku and item.name
]
await sqlite_service.upsert_web_products_batch(web_product_items)
if not orders:
_log_line(run_id, "Nicio comanda gasita.")
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
_current_sync = None
return {
"run_id": run_id,
"status": "completed",
"message": "No orders found",
"json_files": json_count
}
_update_progress("completed", "No orders found")
summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
return summary
_current_sync["progress"] = f"Validating {len(orders)} orders..."
# ── 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)
# Step 2: Validate SKUs (blocking Oracle call -> run in thread)
all_skus = order_reader.get_all_skus(orders)
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
importable, skipped = validation_service.classify_orders(orders, validation)
if cancelled_orders:
_log_line(run_id, f"Comenzi anulate in GoMag: {cancelled_count}")
# Track missing SKUs
for sku in validation["missing"]:
product_name = ""
for order in orders:
for item in order.items:
if item.sku == sku:
product_name = item.name
break
if product_name:
break
await sqlite_service.track_missing_sku(sku, product_name)
# 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")
# Step 3: Record skipped orders
for order, missing_skus in skipped:
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
await sqlite_service.add_import_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
customer_name=customer,
status="SKIPPED",
missing_skus=missing_skus,
items_count=len(order.items)
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))
# ── Single Oracle connection for entire validation phase ──
conn = await asyncio.to_thread(database.get_oracle_connection)
try:
# Step 2a: Find orders already in Oracle (date-range query)
order_dates = [o.date for o in orders if o.date]
if order_dates:
min_date_str = min(order_dates)
try:
min_date = datetime.strptime(min_date_str[:10], "%Y-%m-%d") - timedelta(days=1)
except (ValueError, TypeError):
min_date = _now() - timedelta(days=90)
else:
min_date = _now() - timedelta(days=90)
existing_map = await asyncio.to_thread(
validation_service.check_orders_in_roa, min_date, conn
)
# Step 4: Import valid orders
# 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)
all_skus = order_reader.get_all_skus(orders)
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus, conn, id_gestiuni)
importable, skipped = validation_service.classify_orders(orders, validation)
# ── Split importable into truly_importable vs already_in_roa ──
truly_importable = []
already_in_roa = []
for order in importable:
if order.number in existing_map:
already_in_roa.append(order)
else:
truly_importable.append(order)
_update_progress("validation",
f"{len(truly_importable)} new, {len(already_in_roa)} already imported, {len(skipped)} skipped",
0, len(truly_importable))
_log_line(run_id, f"Validare: {len(truly_importable)} noi, {len(already_in_roa)} deja importate, {len(skipped)} nemapate")
# Step 2c: Build SKU context from skipped orders
sku_context = {}
for order, missing_skus_list in skipped:
if order.billing.is_company and order.billing.company_name:
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:
if sku not in sku_context:
sku_context[sku] = {"orders": [], "customers": []}
if order.number not in sku_context[sku]["orders"]:
sku_context[sku]["orders"].append(order.number)
if customer not in sku_context[sku]["customers"]:
sku_context[sku]["customers"].append(customer)
# Track missing SKUs with context
for sku in validation["missing"]:
product_name = ""
for order in orders:
for item in order.items:
if item.sku == sku:
product_name = item.name
break
if product_name:
break
ctx = sku_context.get(sku, {})
await sqlite_service.track_missing_sku(
sku, product_name,
order_count=len(ctx.get("orders", [])),
order_numbers=json.dumps(ctx.get("orders", [])) if ctx.get("orders") else None,
customers=json.dumps(ctx.get("customers", [])) if ctx.get("customers") else None,
)
# Step 2d: Pre-validate prices for importable articles
if id_pol and (truly_importable or already_in_roa):
_update_progress("validation", "Validating prices...", 0, len(truly_importable))
_log_line(run_id, "Validare preturi...")
all_codmats = set()
for order in (truly_importable + already_in_roa):
for item in order.items:
if item.sku in validation["mapped"]:
pass
elif item.sku in validation["direct"]:
all_codmats.add(item.sku)
# Get standard VAT rate from settings for PROC_TVAV metadata
cota_tva = float(app_settings.get("discount_vat") or 21)
# Dual pricing policy support
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
codmat_policy_map = {}
if all_codmats:
if id_pol_productie:
# Dual-policy: classify articles by cont (sales vs production)
codmat_policy_map = await asyncio.to_thread(
validation_service.validate_and_ensure_prices_dual,
all_codmats, id_pol, id_pol_productie,
conn, validation.get("direct_id_map"),
cota_tva=cota_tva
)
_log_line(run_id,
f"Politici duale: {sum(1 for v in codmat_policy_map.values() if v == id_pol)} vanzare, "
f"{sum(1 for v in codmat_policy_map.values() if v == id_pol_productie)} productie")
else:
# Single-policy (backward compatible)
price_result = await asyncio.to_thread(
validation_service.validate_prices, all_codmats, id_pol,
conn, validation.get("direct_id_map")
)
if price_result["missing_price"]:
logger.info(
f"Auto-adding price 0 for {len(price_result['missing_price'])} "
f"direct articles in policy {id_pol}"
)
await asyncio.to_thread(
validation_service.ensure_prices,
price_result["missing_price"], id_pol,
conn, validation.get("direct_id_map"),
cota_tva=cota_tva
)
# 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:
await asyncio.to_thread(database.pool.release, conn)
# Step 3a: Record already-imported orders (batch)
already_imported_count = len(already_in_roa)
already_batch = []
for order in already_in_roa:
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
id_comanda_roa = existing_map.get(order.number)
order_items_data = [
{"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price, "vat": item.vat,
"mapping_status": "mapped" if item.sku in validation["mapped"] else "direct",
"codmat": None, "id_articol": None, "cantitate_roa": None}
for item in order.items
]
already_batch.append({
"sync_run_id": run_id, "order_number": order.number,
"order_date": order.date, "customer_name": customer,
"status": "ALREADY_IMPORTED", "status_at_run": "ALREADY_IMPORTED",
"id_comanda": id_comanda_roa, "id_partener": None,
"error_message": None, "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 None,
"items": order_items_data,
})
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})")
await sqlite_service.save_orders_batch(already_batch)
# Step 3b: Record skipped orders + store items (batch)
skipped_count = len(skipped)
skipped_batch = []
for order, missing_skus in skipped:
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": "missing" if item.sku in validation["missing"] else
"mapped" if item.sku in validation["mapped"] else "direct",
"codmat": None, "id_articol": None, "cantitate_roa": None}
for item in order.items
]
skipped_batch.append({
"sync_run_id": run_id, "order_number": order.number,
"order_date": order.date, "customer_name": customer,
"status": "SKIPPED", "status_at_run": "SKIPPED",
"id_comanda": None, "id_partener": None,
"error_message": None, "missing_skus": missing_skus,
"items_count": len(order.items),
"shipping_name": shipping_name, "billing_name": billing_name,
"payment_method": payment_method, "delivery_method": delivery_method,
"order_total": order.total or None,
"delivery_cost": order.delivery_cost or None,
"discount_total": order.discount_total or None,
"web_status": order.status or None,
"items": order_items_data,
})
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
await sqlite_service.save_orders_batch(skipped_batch)
_update_progress("skipped", f"Skipped {skipped_count}",
0, len(truly_importable),
{"imported": 0, "skipped": skipped_count, "errors": 0, "already_imported": already_imported_count})
# Step 4: Import only truly new orders
imported_count = 0
error_count = 0
for i, order in enumerate(importable):
_current_sync["progress"] = f"Importing {i+1}/{len(importable)}: #{order.number}"
for i, order in enumerate(truly_importable):
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
_update_progress("import",
f"Import {i+1}/{len(truly_importable)}: #{order.number} {customer}",
i + 1, len(truly_importable),
{"imported": imported_count, "skipped": len(skipped), "errors": error_count,
"already_imported": already_imported_count})
result = await asyncio.to_thread(
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
)
customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}"
# Build order items data for storage (R9)
order_items_data = []
for item in order.items:
ms = "mapped" if item.sku in validation["mapped"] else "direct"
order_items_data.append({
"sku": item.sku, "product_name": item.name,
"quantity": item.quantity, "price": item.price, "vat": item.vat,
"mapping_status": ms, "codmat": None, "id_articol": None,
"cantitate_roa": None
})
# Compute discount split for SQLite storage
ds = import_service.compute_discount_split(order, app_settings)
discount_split_json = json.dumps(ds) if ds else None
if result["success"]:
imported_count += 1
await sqlite_service.add_import_order(
await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
@@ -107,11 +613,29 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
status="IMPORTED",
id_comanda=result["id_comanda"],
id_partener=result["id_partener"],
items_count=len(order.items)
items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
order_total=order.total or None,
delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None,
web_status=order.status or None,
discount_split=discount_split_json,
)
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
# Store ROA address IDs (R9)
await sqlite_service.update_import_order_addresses(
order.number,
id_adresa_facturare=result.get("id_adresa_facturare"),
id_adresa_livrare=result.get("id_adresa_livrare")
)
await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → IMPORTAT (ID: {result['id_comanda']})")
else:
error_count += 1
await sqlite_service.add_import_order(
await sqlite_service.upsert_order(
sync_run_id=run_id,
order_number=order.number,
order_date=order.date,
@@ -119,47 +643,160 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
status="ERROR",
id_partener=result.get("id_partener"),
error_message=result["error"],
items_count=len(order.items)
items_count=len(order.items),
shipping_name=shipping_name,
billing_name=billing_name,
payment_method=payment_method,
delivery_method=delivery_method,
order_total=order.total or None,
delivery_cost=order.delivery_cost or None,
discount_total=order.discount_total or None,
web_status=order.status or None,
discount_split=discount_split_json,
)
await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
await sqlite_service.add_order_items(order.number, order_items_data)
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → EROARE: {result['error']}")
# Safety: stop if too many errors
if error_count > 10:
logger.warning("Too many errors, stopping sync")
break
# Step 4b: Invoice & order status check — sync with Oracle
_update_progress("invoices", "Checking invoices & order status...", 0, 0)
invoices_updated = 0
invoices_cleared = 0
orders_deleted = 0
try:
# 4b-1: Uninvoiced → check for 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_updated += 1
# 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:
logger.warning(f"Invoice/order status check failed: {e}")
# Step 5: Update sync run
total_imported = imported_count + already_imported_count # backward-compat
status = "completed" if error_count <= 10 else "failed"
await sqlite_service.update_sync_run(
run_id, status, len(orders), imported_count, len(skipped), error_count
run_id, status, len(orders), total_imported, len(skipped), error_count,
already_imported=already_imported_count, new_imported=imported_count
)
summary = {
"run_id": run_id,
"status": status,
"json_files": json_count,
"total_orders": len(orders),
"imported": imported_count,
"total_orders": len(orders) + cancelled_count,
"new_orders": len(truly_importable),
"imported": total_imported,
"new_imported": imported_count,
"already_imported": already_imported_count,
"skipped": len(skipped),
"errors": error_count,
"missing_skus": len(validation["missing"])
"cancelled": cancelled_count,
"missing_skus": len(validation["missing"]),
"invoices_updated": invoices_updated,
"invoices_cleared": invoices_cleared,
"orders_deleted_in_roa": orders_deleted,
}
_update_progress("completed",
f"Completed: {imported_count} new, {already_imported_count} already, {len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled",
len(truly_importable), len(truly_importable),
{"imported": imported_count, "skipped": len(skipped), "errors": error_count,
"already_imported": already_imported_count, "cancelled": cancelled_count})
if _current_sync:
_current_sync["status"] = status
_current_sync["finished_at"] = _now().isoformat()
logger.info(
f"Sync {run_id} completed: {imported_count} imported, "
f"{len(skipped)} skipped, {error_count} errors"
f"Sync {run_id} completed: {imported_count} new, {already_imported_count} already imported, "
f"{len(skipped)} skipped, {error_count} errors, {cancelled_count} cancelled"
)
duration = (_now() - started_dt).total_seconds()
_log_line(run_id, "")
cancelled_text = f", {cancelled_count} anulate" if cancelled_count else ""
_run_logs[run_id].append(
f"Finalizat: {imported_count} importate, {already_imported_count} deja importate, "
f"{len(skipped)} nemapate, {error_count} erori{cancelled_text} din {len(orders) + cancelled_count} comenzi | Durata: {int(duration)}s"
)
return summary
except Exception as e:
logger.error(f"Sync {run_id} failed: {e}")
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1)
_log_line(run_id, f"EROARE FATALA: {e}")
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
if _current_sync:
_current_sync["status"] = "failed"
_current_sync["finished_at"] = _now().isoformat()
_current_sync["error"] = str(e)
return {"run_id": run_id, "status": "failed", "error": str(e)}
finally:
_current_sync = None
# Keep _current_sync for 10 seconds so status endpoint can show final result
async def _clear_current_sync():
await asyncio.sleep(10)
global _current_sync
_current_sync = None
asyncio.ensure_future(_clear_current_sync())
async def _clear_run_logs():
await asyncio.sleep(300) # 5 minutes
_run_logs.pop(run_id, None)
asyncio.ensure_future(_clear_run_logs())
def stop_sync():
"""Signal sync to stop. Currently sync runs to completion."""
# For now, sync runs are not cancellable mid-flight.
# Future: use an asyncio.Event for cooperative cancellation.
pass

View File

@@ -3,21 +3,112 @@ from .. import database
logger = logging.getLogger(__name__)
def validate_skus(skus: set[str]) -> dict:
def check_orders_in_roa(min_date, conn) -> dict:
"""Check which orders already exist in Oracle COMENZI by date range.
Returns: {comanda_externa: id_comanda} for all existing orders.
Much faster than IN-clause batching — single query using date index.
"""
if conn is None:
return {}
existing = {}
try:
with conn.cursor() as cur:
cur.execute("""
SELECT comanda_externa, id_comanda FROM COMENZI
WHERE data_comanda >= :min_date
AND comanda_externa IS NOT NULL AND sters = 0
""", {"min_date": min_date})
for row in cur:
existing[str(row[0])] = row[1]
except Exception as e:
logger.error(f"check_orders_in_roa failed: {e}")
logger.info(f"ROA order check (since {min_date}): {len(existing)} existing orders found")
return existing
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.
Returns: {mapped: set, direct: set, missing: set}
Returns: {mapped: set, direct: set, missing: set, direct_id_map: {codmat: {"id_articol": int, "cont": str|None}}}
- mapped: found in ARTICOLE_TERTI (active)
- direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI)
- missing: not found anywhere
- direct_id_map: {codmat: {"id_articol": int, "cont": str|None}} for direct SKUs
"""
if not skus:
return {"mapped": set(), "direct": set(), "missing": set()}
return {"mapped": set(), "direct": set(), "missing": set(), "direct_id_map": {}}
mapped = set()
direct = set()
sku_list = list(skus)
with database.pool.acquire() as conn:
own_conn = conn is None
if own_conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
# Check in batches of 500
for i in range(0, len(sku_list), 500):
@@ -28,27 +119,29 @@ def validate_skus(skus: set[str]) -> dict:
# Check ARTICOLE_TERTI
cur.execute(f"""
SELECT DISTINCT sku FROM ARTICOLE_TERTI
WHERE sku IN ({placeholders}) AND activ = 1
WHERE sku IN ({placeholders}) AND activ = 1 AND sters = 0
""", params)
for row in cur:
mapped.add(row[0])
# Check NOM_ARTICOLE for remaining
remaining = [s for s in batch if s not in mapped]
if remaining:
placeholders2 = ",".join([f":n{j}" for j in range(len(remaining))])
params2 = {f"n{j}": sku for j, sku in enumerate(remaining)}
cur.execute(f"""
SELECT DISTINCT codmat FROM NOM_ARTICOLE
WHERE codmat IN ({placeholders2})
""", params2)
for row in cur:
direct.add(row[0])
# Resolve remaining SKUs via resolve_codmat_ids (consistent id_articol selection)
all_remaining = [s for s in sku_list if s not in mapped]
if all_remaining:
direct_id_map = resolve_codmat_ids(set(all_remaining), id_gestiuni, conn)
direct = set(direct_id_map.keys())
else:
direct_id_map = {}
direct = set()
finally:
if own_conn:
database.pool.release(conn)
missing = skus - mapped - direct
logger.info(f"SKU validation: {len(mapped)} mapped, {len(direct)} direct, {len(missing)} missing")
return {"mapped": mapped, "direct": direct, "missing": missing}
return {"mapped": mapped, "direct": direct, "missing": missing,
"direct_id_map": direct_id_map}
def classify_orders(orders, validation_result):
"""Classify orders as importable or skipped based on SKU validation.
@@ -69,3 +162,240 @@ def classify_orders(orders, validation_result):
importable.append(order)
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:
"""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.
Returns: {"has_price": set_of_codmats, "missing_price": set_of_codmats}
"""
if not codmats:
return {"has_price": set(), "missing_price": set()}
codmat_to_id = _extract_id_map(direct_id_map)
ids_with_price = set()
own_conn = conn is None
if own_conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
# Check which ID_ARTICOLs have a price in the policy
id_list = list(codmat_to_id.values())
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"] = id_pol
cur.execute(f"""
SELECT DISTINCT pa.ID_ARTICOL FROM CRM_POLITICI_PRET_ART pa
WHERE pa.ID_POL = :id_pol AND pa.ID_ARTICOL IN ({placeholders})
""", params)
for row in cur:
ids_with_price.add(row[0])
finally:
if own_conn:
database.pool.release(conn)
# Map back to CODMATs
has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price}
missing_price = codmats - has_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}
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.
Uses batch executemany instead of individual INSERTs.
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:
return
proc_tvav = 1 + (cota_tva / 100) if cota_tva else 1.21
own_conn = conn is None
if own_conn:
conn = database.get_oracle_connection()
try:
with conn.cursor() as cur:
# Get ID_VALUTA for this policy
cur.execute("""
SELECT ID_VALUTA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :id_pol
""", {"id_pol": id_pol})
row = cur.fetchone()
if not row:
logger.error(f"Price policy {id_pol} not found in CRM_POLITICI_PRETURI")
return
id_valuta = row[0]
# Build batch params using direct_id_map (already resolved via resolve_codmat_ids)
batch_params = []
codmat_id_map = _extract_id_map(direct_id_map)
for codmat in codmats:
id_articol = codmat_id_map.get(codmat)
if not id_articol:
logger.warning(f"CODMAT {codmat} not found in NOM_ARTICOLE, skipping price insert")
continue
batch_params.append({
"id_pol": id_pol,
"id_articol": id_articol,
"id_valuta": id_valuta,
"proc_tvav": proc_tvav
})
if batch_params:
cur.executemany("""
INSERT INTO CRM_POLITICI_PRET_ART
(ID_POL, ID_ARTICOL, PRET, ID_VALUTA,
ID_UTIL, DATAORA, PROC_TVAV, PRETFTVA, PRETCTVA)
VALUES
(:id_pol, :id_articol, 0, :id_valuta,
-3, SYSDATE, :proc_tvav, 0, 0)
""", batch_params)
logger.info(f"Batch inserted {len(batch_params)} price entries for policy {id_pol} (PROC_TVAV={proc_tvav})")
conn.commit()
finally:
if own_conn:
database.pool.release(conn)
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,169 +1,440 @@
/* ── Design tokens ───────────────────────────────── */
:root {
--sidebar-width: 220px;
--sidebar-bg: #1e293b;
--sidebar-text: #94a3b8;
--sidebar-active: #ffffff;
--sidebar-hover-bg: #334155;
--body-bg: #f1f5f9;
--card-shadow: 0 1px 3px rgba(0,0,0,0.08);
/* Surfaces */
--body-bg: #f9fafb;
--card-bg: #ffffff;
--card-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--card-radius: 0.5rem;
/* 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 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
background-color: var(--body-bg);
margin: 0;
padding: 0;
}
/* Sidebar */
.sidebar {
/* ── Top Navbar ──────────────────────────────────── */
.top-navbar {
position: fixed;
top: 0;
left: 0;
width: var(--sidebar-width);
height: 100vh;
background-color: var(--sidebar-bg);
padding: 0;
right: 0;
height: 48px;
background: #fff;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1.5rem;
z-index: 1000;
overflow-y: auto;
transition: transform 0.3s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid #334155;
.navbar-brand {
font-weight: 700;
font-size: 1rem;
color: #111827;
white-space: nowrap;
}
.sidebar-header h5 {
color: #fff;
margin: 0;
font-size: 1.1rem;
font-weight: 600;
.navbar-links {
display: flex;
align-items: stretch;
gap: 0;
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 {
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: 1.5rem;
padding-top: 64px;
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-bottom: 1.5rem;
min-height: 100vh;
max-width: 1280px;
margin-left: auto;
margin-right: auto;
}
/* Sidebar toggle button for mobile */
.sidebar-toggle {
position: fixed;
top: 0.5rem;
left: 0.5rem;
z-index: 1100;
border-radius: 0.375rem;
}
/* Cards */
/* ── Cards ───────────────────────────────────────── */
.card {
border: none;
box-shadow: var(--card-shadow);
border-radius: 0.5rem;
border-radius: var(--card-radius);
background: var(--card-bg);
}
.card-header {
background-color: #fff;
border-bottom: 1px solid #e2e8f0;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 0.9rem;
font-size: 0.9375rem;
padding: 0.75rem 1rem;
}
/* Status badges */
.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; }
/* Stat cards */
.stat-card {
text-align: center;
padding: 1rem;
}
.stat-card .stat-value {
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
}
.stat-card .stat-label {
font-size: 0.8rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Tables */
/* ── Tables ──────────────────────────────────────── */
.table {
font-size: 0.875rem;
font-size: 1rem;
}
.table th {
font-weight: 600;
color: #475569;
font-size: 0.8125rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
background: #f9fafb;
padding: 0.75rem 1rem;
border-top: none;
}
/* Forms */
.table td {
padding: 0.625rem 1rem;
color: var(--text-secondary);
font-size: 1rem;
}
/* Zebra striping */
.table tbody tr:nth-child(even) td { background-color: #f7f8fa; }
.table-hover tbody tr:hover td { background-color: #eef2ff !important; }
/* ── Badges — soft pill style ────────────────────── */
.badge {
font-size: 0.8125rem;
font-weight: 500;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
}
.badge.bg-success { background: var(--green-100) !important; color: var(--green-800) !important; }
.badge.bg-info { background: var(--blue-100) !important; color: var(--blue-800) !important; }
.badge.bg-warning { background: var(--yellow-100) !important; color: var(--yellow-800) !important; }
.badge.bg-danger { background: var(--red-100) !important; color: var(--red-800) !important; }
/* 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;
}
.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;
border-radius: 0.375rem;
border-color: #d1d5db;
}
.form-control:focus, .form-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
border-color: var(--blue-600);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* Responsive */
@media (max-width: 767.98px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.show {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.sidebar-toggle {
display: block !important;
}
/* ── Unified Pagination Bar ──────────────────────── */
.pagination-bar {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
/* Autocomplete dropdown */
.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;
transition: background 0.12s, border-color 0.12s;
text-decoration: none;
user-select: none;
}
.page-btn:hover:not(:disabled):not(.active) {
background: #f3f4f6;
border-color: #9ca3af;
color: var(--text-primary);
text-decoration: none;
}
.page-btn.active {
background: var(--blue-600);
border-color: var(--blue-600);
color: #fff;
font-weight: 600;
}
.page-btn:disabled, .page-btn.disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
/* Loading spinner ────────────────────────────────── */
.spinner-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.7);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
/* ── 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 {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.8125rem;
line-height: 1.5;
max-height: 600px;
overflow-y: auto;
padding: 1rem;
margin: 0;
background-color: #1e293b;
color: #e2e8f0;
white-space: pre-wrap;
word-wrap: break-word;
border-radius: 0 0 0.5rem 0.5rem;
}
/* ── Clickable table rows ────────────────────────── */
.table-hover tbody tr[data-href] {
cursor: pointer;
}
.table-hover tbody tr[data-href]:hover {
background-color: #f9fafb;
}
/* ── Sortable table headers ──────────────────────── */
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
background-color: #f3f4f6;
}
.sort-icon {
font-size: 0.75rem;
margin-left: 0.25rem;
color: var(--blue-600);
}
/* ── SKU group visual grouping ───────────────────── */
.sku-group-odd {
background-color: #f8fafc;
}
/* ── Editable cells ──────────────────────────────── */
.editable { cursor: pointer; }
.editable:hover { background-color: #f3f4f6; }
/* ── Order detail modal ──────────────────────────── */
.modal-lg .table-sm td,
.modal-lg .table-sm th {
font-size: 0.875rem;
padding: 0.35rem 0.5rem;
}
/* ── Modal stacking (quickMap over orderDetail) ───── */
#quickMapModal { z-index: 1060; }
#quickMapModal + .modal-backdrop,
.modal-backdrop ~ .modal-backdrop { z-index: 1055; }
/* ── 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 {
text-decoration: line-through;
opacity: 0.5;
}
/* ── Map icon button ─────────────────────────────── */
.btn-map-icon {
color: var(--blue-600);
padding: 0.1rem 0.25rem;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
}
.btn-map-icon:hover { color: var(--blue-700); }
/* ── Last sync summary card columns ─────────────── */
.last-sync-col {
border-right: 1px solid var(--border-color);
}
/* ── Cursor pointer utility ──────────────────────── */
.cursor-pointer { cursor: pointer; }
/* ── Filter bar ──────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.625rem 0;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
font-size: 0.9375rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-pill:hover { background: #f3f4f6; }
.filter-pill.active {
background: var(--blue-700);
border-color: var(--blue-700);
color: #fff;
}
.filter-pill.active .filter-count {
color: rgba(255,255,255,0.9);
}
.filter-count {
font-size: 0.8125rem;
font-weight: 600;
}
/* ── 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;
@@ -175,40 +446,331 @@ body {
overflow-y: auto;
width: 100%;
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
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.8rem;
}
/* Pagination */
.pagination .page-link {
font-size: 0.875rem;
}
/* Loading spinner */
.spinner-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.7);
z-index: 9999;
/* ── Tooltip for Client/Cont ─────────────────────── */
.tooltip-cont {
position: relative;
cursor: default;
}
.tooltip-cont::after {
content: attr(data-tooltip);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #f9fafb;
font-size: 0.75rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
}
.tooltip-cont:hover::after { opacity: 1; }
/* ── Sync card ───────────────────────────────────── */
.sync-card {
background: #fff;
border: 1px solid var(--border-color);
border-radius: var(--card-radius);
overflow: hidden;
margin-bottom: 1rem;
}
.sync-card-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
flex-wrap: wrap;
}
.sync-card-divider {
height: 1px;
background: var(--border-color);
margin: 0;
}
.sync-card-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
font-size: 1rem;
color: var(--text-muted);
cursor: pointer;
transition: background 0.12s;
}
.sync-card-info:hover { background: #f9fafb; }
.sync-card-progress {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
background: #eff6ff;
font-size: 1rem;
color: var(--blue-700);
border-top: 1px solid #dbeafe;
}
/* ── Pulsing live dot (keep as-is) ──────────────── */
.sync-live-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #3b82f6;
animation: pulse-dot 1.2s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
/* ── Status dot (keep as-is) ─────────────────────── */
.sync-status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.sync-status-dot.idle { background: #9ca3af; }
.sync-status-dot.running { background: #3b82f6; animation: pulse-dot 1.2s ease-in-out infinite; }
.sync-status-dot.completed { background: #10b981; }
.sync-status-dot.failed { background: #ef4444; }
/* ── Custom period range inputs ──────────────────── */
.period-custom-range {
display: none;
gap: 0.375rem;
align-items: center;
font-size: 0.9375rem;
}
.period-custom-range.visible { display: flex; }
/* ── select-compact (used in filter bars) ─────────── */
.select-compact {
padding: 0.375rem 0.5rem;
font-size: 0.9375rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
cursor: pointer;
}
/* ── btn-compact (kept for backward compat) ──────── */
.btn-compact {
padding: 0.375rem 0.75rem;
font-size: 0.9375rem;
}
/* ── Result banner ───────────────────────────────── */
.result-banner {
padding: 0.4rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.9375rem;
background: #d1fae5;
color: #065f46;
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

@@ -1,142 +1,148 @@
let refreshInterval = null;
// ── State ─────────────────────────────────────────
let dashPage = 1;
let dashPerPage = 50;
let dashSortCol = 'order_date';
let dashSortDir = 'desc';
let dashSearchTimeout = null;
let currentQmSku = '';
let currentQmOrderNumber = '';
let qmAcTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
// Auto-refresh every 10 seconds
refreshInterval = setInterval(loadDashboard, 10000);
// Sync polling state
let _pollInterval = null;
let _lastSyncStatus = null;
let _lastRunId = null;
let _currentRunId = null;
let _pollIntervalMs = 5000; // default, overridden from settings
let _knownLastRunId = null; // track last_run.run_id to detect missed syncs
// ── Init ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
await initPollInterval();
loadSchedulerStatus();
loadDashOrders();
startSyncPolling();
wireFilterBar();
});
async function loadDashboard() {
await Promise.all([
loadSyncStatus(),
loadSyncHistory(),
loadMissingSkus(),
loadSchedulerStatus()
]);
async function initPollInterval() {
try {
const data = await fetchJSON('/api/settings');
const sec = parseInt(data.dashboard_poll_seconds) || 5;
_pollIntervalMs = sec * 1000;
} catch(e) {}
}
async function loadSyncStatus() {
// ── Smart Sync Polling ────────────────────────────
function startSyncPolling() {
if (_pollInterval) clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, _pollIntervalMs);
pollSyncStatus(); // immediate first call
}
async function pollSyncStatus() {
try {
const res = await fetch('/api/sync/status');
const data = await res.json();
const data = await fetchJSON('/api/sync/status');
updateSyncPanel(data);
const isRunning = data.status === 'running';
const wasRunning = _lastSyncStatus === 'running';
// Update stats
const stats = data.stats || {};
document.getElementById('stat-imported').textContent = stats.imported || 0;
document.getElementById('stat-skipped').textContent = stats.skipped || 0;
document.getElementById('stat-missing').textContent = stats.missing_skus || 0;
// 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;
// Update sync status badge
const badge = document.getElementById('syncStatusBadge');
const status = data.status || 'idle';
badge.textContent = status;
badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
if (isRunning && !wasRunning) {
// Switched to running — speed up polling
clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, 3000);
} else if (!isRunning && wasRunning) {
// Sync just completed — slow down and refresh orders
clearInterval(_pollInterval);
_pollInterval = setInterval(pollSyncStatus, _pollIntervalMs);
loadDashOrders();
} else if (missedSync) {
// Sync completed while we weren't watching (e.g. auto-sync) — refresh orders
loadDashOrders();
}
_lastSyncStatus = data.status;
} catch (e) {
console.warn('Sync status poll failed:', e);
}
}
// Show/hide start/stop buttons
if (status === 'running') {
document.getElementById('btnStartSync').classList.add('d-none');
document.getElementById('btnStopSync').classList.remove('d-none');
document.getElementById('syncProgressText').textContent = data.progress || 'Running...';
} else {
document.getElementById('btnStartSync').classList.remove('d-none');
document.getElementById('btnStopSync').classList.add('d-none');
function updateSyncPanel(data) {
const dot = document.getElementById('syncStatusDot');
const txt = document.getElementById('syncStatusText');
const progressArea = document.getElementById('syncProgressArea');
const progressText = document.getElementById('syncProgressText');
const startBtn = document.getElementById('syncStartBtn');
// Show last run info
if (stats.last_run) {
const lr = stats.last_run;
const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
document.getElementById('syncProgressText').textContent =
`Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} skip, ${lr.errors || 0} err`;
if (dot) {
dot.className = 'sync-status-dot ' + (data.status || 'idle');
}
const statusLabels = { running: 'A ruleaza...', idle: 'Inactiv', completed: 'Finalizat', failed: 'Eroare' };
if (txt) txt.textContent = statusLabels[data.status] || data.status || 'Inactiv';
if (startBtn) startBtn.disabled = data.status === 'running';
// Track current running sync run_id
if (data.status === 'running' && data.run_id) {
_currentRunId = data.run_id;
} else {
_currentRunId = null;
}
// Live progress area
if (progressArea) {
progressArea.style.display = data.status === 'running' ? 'flex' : 'none';
}
if (progressText && data.phase_text) {
progressText.textContent = data.phase_text;
}
// Last run info
const lr = data.last_run;
if (lr) {
_lastRunId = lr.run_id;
const d = document.getElementById('lastSyncDate');
const dur = document.getElementById('lastSyncDuration');
const cnt = document.getElementById('lastSyncCounts');
const st = document.getElementById('lastSyncStatus');
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 (cnt) {
const newImp = lr.new_imported || 0;
const already = lr.already_imported || 0;
if (already > 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 {
document.getElementById('syncProgressText').textContent = '';
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`;
}
}
} catch (err) {
console.error('loadSyncStatus error:', err);
}
}
async function loadSyncHistory() {
try {
const res = await fetch('/api/sync/history?per_page=10');
const data = await res.json();
const tbody = document.getElementById('syncRunsBody');
if (!data.runs || data.runs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
return;
if (st) {
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
}
tbody.innerHTML = data.runs.map(r => {
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
let duration = '-';
if (r.started_at && r.finished_at) {
const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
duration = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
}
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
return `<tr style="cursor:pointer" onclick="window.location='/sync/run/${esc(r.run_id)}'">
<td>${started}</td>
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
<td>${r.total_orders || 0}</td>
<td class="text-success">${r.imported || 0}</td>
<td class="text-warning">${r.skipped || 0}</td>
<td class="text-danger">${r.errors || 0}</td>
<td>${duration}</td>
</tr>`;
}).join('');
} catch (err) {
console.error('loadSyncHistory error:', err);
}
}
async function loadMissingSkus() {
try {
const res = await fetch('/api/validate/missing-skus');
const data = await res.json();
const tbody = document.getElementById('missingSkusBody');
// Update stat card
document.getElementById('stat-missing').textContent = data.unresolved || 0;
const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
if (unresolved.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">Toate SKU-urile sunt mapate</td></tr>';
return;
// Wire last-sync-row click → journal (use current running sync if active)
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
const targetId = _currentRunId || _lastRunId;
if (targetId) window.location = (window.ROOT_PATH || '') + '/logs?run=' + targetId;
});
document.getElementById('lastSyncRow')?.addEventListener('keydown', (e) => {
const targetId = _currentRunId || _lastRunId;
if ((e.key === 'Enter' || e.key === ' ') && targetId) {
window.location = '/logs?run=' + targetId;
}
});
});
tbody.innerHTML = unresolved.slice(0, 10).map(s => `
<tr>
<td><code>${esc(s.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td>
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
<td>
<a href="/mappings?sku=${encodeURIComponent(s.sku)}" class="btn btn-sm btn-outline-primary" title="Creeaza mapare">
<i class="bi bi-plus-lg"></i>
</a>
</td>
</tr>
`).join('');
} catch (err) {
console.error('loadMissingSkus error:', err);
}
}
async function loadSchedulerStatus() {
try {
const res = await fetch('/api/sync/schedule');
const data = await res.json();
document.getElementById('schedulerToggle').checked = data.enabled || false;
if (data.interval_minutes) {
document.getElementById('schedulerInterval').value = data.interval_minutes;
}
} catch (err) {
console.error('loadSchedulerStatus error:', err);
}
}
// ── Sync Controls ─────────────────────────────────
async function startSync() {
try {
@@ -144,8 +150,10 @@ async function startSync() {
const data = await res.json();
if (data.error) {
alert(data.error);
return;
}
loadDashboard();
// Polling will detect the running state — just speed it up immediately
pollSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
@@ -154,43 +162,15 @@ async function startSync() {
async function stopSync() {
try {
await fetch('/api/sync/stop', { method: 'POST' });
loadDashboard();
pollSyncStatus();
} catch (err) {
alert('Eroare: ' + err.message);
}
}
async function scanOrders() {
const btn = document.getElementById('btnScan');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Scanning...';
try {
const res = await fetch('/api/validate/scan', { method: 'POST' });
const data = await res.json();
// Update pending/ready stats
document.getElementById('stat-pending').textContent = data.total_orders || 0;
document.getElementById('stat-ready').textContent = data.importable || 0;
let msg = `Scan complet: ${data.total_orders || 0} comenzi, ${data.importable || 0} ready, ${data.skipped || 0} skipped`;
if (data.skus && data.skus.missing > 0) {
msg += `, ${data.skus.missing} SKU-uri lipsa`;
}
alert(msg);
loadDashboard();
} catch (err) {
alert('Eroare scan: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-search"></i> Scan';
}
}
async function toggleScheduler() {
const enabled = document.getElementById('schedulerToggle').checked;
const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
const interval = parseInt(document.getElementById('schedulerInterval').value) || 10;
try {
await fetch('/api/sync/schedule', {
method: 'PUT',
@@ -209,7 +189,619 @@ async function updateSchedulerInterval() {
}
}
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;');
async function loadSchedulerStatus() {
try {
const res = await fetch('/api/sync/schedule');
const data = await res.json();
document.getElementById('schedulerToggle').checked = data.enabled || false;
if (data.interval_minutes) {
document.getElementById('schedulerInterval').value = data.interval_minutes;
}
} catch (err) {
console.error('loadSchedulerStatus error:', err);
}
}
// ── Filter Bar wiring ─────────────────────────────
function wireFilterBar() {
// Period dropdown
document.getElementById('periodSelect')?.addEventListener('change', function () {
const cr = document.getElementById('customRangeInputs');
if (this.value === 'custom') {
cr?.classList.add('visible');
} else {
cr?.classList.remove('visible');
dashPage = 1;
loadDashOrders();
}
});
// Custom range inputs
['periodStart', 'periodEnd'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => {
const s = document.getElementById('periodStart')?.value;
const e = document.getElementById('periodEnd')?.value;
if (s && e) { dashPage = 1; loadDashOrders(); }
});
});
// Status pills
document.querySelectorAll('.filter-pill[data-status]').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
dashPage = 1;
loadDashOrders();
});
});
// Search — 300ms debounce
document.getElementById('orderSearch')?.addEventListener('input', () => {
clearTimeout(dashSearchTimeout);
dashSearchTimeout = setTimeout(() => {
dashPage = 1;
loadDashOrders();
}, 300);
});
}
// ── Dashboard Orders Table ────────────────────────
function dashSortBy(col) {
if (dashSortCol === col) {
dashSortDir = dashSortDir === 'asc' ? 'desc' : 'asc';
} else {
dashSortCol = col;
dashSortDir = 'asc';
}
document.querySelectorAll('.sort-icon').forEach(span => {
const c = span.dataset.col;
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
});
dashPage = 1;
loadDashOrders();
}
async function loadDashOrders() {
const periodVal = document.getElementById('periodSelect')?.value || '7';
const params = new URLSearchParams();
if (periodVal === 'custom') {
const s = document.getElementById('periodStart')?.value;
const e = document.getElementById('periodEnd')?.value;
if (s && e) {
params.set('period_start', s);
params.set('period_end', e);
params.set('period_days', '0');
}
} else {
params.set('period_days', periodVal);
}
const activeStatus = document.querySelector('.filter-pill.active')?.dataset.status;
if (activeStatus && activeStatus !== 'all') params.set('status', activeStatus);
const search = document.getElementById('orderSearch')?.value?.trim();
if (search) params.set('search', search);
params.set('page', dashPage);
params.set('per_page', dashPerPage);
params.set('sort_by', dashSortCol);
params.set('sort_dir', dashSortDir);
try {
const res = await fetch(`/api/dashboard/orders?${params}`);
const data = await res.json();
// Update filter-pill badge counts
const c = data.counts || {};
const el = (id) => document.getElementById(id);
if (el('cntAll')) el('cntAll').textContent = c.total || 0;
if (el('cntImp')) el('cntImp').textContent = c.imported_all || c.imported || 0;
if (el('cntSkip')) el('cntSkip').textContent = c.skipped || 0;
if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 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 orders = data.orders || [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map(o => {
const dateStr = fmtDate(o.order_date);
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
<td>${statusDot(o.status)}</td>
<td class="text-nowrap">${dateStr}</td>
${renderClientCell(o)}
<td><code>${esc(o.order_number)}</code></td>
<td>${o.items_count || 0}</td>
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td>
<td class="text-center">${invoiceDot(o)}</td>
</tr>`;
}).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
const pag = data.pagination || {};
const totalPages = pag.total_pages || data.pages || 1;
const totalOrders = (data.counts || {}).total || data.total || 0;
const pagOpts = { perPage: dashPerPage, perPageFn: 'dashChangePerPage', perPageOptions: [25, 50, 100, 250] };
const pagHtml = `<small class="text-muted me-auto">${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}</small>` + renderUnifiedPagination(dashPage, totalPages, 'dashGoPage', pagOpts);
const pagDiv = document.getElementById('dashPagination');
if (pagDiv) pagDiv.innerHTML = pagHtml;
const pagDivTop = document.getElementById('dashPaginationTop');
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
// Update sort icons
document.querySelectorAll('.sort-icon').forEach(span => {
const c = span.dataset.col;
span.textContent = c === dashSortCol ? (dashSortDir === 'asc' ? '\u2191' : '\u2193') : '';
});
} catch (err) {
document.getElementById('dashOrdersBody').innerHTML =
`<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
}
}
function dashGoPage(p) {
dashPage = p;
loadDashOrders();
}
function dashChangePerPage(val) {
dashPerPage = parseInt(val) || 50;
dashPage = 1;
loadDashOrders();
}
// ── Client cell with Cont tooltip (Task F4) ───────
function renderClientCell(order) {
const display = (order.customer_name || order.shipping_name || '').trim();
const billing = (order.billing_name || '').trim();
const shipping = (order.shipping_name || '').trim();
const isDiff = display !== shipping && shipping;
if (isDiff) {
return `<td class="tooltip-cont fw-bold" data-tooltip="Livrare: ${escHtml(shipping)}">${escHtml(display)}&nbsp;<sup style="color:#6b7280;font-size:0.65rem">&#9650;</sup></td>`;
}
return `<td class="fw-bold">${escHtml(display || billing || '\u2014')}</td>`;
}
// ── Helper functions ──────────────────────────────
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
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;');
}
// Alias kept for backward compat with inline handlers in modal
function esc(s) { return escHtml(s); }
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function statusLabelText(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 orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
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) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
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 item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
).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 ────────────────────────────
async function openDashOrderDetail(orderNumber) {
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
document.getElementById('detailCustomer').textContent = '...';
document.getElementById('detailDate').textContent = '';
document.getElementById('detailStatus').innerHTML = '';
document.getElementById('detailIdComanda').textContent = '-';
document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
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 existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
}
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
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) {
document.getElementById('detailError').textContent = order.error_message;
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 || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
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' : '-';
// Store items for quick map pre-population
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>
<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>${renderCodmatCell(item)}</td>
<td>${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td class="text-end">${valoare}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
}
// ── Quick Map Modal ───────────────────────────────
function openQuickMap(sku, productName, orderNumber, itemIdx) {
currentQmSku = sku;
currentQmOrderNumber = orderNumber;
document.getElementById('qmSku').textContent = sku;
document.getElementById('qmProductName').textContent = productName || '-';
document.getElementById('qmPctWarning').style.display = 'none';
const container = document.getElementById('qmCodmatLines');
container.innerHTML = '';
// 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();
}
function addQmCodmatLine(prefill) {
const container = document.getElementById('qmCodmatLines');
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');
div.className = 'qm-line';
div.innerHTML = `
<div class="qm-row">
<div class="qm-codmat-wrap position-relative">
<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>
<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">
<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">
${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>'}
</div>
<div class="qm-selected text-muted" style="font-size:0.75rem;padding-left:2px">${escHtml(denumireVal)}</div>
`;
container.appendChild(div);
const input = div.querySelector('.qm-codmat');
const dropdown = div.querySelector('.qm-ac-dropdown');
const selected = div.querySelector('.qm-selected');
input.addEventListener('input', () => {
clearTimeout(qmAcTimeout);
qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function qmAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> &mdash; <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function qmSelectArticle(el, codmat, label) {
const line = el.closest('.qm-line');
line.querySelector('.qm-codmat').value = codmat;
line.querySelector('.qm-selected').textContent = label;
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
}
async function saveQuickMapping() {
const lines = document.querySelectorAll('.qm-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.qm-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('qmPctWarning').style.display = '';
return;
}
}
document.getElementById('qmPctWarning').style.display = 'none';
try {
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
if (currentQmOrderNumber) openDashOrderDetail(currentQmOrderNumber);
loadDashOrders();
} else {
const msg = data.detail || data.error || 'Unknown';
document.getElementById('qmPctWarning').textContent = msg;
document.getElementById('qmPctWarning').style.display = '';
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}

597
api/app/static/js/logs.js Normal file
View File

@@ -0,0 +1,597 @@
// logs.js - Structured order viewer with text log fallback
let currentRunId = null;
let runsPage = 1;
let logPollTimer = null;
let currentFilter = 'all';
let ordersPage = 1;
let currentQmSku = '';
let currentQmOrderNumber = '';
let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc';
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt);
if (isNaN(diffMs) || diffMs < 0) return '-';
const secs = Math.round(diffMs / 1000);
if (secs < 60) return secs + 's';
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
}
function runStatusBadge(status) {
switch ((status || '').toLowerCase()) {
case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
default: return `<span style="font-weight:600">${esc(status)}</span>`;
}
}
function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</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 ────────────────────────────────
async function loadRuns() {
// Load all recent runs for dropdown
try {
const res = await fetch(`/api/sync/history?page=1&per_page=100`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const runs = data.runs || [];
const dd = document.getElementById('runsDropdown');
if (runs.length === 0) {
dd.innerHTML = '<option value="">Niciun sync run</option>';
} else {
dd.innerHTML = '<option value="">-- Selecteaza un run --</option>' +
runs.map(r => {
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '?';
const st = (r.status || '').toUpperCase();
const statusEmoji = st === 'COMPLETED' ? '✓' : st === 'RUNNING' ? '⟳' : '✗';
const newImp = r.new_imported || 0;
const already = r.already_imported || 0;
const imp = r.imported || 0;
const skip = r.skipped || 0;
const err = r.errors || 0;
const impLabel = already > 0 ? `${newImp} noi, ${already} deja` : `${imp} imp`;
const label = `${started}${statusEmoji} ${r.status} (${impLabel}, ${skip} skip, ${err} err)`;
const selected = r.run_id === currentRunId ? 'selected' : '';
return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
}).join('');
}
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile) ddMobile.innerHTML = dd.innerHTML;
} catch (err) {
const dd = document.getElementById('runsDropdown');
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
}
}
// ── Run Selection ────────────────────────────────
async function selectRun(runId) {
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
currentRunId = runId;
currentFilter = 'all';
ordersPage = 1;
const url = new URL(window.location);
if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); }
history.replaceState(null, '', url);
// Sync dropdown selection
const dd = document.getElementById('runsDropdown');
if (dd && dd.value !== runId) dd.value = runId;
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
if (!runId) {
document.getElementById('logViewerSection').style.display = 'none';
return;
}
document.getElementById('logViewerSection').style.display = '';
const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
document.getElementById('logStatusBadge').innerHTML = '...';
document.getElementById('textLogSection').style.display = 'none';
await loadRunOrders(runId, 'all', 1);
// Also load text log in background
fetchTextLog(runId);
}
// ── Per-Order Filtering (R1) ─────────────────────
async function loadRunOrders(runId, statusFilter, page) {
if (statusFilter != null) currentFilter = statusFilter;
if (page != null) ordersPage = page;
// Update filter pill active state
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.classList.toggle('active', btn.dataset.logStatus === currentFilter);
});
try {
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/orders?status=${currentFilter}&page=${ordersPage}&per_page=50&sort_by=${ordersSortColumn}&sort_dir=${ordersSortDirection}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const counts = data.counts || {};
document.getElementById('countAll').textContent = counts.total || 0;
document.getElementById('countImported').textContent = counts.imported || 0;
document.getElementById('countSkipped').textContent = counts.skipped || 0;
document.getElementById('countError').textContent = counts.error || 0;
const alreadyEl = document.getElementById('countAlreadyImported');
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
const tbody = document.getElementById('runOrdersBody');
const orders = data.orders || [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map((o, i) => {
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)}')">
<td>${statusDot(o.status)}</td>
<td>${(ordersPage - 1) * 50 + i + 1}</td>
<td class="text-nowrap">${dateStr}</td>
<td><code>${esc(o.order_number)}</code></td>
<td class="fw-bold">${esc(o.customer_name)}</td>
<td>${o.items_count || 0}</td>
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td>
</tr>`;
}).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
const totalPages = data.pages || 1;
const infoEl = document.getElementById('ordersPageInfo');
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');
if (pagDiv) pagDiv.innerHTML = pagHtml;
const pagDivTop = document.getElementById('ordersPaginationTop');
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
// Update run status badge
const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
const runData = await runRes.json();
if (runData.run) {
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) {
document.getElementById('runOrdersBody').innerHTML =
`<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
}
}
function filterOrders(status) {
loadRunOrders(currentRunId, status, 1);
}
function sortOrdersBy(col) {
if (ordersSortColumn === col) {
ordersSortDirection = ordersSortDirection === 'asc' ? 'desc' : 'asc';
} else {
ordersSortColumn = col;
ordersSortDirection = 'asc';
}
// Update sort icons
document.querySelectorAll('#logViewerSection .sort-icon').forEach(span => {
const c = span.dataset.col;
span.textContent = c === ordersSortColumn ? (ordersSortDirection === 'asc' ? '\u2191' : '\u2193') : '';
});
loadRunOrders(currentRunId, null, 1);
}
// ── Text Log (collapsible) ──────────────────────
function toggleTextLog() {
const section = document.getElementById('textLogSection');
section.style.display = section.style.display === 'none' ? '' : 'none';
if (section.style.display !== 'none' && currentRunId) {
fetchTextLog(currentRunId);
}
}
async function fetchTextLog(runId) {
// Clear any existing poll timer to prevent accumulation
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
try {
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
document.getElementById('logContent').textContent = data.text || '(log gol)';
if (!data.finished) {
if (document.getElementById('autoRefreshToggle')?.checked) {
logPollTimer = setInterval(async () => {
try {
const r = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/text-log`);
const d = await r.json();
if (currentRunId !== runId) { clearInterval(logPollTimer); return; }
document.getElementById('logContent').textContent = d.text || '(log gol)';
const el = document.getElementById('logContent');
el.scrollTop = el.scrollHeight;
if (d.finished) {
clearInterval(logPollTimer);
logPollTimer = null;
loadRuns();
loadRunOrders(runId, currentFilter, ordersPage);
}
} catch (e) { console.error('Poll error:', e); }
}, 2500);
}
}
} catch (err) {
document.getElementById('logContent').textContent = 'Eroare: ' + err.message;
}
}
// ── Multi-CODMAT helper (D1) ─────────────────────
function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
const d = item.codmat_details[0];
return `<code>${esc(d.codmat)}</code>`;
}
// Multi-CODMAT: compact list
return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa} (${d.procent_pret}%)</span></div>`
).join('');
}
// ── Order Detail Modal (R9) ─────────────────────
async function openOrderDetail(orderNumber) {
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
document.getElementById('detailCustomer').textContent = '...';
document.getElementById('detailDate').textContent = '';
document.getElementById('detailStatus').innerHTML = '';
document.getElementById('detailIdComanda').textContent = '-';
document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
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 existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
}
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
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 || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
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 => {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
return `<tr>
<td><code>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${codmatCell}</td>
<td>${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td class="text-end">${valoare}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
}
// ── Quick Map Modal (from order detail) ──────────
let qmAcTimeout = null;
function openQuickMap(sku, productName, orderNumber) {
currentQmSku = sku;
currentQmOrderNumber = orderNumber;
document.getElementById('qmSku').textContent = sku;
document.getElementById('qmProductName').textContent = productName || '-';
document.getElementById('qmPctWarning').style.display = 'none';
// Reset CODMAT lines
const container = document.getElementById('qmCodmatLines');
container.innerHTML = '';
addQmCodmatLine();
// Show quick map on top of order detail (modal stacking)
new bootstrap.Modal(document.getElementById('quickMapModal')).show();
}
function addQmCodmatLine() {
const container = document.getElementById('qmCodmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 qm-line';
div.innerHTML = `
<div class="mb-2 position-relative">
<label class="form-label form-label-sm mb-1">CODMAT (Articol ROA)</label>
<input type="text" class="form-control form-control-sm qm-codmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none qm-ac-dropdown"></div>
<small class="text-muted qm-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 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>
`;
container.appendChild(div);
// Setup autocomplete on the new input
const input = div.querySelector('.qm-codmat');
const dropdown = div.querySelector('.qm-ac-dropdown');
const selected = div.querySelector('.qm-selected');
input.addEventListener('input', () => {
clearTimeout(qmAcTimeout);
qmAcTimeout = setTimeout(() => qmAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function qmAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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="qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function qmSelectArticle(el, codmat, label) {
const line = el.closest('.qm-line');
line.querySelector('.qm-codmat').value = codmat;
line.querySelector('.qm-selected').textContent = label;
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
}
async function saveQuickMapping() {
const lines = document.querySelectorAll('.qm-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.qm-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.qm-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.qm-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
// Validate percentage sum for multi-line
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('qmPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('qmPctWarning').style.display = '';
return;
}
}
document.getElementById('qmPctWarning').style.display = 'none';
try {
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentQmSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
// Refresh order detail items in the still-open modal
if (currentQmOrderNumber) openOrderDetail(currentQmOrderNumber);
// Refresh orders view
loadRunOrders(currentRunId, currentFilter, ordersPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// ── Init ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadRuns();
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.addEventListener('click', function() {
filterOrders(this.dataset.logStatus || 'all');
});
});
const preselected = document.getElementById('preselectedRun');
const urlParams = new URLSearchParams(window.location.search);
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
if (runFromUrl) {
selectRun(runFromUrl);
}
document.getElementById('autoRefreshToggle')?.addEventListener('change', (e) => {
if (e.target.checked) {
// Resume polling if we have an active run
if (currentRunId) fetchTextLog(currentRunId);
} else {
// Pause polling
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,9 +1,19 @@
let currentPage = 1;
let mappingsPerPage = 50;
let currentSearch = '';
let searchTimeout = null;
let sortColumn = 'sku';
let sortDirection = 'asc';
let editingMapping = null; // {sku, codmat} when editing
let pctFilter = 'all';
// Load on page ready
document.addEventListener('DOMContentLoaded', loadMappings);
document.addEventListener('DOMContentLoaded', () => {
loadMappings();
initAddModal();
initDeleteModal();
initPctFilterPills();
});
function debounceSearch() {
clearTimeout(searchTimeout);
@@ -14,156 +24,469 @@ function debounceSearch() {
}, 300);
}
// ── Sorting (R7) ─────────────────────────────────
function sortBy(col) {
if (sortColumn === col) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = col;
sortDirection = 'asc';
}
currentPage = 1;
loadMappings();
}
function updateSortIcons() {
document.querySelectorAll('.sort-icon').forEach(span => {
const col = span.dataset.col;
if (col === sortColumn) {
span.textContent = sortDirection === 'asc' ? '\u2191' : '\u2193';
} else {
span.textContent = '';
}
});
}
// ── Pct Filter Pills ─────────────────────────────
function initPctFilterPills() {
document.querySelectorAll('.filter-pill[data-pct]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
pctFilter = this.dataset.pct;
currentPage = 1;
loadMappings();
});
});
}
function updatePctCounts(counts) {
if (!counts) return;
const elAll = document.getElementById('mCntAll');
const elComplete = document.getElementById('mCntComplete');
const elIncomplete = document.getElementById('mCntIncomplete');
if (elAll) elAll.textContent = counts.total || 0;
if (elComplete) elComplete.textContent = counts.complete || 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 ────────────────────────────────
async function loadMappings() {
const showInactive = document.getElementById('showInactive')?.checked;
const showDeleted = document.getElementById('showDeleted')?.checked;
const params = new URLSearchParams({
search: currentSearch,
page: currentPage,
per_page: 50
per_page: mappingsPerPage,
sort_by: sortColumn,
sort_dir: sortDirection
});
if (showDeleted) params.set('show_deleted', 'true');
if (pctFilter && pctFilter !== 'all') params.set('pct_filter', pctFilter);
try {
const res = await fetch(`/api/mappings?${params}`);
const data = await res.json();
renderTable(data.mappings);
let mappings = data.mappings || [];
// Client-side filter for inactive unless toggle is on
// (keep deleted rows visible when showDeleted is on, even if inactive)
if (!showInactive) {
mappings = mappings.filter(m => m.activ || m.sters);
}
updatePctCounts(data.counts);
renderTable(mappings, showDeleted);
renderPagination(data);
updateSortIcons();
} catch (err) {
document.getElementById('mappingsBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
document.getElementById('mappingsFlatList').innerHTML =
`<div class="flat-row text-danger py-3 justify-content-center">Eroare: ${err.message}</div>`;
}
}
function renderTable(mappings) {
const tbody = document.getElementById('mappingsBody');
function renderTable(mappings, showDeleted) {
const container = document.getElementById('mappingsFlatList');
if (!mappings || mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" 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;
}
tbody.innerHTML = mappings.map(m => `
<tr>
<td><strong>${esc(m.sku)}</strong></td>
<td><code>${esc(m.codmat)}</code></td>
<td>${esc(m.denumire || '-')}</td>
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})">${m.cantitate_roa}</td>
<td class="editable" onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})">${m.procent_pret}%</td>
<td>
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" style="cursor:pointer"
onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})">
${m.activ ? 'Activ' : 'Inactiv'}
</span>
</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Dezactiveaza">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
let prevSku = null;
let html = '';
mappings.forEach(m => {
const isNewGroup = m.sku !== prevSku;
if (isNewGroup) {
let pctBadge = '';
if (m.pct_total !== undefined) {
pctBadge = m.is_complete
? ` <span class="badge-pct complete">&#10003; 100%</span>`
: ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
}
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
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 += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
<code>${esc(m.codmat)}</code>
<span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
<span class="text-nowrap" style="font-size:0.875rem">
<span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
· <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
</span>
</div>`;
prevSku = m.sku;
});
container.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) {
const info = document.getElementById('pageInfo');
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`;
const ul = document.getElementById('pagination');
if (data.pages <= 1) { ul.innerHTML = ''; return; }
let html = '';
// Previous
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">&laquo;</a></li>`;
// Pages (show max 7)
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>`;
}
// Next
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;
const pagOpts = { perPage: mappingsPerPage, perPageFn: 'mappingsChangePerPage', perPageOptions: [25, 50, 100, 250] };
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 top = document.getElementById('mappingsPagTop');
const bot = document.getElementById('mappingsPagBottom');
if (top) top.innerHTML = pagHtml;
if (bot) bot.innerHTML = pagHtml;
}
function mappingsChangePerPage(val) { mappingsPerPage = parseInt(val) || 50; currentPage = 1; loadMappings(); }
function goPage(p) {
currentPage = p;
loadMappings();
}
// Autocomplete for CODMAT
let acTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('inputCodmat');
if (!input) return;
// ── Multi-CODMAT Add Modal (R11) ─────────────────
let acTimeouts = {};
function initAddModal() {
const modal = document.getElementById('addModal');
if (!modal) return;
modal.addEventListener('show.bs.modal', () => {
if (!editingMapping) {
clearAddForm();
}
});
modal.addEventListener('hidden.bs.modal', () => {
editingMapping = null;
document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
});
}
function clearAddForm() {
document.getElementById('inputSku').value = '';
document.getElementById('inputSku').readOnly = false;
document.getElementById('addModalProductName').style.display = 'none';
document.getElementById('pctWarning').style.display = 'none';
document.getElementById('addModalTitle').textContent = 'Adauga Mapare';
const container = document.getElementById('codmatLines');
container.innerHTML = '';
addCodmatLine();
}
async function openEditModal(sku, codmat, cantitate, procent) {
editingMapping = { sku, codmat };
document.getElementById('addModalTitle').textContent = 'Editare Mapare';
document.getElementById('inputSku').value = sku;
document.getElementById('inputSku').readOnly = false;
document.getElementById('pctWarning').style.display = 'none';
const container = document.getElementById('codmatLines');
container.innerHTML = '';
try {
// Fetch all CODMATs for this SKU
const res = await fetch(`/api/mappings?search=${encodeURIComponent(sku)}&per_page=100`);
const data = await res.json();
const allMappings = (data.mappings || []).filter(m => m.sku === sku && !m.sters);
// 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();
}
function addCodmatLine() {
const container = document.getElementById('codmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 codmat-line';
div.innerHTML = `
<div class="row g-2 align-items-center">
<div class="col position-relative">
<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>
<small class="text-muted cl-selected"></small>
</div>
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
</div>
<div class="col-auto" style="width:90px">
<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>
`;
container.appendChild(div);
// Setup autocomplete
const input = div.querySelector('.cl-codmat');
const dropdown = div.querySelector('.cl-ac-dropdown');
const selected = div.querySelector('.cl-selected');
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => autocomplete(input.value), 250);
const key = 'cl_' + idx;
clearTimeout(acTimeouts[key]);
acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200);
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
});
}
async function autocomplete(q) {
const dropdown = document.getElementById('autocompleteDropdown');
async function clAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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; }
if (!data.results || data.results.length === 0) {
dropdown.classList.add('d-none');
return;
}
dropdown.innerHTML = data.results.map(r => `
<div class="autocomplete-item" onmousedown="selectArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
<span class="codmat">${esc(r.codmat)}</span>
<br><span class="denumire">${esc(r.denumire)}</span>
</div>
`).join('');
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="clSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch (err) {
dropdown.classList.add('d-none');
}
} catch { dropdown.classList.add('d-none'); }
}
function selectArticle(codmat, denumire) {
document.getElementById('inputCodmat').value = codmat;
document.getElementById('selectedArticle').textContent = denumire;
document.getElementById('autocompleteDropdown').classList.add('d-none');
function clSelectArticle(el, codmat, label) {
const line = el.closest('.codmat-line');
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-selected').textContent = label;
line.querySelector('.cl-ac-dropdown').classList.add('d-none');
}
// Save mapping (create)
async function saveMapping() {
const sku = document.getElementById('inputSku').value.trim();
const codmat = document.getElementById('inputCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inputProcent').value) || 100;
if (!sku) { alert('SKU este obligatoriu'); return; }
if (!sku || !codmat) { alert('SKU si CODMAT sunt obligatorii'); return; }
const lines = document.querySelectorAll('.codmat-line');
const mappings = [];
for (const line of lines) {
const codmat = line.querySelector('.cl-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.cl-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.cl-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Adauga cel putin un CODMAT'); return; }
// Validate percentage for multi-line
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('pctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('pctWarning').style.display = '';
return;
}
}
document.getElementById('pctWarning').style.display = 'none';
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
});
const data = await res.json();
let res;
if (editingMapping) {
if (mappings.length === 1) {
// Single CODMAT edit: use existing PUT endpoint
res = await fetch(`/api/mappings/${encodeURIComponent(editingMapping.sku)}/${encodeURIComponent(editingMapping.codmat)}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
new_sku: sku,
new_codmat: mappings[0].codmat,
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) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
clearForm();
editingMapping = null;
loadMappings();
} else if (res.status === 409) {
handleMappingConflict(data);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
@@ -172,93 +495,212 @@ async function saveMapping() {
}
}
function clearForm() {
document.getElementById('inputSku').value = '';
document.getElementById('inputCodmat').value = '';
document.getElementById('inputCantitate').value = '1';
document.getElementById('inputProcent').value = '100';
document.getElementById('selectedArticle').textContent = '';
}
// ── Inline Add Row ──────────────────────────────
// Inline edit
function editCell(td, sku, codmat, field, currentValue) {
if (td.querySelector('input')) return; // Already editing
let inlineAddVisible = false;
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';
function showInlineAddRow() {
// On mobile, open the full modal instead
if (window.innerWidth < 768) {
new bootstrap.Modal(document.getElementById('addModal')).show();
return;
}
const originalText = td.textContent;
td.textContent = '';
td.appendChild(input);
input.focus();
input.select();
if (inlineAddVisible) return;
inlineAddVisible = true;
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
td.textContent = originalText;
return;
}
const container = document.getElementById('mappingsFlatList');
const row = document.createElement('div');
row.id = 'inlineAddRow';
row.className = 'flat-row';
row.style.background = '#eff6ff';
row.style.gap = '0.5rem';
row.innerHTML = `
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
<div class="position-relative" style="flex:1;min-width:0">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
<small class="text-muted" id="inlineSelected"></small>
</div>
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
<button class="btn btn-sm btn-success" 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>
`;
container.insertBefore(row, container.firstChild);
document.getElementById('inlineSku').focus();
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;
}
};
// Setup autocomplete for inline CODMAT
const input = document.getElementById('inlineCodmat');
const dropdown = document.getElementById('inlineAcDropdown');
const selected = document.getElementById('inlineSelected');
let inlineAcTimeout = null;
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') { td.textContent = originalText; }
input.addEventListener('input', () => {
clearTimeout(inlineAcTimeout);
inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
// Toggle active
async function toggleActive(sku, codmat, currentActive) {
async function inlineAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ activ: currentActive ? 0 : 1 })
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="inlineSelectArticle('${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function inlineSelectArticle(codmat, label) {
document.getElementById('inlineCodmat').value = codmat;
document.getElementById('inlineSelected').textContent = label;
document.getElementById('inlineAcDropdown').classList.add('d-none');
}
async function saveInlineMapping() {
const sku = document.getElementById('inlineSku').value.trim();
const codmat = document.getElementById('inlineCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('inlineCantitate').value) || 1;
const procent = parseFloat(document.getElementById('inlineProcent').value) || 100;
if (!sku) { alert('SKU este obligatoriu'); return; }
if (!codmat) { alert('CODMAT este obligatoriu'); return; }
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
});
const data = await res.json();
if (data.success) loadMappings();
if (data.success) {
cancelInlineAdd();
loadMappings();
} else if (res.status === 409) {
handleMappingConflict(data);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// Delete (soft)
function deleteMappingConfirm(sku, codmat) {
if (confirm(`Dezactivezi maparea ${sku} -> ${codmat}?`)) {
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'DELETE'
}).then(r => r.json()).then(d => {
if (d.success) loadMappings();
else alert('Eroare: ' + (d.error || 'Delete failed'));
function cancelInlineAdd() {
const row = document.getElementById('inlineAddRow');
if (row) row.remove();
inlineAddVisible = false;
}
// ── Toggle Active with Toast Undo ────────────────
async function toggleActive(sku, codmat, currentActive) {
const newActive = currentActive ? 0 : 1;
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activ: newActive })
});
const data = await res.json();
if (!data.success) return;
loadMappings();
// Show toast with undo
const action = newActive ? 'activata' : 'dezactivata';
showUndoToast(`Mapare ${sku} \u2192 ${codmat} ${action}.`, () => {
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activ: currentActive })
}).then(() => loadMappings());
});
} catch (err) { alert('Eroare: ' + err.message); }
}
function showUndoToast(message, undoCallback) {
document.getElementById('toastMessage').textContent = message;
const undoBtn = document.getElementById('toastUndoBtn');
// Clone to remove old listeners
const newBtn = undoBtn.cloneNode(true);
undoBtn.parentNode.replaceChild(newBtn, undoBtn);
newBtn.id = 'toastUndoBtn';
if (undoCallback) {
newBtn.style.display = '';
newBtn.addEventListener('click', () => {
undoCallback();
const toastEl = document.getElementById('undoToast');
const inst = bootstrap.Toast.getInstance(toastEl);
if (inst) inst.hide();
});
} else {
newBtn.style.display = 'none';
}
const toast = new bootstrap.Toast(document.getElementById('undoToast'));
toast.show();
}
// ── Delete with Modal Confirmation ──────────────
let pendingDelete = null;
function initDeleteModal() {
const btn = document.getElementById('confirmDeleteBtn');
if (!btn) return;
btn.addEventListener('click', async () => {
if (!pendingDelete) return;
const { sku, codmat } = pendingDelete;
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'DELETE'
});
const data = await res.json();
bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
if (data.success) loadMappings();
else alert('Eroare: ' + (data.error || 'Delete failed'));
} catch (err) {
bootstrap.Modal.getInstance(document.getElementById('deleteConfirmModal')).hide();
alert('Eroare: ' + err.message);
}
pendingDelete = null;
});
}
function deleteMappingConfirm(sku, codmat) {
pendingDelete = { sku, codmat };
document.getElementById('deleteSkuText').textContent = sku;
document.getElementById('deleteCodmatText').textContent = codmat;
new bootstrap.Modal(document.getElementById('deleteConfirmModal')).show();
}
// ── Restore Deleted ──────────────────────────────
async function restoreMapping(sku, codmat) {
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, {
method: 'POST'
});
const data = await res.json();
if (data.success) loadMappings();
else alert('Eroare: ' + (data.error || 'Restore failed'));
} catch (err) {
alert('Eroare: ' + err.message);
}
}
// CSV import
// ── CSV ──────────────────────────────────────────
async function importCsv() {
const fileInput = document.getElementById('csvFile');
if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
@@ -267,15 +709,15 @@ async function importCsv() {
formData.append('file', fileInput.files[0]);
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();
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) {
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;
loadMappings();
@@ -284,16 +726,33 @@ 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 = (window.ROOT_PATH || '') + '/api/mappings/csv-template'; }
// ── Duplicate / Conflict handling ────────────────
function handleMappingConflict(data) {
const msg = data.error || 'Conflict la salvare';
if (data.can_restore) {
const restore = confirm(`${msg}\n\nDoriti sa restaurati maparea stearsa?`);
if (restore) {
// Find sku/codmat from the inline row or modal
const sku = (document.getElementById('inlineSku') || document.getElementById('inputSku'))?.value?.trim();
const codmat = (document.getElementById('inlineCodmat') || document.querySelector('.cl-codmat'))?.value?.trim();
if (sku && codmat) {
fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}/restore`, { method: 'POST' })
.then(r => r.json())
.then(d => {
if (d.success) { cancelInlineAdd(); loadMappings(); }
else alert('Eroare la restaurare: ' + (d.error || ''));
});
}
}
} else {
showUndoToast(msg, null);
// Show non-dismissible inline error
const warn = document.getElementById('pctWarning');
if (warn) { warn.textContent = msg; warn.style.display = ''; }
}
}
function downloadTemplate() {
window.location.href = '/api/mappings/csv-template';
}
// Escape HTML
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>
<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="/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>
<body>
<!-- Sidebar -->
<nav id="sidebar" class="sidebar">
<div class="sidebar-header">
<h5><i class="bi bi-box-seam"></i> GoMag Import</h5>
</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_sync %}{% endblock %}" href="/sync">
<i class="bi bi-arrow-repeat"></i> Import Comenzi
</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>
</ul>
<div class="sidebar-footer">
<small class="text-muted">v1.0</small>
<!-- Top Navbar -->
<nav class="top-navbar">
<div class="navbar-brand">GoMag Import</div>
<div class="navbar-links">
<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>
<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>
<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 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>
<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>
</div>
</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 class="main-content">
{% block content %}{% endblock %}
</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="{{ rp }}/static/js/shared.js?v=11"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -3,133 +3,206 @@
{% block nav_dashboard %}active{% endblock %}
{% block content %}
<h4 class="mb-4">Dashboard</h4>
<h4 class="mb-4">Panou de Comanda</h4>
<!-- Stat cards row -->
<div class="row g-3 mb-4" id="statsRow">
<div class="col">
<div class="card stat-card">
<div class="stat-value text-secondary" id="stat-pending">-</div>
<div class="stat-label">In Asteptare</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-primary" id="stat-ready">-</div>
<div class="stat-label">Ready</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-success" id="stat-imported">-</div>
<div class="stat-label">Imported</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-warning" id="stat-skipped">-</div>
<div class="stat-label">Skipped</div>
</div>
</div>
<div class="col">
<div class="card stat-card">
<div class="stat-value text-danger" id="stat-missing">-</div>
<div class="stat-label">SKU Lipsa</div>
</div>
<!-- Sync Card (unified two-row panel) -->
<div class="sync-card">
<!-- TOP ROW: Status + Controls -->
<div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" class="text-secondary">Inactiv</span>
<div class="d-flex align-items-center gap-2">
<label class="d-flex align-items-center gap-1 text-muted">
Auto:
<input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()">
</label>
<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="10" selected>10 min</option>
<option value="30">30 min</option>
</select>
<button id="syncStartBtn" class="btn btn-sm btn-primary" onclick="startSync()">&#9654; Start Sync</button>
</div>
</div>
<div class="sync-card-divider"></div>
<!-- BOTTOM ROW: Last sync info (clickable → jurnal) -->
<div class="sync-card-info" id="lastSyncRow" role="button" tabindex="0" title="Ver jurnal sync">
<span id="lastSyncDate" class="fw-medium">&#8212;</span>
<span id="lastSyncDuration" class="text-muted">&#8212;</span>
<span id="lastSyncCounts">&#8212;</span>
<span id="lastSyncStatus">&#8212;</span>
<span class="ms-auto small text-muted">&#8599; jurnal</span>
</div>
<!-- LIVE PROGRESS (shown only when sync is running) -->
<div class="sync-card-progress" id="syncProgressArea" style="display:none;">
<span class="sync-live-dot"></span>
<span id="syncProgressText">Se proceseaza...</span>
</div>
</div>
<!-- Sync Control -->
<!-- Orders Table -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Sync Control</span>
<span class="badge bg-secondary" id="syncStatusBadge">idle</span>
<div class="card-header">
<span>Comenzi</span>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<button class="btn btn-success btn-sm" id="btnStartSync" onclick="startSync()">
<i class="bi bi-play-fill"></i> Start Sync
</button>
<button class="btn btn-outline-secondary btn-sm" id="btnScan" onclick="scanOrders()">
<i class="bi bi-search"></i> Scan
</button>
<button class="btn btn-danger btn-sm d-none" id="btnStopSync" onclick="stopSync()">
<i class="bi bi-stop-fill"></i> Stop
</button>
<div class="card-body py-2 px-3">
<div class="filter-bar" id="ordersFilterBar">
<!-- Period dropdown -->
<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="7" selected>7 zile</option>
<option value="30">30 zile</option>
<option value="90">3 luni</option>
<option value="0">Toate</option>
<option value="custom">Perioada personalizata...</option>
</select>
<!-- Custom date range (hidden until 'custom' selected) -->
<div class="period-custom-range" id="customRangeInputs">
<input type="date" id="periodStart" class="select-compact">
<span>&#8212;</span>
<input type="date" id="periodEnd" class="select-compact">
</div>
<input type="search" id="orderSearch" placeholder="Cauta comanda, client..." class="search-input">
<!-- Status pills -->
<button class="filter-pill active d-none d-md-inline-flex" data-status="all">Toate <span class="filter-count fc-neutral" id="cntAll">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="IMPORTED">Importat <span class="filter-count fc-green" id="cntImp">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="cntSkip">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="ERROR">Erori <span class="filter-count fc-red" id="cntErr">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
<button class="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>
<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 id="dashPaginationTop" class="pag-strip"></div>
<div class="card-body p-0">
<div id="dashMobileList" class="mobile-list"></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<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('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="text-end">Transport</th>
<th class="text-end">Discount</th>
<th class="text-end">Total</th>
<th style="width:28px" title="Facturat">F</th>
</tr>
</thead>
<tbody id="dashOrdersBody">
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
</div>
</div>
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
</div>
<!-- Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="col-auto">
<div class="form-check form-switch d-inline-block me-2">
<input class="form-check-input" type="checkbox" id="schedulerToggle" onchange="toggleScheduler()">
<label class="form-check-label" for="schedulerToggle">Scheduler</label>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</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. 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>
<select class="form-select form-select-sm d-inline-block" style="width:auto" id="schedulerInterval" onchange="updateSchedulerInterval()">
<option value="1">1 min</option>
<option value="5" selected>5 min</option>
<option value="10">10 min</option>
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">60 min</option>
</select>
<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">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th>Cant.</th>
<th>Pret</th>
<th class="text-end">Valoare</th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="col">
<small class="text-muted" id="syncProgressText"></small>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<!-- Recent Sync Runs -->
<div class="card mb-4">
<div class="card-header">Ultimele Sync Runs</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Data</th>
<th>Status</th>
<th>Total</th>
<th>OK</th>
<th>Skip</th>
<th>Err</th>
<th>Durata</th>
</tr>
</thead>
<tbody id="syncRunsBody">
<tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Missing SKUs (quick resolve) -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>SKU-uri Lipsa</span>
<a href="/missing-skus" class="btn btn-sm btn-outline-primary">Vezi toate</a>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>SKU</th>
<th>Produs</th>
<th>Data</th>
<th>Actiune</th>
</tr>
</thead>
<tbody id="missingSkusBody">
<tr><td colspan="4" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
<!-- Quick Map Modal (used from order detail) -->
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div style="margin-bottom:8px; font-size:0.85rem">
<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 id="qmCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="addQmCodmatLine()" style="font-size:0.8rem; padding:2px 10px">
+ CODMAT
</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>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
<button type="button" class="btn btn-primary" id="qmSaveBtn" onclick="saveQuickMapping()">Salveaza</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=17"></script>
{% endblock %}

187
api/app/templates/logs.html Normal file
View File

@@ -0,0 +1,187 @@
{% extends "base.html" %}
{% block title %}Jurnale Import - GoMag Import{% endblock %}
{% block nav_logs %}active{% endblock %}
{% block content %}
<h4 class="mb-4">Jurnale Import</h4>
<!-- Sync Run Selector + Status + Controls (single card) -->
<div class="card mb-3">
<div class="card-body py-2">
<!-- 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>
<select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)" style="max-width:400px">
<option value="">Se incarca...</option>
</select>
<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>
<!-- Detail Viewer (shown when run selected) -->
<div id="logViewerSection" style="display:none;">
<!-- Filter pills -->
<div class="filter-bar mb-3" id="orderFilterPills">
<button class="filter-pill active d-none d-md-inline-flex" data-log-status="all">Toate <span class="filter-count fc-neutral" id="countAll">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="IMPORTED">Importate <span class="filter-count fc-green" id="countImported">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ALREADY_IMPORTED">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
</div>
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
<!-- Orders table -->
<div class="card mb-3">
<div id="ordersPaginationTop" class="pag-strip"></div>
<div class="card-body p-0">
<div id="logsMobileList" class="mobile-list"></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:24px"></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_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('items_count')">Articole <span class="sort-icon" data-col="items_count"></span></th>
<th class="text-end">Transport</th>
<th class="text-end">Discount</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody id="runOrdersBody">
<tr><td colspan="9" class="text-center text-muted py-3">Selecteaza un sync run</td></tr>
</tbody>
</table>
</div>
</div>
<div id="ordersPagination" class="pag-strip pag-strip-bottom"></div>
</div>
<!-- Collapsible text log -->
<div id="textLogSection" style="display:none;">
<div class="card">
<div class="card-header">Log text brut</div>
<pre class="log-viewer" id="logContent">Se incarca...</pre>
</div>
</div>
</div>
<!-- Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</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. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
</div>
</div>
<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">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th>Cant.</th>
<th>Pret</th>
<th class="text-end">Valoare</th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<!-- Quick Map Modal (used from order detail) -->
<div class="modal fade" id="quickMapModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mapeaza SKU: <code id="qmSku"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<small class="text-muted">Produs web:</small> <strong id="qmProductName"></strong>
</div>
<div id="qmCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addQmCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
</button>
<div id="qmPctWarning" class="text-danger mt-2" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
<button type="button" class="btn btn-primary" onclick="saveQuickMapping()">Salveaza</button>
</div>
</div>
</div>
</div>
<!-- Hidden field for pre-selected run from URL/server -->
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=9"></script>
{% endblock %}

View File

@@ -5,11 +5,23 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4>
<div>
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
<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-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-plus-lg"></i> Adauga Mapare</button>
<div class="d-flex align-items-center gap-2">
<!-- Desktop buttons -->
<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-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</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-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>
@@ -23,39 +35,42 @@
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>SKU</th>
<th>CODMAT</th>
<th>Denumire</th>
<th>Cantitate ROA</th>
<th>Procent Pret</th>
<th>Activ</th>
<th style="width:100px">Actiuni</th>
</tr>
</thead>
<tbody id="mappingsBody">
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div>
<!-- Filter controls -->
<div class="d-flex align-items-center mb-3 gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showInactive" onchange="loadMappings()">
<label class="form-check-label" for="showInactive">Arata inactive</label>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted" id="pageInfo"></small>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
</nav>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showDeleted" onchange="loadMappings()">
<label class="form-check-label" for="showDeleted">Arata sterse</label>
</div>
</div>
<!-- Add/Edit Modal -->
<!-- Percentage filter pills -->
<div class="filter-bar" id="mappingsFilterBar">
<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 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 d-none d-md-inline-flex" data-pct="incomplete">Incomplete <span class="filter-count fc-yellow" id="mCntIncomplete">0</span></button>
</div>
<div class="d-md-none mb-2" id="mappingsMobileSeg"></div>
<!-- Top pagination -->
<div id="mappingsPagTop" class="pag-strip"></div>
<!-- Flat-row list (unified desktop + mobile) -->
<div class="card">
<div class="card-body p-0">
<div id="mappingsFlatList" class="mappings-flat-list">
<div class="flat-row text-muted py-4 justify-content-center">Se incarca...</div>
</div>
</div>
<div id="mappingsPagBottom" class="pag-strip pag-strip-bottom"></div>
</div>
<!-- Add/Edit Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="addModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addModalTitle">Adauga Mapare</h5>
@@ -66,22 +81,17 @@
<label class="form-label">SKU</label>
<input type="text" class="form-control" id="inputSku" placeholder="Ex: 8714858124284">
</div>
<div class="mb-3 position-relative">
<label class="form-label">CODMAT (Articol ROA)</label>
<input type="text" class="form-control" id="inputCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none" id="autocompleteDropdown"></div>
<small class="text-muted" id="selectedArticle"></small>
<div class="mb-2" id="addModalProductName" style="display:none;">
<small class="text-muted">Produs web:</small> <strong id="inputProductName"></strong>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">Cantitate ROA</label>
<input type="number" class="form-control" id="inputCantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-6 mb-3">
<label class="form-label">Procent Pret (%)</label>
<input type="number" class="form-control" id="inputProcent" value="100" step="0.01" min="0" max="100">
</div>
<hr>
<div id="codmatLines">
<!-- Dynamic CODMAT lines will be added here -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
</button>
<div id="pctWarning" class="text-danger mt-2" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
@@ -111,8 +121,38 @@
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfirmModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmare stergere</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
Sigur vrei sa stergi maparea?<br>
SKU: <code id="deleteSkuText"></code><br>
CODMAT: <code id="deleteCodmatText"></code>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">Sterge</button>
</div>
</div>
</div>
</div>
<!-- Toast container for undo actions -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index:1080">
<div id="undoToast" class="toast" role="alert" data-bs-autohide="true" data-bs-delay="5000">
<div class="toast-body d-flex align-items-center gap-2">
<span id="toastMessage"></span>
<button class="btn btn-sm btn-outline-primary ms-auto" id="toastUndoBtn">Anuleaza</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/mappings.js"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=7"></script>
{% endblock %}

View File

@@ -5,41 +5,67 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">SKU-uri Lipsa</h4>
<div>
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportMissingCsv()">
<i class="bi bi-download"></i> Export CSV
</button>
<button class="btn btn-sm btn-outline-primary" onclick="scanForMissing()">
<i class="bi bi-search"></i> Re-Scan
</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>
<!-- Unified filter bar -->
<div class="filter-bar" id="skusFilterBar">
<button class="filter-pill active d-none d-md-inline-flex" data-sku-status="unresolved">
Nerezolvate <span class="filter-count fc-yellow" id="cntUnres">0</span>
</button>
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="resolved">
Rezolvate <span class="filter-count fc-green" id="cntRes">0</span>
</button>
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="all">
Toate <span class="filter-count fc-neutral" id="cntAllSkus">0</span>
</button>
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2 d-none d-md-inline-flex">&#8635; Re-scan</button>
<span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
<span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span>
</span>
</div>
<div class="d-md-none mb-2" id="skusMobileSeg"></div>
<!-- Result banner -->
<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-body p-0">
<div id="missingMobileList" class="mobile-list"></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Status</th>
<th>SKU</th>
<th>Produs</th>
<th>First Seen</th>
<th>Status</th>
<th>Actiune</th>
</tr>
</thead>
<tbody id="missingBody">
<tr><td colspan="5" 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>
</table>
</div>
</div>
<div class="card-footer">
<small class="text-muted" id="missingInfo"></small>
</div>
</div>
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
<!-- Map SKU Modal -->
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
@@ -48,22 +74,16 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3 position-relative">
<label class="form-label">CODMAT (Articol ROA)</label>
<input type="text" class="form-control" id="mapCodmat" placeholder="Cauta codmat sau denumire..." autocomplete="off">
<div class="autocomplete-dropdown d-none" id="mapAutocomplete"></div>
<small class="text-muted" id="mapSelectedArticle"></small>
<div class="mb-2">
<small class="text-muted">Produs web:</small> <strong id="mapProductName"></strong>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">Cantitate ROA</label>
<input type="number" class="form-control" id="mapCantitate" value="1" step="0.001" min="0.001">
</div>
<div class="col-6 mb-3">
<label class="form-label">Procent Pret (%)</label>
<input type="number" class="form-control" id="mapProcent" value="100" step="0.01" min="0" max="100">
</div>
<div id="mapCodmatLines">
<!-- Dynamic CODMAT lines -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addMapCodmatLine()">
<i class="bi bi-plus"></i> Adauga CODMAT
</button>
<div id="mapPctWarning" class="text-danger mt-2" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuleaza</button>
@@ -77,123 +97,288 @@
{% block scripts %}
<script>
let currentMapSku = '';
let acTimeout = null;
let mapAcTimeout = null;
let currentPage = 1;
let skuStatusFilter = 'unresolved';
let missingPerPage = 20;
document.addEventListener('DOMContentLoaded', () => {
loadMissing();
function missingChangePerPage(val) { missingPerPage = parseInt(val) || 20; currentPage = 1; loadMissingSkus(); }
const input = document.getElementById('mapCodmat');
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => autocompleteMap(input.value), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('mapAutocomplete').classList.add('d-none'), 200);
// ── Filter pills ──────────────────────────────────
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
this.classList.add('active');
skuStatusFilter = this.dataset.skuStatus;
currentPage = 1;
loadMissingSkus();
});
});
async function loadMissing() {
// ── Search with debounce ─────────────────────────
let skuSearchTimer = null;
document.getElementById('skuSearch')?.addEventListener('input', function() {
clearTimeout(skuSearchTimer);
skuSearchTimer = setTimeout(() => { currentPage = 1; loadMissingSkus(); }, 300);
});
// ── Rescan ────────────────────────────────────────
document.getElementById('rescanBtn')?.addEventListener('click', async function() {
this.disabled = true;
const prog = document.getElementById('rescanProgress');
const result = document.getElementById('rescanResult');
const progText = document.getElementById('rescanProgressText');
if (prog) { prog.style.display = 'flex'; }
if (result) result.style.display = 'none';
try {
const res = await fetch('/api/validate/missing-skus');
const data = await res.json();
const tbody = document.getElementById('missingBody');
document.getElementById('missingInfo').textContent =
`Total: ${data.total || 0} | Nerezolvate: ${data.unresolved || 0}`;
const skus = data.missing_skus || [];
if (skus.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Toate SKU-urile sunt mapate!</td></tr>';
return;
const data = await fetch('/api/validate/scan', { method: 'POST' }).then(r => r.json());
if (progText) progText.textContent = 'Gata.';
if (result) {
result.innerHTML = `&#10003; ${data.total_skus_scanned || 0} scanate &nbsp;|&nbsp; ${data.new_missing || 0} noi lipsa &nbsp;|&nbsp; ${data.auto_resolved || 0} rezolvate`;
result.style.display = 'block';
}
loadMissingSkus();
} catch(e) {
if (progText) progText.textContent = 'Eroare.';
} finally {
this.disabled = false;
setTimeout(() => { if (prog) prog.style.display = 'none'; }, 2500);
}
});
tbody.innerHTML = skus.map(s => {
const statusBadge = s.resolved
? '<span class="badge bg-success">Rezolvat</span>'
: '<span class="badge bg-warning text-dark">Nerezolvat</span>';
document.addEventListener('DOMContentLoaded', () => {
loadMissingSkus();
});
return `<tr class="${s.resolved ? 'table-light' : ''}">
<td><code>${esc(s.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td>
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
<td>${statusBadge}</td>
<td>
${!s.resolved ? `<button class="btn btn-sm btn-outline-primary" onclick="openMapModal('${esc(s.sku)}')">
<i class="bi bi-link-45deg"></i> Mapeaza
</button>` : `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
</td>
</tr>`;
function resolvedParamFor(statusFilter) {
if (statusFilter === 'resolved') return 1;
if (statusFilter === 'all') return -1;
return 0; // unresolved (default)
}
function loadMissingSkus(page) {
currentPage = page || currentPage;
const params = new URLSearchParams();
const resolvedVal = resolvedParamFor(skuStatusFilter);
params.set('resolved', resolvedVal);
params.set('page', currentPage);
params.set('per_page', missingPerPage);
const search = document.getElementById('skuSearch')?.value?.trim();
if (search) params.set('search', search);
fetch('/api/validate/missing-skus?' + params.toString())
.then(r => r.json())
.then(data => {
const c = data.counts || {};
const el = id => document.getElementById(id);
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
if (el('cntRes')) el('cntRes').textContent = c.resolved || 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);
renderPagination(data);
})
.catch(err => {
document.getElementById('missingBody').innerHTML =
`<tr><td colspan="4" class="text-center text-danger">${err.message}</td></tr>`;
});
}
// Keep backward compat alias
function loadMissing(page) { loadMissingSkus(page); }
function renderMissingSkusTable(skus, data) {
const tbody = document.getElementById('missingBody');
const mobileList = document.getElementById('missingMobileList');
if (!skus || skus.length === 0) {
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
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;
}
tbody.innerHTML = skus.map(s => {
const trAttrs = !s.resolved
? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
: '';
return `<tr${trAttrs}>
<td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
<td><code>${esc(s.sku)}</code></td>
<td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td>
${!s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
<i class="bi bi-link-45deg"></i>
</a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`}
</td>
</tr>`;
}).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('');
} catch (err) {
document.getElementById('missingBody').innerHTML =
`<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
}
}
function openMapModal(sku) {
function renderPagination(data) {
const pagOpts = { perPage: missingPerPage, perPageFn: 'missingChangePerPage', perPageOptions: [20, 50, 100] };
const infoHtml = `<small class="text-muted me-auto">Total: ${data.total || 0} | Pagina ${data.page || 1} din ${data.pages || 1}</small>`;
const pagHtml = infoHtml + renderUnifiedPagination(data.page || 1, data.pages || 1, 'loadMissing', pagOpts);
const top = document.getElementById('skusPagTop');
const bot = document.getElementById('skusPagBottom');
if (top) top.innerHTML = pagHtml;
if (bot) bot.innerHTML = pagHtml;
}
// ── Multi-CODMAT Map Modal ───────────────────────
function openMapModal(sku, productName) {
currentMapSku = sku;
document.getElementById('mapSku').textContent = sku;
document.getElementById('mapCodmat').value = '';
document.getElementById('mapCantitate').value = '1';
document.getElementById('mapProcent').value = '100';
document.getElementById('mapSelectedArticle').textContent = '';
document.getElementById('mapProductName').textContent = productName || '-';
document.getElementById('mapPctWarning').style.display = 'none';
const container = document.getElementById('mapCodmatLines');
container.innerHTML = '';
addMapCodmatLine();
new bootstrap.Modal(document.getElementById('mapModal')).show();
}
async function autocompleteMap(q) {
const dropdown = document.getElementById('mapAutocomplete');
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
function addMapCodmatLine() {
const container = document.getElementById('mapCodmatLines');
const idx = container.children.length;
const div = document.createElement('div');
div.className = 'border rounded p-2 mb-2 mc-line';
div.innerHTML = `
<div class="row g-2 align-items-center">
<div class="col position-relative">
<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>
<small class="text-muted mc-selected"></small>
</div>
<div class="col-auto" style="width:90px">
<input type="number" class="form-control form-control-sm mc-cantitate" value="1" step="0.001" min="0.001" placeholder="Cant." title="Cantitate ROA">
</div>
<div class="col-auto" style="width:90px">
<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>
`;
container.appendChild(div);
const input = div.querySelector('.mc-codmat');
const dropdown = div.querySelector('.mc-ac-dropdown');
const selected = div.querySelector('.mc-selected');
input.addEventListener('input', () => {
clearTimeout(mapAcTimeout);
mapAcTimeout = setTimeout(() => mcAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
async function mcAutocomplete(input, dropdown, selectedEl) {
const q = input.value;
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; }
if (!data.results || data.results.length === 0) {
dropdown.classList.add('d-none');
return;
}
dropdown.innerHTML = data.results.map(r => `
<div class="autocomplete-item" onmousedown="selectMapArticle('${esc(r.codmat)}', '${esc(r.denumire)}')">
<span class="codmat">${esc(r.codmat)}</span>
<br><span class="denumire">${esc(r.denumire)}</span>
</div>
`).join('');
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="mcSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch (err) {
dropdown.classList.add('d-none');
}
} catch { dropdown.classList.add('d-none'); }
}
function selectMapArticle(codmat, denumire) {
document.getElementById('mapCodmat').value = codmat;
document.getElementById('mapSelectedArticle').textContent = denumire;
document.getElementById('mapAutocomplete').classList.add('d-none');
function mcSelectArticle(el, codmat, label) {
const line = el.closest('.mc-line');
line.querySelector('.mc-codmat').value = codmat;
line.querySelector('.mc-selected').textContent = label;
line.querySelector('.mc-ac-dropdown').classList.add('d-none');
}
async function saveQuickMap() {
const codmat = document.getElementById('mapCodmat').value.trim();
const cantitate = parseFloat(document.getElementById('mapCantitate').value) || 1;
const procent = parseFloat(document.getElementById('mapProcent').value) || 100;
const lines = document.querySelectorAll('.mc-line');
const mappings = [];
if (!codmat) { alert('Selecteaza un CODMAT'); return; }
for (const line of lines) {
const codmat = line.querySelector('.mc-codmat').value.trim();
const cantitate = parseFloat(line.querySelector('.mc-cantitate').value) || 1;
const procent = parseFloat(line.querySelector('.mc-procent').value) || 100;
if (!codmat) continue;
mappings.push({ codmat, cantitate_roa: cantitate, procent_pret: procent });
}
if (mappings.length === 0) { alert('Selecteaza cel putin un CODMAT'); return; }
if (mappings.length > 1) {
const totalPct = mappings.reduce((s, m) => s + m.procent_pret, 0);
if (Math.abs(totalPct - 100) > 0.01) {
document.getElementById('mapPctWarning').textContent = `Suma procentelor trebuie sa fie 100% (actual: ${totalPct.toFixed(2)}%)`;
document.getElementById('mapPctWarning').style.display = '';
return;
}
}
document.getElementById('mapPctWarning').style.display = 'none';
try {
const res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sku: currentMapSku,
codmat: codmat,
cantitate_roa: cantitate,
procent_pret: procent
})
});
let res;
if (mappings.length === 1) {
res = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentMapSku, codmat: mappings[0].codmat, cantitate_roa: mappings[0].cantitate_roa, procent_pret: mappings[0].procent_pret })
});
} else {
res = await fetch('/api/mappings/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: currentMapSku, mappings })
});
}
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('mapModal')).hide();
loadMissing();
loadMissingSkus(currentPage);
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
@@ -202,22 +387,9 @@ async function saveQuickMap() {
}
}
async function scanForMissing() {
try {
await fetch('/api/validate/scan', { method: 'POST' });
loadMissing();
} catch (err) {
alert('Eroare scan: ' + err.message);
}
}
function exportMissingCsv() {
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>
{% 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

@@ -1,158 +0,0 @@
{% extends "base.html" %}
{% block title %}Sync Run - GoMag Import{% endblock %}
{% block nav_sync %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="/" class="text-decoration-none text-muted"><i class="bi bi-arrow-left"></i> Dashboard</a>
<h4 class="mb-0 mt-1">Sync Run <small class="text-muted" id="runId">{{ run_id }}</small></h4>
</div>
<span class="badge bg-secondary fs-6" id="runStatusBadge">-</span>
</div>
<!-- Run summary -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value" id="runTotal">-</div>
<div class="stat-label">Total Comenzi</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-success" id="runImported">-</div>
<div class="stat-label">Imported</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-warning" id="runSkipped">-</div>
<div class="stat-label">Skipped</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-danger" id="runErrors">-</div>
<div class="stat-label">Errors</div>
</div>
</div>
</div>
<div class="mb-3">
<small class="text-muted" id="runTiming"></small>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-header">Comenzi</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Nr Comanda</th>
<th>Data</th>
<th>Client</th>
<th>Articole</th>
<th>Status</th>
<th>Detalii</th>
</tr>
</thead>
<tbody id="ordersBody">
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const RUN_ID = '{{ run_id }}';
document.addEventListener('DOMContentLoaded', loadRunDetail);
async function loadRunDetail() {
try {
const res = await fetch(`/api/sync/run/${RUN_ID}`);
const data = await res.json();
if (data.error) {
document.getElementById('ordersBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${data.error}</td></tr>`;
return;
}
const run = data.run;
// Update summary
document.getElementById('runTotal').textContent = run.total_orders || 0;
document.getElementById('runImported').textContent = run.imported || 0;
document.getElementById('runSkipped').textContent = run.skipped || 0;
document.getElementById('runErrors').textContent = run.errors || 0;
const badge = document.getElementById('runStatusBadge');
badge.textContent = run.status;
badge.className = 'badge fs-6 ' + (run.status === 'completed' ? 'bg-success' : run.status === 'running' ? 'bg-primary' : 'bg-danger');
// Timing
if (run.started_at) {
let timing = 'Start: ' + new Date(run.started_at).toLocaleString('ro-RO');
if (run.finished_at) {
const sec = Math.round((new Date(run.finished_at) - new Date(run.started_at)) / 1000);
timing += ` | Durata: ${sec < 60 ? sec + 's' : Math.floor(sec/60) + 'm ' + (sec%60) + 's'}`;
}
document.getElementById('runTiming').textContent = timing;
}
// Orders table
const orders = data.orders || [];
if (orders.length === 0) {
document.getElementById('ordersBody').innerHTML =
'<tr><td colspan="7" class="text-center text-muted py-4">Nicio comanda</td></tr>';
return;
}
document.getElementById('ordersBody').innerHTML = orders.map((o, i) => {
const statusClass = o.status === 'IMPORTED' ? 'badge-imported' : o.status === 'SKIPPED' ? 'badge-skipped' : 'badge-error';
let details = '';
if (o.status === 'IMPORTED' && o.id_comanda) {
details = `<small class="text-success">ID: ${o.id_comanda}</small>`;
} else if (o.status === 'SKIPPED' && o.missing_skus) {
try {
const skus = JSON.parse(o.missing_skus);
details = `<small class="text-warning">SKU lipsa: ${skus.map(s => '<code>' + esc(s) + '</code>').join(', ')}</small>`;
} catch(e) {
details = `<small class="text-warning">${esc(o.missing_skus)}</small>`;
}
} else if (o.status === 'ERROR' && o.error_message) {
details = `<small class="text-danger">${esc(o.error_message).substring(0, 100)}</small>`;
}
return `<tr>
<td>${i + 1}</td>
<td><strong>${esc(o.order_number)}</strong></td>
<td><small>${o.order_date ? o.order_date.substring(0, 10) : '-'}</small></td>
<td>${esc(o.customer_name)}</td>
<td>${o.items_count || '-'}</td>
<td><span class="badge ${statusClass}">${o.status}</span></td>
<td>${details}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('ordersBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
}
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
{% endblock %}

View File

@@ -538,7 +538,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
END IF;
v_cod_fiscal_curat := TRIM(p_cod_fiscal);
v_denumire_curata := TRIM(p_denumire);
v_denumire_curata := UPPER(TRIM(p_denumire));
-- STEP 1: Cautare dupa cod fiscal (prioritate 1)
IF v_cod_fiscal_curat IS NOT NULL AND
@@ -584,6 +584,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
IF v_este_persoana_fizica = 1 THEN
-- pINFO('Detectata persoana fizica (CUI 13 cifre)', 'IMPORT_PARTENERI');
separa_nume_prenume(v_denumire_curata, v_nume, v_prenume);
v_nume := UPPER(v_nume);
v_prenume := UPPER(v_prenume);
-- pINFO('Nume separat: NUME=' || NVL(v_nume, 'NULL') || ', PRENUME=' || NVL(v_prenume, 'NULL'), 'IMPORT_PARTENERI');
END IF;
@@ -591,7 +593,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_PARTENERI AS
BEGIN
IF v_este_persoana_fizica = 1 THEN
-- Pentru persoane fizice
pack_def.adauga_partener(tcDenumire => v_nume, -- nume de familie pentru persoane fizice
pack_def.adauga_partener(tcDenumire => v_nume || ' ' || v_prenume,
tcNume => v_nume,
tcPrenume => v_prenume,
tcCod_fiscal => v_cod_fiscal_curat,

View File

@@ -18,6 +18,8 @@
-- p_json_articole accepta:
-- - array 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.
-- Daca comanda exista deja (comanda_externa), nu se dubleaza.
-- 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_pol IN NUMBER DEFAULT NULL,
p_id_sectie IN NUMBER DEFAULT NULL,
p_id_gestiune IN VARCHAR2 DEFAULT NULL,
v_id_comanda OUT NUMBER);
-- Functii pentru managementul erorilor (pentru orchestrator VFP)
@@ -86,6 +89,60 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
g_last_error := NULL;
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
-- ================================================================
@@ -97,6 +154,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
p_id_adresa_facturare IN NUMBER DEFAULT NULL,
p_id_pol 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_data_livrare DATE;
v_sku VARCHAR2(100);
@@ -113,6 +171,7 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_codmat VARCHAR2(50);
v_cantitate_roa NUMBER;
v_pret_unitar NUMBER;
v_id_pol_articol NUMBER; -- id_pol per articol (din JSON), prioritar fata de p_id_pol
-- pljson
l_json_articole CLOB := p_json_articole;
@@ -189,18 +248,33 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
v_pret_web := TO_NUMBER(v_json_obj.get_string('price'));
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
-- Cauta mai intai in ARTICOLE_TERTI (mapari speciale / seturi)
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
JOIN nom_articole na ON na.codmat = at.codmat
WHERE at.sku = v_sku
AND at.activ = 1
AND at.sters = 0
ORDER BY at.procent_pret DESC) LOOP
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_pret_unitar := CASE WHEN v_pret_web IS NOT NULL
THEN (v_pret_web * rec.procent_pret / 100) / rec.cantitate_roa
@@ -209,8 +283,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
BEGIN
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => rec.id_articol,
V_ID_POL => p_id_pol,
V_ID_ARTICOL => v_id_articol,
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_CANTITATE => v_cantitate_roa,
V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util,
@@ -225,39 +299,34 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
END;
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
BEGIN
SELECT id_articol, codmat
INTO v_id_articol, v_codmat
FROM nom_articole
WHERE codmat = v_sku;
v_id_articol := resolve_id_articol(v_sku, 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) ||
'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE (activ): ' || v_sku;
ELSE
v_codmat := v_sku;
v_pret_unitar := NVL(v_pret_web, 0);
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_id_articol,
V_ID_POL => p_id_pol,
V_CANTITATE => v_cantitate_web,
V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_vat);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
WHEN NO_DATA_FOUND THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE: ' || v_sku;
WHEN TOO_MANY_ROWS THEN
v_articole_eroare := v_articole_eroare + 1;
g_last_error := g_last_error || CHR(10) ||
'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;
BEGIN
PACK_COMENZI.adauga_articol_comanda(V_ID_COMANDA => v_id_comanda,
V_ID_ARTICOL => v_id_articol,
V_ID_POL => NVL(v_id_pol_articol, p_id_pol),
V_CANTITATE => v_cantitate_web,
V_PRET => v_pret_unitar,
V_ID_UTIL => c_id_util,
V_ID_SECTIE => p_id_sectie,
V_PTVA => v_vat);
v_articole_procesate := v_articole_procesate + 1;
EXCEPTION
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

View File

@@ -0,0 +1,12 @@
-- ====================================================================
-- 07_alter_articole_terti_sters.sql
-- Adauga coloana "sters" in ARTICOLE_TERTI pentru soft-delete real
-- (separat de "activ" care e toggle business)
-- ====================================================================
ALTER TABLE ARTICOLE_TERTI ADD sters NUMBER(1) DEFAULT 0;
ALTER TABLE ARTICOLE_TERTI ADD CONSTRAINT chk_art_terti_sters CHECK (sters IN (0, 1));
-- Verifica ca toate randurile existente au sters=0
-- SELECT COUNT(*) FROM ARTICOLE_TERTI WHERE sters IS NULL;
-- UPDATE ARTICOLE_TERTI SET sters = 0 WHERE sters IS NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -8,3 +8,5 @@ apscheduler==3.10.4
python-dotenv==1.0.1
pydantic-settings==2.7.1
httpx==0.28.1
pytest>=8.0.0
pytest-asyncio>=0.23.0

View File

@@ -96,6 +96,9 @@ GET_ROUTES = [
("GET /api/sync/history", "/api/sync/history", [200], False),
("GET /api/sync/schedule", "/api/sync/schedule", [200], False),
("GET /api/validate/missing-skus", "/api/validate/missing-skus", [200], False),
("GET /api/validate/missing-skus?page=1", "/api/validate/missing-skus?page=1&per_page=10", [200], False),
("GET /logs (HTML)", "/logs", [200, 500], False),
("GET /api/sync/run/nonexistent/log", "/api/sync/run/nonexistent/log", [200, 404], False),
("GET /api/articles/search?q=ab", "/api/articles/search?q=ab", [200, 503], True),
]

82
api/tests/e2e/conftest.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Playwright E2E test fixtures.
Starts the FastAPI app on a random port with test SQLite, no Oracle.
"""
import os
import sys
import tempfile
import pytest
import subprocess
import time
import socket
def _free_port():
with socket.socket() as s:
s.bind(('', 0))
return s.getsockname()[1]
@pytest.fixture(scope="session")
def app_url():
"""Start the FastAPI app as a subprocess and return its URL."""
port = _free_port()
tmpdir = tempfile.mkdtemp()
sqlite_path = os.path.join(tmpdir, "e2e_test.db")
env = os.environ.copy()
env.update({
"FORCE_THIN_MODE": "true",
"SQLITE_DB_PATH": sqlite_path,
"ORACLE_DSN": "dummy",
"ORACLE_USER": "dummy",
"ORACLE_PASSWORD": "dummy",
"JSON_OUTPUT_DIR": tmpdir,
})
api_dir = os.path.join(os.path.dirname(__file__), "..", "..")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=api_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for startup (up to 15 seconds)
url = f"http://127.0.0.1:{port}"
for _ in range(30):
try:
import urllib.request
urllib.request.urlopen(f"{url}/health", timeout=1)
break
except Exception:
time.sleep(0.5)
else:
proc.kill()
stdout, stderr = proc.communicate()
raise RuntimeError(
f"App failed to start on port {port}.\n"
f"STDOUT: {stdout.decode()[-2000:]}\n"
f"STDERR: {stderr.decode()[-2000:]}"
)
yield url
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="session")
def seed_test_data(app_url):
"""
Seed SQLite with test data via API calls.
Oracle is unavailable in E2E tests — only SQLite-backed pages are
fully functional. This fixture exists as a hook for future seeding;
for now E2E tests validate UI structure on empty-state pages.
"""
return app_url

View File

@@ -0,0 +1,171 @@
"""
E2E verification: Dashboard page against the live app (localhost:5003).
Run with:
python -m pytest api/tests/e2e/test_dashboard_live.py -v --headed
This tests the LIVE app, not a test instance. Requires the app to be running.
"""
import pytest
from playwright.sync_api import sync_playwright, Page, expect
BASE_URL = "http://localhost:5003"
@pytest.fixture(scope="module")
def browser_page():
"""Launch browser and yield a page connected to the live app."""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 900})
page = context.new_page()
yield page
browser.close()
class TestDashboardPageLoad:
"""Verify dashboard page loads and shows expected structure."""
def test_dashboard_loads(self, browser_page: Page):
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
expect(browser_page.locator("h4")).to_contain_text("Panou de Comanda")
def test_sync_control_visible(self, browser_page: Page):
expect(browser_page.locator("#btnStartSync")).to_be_visible()
expect(browser_page.locator("#syncStatusBadge")).to_be_visible()
def test_last_sync_card_populated(self, browser_page: Page):
"""The lastSyncBody should show data from previous runs."""
last_sync_date = browser_page.locator("#lastSyncDate")
expect(last_sync_date).to_be_visible()
text = last_sync_date.text_content()
assert text and text != "-", f"Expected last sync date to be populated, got: '{text}'"
def test_last_sync_imported_count(self, browser_page: Page):
imported_el = browser_page.locator("#lastSyncImported")
text = imported_el.text_content()
count = int(text) if text and text.isdigit() else 0
assert count >= 0, f"Expected imported count >= 0, got: {text}"
def test_last_sync_status_badge(self, browser_page: Page):
status_el = browser_page.locator("#lastSyncStatus .badge")
expect(status_el).to_be_visible()
text = status_el.text_content()
assert text in ("completed", "running", "failed"), f"Unexpected status: {text}"
class TestDashboardOrdersTable:
"""Verify orders table displays data from SQLite."""
def test_orders_table_has_rows(self, browser_page: Page):
"""Dashboard should show orders from previous sync runs."""
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
# Wait for the orders to load (async fetch)
browser_page.wait_for_timeout(2000)
rows = browser_page.locator("#dashOrdersBody tr")
count = rows.count()
assert count > 0, "Expected at least 1 order row in dashboard table"
def test_orders_count_badges(self, browser_page: Page):
"""Filter badges should show counts."""
all_count = browser_page.locator("#dashCountAll").text_content()
assert all_count and int(all_count) > 0, f"Expected total count > 0, got: {all_count}"
def test_first_order_has_columns(self, browser_page: Page):
"""First row should have order number, date, customer, etc."""
first_row = browser_page.locator("#dashOrdersBody tr").first
cells = first_row.locator("td")
assert cells.count() >= 6, f"Expected at least 6 columns, got: {cells.count()}"
# Order number should be a code element
order_code = first_row.locator("td code").first
expect(order_code).to_be_visible()
def test_filter_imported(self, browser_page: Page):
"""Click 'Importate' filter and verify table updates."""
browser_page.locator("#dashFilterBtns button", has_text="Importate").click()
browser_page.wait_for_timeout(1000)
imported_count = browser_page.locator("#dashCountImported").text_content()
if imported_count and int(imported_count) > 0:
rows = browser_page.locator("#dashOrdersBody tr")
assert rows.count() > 0, "Expected imported orders to show"
# All visible rows should have 'Importat' badge
badges = browser_page.locator("#dashOrdersBody .badge.bg-success")
assert badges.count() > 0, "Expected green 'Importat' badges"
def test_filter_all_reset(self, browser_page: Page):
"""Click 'Toate' to reset filter."""
browser_page.locator("#dashFilterBtns button", has_text="Toate").click()
browser_page.wait_for_timeout(1000)
rows = browser_page.locator("#dashOrdersBody tr")
assert rows.count() > 0, "Expected orders after resetting filter"
class TestDashboardOrderDetail:
"""Verify order detail modal opens and shows data."""
def test_click_order_opens_modal(self, browser_page: Page):
browser_page.goto(f"{BASE_URL}/")
browser_page.wait_for_load_state("networkidle")
browser_page.wait_for_timeout(2000)
# Click the first order row
first_row = browser_page.locator("#dashOrdersBody tr").first
first_row.click()
browser_page.wait_for_timeout(1500)
# Modal should be visible
modal = browser_page.locator("#orderDetailModal")
expect(modal).to_be_visible()
# Order number should be populated
order_num = browser_page.locator("#detailOrderNumber").text_content()
assert order_num and order_num != "#", f"Expected order number in modal, got: {order_num}"
def test_modal_shows_customer(self, browser_page: Page):
customer = browser_page.locator("#detailCustomer").text_content()
assert customer and customer not in ("...", "-"), f"Expected customer name, got: {customer}"
def test_modal_shows_items(self, browser_page: Page):
items_rows = browser_page.locator("#detailItemsBody tr")
assert items_rows.count() > 0, "Expected at least 1 item in order detail"
def test_close_modal(self, browser_page: Page):
browser_page.locator("#orderDetailModal .btn-close").click()
browser_page.wait_for_timeout(500)
class TestDashboardAPIEndpoints:
"""Verify API endpoints return expected data."""
def test_api_dashboard_orders(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/dashboard/orders")
assert response.ok, f"API returned {response.status}"
data = response.json()
assert "orders" in data, "Expected 'orders' key in response"
assert "counts" in data, "Expected 'counts' key in response"
assert len(data["orders"]) > 0, "Expected at least 1 order"
def test_api_sync_status(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/sync/status")
assert response.ok
data = response.json()
assert "status" in data
assert "stats" in data
def test_api_sync_history(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/sync/history?per_page=5")
assert response.ok
data = response.json()
assert "runs" in data
assert len(data["runs"]) > 0, "Expected at least 1 sync run"
def test_api_missing_skus(self, browser_page: Page):
response = browser_page.request.get(f"{BASE_URL}/api/validate/missing-skus")
assert response.ok
data = response.json()
assert "missing_skus" in data

View File

@@ -0,0 +1,57 @@
"""E2E: Logs page with per-order filtering and date display."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_logs(page: Page, app_url: str):
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
def test_logs_page_loads(page: Page):
"""Verify the logs page renders with sync runs table."""
expect(page.locator("h4")).to_contain_text("Jurnale Import")
expect(page.locator("#runsTableBody")).to_be_visible()
def test_sync_runs_table_headers(page: Page):
"""Verify table has correct column headers."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert "Data" in texts, f"Expected 'Data' header, got: {texts}"
assert "Status" in texts, f"Expected 'Status' header, got: {texts}"
assert "Comenzi" in texts, f"Expected 'Comenzi' header, got: {texts}"
def test_filter_buttons_exist(page: Page):
"""Verify the log viewer section is initially hidden (no run selected yet)."""
viewer = page.locator("#logViewerSection")
expect(viewer).to_be_hidden()
def test_order_detail_modal_structure(page: Page):
"""Verify the order detail modal exists in DOM with required fields."""
modal = page.locator("#orderDetailModal")
expect(modal).to_be_attached()
expect(page.locator("#detailOrderNumber")).to_be_attached()
expect(page.locator("#detailCustomer")).to_be_attached()
expect(page.locator("#detailDate")).to_be_attached()
expect(page.locator("#detailItemsBody")).to_be_attached()
def test_quick_map_modal_structure(page: Page):
"""Verify quick map modal exists with multi-CODMAT support."""
modal = page.locator("#quickMapModal")
expect(modal).to_be_attached()
expect(page.locator("#qmSku")).to_be_attached()
expect(page.locator("#qmProductName")).to_be_attached()
expect(page.locator("#qmCodmatLines")).to_be_attached()
def test_text_log_toggle(page: Page):
"""Verify text log section is hidden initially and toggle button is in DOM."""
section = page.locator("#textLogSection")
expect(section).to_be_hidden()
# Toggle button lives inside logViewerSection which is also hidden
expect(page.locator("#btnShowTextLog")).to_be_attached()

View File

@@ -0,0 +1,81 @@
"""E2E: Mappings page with sortable headers, grouping, multi-CODMAT modal."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_mappings(page: Page, app_url: str):
page.goto(f"{app_url}/mappings")
page.wait_for_load_state("networkidle")
def test_mappings_page_loads(page: Page):
"""Verify mappings page renders."""
expect(page.locator("h4")).to_contain_text("Mapari SKU")
def test_sortable_headers_present(page: Page):
"""R7: Verify sortable column headers with sort icons."""
sortable_ths = page.locator("th.sortable")
count = sortable_ths.count()
assert count >= 5, f"Expected at least 5 sortable columns, got {count}"
sort_icons = page.locator(".sort-icon")
assert sort_icons.count() >= 5, f"Expected at least 5 sort-icon spans, got {sort_icons.count()}"
def test_product_name_column_exists(page: Page):
"""R4: Verify 'Produs Web' column exists in header."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert any("Produs Web" in t for t in texts), f"'Produs Web' column not found in headers: {texts}"
def test_um_column_exists(page: Page):
"""R12: Verify 'UM' column exists in header."""
headers = page.locator("thead th")
texts = headers.all_text_contents()
assert any("UM" in t for t in texts), f"'UM' column not found in headers: {texts}"
def test_show_inactive_toggle_exists(page: Page):
"""R5: Verify 'Arata inactive' toggle is present."""
toggle = page.locator("#showInactive")
expect(toggle).to_be_visible()
label = page.locator("label[for='showInactive']")
expect(label).to_contain_text("Arata inactive")
def test_sort_click_changes_icon(page: Page):
"""R7: Clicking a sortable header should display a sort direction arrow."""
sku_header = page.locator("th.sortable", has_text="SKU")
sku_header.click()
page.wait_for_timeout(500)
icon = page.locator(".sort-icon[data-col='sku']")
text = icon.text_content()
assert text in ("", ""), f"Expected sort arrow (↑ or ↓), got '{text}'"
def test_add_modal_multi_codmat(page: Page):
"""R11: Verify the add mapping modal supports multiple CODMAT lines."""
page.locator("button", has_text="Adauga Mapare").click()
page.wait_for_timeout(500)
codmat_lines = page.locator(".codmat-line")
assert codmat_lines.count() >= 1, "Expected at least one CODMAT line in modal"
page.locator("button", has_text="Adauga CODMAT").click()
page.wait_for_timeout(300)
assert codmat_lines.count() >= 2, "Expected a second CODMAT line after clicking Adauga CODMAT"
# Second line must have a remove button
remove_btns = page.locator(".codmat-line:nth-child(2) button.btn-outline-danger")
assert remove_btns.count() >= 1, "Second CODMAT line is missing remove button"
def test_search_input_exists(page: Page):
"""Verify search input is present with the correct placeholder."""
search = page.locator("#searchInput")
expect(search).to_be_visible()
expect(search).to_have_attribute("placeholder", "Cauta SKU, CODMAT sau denumire...")

View File

@@ -0,0 +1,68 @@
"""E2E: Missing SKUs page with resolved toggle and multi-CODMAT modal."""
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(autouse=True)
def navigate_to_missing(page: Page, app_url: str):
page.goto(f"{app_url}/missing-skus")
page.wait_for_load_state("networkidle")
def test_missing_skus_page_loads(page: Page):
"""Verify the page renders with the correct heading."""
expect(page.locator("h4")).to_contain_text("SKU-uri Lipsa")
def test_resolved_toggle_buttons(page: Page):
"""R10: Verify resolved filter buttons exist and Nerezolvate is active by default."""
expect(page.locator("#btnUnresolved")).to_be_visible()
expect(page.locator("#btnResolved")).to_be_visible()
expect(page.locator("#btnAll")).to_be_visible()
classes = page.locator("#btnUnresolved").get_attribute("class")
assert "btn-primary" in classes, f"Expected #btnUnresolved to be active (btn-primary), got classes: {classes}"
def test_resolved_toggle_switches(page: Page):
"""R10: Clicking resolved/all toggles changes active state correctly."""
# Click "Rezolvate"
page.locator("#btnResolved").click()
page.wait_for_timeout(500)
classes_res = page.locator("#btnResolved").get_attribute("class")
assert "btn-success" in classes_res, f"Expected #btnResolved to be active (btn-success), got: {classes_res}"
classes_unr = page.locator("#btnUnresolved").get_attribute("class")
assert "btn-outline" in classes_unr, f"Expected #btnUnresolved to be outline after deactivation, got: {classes_unr}"
# Click "Toate"
page.locator("#btnAll").click()
page.wait_for_timeout(500)
classes_all = page.locator("#btnAll").get_attribute("class")
assert "btn-secondary" in classes_all, f"Expected #btnAll to be active (btn-secondary), got: {classes_all}"
def test_map_modal_multi_codmat(page: Page):
"""R11: Verify the mapping modal supports multiple CODMATs."""
modal = page.locator("#mapModal")
expect(modal).to_be_attached()
add_btn = page.locator("#mapModal button", has_text="Adauga CODMAT")
expect(add_btn).to_be_attached()
expect(page.locator("#mapProductName")).to_be_attached()
expect(page.locator("#mapPctWarning")).to_be_attached()
def test_export_csv_button(page: Page):
"""Verify Export CSV button is visible on the page."""
btn = page.locator("button", has_text="Export CSV")
expect(btn).to_be_visible()
def test_rescan_button(page: Page):
"""Verify Re-Scan button is visible on the page."""
btn = page.locator("button", has_text="Re-Scan")
expect(btn).to_be_visible()

View File

@@ -0,0 +1,52 @@
"""E2E: Order detail modal structure and inline mapping."""
import pytest
from playwright.sync_api import Page, expect
def test_order_detail_modal_has_roa_ids(page: Page, app_url: str):
"""R9: Verify order detail modal contains all ROA ID labels."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
modal = page.locator("#orderDetailModal")
expect(modal).to_be_attached()
modal_html = modal.inner_html()
assert "ID Comanda ROA" in modal_html, "Missing 'ID Comanda ROA' label in order detail modal"
assert "ID Partener" in modal_html, "Missing 'ID Partener' label in order detail modal"
assert "ID Adr. Facturare" in modal_html, "Missing 'ID Adr. Facturare' label in order detail modal"
assert "ID Adr. Livrare" in modal_html, "Missing 'ID Adr. Livrare' label in order detail modal"
def test_order_detail_items_table_columns(page: Page, app_url: str):
"""R9: Verify items table has all required columns."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
headers = page.locator("#orderDetailModal thead th")
texts = headers.all_text_contents()
required_columns = ["SKU", "Produs", "Cant.", "Pret", "TVA", "CODMAT", "Status", "Actiune"]
for col in required_columns:
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
def test_quick_map_from_order_detail(page: Page, app_url: str):
"""R9+R11: Verify quick map modal is reachable from order detail context."""
page.goto(f"{app_url}/logs")
page.wait_for_load_state("networkidle")
modal = page.locator("#quickMapModal")
expect(modal).to_be_attached()
expect(page.locator("#qmCodmatLines")).to_be_attached()
expect(page.locator("#qmPctWarning")).to_be_attached()
def test_dashboard_navigates_to_logs(page: Page, app_url: str):
"""Verify the sidebar on the dashboard contains a link to the logs page."""
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
logs_link = page.locator("a[href='/logs']")
expect(logs_link).to_be_visible()

View File

@@ -0,0 +1,613 @@
"""
Test Phase 5.1: Backend Functionality Tests (no Oracle required)
================================================================
Tests all new backend features: web_products, order_items, order detail,
run orders filtered, address updates, missing SKUs toggle, and API endpoints.
Run:
cd api && python -m pytest tests/test_requirements.py -v
"""
import os
import sys
import tempfile
# --- Set env vars BEFORE any app import ---
_tmpdir = tempfile.mkdtemp()
_sqlite_path = os.path.join(_tmpdir, "test_import.db")
os.environ["FORCE_THIN_MODE"] = "true"
os.environ["SQLITE_DB_PATH"] = _sqlite_path
os.environ["ORACLE_DSN"] = "dummy"
os.environ["ORACLE_USER"] = "dummy"
os.environ["ORACLE_PASSWORD"] = "dummy"
os.environ["JSON_OUTPUT_DIR"] = _tmpdir
# Add api/ to path so we can import app
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _api_dir not in sys.path:
sys.path.insert(0, _api_dir)
import pytest
import pytest_asyncio
from app.database import init_sqlite
from app.services import sqlite_service
# Initialize SQLite once before any tests run
init_sqlite()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def client():
"""TestClient with lifespan (startup/shutdown) so SQLite routes work."""
from fastapi.testclient import TestClient
from app.main import app
with TestClient(app, raise_server_exceptions=False) as c:
yield c
@pytest.fixture(autouse=True, scope="module")
def seed_baseline_data():
"""
Seed the sync run and orders used by multiple tests so they run in any order.
We use asyncio.run() because this is a synchronous fixture but needs to call
async service functions.
"""
import asyncio
async def _seed():
# Create sync run RUN001
await sqlite_service.create_sync_run("RUN001", 1)
# Add the first order (IMPORTED) with items
await sqlite_service.add_import_order(
"RUN001", "ORD001", "2025-01-15", "Test Client", "IMPORTED",
id_comanda=100, id_partener=200, items_count=2
)
items = [
{
"sku": "SKU1",
"product_name": "Prod 1",
"quantity": 2.0,
"price": 10.0,
"vat": 1.9,
"mapping_status": "direct",
"codmat": "SKU1",
"id_articol": 500,
"cantitate_roa": 2.0,
},
{
"sku": "SKU2",
"product_name": "Prod 2",
"quantity": 1.0,
"price": 20.0,
"vat": 3.8,
"mapping_status": "missing",
"codmat": None,
"id_articol": None,
"cantitate_roa": None,
},
]
await sqlite_service.add_order_items("RUN001", "ORD001", items)
# Add more orders for filter tests
await sqlite_service.add_import_order(
"RUN001", "ORD002", "2025-01-16", "Client 2", "SKIPPED",
missing_skus=["SKU99"], items_count=1
)
await sqlite_service.add_import_order(
"RUN001", "ORD003", "2025-01-17", "Client 3", "ERROR",
error_message="Test error", items_count=3
)
asyncio.run(_seed())
yield
# ---------------------------------------------------------------------------
# Section 1: web_products CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upsert_web_product():
"""First upsert creates the row; second increments order_count."""
await sqlite_service.upsert_web_product("SKU001", "Product One")
name = await sqlite_service.get_web_product_name("SKU001")
assert name == "Product One"
# Second upsert should increment order_count (no assertion on count here,
# but must not raise and batch lookup should still find it)
await sqlite_service.upsert_web_product("SKU001", "Product One")
batch = await sqlite_service.get_web_products_batch(["SKU001", "NONEXIST"])
assert "SKU001" in batch
assert "NONEXIST" not in batch
@pytest.mark.asyncio
async def test_web_product_name_update():
"""Empty name should NOT overwrite an existing product name."""
await sqlite_service.upsert_web_product("SKU002", "Good Name")
await sqlite_service.upsert_web_product("SKU002", "")
name = await sqlite_service.get_web_product_name("SKU002")
assert name == "Good Name"
@pytest.mark.asyncio
async def test_get_web_product_name_missing():
"""Lookup for an SKU that was never inserted should return empty string."""
name = await sqlite_service.get_web_product_name("DEFINITELY_NOT_THERE_XYZ")
assert name == ""
@pytest.mark.asyncio
async def test_get_web_products_batch_empty():
"""Batch lookup with empty list should return empty dict without error."""
result = await sqlite_service.get_web_products_batch([])
assert result == {}
# ---------------------------------------------------------------------------
# Section 2: order_items CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_and_get_order_items():
"""Verify the items seeded in baseline data are retrievable."""
fetched = await sqlite_service.get_order_items("ORD001")
assert len(fetched) == 2
assert fetched[0]["sku"] == "SKU1"
assert fetched[1]["mapping_status"] == "missing"
@pytest.mark.asyncio
async def test_get_order_items_mapping_status():
"""First item should be 'direct', second should be 'missing'."""
fetched = await sqlite_service.get_order_items("ORD001")
assert fetched[0]["mapping_status"] == "direct"
assert fetched[1]["codmat"] is None
assert fetched[1]["id_articol"] is None
@pytest.mark.asyncio
async def test_get_order_items_for_nonexistent_order():
"""Items query for an unknown order should return an empty list."""
fetched = await sqlite_service.get_order_items("NONEXIST_ORDER")
assert fetched == []
# ---------------------------------------------------------------------------
# Section 3: order detail
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_order_detail():
"""Order detail returns order metadata plus its line items."""
detail = await sqlite_service.get_order_detail("ORD001")
assert detail is not None
assert detail["order"]["order_number"] == "ORD001"
assert len(detail["items"]) == 2
@pytest.mark.asyncio
async def test_get_order_detail_not_found():
"""Non-existent order returns None."""
detail = await sqlite_service.get_order_detail("NONEXIST")
assert detail is None
@pytest.mark.asyncio
async def test_get_order_detail_status():
"""Seeded ORD001 should have IMPORTED status."""
detail = await sqlite_service.get_order_detail("ORD001")
assert detail["order"]["status"] == "IMPORTED"
# ---------------------------------------------------------------------------
# Section 4: run orders filtered
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_run_orders_filtered_all():
"""All orders in run should total 3 with correct status counts."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 50)
assert result["total"] == 3
assert result["counts"]["imported"] == 1
assert result["counts"]["skipped"] == 1
assert result["counts"]["error"] == 1
@pytest.mark.asyncio
async def test_get_run_orders_filtered_imported():
"""Filter IMPORTED should return only ORD001."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "IMPORTED", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD001"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_skipped():
"""Filter SKIPPED should return only ORD002."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "SKIPPED", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD002"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_error():
"""Filter ERROR should return only ORD003."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "ERROR", 1, 50)
assert result["total"] == 1
assert result["orders"][0]["order_number"] == "ORD003"
@pytest.mark.asyncio
async def test_get_run_orders_filtered_unknown_run():
"""Unknown run_id should return zero orders without error."""
result = await sqlite_service.get_run_orders_filtered("NO_SUCH_RUN", "all", 1, 50)
assert result["total"] == 0
assert result["orders"] == []
@pytest.mark.asyncio
async def test_get_run_orders_filtered_pagination():
"""Pagination: page 1 with per_page=1 should return 1 order."""
result = await sqlite_service.get_run_orders_filtered("RUN001", "all", 1, 1)
assert len(result["orders"]) == 1
assert result["total"] == 3
assert result["pages"] == 3
# ---------------------------------------------------------------------------
# Section 5: update_import_order_addresses
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_import_order_addresses():
"""Address IDs should be persisted and retrievable via get_order_detail."""
await sqlite_service.update_import_order_addresses(
"ORD001", "RUN001",
id_adresa_facturare=300,
id_adresa_livrare=400
)
detail = await sqlite_service.get_order_detail("ORD001")
assert detail["order"]["id_adresa_facturare"] == 300
assert detail["order"]["id_adresa_livrare"] == 400
@pytest.mark.asyncio
async def test_update_import_order_addresses_null():
"""Updating with None should be accepted without error."""
await sqlite_service.update_import_order_addresses(
"ORD001", "RUN001",
id_adresa_facturare=None,
id_adresa_livrare=None
)
detail = await sqlite_service.get_order_detail("ORD001")
assert detail is not None # row still exists
# ---------------------------------------------------------------------------
# Section 6: missing SKUs resolved toggle (R10)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_missing_skus_resolved_toggle():
"""resolved=-1 returns all; resolved=0/1 returns only matching rows."""
await sqlite_service.track_missing_sku("MISS1", "Missing Product 1")
await sqlite_service.track_missing_sku("MISS2", "Missing Product 2")
await sqlite_service.resolve_missing_sku("MISS2")
# Unresolved only (default)
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0)
assert all(s["resolved"] == 0 for s in result["missing_skus"])
# Resolved only
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=1)
assert all(s["resolved"] == 1 for s in result["missing_skus"])
# All (resolved=-1)
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=-1)
assert result["total"] >= 2
@pytest.mark.asyncio
async def test_track_missing_sku_idempotent():
"""Tracking the same SKU twice should not raise (INSERT OR IGNORE)."""
await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product")
await sqlite_service.track_missing_sku("IDEMPOTENT_SKU", "Some Product")
result = await sqlite_service.get_missing_skus_paginated(1, 20, resolved=0)
sku_list = [s["sku"] for s in result["missing_skus"]]
assert sku_list.count("IDEMPOTENT_SKU") == 1
@pytest.mark.asyncio
async def test_missing_skus_pagination():
"""Pagination response includes total, page, per_page, pages fields."""
result = await sqlite_service.get_missing_skus_paginated(1, 1, resolved=-1)
assert "total" in result
assert "page" in result
assert "per_page" in result
assert "pages" in result
assert len(result["missing_skus"]) <= 1
# ---------------------------------------------------------------------------
# Section 7: API endpoints via TestClient
# ---------------------------------------------------------------------------
def test_api_sync_run_orders(client):
"""R1: GET /api/sync/run/{run_id}/orders returns orders and counts."""
resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=50")
assert resp.status_code == 200
data = resp.json()
assert "orders" in data
assert "counts" in data
def test_api_sync_run_orders_filtered(client):
"""R1: Filtering by status=IMPORTED returns only IMPORTED orders."""
resp = client.get("/api/sync/run/RUN001/orders?status=IMPORTED")
assert resp.status_code == 200
data = resp.json()
assert all(o["status"] == "IMPORTED" for o in data["orders"])
def test_api_sync_run_orders_pagination_fields(client):
"""R1: Paginated response includes total, page, per_page, pages."""
resp = client.get("/api/sync/run/RUN001/orders?status=all&page=1&per_page=10")
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "page" in data
assert "per_page" in data
assert "pages" in data
def test_api_sync_run_orders_unknown_run(client):
"""R1: Unknown run_id returns empty orders list, not 4xx/5xx."""
resp = client.get("/api/sync/run/NO_SUCH_RUN/orders")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
def test_api_order_detail(client):
"""R9: GET /api/sync/order/{order_number} returns order and items."""
resp = client.get("/api/sync/order/ORD001")
assert resp.status_code == 200
data = resp.json()
assert "order" in data
assert "items" in data
def test_api_order_detail_not_found(client):
"""R9: Non-existent order number returns error key."""
resp = client.get("/api/sync/order/NONEXIST")
assert resp.status_code == 200
data = resp.json()
assert "error" in data
def test_api_missing_skus_resolved_toggle(client):
"""R10: resolved=-1 returns all missing SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=-1")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
def test_api_missing_skus_resolved_unresolved(client):
"""R10: resolved=0 returns only unresolved SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=0")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
assert all(s["resolved"] == 0 for s in data["missing_skus"])
def test_api_missing_skus_resolved_only(client):
"""R10: resolved=1 returns only resolved SKUs."""
resp = client.get("/api/validate/missing-skus?resolved=1")
assert resp.status_code == 200
data = resp.json()
assert "missing_skus" in data
assert all(s["resolved"] == 1 for s in data["missing_skus"])
def test_api_missing_skus_csv_format(client):
"""R8: CSV export has mapping-compatible columns."""
resp = client.get("/api/validate/missing-skus-csv")
assert resp.status_code == 200
content = resp.content.decode("utf-8-sig")
header_line = content.split("\n")[0].strip()
assert header_line == "sku,codmat,cantitate_roa,procent_pret,product_name"
def test_api_mappings_sort_params(client):
"""R7: Sort params accepted - no 422 validation error even without Oracle."""
resp = client.get("/api/mappings?sort_by=sku&sort_dir=desc")
# 200 if Oracle available, 503 if not - but never 422 (invalid params)
assert resp.status_code in [200, 503]
def test_api_mappings_sort_params_asc(client):
"""R7: sort_dir=asc is also accepted without 422."""
resp = client.get("/api/mappings?sort_by=codmat&sort_dir=asc")
assert resp.status_code in [200, 503]
def test_api_batch_mappings_validation_percentage(client):
"""R11: Batch endpoint rejects procent_pret that does not sum to 100."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU",
"mappings": [
{"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60},
{"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 30},
]
})
data = resp.json()
# 60 + 30 = 90, not 100 -> must fail validation
assert data.get("success") is False
assert "100%" in data.get("error", "")
def test_api_batch_mappings_validation_exact_100(client):
"""R11: Batch with procent_pret summing to exactly 100 passes validation layer."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU_VALID",
"mappings": [
{"codmat": "COD1", "cantitate_roa": 1, "procent_pret": 60},
{"codmat": "COD2", "cantitate_roa": 1, "procent_pret": 40},
]
})
data = resp.json()
# Validation passes; may fail with 503/error if Oracle is unavailable,
# but must NOT return the percentage error message
assert "100%" not in data.get("error", "")
def test_api_batch_mappings_no_mappings(client):
"""R11: Batch endpoint rejects empty mappings list."""
resp = client.post("/api/mappings/batch", json={
"sku": "TESTSKU",
"mappings": []
})
data = resp.json()
assert data.get("success") is False
def test_api_sync_status(client):
"""GET /api/sync/status returns status and stats keys."""
resp = client.get("/api/sync/status")
assert resp.status_code == 200
data = resp.json()
assert "stats" in data
def test_api_sync_history(client):
"""GET /api/sync/history returns paginated run history."""
resp = client.get("/api/sync/history")
assert resp.status_code == 200
data = resp.json()
assert "runs" in data
assert "total" in data
def test_api_missing_skus_pagination_params(client):
"""Pagination params page and per_page are respected."""
resp = client.get("/api/validate/missing-skus?page=1&per_page=2&resolved=-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["missing_skus"]) <= 2
assert data["per_page"] == 2
def test_api_csv_template(client):
"""GET /api/mappings/csv-template returns a CSV file without Oracle."""
resp = client.get("/api/mappings/csv-template")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Section 8: Chronological sorting (R3)
# ---------------------------------------------------------------------------
def test_chronological_sort():
"""R3: Orders sorted oldest-first when sorted by date string."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="3", number="003", date="2025-03-01", billing=OrderBilling()),
OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling()),
OrderData(id="2", number="002", date="2025-02-01", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
assert orders[0].number == "001"
assert orders[1].number == "002"
assert orders[2].number == "003"
def test_chronological_sort_stable_on_equal_dates():
"""R3: Two orders with the same date preserve relative order."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="A", number="A01", date="2025-05-01", billing=OrderBilling()),
OrderData(id="B", number="B01", date="2025-05-01", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
# Both dates equal; stable sort preserves original order
assert orders[0].number == "A01"
assert orders[1].number == "B01"
def test_chronological_sort_empty_date_last():
"""R3: Orders with missing date (empty string) sort before dated orders."""
from app.services.order_reader import OrderData, OrderBilling
orders = [
OrderData(id="2", number="002", date="2025-06-01", billing=OrderBilling()),
OrderData(id="1", number="001", date="", billing=OrderBilling()),
]
orders.sort(key=lambda o: o.date or "")
# '' sorts before '2025-...' lexicographically
assert orders[0].number == "001"
assert orders[1].number == "002"
# ---------------------------------------------------------------------------
# Section 9: OrderData dataclass integrity
# ---------------------------------------------------------------------------
def test_order_data_defaults():
"""OrderData can be constructed with only id, number, date."""
from app.services.order_reader import OrderData, OrderBilling
order = OrderData(id="1", number="001", date="2025-01-01", billing=OrderBilling())
assert order.status == ""
assert order.items == []
assert order.shipping is None
def test_order_billing_defaults():
"""OrderBilling has sensible defaults."""
from app.services.order_reader import OrderBilling
b = OrderBilling()
assert b.is_company is False
assert b.company_name == ""
assert b.email == ""
def test_get_all_skus():
"""get_all_skus extracts a unique set of SKUs from all orders."""
from app.services.order_reader import OrderData, OrderBilling, OrderItem, get_all_skus
orders = [
OrderData(
id="1", number="001", date="2025-01-01",
billing=OrderBilling(),
items=[
OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9),
OrderItem(sku="B", name="Prod B", price=20, quantity=2, vat=3.8),
]
),
OrderData(
id="2", number="002", date="2025-01-02",
billing=OrderBilling(),
items=[
OrderItem(sku="A", name="Prod A", price=10, quantity=1, vat=1.9),
OrderItem(sku="C", name="Prod C", price=5, quantity=3, vat=0.95),
]
),
]
skus = get_all_skus(orders)
assert skus == {"A", "B", "C"}

View File

@@ -1,9 +1,9 @@
ROA_CENTRAL =
(DESCRIPTION =
(ADDRESS_LIST =
(ADDRESS = (PROTOCOL = TCP)(HOST = 10.0.20.122)(PORT = 1521))
(ADDRESS = (PROTOCOL = tcp)(HOST = 10.0.20.121)(PORT = 1521))
)
(CONNECT_DATA =
(SID = ROA)
(SERVICE_NAME = ROA)
)
)

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()

55
start.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# Start GoMag Import Manager - WSL/Linux
cd "$(dirname "$0")"
# Create venv if it doesn't exist
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
fi
# Activate venv
source venv/bin/activate
# Install/update dependencies if needed
if [ api/requirements.txt -nt venv/.deps_installed ] || [ ! -f venv/.deps_installed ]; then
echo "Installing dependencies..."
pip install -r api/requirements.txt
touch venv/.deps_installed
fi
# Stop any existing instance on port 5003
EXISTING_PIDS=$(lsof -ti tcp:5003 2>/dev/null)
if [ -n "$EXISTING_PIDS" ]; then
echo "Stopping existing process(es) on port 5003 (PID $EXISTING_PIDS)..."
echo "$EXISTING_PIDS" | xargs kill 2>/dev/null
sleep 2
fi
# Oracle config
export TNS_ADMIN="$(pwd)/api"
# Detect Oracle Instant Client path from .env or use default
INSTANTCLIENT_PATH=""
if [ -f "api/.env" ]; then
INSTANTCLIENT_PATH=$(grep -E "^INSTANTCLIENTPATH=" api/.env | cut -d'=' -f2- | tr -d ' ')
fi
# Fallback to default path if not set in .env
if [ -z "$INSTANTCLIENT_PATH" ]; then
INSTANTCLIENT_PATH="/opt/oracle/instantclient_21_15"
fi
if [ -d "$INSTANTCLIENT_PATH" ]; then
echo "Oracle Instant Client found: $INSTANTCLIENT_PATH (thick mode)"
export LD_LIBRARY_PATH="$INSTANTCLIENT_PATH:$LD_LIBRARY_PATH"
else
echo "WARN: Oracle Instant Client NOT found la: $INSTANTCLIENT_PATH"
echo " Se va folosi thin mode (Oracle 12.1+ necesar)."
echo " Pentru thick mode: instaleaza instantclient sau seteaza INSTANTCLIENTPATH in api/.env"
# Force thin mode so app doesn't try to load missing libraries
export FORCE_THIN_MODE=true
fi
cd api
echo "Starting GoMag Import Manager on http://0.0.0.0:5003"
python -m uvicorn app.main:app --host 0.0.0.0 --port 5003

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"

View File

@@ -1,320 +0,0 @@
*-- ApplicationSetup.prg - Clasa pentru configurarea si setup-ul aplicatiei
*-- Contine toate functiile pentru settings.ini si configurare
*-- Autor: Claude AI
*-- Data: 10 septembrie 2025
DEFINE CLASS ApplicationSetup AS Custom
*-- Proprietati publice
cAppPath = ""
cIniFile = ""
oSettings = NULL
lInitialized = .F.
*-- Constructor
PROCEDURE Init
PARAMETERS tcAppPath
IF !EMPTY(tcAppPath)
THIS.cAppPath = ADDBS(tcAppPath)
ELSE
THIS.cAppPath = ADDBS(JUSTPATH(SYS(16,0)))
ENDIF
THIS.cIniFile = THIS.cAppPath + "settings.ini"
THIS.lInitialized = .F.
ENDPROC
*-- Functie pentru incarcarea tuturor setarilor din fisierul INI
PROCEDURE LoadSettings
PARAMETERS tcIniFile
LOCAL loSettings
IF EMPTY(tcIniFile)
tcIniFile = THIS.cIniFile
ENDIF
*-- Cream un obiect pentru toate setarile
loSettings = CREATEOBJECT("Empty")
*-- Sectiunea API
ADDPROPERTY(loSettings, "ApiBaseUrl", ReadPini("API", "ApiBaseUrl", tcIniFile))
ADDPROPERTY(loSettings, "OrderApiUrl", ReadPini("API", "OrderApiUrl", tcIniFile))
ADDPROPERTY(loSettings, "ApiKey", ReadPini("API", "ApiKey", tcIniFile))
ADDPROPERTY(loSettings, "ApiShop", ReadPini("API", "ApiShop", tcIniFile))
ADDPROPERTY(loSettings, "UserAgent", ReadPini("API", "UserAgent", tcIniFile))
ADDPROPERTY(loSettings, "ContentType", ReadPini("API", "ContentType", tcIniFile))
*-- Sectiunea PAGINATION
ADDPROPERTY(loSettings, "Limit", VAL(ReadPini("PAGINATION", "Limit", tcIniFile)))
*-- Sectiunea OPTIONS
ADDPROPERTY(loSettings, "GetProducts", ReadPini("OPTIONS", "GetProducts", tcIniFile) = "1")
ADDPROPERTY(loSettings, "GetOrders", ReadPini("OPTIONS", "GetOrders", tcIniFile) = "1")
*-- Sectiunea FILTERS
ADDPROPERTY(loSettings, "OrderDaysBack", VAL(ReadPini("FILTERS", "OrderDaysBack", tcIniFile)))
*-- Sectiunea ORACLE - pentru conexiunea la database
ADDPROPERTY(loSettings, "OracleUser", ReadPini("ORACLE", "OracleUser", tcIniFile))
ADDPROPERTY(loSettings, "OraclePassword", ReadPini("ORACLE", "OraclePassword", tcIniFile))
ADDPROPERTY(loSettings, "OracleDSN", ReadPini("ORACLE", "OracleDSN", tcIniFile))
*-- Sectiunea SYNC - pentru configurarea sincronizarii
ADDPROPERTY(loSettings, "AdapterProgram", ReadPini("SYNC", "AdapterProgram", tcIniFile))
ADDPROPERTY(loSettings, "JsonFilePattern", ReadPini("SYNC", "JsonFilePattern", tcIniFile))
ADDPROPERTY(loSettings, "AutoRunAdapter", ReadPini("SYNC", "AutoRunAdapter", tcIniFile) = "1")
*-- Sectiunea ROA - pentru configurarea sistemului ROA
LOCAL lcRoaValue
*-- IdPol - NULL sau valoare numerica
lcRoaValue = UPPER(ALLTRIM(ReadPini("ROA", "IdPol", tcIniFile)))
IF lcRoaValue = "NULL" OR EMPTY(lcRoaValue)
ADDPROPERTY(loSettings, "IdPol", .NULL.)
ELSE
ADDPROPERTY(loSettings, "IdPol", VAL(lcRoaValue))
ENDIF
*-- IdGestiune - NULL sau valoare numerica
lcRoaValue = UPPER(ALLTRIM(ReadPini("ROA", "IdGestiune", tcIniFile)))
IF lcRoaValue = "NULL" OR EMPTY(lcRoaValue)
ADDPROPERTY(loSettings, "IdGestiune", .NULL.)
ELSE
ADDPROPERTY(loSettings, "IdGestiune", VAL(lcRoaValue))
ENDIF
*-- IdSectie - NULL sau valoare numerica
lcRoaValue = UPPER(ALLTRIM(ReadPini("ROA", "IdSectie", tcIniFile)))
IF lcRoaValue = "NULL" OR EMPTY(lcRoaValue)
ADDPROPERTY(loSettings, "IdSectie", .NULL.)
ELSE
ADDPROPERTY(loSettings, "IdSectie", VAL(lcRoaValue))
ENDIF
*-- Salvare in proprietatea clasei
THIS.oSettings = loSettings
RETURN loSettings
ENDPROC
*-- Functie pentru crearea unui fisier INI implicit cu setari de baza
PROCEDURE CreateDefaultIni
PARAMETERS tcIniFile
LOCAL llSuccess
IF EMPTY(tcIniFile)
tcIniFile = THIS.cIniFile
ENDIF
llSuccess = .T.
TRY
*-- Sectiunea API
WritePini("API", "ApiBaseUrl", "https://api.gomag.ro/api/v1/product/read/json?enabled=1", tcIniFile)
WritePini("API", "OrderApiUrl", "https://api.gomag.ro/api/v1/order/read/json", tcIniFile)
WritePini("API", "ApiKey", "YOUR_API_KEY_HERE", tcIniFile)
WritePini("API", "ApiShop", "https://yourstore.gomag.ro", tcIniFile)
WritePini("API", "UserAgent", "Mozilla/5.0", tcIniFile)
WritePini("API", "ContentType", "application/json", tcIniFile)
*-- Sectiunea PAGINATION
WritePini("PAGINATION", "Limit", "100", tcIniFile)
*-- Sectiunea OPTIONS
WritePini("OPTIONS", "GetProducts", "1", tcIniFile)
WritePini("OPTIONS", "GetOrders", "1", tcIniFile)
*-- Sectiunea FILTERS
WritePini("FILTERS", "OrderDaysBack", "7", tcIniFile)
*-- Sectiunea ORACLE - conexiune database
WritePini("ORACLE", "OracleUser", "MARIUSM_AUTO", tcIniFile)
WritePini("ORACLE", "OraclePassword", "ROMFASTSOFT", tcIniFile)
WritePini("ORACLE", "OracleDSN", "ROA_CENTRAL", tcIniFile)
*-- Sectiunea SYNC - configurare sincronizare
WritePini("SYNC", "AdapterProgram", "gomag-adapter.prg", tcIniFile)
WritePini("SYNC", "JsonFilePattern", "gomag_orders*.json", tcIniFile)
WritePini("SYNC", "AutoRunAdapter", "1", tcIniFile)
*-- Sectiunea ROA - configurare sistem ROA
WritePini("ROA", "IdPol", "NULL", tcIniFile)
WritePini("ROA", "IdGestiune", "NULL", tcIniFile)
WritePini("ROA", "IdSectie", "NULL", tcIniFile)
CATCH
llSuccess = .F.
ENDTRY
RETURN llSuccess
ENDPROC
*-- Functie pentru validarea setarilor obligatorii
PROCEDURE ValidateSettings
LPARAMETERS toSettings
LOCAL llValid, lcErrors
IF PCOUNT() = 0
toSettings = THIS.oSettings
ENDIF
IF TYPE('toSettings') <> 'O' OR ISNULL(toSettings)
RETURN .F.
ENDIF
llValid = .T.
lcErrors = ""
*-- Verificare setari API obligatorii
IF EMPTY(toSettings.ApiKey) OR toSettings.ApiKey = "YOUR_API_KEY_HERE"
llValid = .F.
lcErrors = lcErrors + "ApiKey nu este setat corect in settings.ini" + CHR(13) + CHR(10)
ENDIF
IF EMPTY(toSettings.ApiShop) OR "yourstore.gomag.ro" $ toSettings.ApiShop
llValid = .F.
lcErrors = lcErrors + "ApiShop nu este setat corect in settings.ini" + CHR(13) + CHR(10)
ENDIF
*-- Verificare setari Oracle obligatorii (doar pentru sync)
IF TYPE('toSettings.OracleUser') = 'C' AND EMPTY(toSettings.OracleUser)
llValid = .F.
lcErrors = lcErrors + "OracleUser nu este setat in settings.ini" + CHR(13) + CHR(10)
ENDIF
IF TYPE('toSettings.OracleDSN') = 'C' AND EMPTY(toSettings.OracleDSN)
llValid = .F.
lcErrors = lcErrors + "OracleDSN nu este setat in settings.ini" + CHR(13) + CHR(10)
ENDIF
*-- Log erorile daca exista
IF !llValid AND TYPE('gcLogFile') = 'C'
LogMessage("Erori validare settings.ini:", "ERROR", gcLogFile)
LogMessage(lcErrors, "ERROR", gcLogFile)
ENDIF
RETURN llValid
ENDPROC
*-- Functie pentru configurarea initiala a aplicatiei
PROCEDURE Setup
LOCAL llSetupOk
llSetupOk = .T.
*-- Verificare existenta settings.ini
IF !CheckIniFile(THIS.cIniFile)
IF TYPE('gcLogFile') = 'C'
LogMessage("ATENTIE: Fisierul settings.ini nu a fost gasit!", "WARN", gcLogFile)
LogMessage("Cream un fisier settings.ini implicit...", "INFO", gcLogFile)
ENDIF
IF THIS.CreateDefaultIni()
IF TYPE('gcLogFile') = 'C'
LogMessage("Fisier settings.ini creat cu succes.", "INFO", gcLogFile)
LogMessage("IMPORTANT: Modifica setarile din settings.ini (ApiKey, ApiShop) inainte de a rula scriptul din nou!", "INFO", gcLogFile)
ENDIF
llSetupOk = .F. && Opreste executia pentru a permite configurarea
ELSE
IF TYPE('gcLogFile') = 'C'
LogMessage("EROARE: Nu s-a putut crea fisierul settings.ini!", "ERROR", gcLogFile)
ENDIF
llSetupOk = .F.
ENDIF
ENDIF
*-- Incarca setarile daca setup-ul este OK
IF llSetupOk
THIS.LoadSettings()
THIS.lInitialized = .T.
ENDIF
RETURN llSetupOk
ENDPROC
*-- Functie pentru afisarea informatiilor despre configuratie
PROCEDURE DisplaySettingsInfo
LPARAMETERS toSettings
LOCAL lcInfo
IF PCOUNT() = 0
toSettings = THIS.oSettings
ENDIF
IF TYPE('toSettings') <> 'O' OR ISNULL(toSettings)
RETURN .F.
ENDIF
IF TYPE('gcLogFile') != 'C'
RETURN .F.
ENDIF
lcInfo = "=== CONFIGURATIE APLICATIE ==="
LogMessage(lcInfo, "INFO", gcLogFile)
*-- API Settings
LogMessage("API: " + toSettings.ApiShop, "INFO", gcLogFile)
LogMessage("Orders Days Back: " + TRANSFORM(toSettings.OrderDaysBack), "INFO", gcLogFile)
LogMessage("Get Products: " + IIF(toSettings.GetProducts, "DA", "NU"), "INFO", gcLogFile)
LogMessage("Get Orders: " + IIF(toSettings.GetOrders, "DA", "NU"), "INFO", gcLogFile)
*-- Oracle Settings (doar daca exista)
IF TYPE('toSettings.OracleUser') = 'C' AND !EMPTY(toSettings.OracleUser)
LogMessage("Oracle User: " + toSettings.OracleUser, "INFO", gcLogFile)
LogMessage("Oracle DSN: " + toSettings.OracleDSN, "INFO", gcLogFile)
ENDIF
*-- Sync Settings (doar daca exista)
IF TYPE('toSettings.AdapterProgram') = 'C' AND !EMPTY(toSettings.AdapterProgram)
LogMessage("Adapter Program: " + toSettings.AdapterProgram, "INFO", gcLogFile)
LogMessage("JSON Pattern: " + toSettings.JsonFilePattern, "INFO", gcLogFile)
LogMessage("Auto Run Adapter: " + IIF(toSettings.AutoRunAdapter, "DA", "NU"), "INFO", gcLogFile)
ENDIF
LogMessage("=== SFARSIT CONFIGURATIE ===", "INFO", gcLogFile)
RETURN .T.
ENDPROC
*-- Metoda pentru setup complet cu validare
PROCEDURE Initialize
LOCAL llSuccess
llSuccess = THIS.Setup()
IF llSuccess
llSuccess = THIS.ValidateSettings()
IF llSuccess
THIS.DisplaySettingsInfo()
ENDIF
ENDIF
RETURN llSuccess
ENDPROC
*-- Functie pentru obtinerea setarilor
PROCEDURE GetSettings
RETURN THIS.oSettings
ENDPROC
*-- Functie pentru obtinerea path-ului aplicatiei
PROCEDURE GetAppPath
RETURN THIS.cAppPath
ENDPROC
*-- Functie pentru obtinerea path-ului fisierului INI
PROCEDURE GetIniFile
RETURN THIS.cIniFile
ENDPROC
ENDDEFINE
*-- ApplicationSetup Class - Clasa pentru configurarea si setup-ul aplicatiei
*-- Caracteristici:
*-- - Gestionare completa a settings.ini cu toate sectiunile
*-- - Creare fisier implicit cu valori default
*-- - Validare setari obligatorii pentru functionare
*-- - Setup si initializare completa cu o singura metoda
*-- - Afisarea informatiilor despre configuratia curenta
*-- - Proprietati pentru acces facil la configuratii si paths

View File

@@ -1,425 +0,0 @@
*-- Script Visual FoxPro 9 pentru accesul la GoMag API cu paginare completa
*-- Autor: Claude AI
*-- Data: 26.08.2025
SET SAFETY OFF
SET CENTURY ON
SET DATE DMY
SET EXACT ON
SET ANSI ON
SET DELETED ON
*-- Setari principale
LOCAL lcApiBaseUrl, lcApiUrl, lcApiKey, lcUserAgent, lcContentType
LOCAL loHttp, lcResponse, lcJsonResponse
LOCAL laHeaders[10], lnHeaderCount
Local lcApiShop, lcCsvFileName, lcErrorResponse, lcFileName, lcLogContent, lcLogFileName, lcPath
Local lcStatusText, lnStatusCode, loError
Local lnLimit, lnCurrentPage, llHasMorePages, loAllJsonData, lnTotalPages, lnTotalProducts
Local lcOrderApiUrl, loAllOrderData, lcOrderCsvFileName, lcOrderJsonFileName
Local ldStartDate, lcStartDateStr
Local lcIniFile
LOCAL llGetProducts, llGetOrders
PRIVATE loJsonData, gcLogFile, gnStartTime, gnProductsProcessed, gnOrdersProcessed
*-- Initializare logging si statistici
gnStartTime = SECONDS()
gnProductsProcessed = 0
gnOrdersProcessed = 0
gcLogFile = InitLog("gomag_sync")
*-- Cream directorul output daca nu existe
LOCAL lcOutputDir
lcOutputDir = gcAppPath + "output"
IF !DIRECTORY(lcOutputDir)
MKDIR (lcOutputDir)
ENDIF
*-- Creare si initializare clasa setup aplicatie
LOCAL loAppSetup
loAppSetup = CREATEOBJECT("ApplicationSetup", gcAppPath)
*-- Setup complet cu validare
IF !loAppSetup.Initialize()
LogMessage("EROARE: Setup-ul aplicatiei a esuat sau necesita configurare!", "ERROR", gcLogFile)
RETURN .F.
ENDIF
*-- Configurare API din settings.ini
lcApiBaseUrl = goSettings.ApiBaseUrl
lcOrderApiUrl = goSettings.OrderApiUrl
lcApiKey = goSettings.ApiKey
lcApiShop = goSettings.ApiShop
lcUserAgent = goSettings.UserAgent
lcContentType = goSettings.ContentType
lnLimit = goSettings.Limit
llGetProducts = goSettings.GetProducts
llGetOrders = goSettings.GetOrders
lnCurrentPage = 1 && Pagina de start
llHasMorePages = .T. && Flag pentru paginare
loAllJsonData = NULL && Obiect pentru toate datele
*-- Calculare data pentru ultimele X zile (din settings.ini)
ldStartDate = DATE() - goSettings.OrderDaysBack
lcStartDateStr = TRANSFORM(YEAR(ldStartDate)) + "-" + ;
RIGHT("0" + TRANSFORM(MONTH(ldStartDate)), 2) + "-" + ;
RIGHT("0" + TRANSFORM(DAY(ldStartDate)), 2)
*******************************************
*-- Sterg fisiere JSON comenzi anterioare
lcDirJson = gcAppPath + "output\"
lcJsonPattern = m.lcDirJson + goSettings.JsonFilePattern
lnJsonFiles = ADIR(laJsonFiles, lcJsonPattern)
FOR lnFile = 1 TO m.lnJsonFiles
lcFile = m.lcDirJson + laJsonFiles[m.lnFile,1]
IF FILE(m.lcFile)
DELETE FILE (m.lcFile)
ENDIF
ENDFOR
*******************************************
*-- Verificare daca avem WinHttp disponibil
TRY
loHttp = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
CATCH TO loError
LogMessage("Eroare la crearea obiectului WinHttp: " + loError.Message, "ERROR", gcLogFile)
RETURN .F.
ENDTRY
*-- SECTIUNEA PRODUSE - se executa doar daca llGetProducts = .T.
IF llGetProducts
LogMessage("[PRODUCTS] Starting product retrieval", "INFO", gcLogFile)
*-- Bucla pentru preluarea tuturor produselor (paginare)
loAllJsonData = CREATEOBJECT("Empty")
ADDPROPERTY(loAllJsonData, "products", CREATEOBJECT("Empty"))
ADDPROPERTY(loAllJsonData, "total", 0)
ADDPROPERTY(loAllJsonData, "pages", 0)
lnTotalProducts = 0
DO WHILE llHasMorePages
*-- Construire URL cu paginare
lcApiUrl = lcApiBaseUrl + "&page=" + TRANSFORM(lnCurrentPage) + "&limit=" + TRANSFORM(lnLimit)
LogMessage("[PRODUCTS] Page " + TRANSFORM(lnCurrentPage) + " fetching...", "INFO", gcLogFile)
*-- Configurare request
TRY
*-- Initializare request GET
loHttp.Open("GET", lcApiUrl, .F.)
*-- Setare headers conform documentatiei GoMag
loHttp.SetRequestHeader("User-Agent", lcUserAgent)
loHttp.SetRequestHeader("Content-Type", lcContentType)
loHttp.SetRequestHeader("Accept", "application/json")
loHttp.SetRequestHeader("Apikey", lcApiKey) && Header pentru API Key
loHttp.SetRequestHeader("ApiShop", lcApiShop) && Header pentru shop URL
*-- Setari timeout
loHttp.SetTimeouts(30000, 30000, 30000, 30000) && 30 secunde pentru fiecare
*-- Trimitere request
loHttp.Send()
*-- Verificare status code
lnStatusCode = loHttp.Status
lcStatusText = loHttp.StatusText
IF lnStatusCode = 200
*-- Success - preluare raspuns
lcResponse = loHttp.ResponseText
*-- Parsare JSON cu nfjson
SET PATH TO nfjson ADDITIVE
loJsonData = nfJsonRead(lcResponse)
IF !ISNULL(loJsonData)
*-- Prima pagina - setam informatiile generale
IF lnCurrentPage = 1
LogMessage("[PRODUCTS] Analyzing JSON structure...", "INFO", gcLogFile)
LOCAL ARRAY laJsonProps[1]
lnPropCount = AMEMBERS(laJsonProps, loJsonData, 0)
FOR lnDebugIndex = 1 TO MIN(lnPropCount, 10) && Primele 10 proprietati
lcPropName = laJsonProps(lnDebugIndex)
lcPropType = TYPE('loJsonData.' + lcPropName)
LogMessage("[PRODUCTS] Property: " + lcPropName + " (Type: " + lcPropType + ")", "DEBUG", gcLogFile)
ENDFOR
IF TYPE('loJsonData.total') = 'C' OR TYPE('loJsonData.total') = 'N'
loAllJsonData.total = VAL(TRANSFORM(loJsonData.total))
ENDIF
IF TYPE('loJsonData.pages') = 'C' OR TYPE('loJsonData.pages') = 'N'
loAllJsonData.pages = VAL(TRANSFORM(loJsonData.pages))
ENDIF
LogMessage("[PRODUCTS] Total items: " + TRANSFORM(loAllJsonData.total) + " | Pages: " + TRANSFORM(loAllJsonData.pages), "INFO", gcLogFile)
ENDIF
*-- Adaugare produse din pagina curenta
LOCAL llHasProducts, lnProductsFound
llHasProducts = .F.
lnProductsFound = 0
IF TYPE('loJsonData.products') = 'O'
*-- Numaram produsele din obiectul products
lnProductsFound = AMEMBERS(laProductsPage, loJsonData.products, 0)
IF lnProductsFound > 0
DO MergeProducts WITH loAllJsonData, loJsonData
llHasProducts = .T.
LogMessage("[PRODUCTS] Found: " + TRANSFORM(lnProductsFound) + " products in page " + TRANSFORM(lnCurrentPage), "INFO", gcLogFile)
gnProductsProcessed = gnProductsProcessed + lnProductsFound
ENDIF
ENDIF
IF !llHasProducts
LogMessage("[PRODUCTS] WARNING: No products found in JSON response for page " + TRANSFORM(lnCurrentPage), "WARN", gcLogFile)
ENDIF
*-- Verificare daca mai sunt pagini
IF TYPE('loJsonData.pages') = 'C' OR TYPE('loJsonData.pages') = 'N'
lnTotalPages = VAL(TRANSFORM(loJsonData.pages))
IF lnCurrentPage >= lnTotalPages
llHasMorePages = .F.
ENDIF
ELSE
*-- Daca nu avem info despre pagini, verificam daca sunt produse
IF TYPE('loJsonData.products') != 'O'
llHasMorePages = .F.
ENDIF
ENDIF
lnCurrentPage = lnCurrentPage + 1
ELSE
*-- Salvare raspuns JSON raw in caz de eroare de parsare
lcFileName = "gomag_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".json"
STRTOFILE(lcResponse, lcFileName)
llHasMorePages = .F.
ENDIF
ELSE
*-- Eroare HTTP - salvare in fisier de log
lcLogFileName = "gomag_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
lcLogContent = "HTTP Error " + TRANSFORM(lnStatusCode) + ": " + lcStatusText + CHR(13) + CHR(10)
*-- Incearca sa citesti raspunsul pentru detalii despre eroare
TRY
lcErrorResponse = loHttp.ResponseText
IF !EMPTY(lcErrorResponse)
lcLogContent = lcLogContent + "Error Details:" + CHR(13) + CHR(10) + lcErrorResponse
ENDIF
CATCH
lcLogContent = lcLogContent + "Could not read error details"
ENDTRY
STRTOFILE(lcLogContent, lcLogFileName)
llHasMorePages = .F.
ENDIF
CATCH TO loError
*-- Salvare erori in fisier de log pentru pagina curenta
lcLogFileName = "gomag_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
lcLogContent = "Script Error on page " + TRANSFORM(lnCurrentPage) + ":" + CHR(13) + CHR(10) +;
"Error Number: " + TRANSFORM(loError.ErrorNo) + CHR(13) + CHR(10) +;
"Error Message: " + loError.Message + CHR(13) + CHR(10) +;
"Error Line: " + TRANSFORM(loError.LineNo)
STRTOFILE(lcLogContent, lcLogFileName)
llHasMorePages = .F.
ENDTRY
IF llHasMorePages
INKEY(1) && Pauza de 10 secunde pentru a evita "Limitele API depasite"
ENDIF
ENDDO
*-- Salvare array JSON cu toate produsele
IF !ISNULL(loAllJsonData) AND TYPE('loAllJsonData.products') = 'O'
lcJsonFileName = lcOutputDir + "\gomag_all_products_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".json"
DO SaveProductsArray WITH loAllJsonData, lcJsonFileName
LogMessage("[PRODUCTS] JSON saved: " + lcJsonFileName, "INFO", gcLogFile)
*-- Calculam numarul de produse procesate
IF TYPE('loAllJsonData.products') = 'O'
LOCAL ARRAY laProducts[1]
lnPropCount = AMEMBERS(laProducts, loAllJsonData.products, 0)
gnProductsProcessed = lnPropCount
ENDIF
ENDIF
ELSE
LogMessage("[PRODUCTS] Skipped product retrieval (llGetProducts = .F.)", "INFO", gcLogFile)
ENDIF
*-- SECTIUNEA COMENZI - se executa doar daca llGetOrders = .T.
IF llGetOrders
LogMessage("[ORDERS] =======================================", "INFO", gcLogFile)
LogMessage("[ORDERS] RETRIEVING ORDERS FROM LAST " + TRANSFORM(goSettings.OrderDaysBack) + " DAYS", "INFO", gcLogFile)
LogMessage("[ORDERS] Start date: " + lcStartDateStr, "INFO", gcLogFile)
LogMessage("[ORDERS] =======================================", "INFO", gcLogFile)
*-- Reinitializare pentru comenzi
lnCurrentPage = 1
llHasMorePages = .T.
loAllOrderData = CREATEOBJECT("Empty")
ADDPROPERTY(loAllOrderData, "orders", CREATEOBJECT("Empty"))
ADDPROPERTY(loAllOrderData, "total", 0)
ADDPROPERTY(loAllOrderData, "pages", 0)
*-- Bucla pentru preluarea comenzilor
DO WHILE llHasMorePages
*-- Construire URL cu paginare si filtrare pe data (folosind startDate conform documentatiei GoMag)
lcApiUrl = lcOrderApiUrl + "?startDate=" + lcStartDateStr + "&page=" + TRANSFORM(lnCurrentPage) + "&limit=" + TRANSFORM(lnLimit)
LogMessage("[ORDERS] Page " + TRANSFORM(lnCurrentPage) + " fetching...", "INFO", gcLogFile)
*-- Configurare request
TRY
*-- Initializare request GET
loHttp.Open("GET", lcApiUrl, .F.)
*-- Setare headers conform documentatiei GoMag
loHttp.SetRequestHeader("User-Agent", lcUserAgent)
loHttp.SetRequestHeader("Content-Type", lcContentType)
loHttp.SetRequestHeader("Accept", "application/json")
loHttp.SetRequestHeader("Apikey", lcApiKey) && Header pentru API Key
loHttp.SetRequestHeader("ApiShop", lcApiShop) && Header pentru shop URL
*-- Setari timeout
loHttp.SetTimeouts(30000, 30000, 30000, 30000) && 30 secunde pentru fiecare
*-- Trimitere request
loHttp.Send()
*-- Verificare status code
lnStatusCode = loHttp.Status
lcStatusText = loHttp.StatusText
IF lnStatusCode = 200
*-- Success - preluare raspuns
lcResponse = loHttp.ResponseText
*-- SALVARE DIRECTA: Salveaza raspunsul RAW exact cum vine din API, pe pagini
lcOrderJsonFileName = lcOutputDir + "\gomag_orders_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".json"
STRTOFILE(lcResponse, lcOrderJsonFileName)
LogMessage("[ORDERS] JSON RAW salvat: " + lcOrderJsonFileName, "INFO", gcLogFile)
*-- Parsare JSON pentru obtinerea numarului de pagini
SET PATH TO nfjson ADDITIVE
loOrdersJsonData = nfJsonRead(lcResponse)
IF !ISNULL(loOrdersJsonData)
*-- Extragere informatii paginare din JSON procesat
IF lnCurrentPage = 1
IF TYPE('loOrdersJsonData.total') = 'C' OR TYPE('loOrdersJsonData.total') = 'N'
LOCAL lnTotalOrders
lnTotalOrders = VAL(TRANSFORM(loOrdersJsonData.total))
LogMessage("[ORDERS] Total orders: " + TRANSFORM(lnTotalOrders), "INFO", gcLogFile)
ENDIF
ENDIF
IF TYPE('loOrdersJsonData.pages') = 'C' OR TYPE('loOrdersJsonData.pages') = 'N'
lnTotalPages = VAL(TRANSFORM(loOrdersJsonData.pages))
IF lnCurrentPage = 1
LogMessage("[ORDERS] Total pages: " + TRANSFORM(lnTotalPages), "INFO", gcLogFile)
ENDIF
IF lnCurrentPage >= lnTotalPages
llHasMorePages = .F.
LogMessage("[ORDERS] Reached last page (" + TRANSFORM(lnCurrentPage) + "/" + TRANSFORM(lnTotalPages) + ")", "INFO", gcLogFile)
ENDIF
ELSE
*-- Fallback: verificare daca mai sunt comenzi in pagina
IF TYPE('loOrdersJsonData.orders') != 'O'
llHasMorePages = .F.
LogMessage("[ORDERS] No orders found in response, stopping pagination", "INFO", gcLogFile)
ENDIF
ENDIF
*-- Numarare comenzi din pagina curenta
IF TYPE('loOrdersJsonData.orders') = 'O'
LOCAL lnOrdersInPage
lnOrdersInPage = AMEMBERS(laOrdersPage, loOrdersJsonData.orders, 0)
gnOrdersProcessed = gnOrdersProcessed + lnOrdersInPage
LogMessage("[ORDERS] Found " + TRANSFORM(lnOrdersInPage) + " orders in page " + TRANSFORM(lnCurrentPage), "INFO", gcLogFile)
ENDIF
ELSE
*-- Eroare la parsarea JSON
LogMessage("[ORDERS] ERROR: Could not parse JSON response for page " + TRANSFORM(lnCurrentPage), "ERROR", gcLogFile)
llHasMorePages = .F.
ENDIF
lnCurrentPage = lnCurrentPage + 1
ELSE
*-- Eroare HTTP - salvare in fisier de log
lcLogFileName = "gomag_order_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
lcLogContent = "HTTP Error " + TRANSFORM(lnStatusCode) + ": " + lcStatusText + CHR(13) + CHR(10)
*-- Incearca sa citesti raspunsul pentru detalii despre eroare
TRY
lcErrorResponse = loHttp.ResponseText
IF !EMPTY(lcErrorResponse)
lcLogContent = lcLogContent + "Error Details:" + CHR(13) + CHR(10) + lcErrorResponse
ENDIF
CATCH
lcLogContent = lcLogContent + "Could not read error details"
ENDTRY
STRTOFILE(lcLogContent, lcLogFileName)
llHasMorePages = .F.
ENDIF
CATCH TO loError
*-- Salvare erori in fisier de log pentru pagina curenta
lcLogFileName = "gomag_order_error_page" + TRANSFORM(lnCurrentPage) + "_" + DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "") + ".log"
lcLogContent = "Script Error on page " + TRANSFORM(lnCurrentPage) + ":" + CHR(13) + CHR(10) +;
"Error Number: " + TRANSFORM(loError.ErrorNo) + CHR(13) + CHR(10) +;
"Error Message: " + loError.Message + CHR(13) + CHR(10) +;
"Error Line: " + TRANSFORM(loError.LineNo)
STRTOFILE(lcLogContent, lcLogFileName)
llHasMorePages = .F.
ENDTRY
IF llHasMorePages
INKEY(1) && Pauza de 10 secunde pentru a evita "Limitele API depasite"
ENDIF
ENDDO
LogMessage("[ORDERS] JSON files salvate pe pagini separate in directorul output/", "INFO", gcLogFile)
LogMessage("[ORDERS] Total orders processed: " + TRANSFORM(gnOrdersProcessed), "INFO", gcLogFile)
ELSE
LogMessage("[ORDERS] Skipped order retrieval (llGetOrders = .F.)", "INFO", gcLogFile)
ENDIF
*-- Curatare
loHttp = NULL
*-- Inchidere logging cu statistici finale
CloseLog(gnStartTime, gnProductsProcessed, gnOrdersProcessed, gcLogFile)
*-- Functiile utilitare au fost mutate in utils.prg
*-- Scriptul cu paginare completa pentru preluarea tuturor produselor si comenzilor
*-- Caracteristici principale:
*-- - Paginare automata pentru toate produsele si comenzile
*-- - Pauze intre cereri pentru respectarea rate limiting
*-- - Salvare JSON array-uri pure (fara metadata de paginare)
*-- - Utilizare nfjsoncreate pentru generare JSON corecta
*-- - Logging separat pentru fiecare pagina in caz de eroare
*-- - Afisare progres in timpul executiei
*-- INSTRUCTIUNI DE UTILIZARE:
*-- 1. Modifica settings.ini cu setarile tale:
*-- - ApiKey: cheia ta API de la GoMag
*-- - ApiShop: URL-ul magazinului tau
*-- - GetProducts: 1 pentru a prelua produse, 0 pentru a sari peste
*-- - GetOrders: 1 pentru a prelua comenzi, 0 pentru a sari peste
*-- - OrderDaysBack: numarul de zile pentru preluarea comenzilor
*-- 2. Ruleaza scriptul - va prelua doar ce ai selectat
*-- 3. Verifica fisierele JSON generate cu array-uri pure
*-- Script optimizat cu salvare JSON array-uri - verificati fisierele generate

View File

@@ -1,7 +0,0 @@
alter table COMENZI_ELEMENTE add ptva number(5,2);
comment on column COMENZI_ELEMENTE.ptva is 'PROCENT TVA (11,21)';
-- ARTICOLE_TERTI
-- PACK_COMENZI
-- PACK_IMPORT_PARTENERI
-- PACK_IMPORT_COMENZI

View File

@@ -1,50 +0,0 @@
PL/SQL Developer Test script 3.0
12
begin
-- Call the procedure
PACK_IMPORT_COMENZI.importa_comanda(p_nr_comanda_ext => '479317993',
p_data_comanda => TO_DATE('03032026','DDMMYYYY'),
p_id_partener => 1424,
p_json_articole => '{"baseprice":"46","ean":"5941623003366","id":"137","name":"Coffee Creamer Doncafe Lapte Praf 1 Kg","price":"40.99","quantity":"1.00","sku":"5941623003366","type":"product","vat":"21"}',
p_id_adresa_livrare => 1213,
p_id_adresa_facturare => 1213,
p_id_pol => 39,
p_id_sectie => NULL,
v_id_comanda => :v_id_comanda);
end;
9
p_nr_comanda_ext
1
479317993
-5
p_data_comanda
1
3/3/2026
-12
p_id_partener
1
1424
-4
p_json_articole
1
<CLOB>
-112
p_id_adresa_livrare
1
1213
-4
p_id_adresa_facturare
1
1213
-4
p_id_pol
1
39
-4
p_id_sectie
0
-4
v_id_comanda
0
4
0

View File

@@ -1,381 +0,0 @@
*-------------------------------------------------------------------
* Created by Marco Plaza @vfp2Nofox
* ver 1.100 - 24/02/2016
* enabled collection processing
* ver 1.101 - 24/02/2016
* solved indentation on nested collections
* ver 1.110 -11/03/2016
* -added support for collections inside arrays
* -user can pass aMemembersFlag value
* ( since Json is intended for DTO creation default value is 'U' )
* check amembers topic on vfp help file for usage
* changed cr to crlf
* Added Json validation ; throws error for invalid Json.
* ver 1.120
* encode control characters ( chr(0) ~ chr(31) )
*-----------------------------------------------------------------------
Parameters ovfp,FormattedOutput,nonullarrayitem,crootName,aMembersFlag
#Define crlf Chr(13)+Chr(10)
Private All
aMembersFlag = Evl(m.aMembersFlag,'U')
esarray = Type('oVfp',1) = 'A'
esobjeto = Vartype(m.ovfp) = 'O'
If !m.esarray And !m.esobjeto
Error 'must supply a vfp object/array'
Endif
_nivel = Iif( Cast(m.formattedOutput As l ) , 1, -1)
Do Case
Case esarray
ojson = Createobject('empty')
AddProperty(ojson,'array(1)')
Acopy(ovfp,ojson.Array)
cjson = procobject(ojson,.F.,m.nonullarrayitem,m.aMembersFlag)
cjson = Substr( m.cjson,At('[',m.cjson))
Case Type('oVfp.BaseClass')='C' And ovfp.BaseClass = 'Collection'
cjson = procobject(ovfp,.T.,m.nonullarrayitem,m.aMembersFlag)
crootName = Evl(m.crootName,'collection')
cjson = '{"'+m.crootName+collTagName(ovfp)+'": '+cjson+'}'+Iif(FormattedOutput,crlf,'')+'}'
Otherwise
cjson = '{'+procobject(ovfp,.F.,m.nonullarrayitem,m.aMembersFlag)+'}'
Endcase
Return Ltrim(cjson)
*----------------------------------------
Function collTagName(thiscoll)
*----------------------------------------
Return Iif( m.thiscoll.Count > 0 And !Empty( m.thiscoll.GetKey(1) ), '_kv_collection','_kl_collection' )
*----------------------------------------------------------------------------------
Function procobject(obt,iscollection,nonullarrayitem,aMembersFlag)
*----------------------------------------------------------------------------------
If Isnull(obt)
Return 'null'
Endif
Private All Except _nivel
este = ''
xtabs = nivel(2)
bc = Iif(Type('m.obt.class')='C',m.obt.Class,'?')
iscollection = bc = 'Collection'
If m.iscollection
este = este+'{ '+xtabs
xtabs = nivel(2)
este = este+'"collectionitems": ['+xtabs
procCollection(obt,m.nonullarrayitem,m.aMembersFlag)
xtabs = nivel(-2)
este = este+xtabs+']'
Else
Amembers(am,m.obt,0,m.aMembersFlag)
If Vartype(m.am) = 'U'
xtabs=m.nivel(-2)
Return ''
Endif
nm = Alen(am)
For x1 = 1 To m.nm
Var = Lower(am(m.x1))
este = m.este+Iif(m.x1>1,',','')+m.xtabs
este = m.este+["]+Strtran(m.var,'_vfpsafe_','')+[":]
esobjeto = Type('m.obt.&Var')='O'
If Type('m.obt.&var') = 'U'
este = m.este+["unable to evaluate expression"]
Loop
Endif
esarray = Type('m.obt.&Var',1) = 'A'
Do Case
Case m.esarray
procarray(obt,m.var,m.nonullarrayitem)
Case m.esobjeto
thiso=m.obt.&Var
bc = Iif(Type('m.thiso.class')='C',m.thiso.Class,'?')
If bc = 'Collection'
este = Rtrim(m.este,1,'":')+ collTagName( m.thiso )+'":'
este = m.este+procobject(m.obt.&Var,.T.,m.nonullarrayitem,m.aMembersFlag)+[}]
Else
este = m.este+[{]+procobject(m.obt.&Var,.F.,m.nonullarrayitem,m.aMembersFlag)+[}]
Endif
Otherwise
este = este+concatval(m.obt.&Var)
Endcase
Endfor
Endif
xtabs = nivel(-2)
este = este+m.xtabs
Return m.este
*----------------------------------------------------
Procedure procarray(obt,arrayName,nonullarrayitem)
*----------------------------------------------------
nrows = Alen(m.obt.&arrayName,1)
ncols = Alen(m.obt.&arrayName,2)
bidim = m.ncols > 0
ncols = Iif(m.ncols=0,m.nrows,m.ncols)
titems = Alen(m.obt.&arrayName)
xtabs=nivel(2)
este = m.este+'['+m.xtabs
nelem = 1
Do While nelem <= m.titems
este = este+Iif(m.nelem>1,','+m.xtabs,'')
If m.bidim
xtabs = nivel(2)
este = m.este+'['+m.xtabs
Endif
For pn = m.nelem To m.nelem+m.ncols-1
elem = m.obt.&arrayName( m.pn )
este = m.este+Iif(m.pn>m.nelem,','+m.xtabs,'')
If Vartype(m.elem) # 'O'
If m.nelem+m.ncols-1 = 1 And Isnull(m.elem) And m.nonullarrayitem
este = m.este+""
Else
este = m.este+concatval(m.elem)
Endif
Else
bc = Iif(Type('m.elem.class')='C',m.elem.Class,'?')
If bc = 'Collection'
este = m.este+' { "collection'+ collTagName( m.elem )+'":'
este = m.este+procobject(m.elem ,.T.,m.nonullarrayitem,m.aMembersFlag)
este = este + '}'+m.xtabs+'}'
Else
este = m.este+[{]+procobject(m.elem ,.F.,m.nonullarrayitem,m.aMembersFlag)+[}]
Endif
Endif
Endfor
nelem = m.pn
If m.bidim
xtabs=nivel(-2)
este = m.este+m.xtabs+']'
Endif
Enddo
xtabs=nivel(-2)
este = m.este+m.xtabs+']'
*-----------------------------
Function nivel(N)
*-----------------------------
If m._nivel = -1
Return ''
Else
_nivel= m._nivel+m.n
Return crlf+Replicate(' ',m._nivel)
Endif
*-----------------------------
Function concatval(valor)
*-----------------------------
#Define specialChars ["\/]+Chr(127)+Chr(12)+Chr(10)+Chr(13)+Chr(9)+Chr(0)+Chr(1)+Chr(2)+Chr(3)+Chr(4)+Chr(5)+Chr(6)+Chr(7)+Chr(8)+Chr(9)+Chr(10)+Chr(11)+Chr(12)+Chr(13)+Chr(14)+Chr(15)+Chr(16)+Chr(17)+Chr(18)+Chr(19)+Chr(20)+Chr(21)+Chr(22)+Chr(23)+Chr(24)+Chr(25)+Chr(26)+Chr(27)+Chr(28)+Chr(29)+Chr(30)+Chr(31)
If Isnull(m.valor)
Return 'null'
Else
tvar = Vartype(m.valor)
** no cambiar el orden de ejecuci<63>n!
Do Case
Case m.tvar $ 'FBYINQ'
vc = Rtrim(Cast( m.valor As c(32)))
Case m.tvar = 'L'
vc = Iif(m.valor,'true','false')
Case m.tvar $ 'DT'
vc = ["]+Ttoc(m.valor,3)+["]
Case mustEncode(m.valor)
vc = ["]+escapeandencode(m.valor)+["]
Case m.tvar $ 'CVM'
vc = ["]+Rtrim(m.valor)+["]
Case m.tvar $ 'GQW'
vc = ["]+Strconv(m.valor,13)+["]
Endcase
Return m.vc
Endif
*-----------------------------------
Function mustEncode(valor)
*-----------------------------------
Return Len(Chrtran(m.valor,specialChars,'')) <> Len(m.valor)
*-------------------------------
Function escapeandencode(valun)
*-------------------------------
valun = Strtran(m.valun,'\','\\')
valun = Strtran(m.valun,'"','\"')
*valun = Strtran(m.valun,'/','\/')
If !mustEncode(m.valun)
Return
Endif
valun = Strtran(m.valun,Chr(127),'\b')
valun = Strtran(m.valun,Chr(12),'\f')
valun = Strtran(m.valun,Chr(10),'\n')
valun = Strtran(m.valun,Chr(13),'\r')
valun = Strtran(m.valun,Chr(9),'\t')
If !mustEncode(m.valun)
Return
Endif
Local x
For x = 0 To 31
valun = Strtran(m.valun,Chr(m.x),'\u'+Right(Transform(m.x,'@0'),4))
Endfor
Return Rtrim(m.valun)
*---------------------------------------------------------------
Function procCollection(obt,nonullArrayItems,aMembersFlag )
*---------------------------------------------------------------
Local iscollection
With obt
nm = .Count
conllave = .Count > 0 And !Empty(.GetKey(1))
For x1 = 1 To .Count
If conllave
elem = Createobject('empty')
AddProperty(elem,'Key', .GetKey(x1) )
AddProperty(elem,'Value',.Item(x1))
Else
elem = .Item(x1)
Endif
este = este+Iif(x1>1,','+xtabs,'')
If Vartype(elem) # 'O'
este = este+concatval(m.elem)
Else
If Vartype( m.elem.BaseClass ) = 'C' And m.elem.BaseClass = 'Collection'
iscollection = .T.
este = m.este+'{ '+m.xtabs+'"collection'+collTagName(m.elem)+'" :'
xtabs = nivel(2)
Else
iscollection = .F.
m.este = m.este+'{'
Endif
este = este+procobject(m.elem, m.iscollection , m.nonullarrayitem, m.aMembersFlag )
este = este+'}'
If m.iscollection
xtabs = nivel(-2)
este = este+m.xtabs+'}'
Endif
Endif
Endfor
este = Rtrim(m.este,1,m.xtabs)
Endwith

View File

@@ -1,775 +0,0 @@
*-------------------------------------------------------------------
* Created by Marco Plaza vfp2nofox@gmail.com / @vfp2Nofox
* ver 2.000 - 26/03/2016
* ver 2.090 - 22/07/2016 :
* improved error management
* nfjsonread will return .null. for invalid json
*-------------------------------------------------------------------
Lparameters cjsonstr,isFileName,reviveCollection
#Define crlf Chr(13)+Chr(10)
Private All
stackLevels=Astackinfo(aerrs)
If m.stackLevels > 1
calledFrom = 'called From '+aerrs(m.stackLevels-1,4)+' line '+Transform(aerrs(m.stackLevels-1,5))
Else
calledFrom = ''
Endif
oJson = nfJsonCreate2(cjsonstr,isFileName,reviveCollection)
Return Iif(Vartype(m.oJson)='O',m.oJson,.Null.)
*-------------------------------------------------------------------------
Function nfJsonCreate2(cjsonstr,isFileName,reviveCollection)
*-------------------------------------------------------------------------
* validate parameters:
Do Case
Case ;
Vartype(m.cjsonstr) # 'C' Or;
Vartype(m.reviveCollection) # 'L' Or ;
Vartype(m.isFileName) # 'L'
jERROR('invalid parameter type')
Case m.isFileName And !File(m.cjsonstr)
jERROR('File "'+Rtrim(Left(m.cjsonstr,255))+'" does not exist')
Endcase
* process json:
If m.isFileName
cjsonstr = Filetostr(m.cjsonstr)
Endif
cJson = Rtrim(Chrtran(m.cjsonstr,Chr(13)+Chr(9)+Chr(10),''))
pChar = Left(Ltrim(m.cJson),1)
nl = Alines(aj,m.cJson,20,'{','}','"',',',':','[',']')
For xx = 1 To Alen(aj)
If Left(Ltrim(aj(m.xx)),1) $ '{}",:[]' Or Left(Ltrim(m.aj(m.xx)),4) $ 'true/false/null'
aj(m.xx) = Ltrim(aj(m.xx))
Endif
Endfor
Try
x = 1
cError = ''
oStack = Createobject('stack')
oJson = Createobject('empty')
Do Case
Case aj(1)='{'
x = 1
oStack.pushObject()
procstring(m.oJson)
Case aj(1) = '['
x = 0
procstring(m.oJson,.T.)
Otherwise
Error 'Invalid Json: expecting [{ received '+m.pChar
Endcase
If m.reviveCollection
oJson = reviveCollection(m.oJson)
Endif
Catch To oerr
strp = ''
For Y = 1 To m.x
strp = m.strp+aj(m.y)
Endfor
Do Case
Case oerr.ErrorNo = 1098
cError = ' Invalid Json: '+ m.oerr.Message+crlf+' Parsing: '+Right(m.strp,80)
*+' program line: '+Transform(oerr.Lineno)+' array item '+Transform(m.x)
Case oerr.ErrorNo = 2034
cError = ' INVALID DATE: '+crlf+' Parsing: '+Right(m.strp,80)
Otherwise
cError = 'program error # '+Transform(m.oerr.ErrorNo)+crlf+m.oerr.Message+' at: '+Transform(oerr.Lineno)+crlf+' Parsing ('+Transform(m.x)+') '
Endcase
Endtry
If !Empty(m.cError)
jERROR(m.cError)
Endif
Return m.oJson
*------------------------------------------------
Procedure jERROR( cMessage )
*------------------------------------------------
Error 'nfJson ('+m.calledFrom+'):'+crlf+m.cMessage
Return To nfJsonRead
*--------------------------------------------------------------------------------
Procedure procstring(obj,eValue)
*--------------------------------------------------------------------------------
#Define cvalid 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890_'
#Define creem '_______________________________________________________________'
Private rowpos,colpos,bidim,ncols,arrayName,expecting,arrayLevel,vari
Private expectingPropertyName,expectingValue,objectOpen
expectingPropertyName = !m.eValue
expectingValue = m.eValue
expecting = Iif(expectingPropertyName,'"}','')
objectOpen = .T.
bidim = .F.
colpos = 0
rowpos = 0
arrayLevel = 0
arrayName = ''
vari = ''
ncols = 0
Do While m.objectOpen
x = m.x+1
Do Case
Case m.x > m.nl
m.x = m.nl
If oStack.Count > 0
Error 'expecting '+m.expecting
Endif
Return
Case aj(m.x) = '}' And '}' $ m.expecting
closeObject()
Case aj(x) = ']' And ']' $ m.expecting
closeArray()
Case m.expecting = ':'
If aj(m.x) = ':'
expecting = ''
Loop
Else
Error 'expecting : received '+aj(m.x)
Endif
Case ',' $ m.expecting
Do Case
Case aj(x) = ','
expecting = Iif( '[' $ m.expecting , '[' , '' )
Case Not aj(m.x) $ m.expecting
Error 'expecting '+m.expecting+' received '+aj(m.x)
Otherwise
expecting = Strtran(m.expecting,',','')
Endcase
Case m.expectingPropertyName
If aj(m.x) = '"'
propertyName(m.obj)
Else
Error 'expecting "'+m.expecting+' received '+aj(m.x)
Endif
Case m.expectingValue
If m.expecting == '[' And m.aj(m.x) # '['
Error 'expecting [ received '+aj(m.x)
Else
procValue(m.obj)
Endif
Endcase
Enddo
*----------------------------------------------------------
Function anuevoel(obj,arrayName,valasig,bidim,colpos,rowpos)
*----------------------------------------------------------
If m.bidim
colpos = m.colpos+1
If colpos > m.ncols
ncols = m.colpos
Endif
Dimension obj.&arrayName(m.rowpos,m.ncols)
obj.&arrayName(m.rowpos,m.colpos) = m.valasig
If Vartype(m.valasig) = 'O'
procstring(obj.&arrayName(m.rowpos,m.colpos))
Endif
Else
rowpos = m.rowpos+1
Dimension obj.&arrayName(m.rowpos)
obj.&arrayName(m.rowpos) = m.valasig
If Vartype(m.valasig) = 'O'
procstring(obj.&arrayName(m.rowpos))
Endif
Endif
*-----------------------------------------
Function unescunicode( Value )
*-----------------------------------------
noc=1
Do While .T.
posunicode = At('\u',m.value,m.noc)
If m.posunicode = 0
Return
Endif
If Substr(m.value,m.posunicode-1,1) = '\' And Substr(m.value,m.posunicode-2,1) # '\'
noc=m.noc+1
Loop
Endif
nunic = Evaluate('0x'+ Substr(m.value,m.posunicode+2,4) )
If Between(m.nunic,0,255)
unicodec = Chr(m.nunic)
Else
unicodec = '&#'+Transform(m.nunic)+';'
Endif
Value = Stuff(m.value,m.posunicode,6,m.unicodec)
Enddo
*-----------------------------------
Function unescapecontrolc( Value )
*-----------------------------------
If At('\', m.value) = 0
Return
Endif
* unescape special characters:
Private aa,elem,unesc
Declare aa(1)
=Alines(m.aa,m.value,18,'\\','\b','\f','\n','\r','\t','\"','\/')
unesc =''
#Define sustb 'bnrt/"'
#Define sustr Chr(127)+Chr(10)+Chr(13)+Chr(9)+Chr(47)+Chr(34)
For Each elem In m.aa
If ! m.elem == '\\' And Right(m.elem,2) = '\'
elem = Left(m.elem,Len(m.elem)-2)+Chrtran(Right(m.elem,1),sustb,sustr)
Endif
unesc = m.unesc+m.elem
Endfor
Value = m.unesc
*--------------------------------------------
Procedure propertyName(obj)
*--------------------------------------------
vari=''
Do While ( Right(m.vari,1) # '"' Or ( Right(m.vari,2) = '\"' And Right(m.vari,3) # '\\"' ) ) And Alen(aj) > m.x
x=m.x+1
vari = m.vari+aj(m.x)
Enddo
If Right(m.vari,1) # '"'
Error ' expecting " received '+ Right(Rtrim(m.vari),1)
Endif
vari = Left(m.vari,Len(m.vari)-1)
vari = Iif(Isalpha(m.vari),'','_')+m.vari
vari = Chrtran( vari, Chrtran( vari, cvalid,'' ) , creem )
If vari = 'tabindex'
vari = '_tabindex'
Endif
expecting = ':'
expectingValue = .T.
expectingPropertyName = .F.
*-------------------------------------------------------------
Procedure procValue(obj)
*-------------------------------------------------------------
Do Case
Case aj(m.x) = '{'
oStack.pushObject()
If m.arrayLevel = 0
AddProperty(obj,m.vari,Createobject('empty'))
procstring(obj.&vari)
expectingPropertyName = .T.
expecting = ',}'
expectingValue = .F.
Else
anuevoel(m.obj,m.arrayName,Createobject('empty'),m.bidim,@colpos,@rowpos)
expectingPropertyName = .F.
expecting = ',]'
expectingValue = .T.
Endif
Case aj(x) = '['
oStack.pushArray()
Do Case
Case m.arrayLevel = 0
arrayName = Evl(m.vari,'array')
rowpos = 0
colpos = 0
bidim = .F.
#DEFINE EMPTYARRAYFLAG '_EMPTY_ARRAY_FLAG_'
Try
AddProperty(obj,(m.arrayName+'(1)'),EMPTYARRAYFLAG)
Catch
m.arrayName = m.arrayName+'_vfpSafe_'
AddProperty(obj,(m.arrayName+'(1)'),EMPTYARRAYFLAG)
Endtry
Case m.arrayLevel = 1 And !m.bidim
rowpos = 1
colpos = 0
ncols = 1
Dime obj.&arrayName(1,2)
bidim = .T.
Endcase
arrayLevel = m.arrayLevel+1
vari=''
expecting = Iif(!m.bidim,'[]{',']')
expectingValue = .T.
expectingPropertyName = .F.
Otherwise
isstring = aj(m.x)='"'
x = m.x + Iif(m.isstring,1,0)
Value = ''
Do While .T.
Value = m.value+m.aj(m.x)
If m.isstring
If Right(m.value,1) = '"' And ( Right(m.value,2) # '\"' Or Right(m.value,3) = '\\' )
Exit
Endif
Else
If Right(m.value,1) $ '}],' And ( Left(Right(m.value,2),1) # '\' Or Left(Right(Value,3),2) = '\\')
Exit
Endif
Endif
If m.x < Alen(aj)
x = m.x+1
Else
Exit
Endif
Enddo
closeChar = Right(m.value,1)
Value = Rtrim(m.value,1,m.closeChar)
If Empty(Value) And Not ( m.isstring And m.closeChar = '"' )
Error 'Expecting value received '+m.closeChar
Endif
Do Case
Case m.isstring
If m.closeChar # '"'
Error 'expecting " received '+m.closeChar
Endif
Case oStack.isObject() And Not m.closeChar $ ',}'
Error 'expecting ,} received '+m.closeChar
Case oStack.isArray() And Not m.closeChar $ ',]'
Error 'expecting ,] received '+m.closeChar
Endcase
If m.isstring
* don't change this lines sequence!:
unescunicode(@Value) && 1
unescapecontrolc(@Value) && 2
Value = Strtran(m.value,'\\','\') && 3
** check for Json Date:
If isJsonDt( m.value )
Value = jsonDateToDT( m.value )
Endif
Else
Value = Alltrim(m.value)
Do Case
Case m.value == 'null'
Value = .Null.
Case m.value == 'true' Or m.value == 'false'
Value = Value='true'
Case Empty(Chrtran(m.value,'-1234567890.E','')) And Occurs('.',m.value) <= 1 And Occurs('-',m.value) <= 1 And Occurs('E',m.value)<=1
If Not 'E' $ m.value
Value = Cast( m.value As N( Len(m.value) , Iif(At('.',m.value)>0,Len(m.value)-At( '.',m.value) ,0) ))
Endif
Otherwise
Error 'expecting "|number|null|true|false| received '+aj(m.x)
Endcase
Endif
If m.arrayLevel = 0
AddProperty(obj,m.vari,m.value)
expecting = '}'
expectingValue = .F.
expectingPropertyName = .T.
Else
anuevoel(obj,m.arrayName,m.value,m.bidim,@colpos,@rowpos)
expecting = ']'
expectingValue = .T.
expectingPropertyName = .F.
Endif
expecting = Iif(m.isstring,',','')+m.expecting
Do Case
Case m.closeChar = ']'
closeArray()
Case m.closeChar = '}'
closeObject()
Endcase
Endcase
*------------------------------
Function closeArray()
*------------------------------
If oStack.Pop() # 'A'
Error 'unexpected ] '
Endif
If m.arrayLevel = 0
Error 'unexpected ] '
Endif
arrayLevel = m.arrayLevel-1
If m.arrayLevel = 0
arrayName = ''
rowpos = 0
colpos = 0
expecting = Iif(oStack.isObject(),',}','')
expectingPropertyName = .T.
expectingValue = .F.
Else
If m.bidim
rowpos = m.rowpos+1
colpos = 0
expecting = ',]['
Else
expecting = ',]'
Endif
expectingValue = .T.
expectingPropertyName = .F.
Endif
*-------------------------------------
Procedure closeObject
*-------------------------------------
If oStack.Pop() # 'O'
Error 'unexpected }'
Endif
If m.arrayLevel = 0
expecting = ',}'
expectingValue = .F.
expectingPropertyName = .T.
objectOpen = .F.
Else
expecting = ',]'
expectingValue = .T.
expectingPropertyName = .F.
Endif
*----------------------------------------------
Function reviveCollection( o )
*----------------------------------------------
Private All
oConv = Createobject('empty')
nProp = Amembers(elem,m.o,0,'U')
For x = 1 To m.nProp
estaVar = m.elem(x)
esArray = .F.
esColeccion = Type('m.o.'+m.estaVar) = 'O' And Right( m.estaVar , 14 ) $ '_KV_COLLECTION,_KL_COLLECTION' And Type( 'm.o.'+m.estaVar+'.collectionitems',1) = 'A'
Do Case
Case m.esColeccion
estaProp = Createobject('collection')
tv = m.o.&estaVar
m.keyValColl = Right( m.estaVar , 14 ) = '_KV_COLLECTION'
For T = 1 To Alen(m.tv.collectionItems)
If m.keyValColl
esteval = m.tv.collectionItems(m.T).Value
Else
esteval = m.tv.collectionItems(m.T)
ENDIF
IF VARTYPE(m.esteval) = 'C' AND m.esteval = emptyarrayflag
loop
ENDIF
If Vartype(m.esteval) = 'O' Or Type('esteVal',1) = 'A'
esteval = reviveCollection(m.esteval)
Endif
If m.keyValColl
estaProp.Add(esteval,m.tv.collectionItems(m.T).Key)
Else
estaProp.Add(m.esteval)
Endif
Endfor
Case Type('m.o.'+m.estaVar,1) = 'A'
esArray = .T.
For T = 1 To Alen(m.o.&estaVar)
Dimension &estaVar(m.T)
If Type('m.o.&estaVar(m.T)') = 'O'
&estaVar(m.T) = reviveCollection(m.o.&estaVar(m.T))
Else
&estaVar(m.T) = m.o.&estaVar(m.T)
Endif
Endfor
Case Type('m.o.'+estaVar) = 'O'
estaProp = reviveCollection(m.o.&estaVar)
Otherwise
estaProp = m.o.&estaVar
Endcase
estaVar = Strtran( m.estaVar,'_KV_COLLECTION', '' )
estaVar = Strtran( m.estaVar, '_KL_COLLECTION', '' )
Do Case
Case m.esColeccion
AddProperty(m.oConv,m.estaVar,m.estaProp)
Case m.esArray
AddProperty(m.oConv,m.estaVar+'(1)')
Acopy(&estaVar,m.oConv.&estaVar)
Otherwise
AddProperty(m.oConv,m.estaVar,m.estaProp)
Endcase
Endfor
Try
retCollection = m.oConv.Collection.BaseClass = 'Collection'
Catch
retCollection = .F.
Endtry
If m.retCollection
Return m.oConv.Collection
Else
Return m.oConv
Endif
*----------------------------------
Function isJsonDt( cstr )
*----------------------------------
Return Iif( Len(m.cstr) = 19 ;
AND Len(Chrtran(m.cstr,'01234567890:T-','')) = 0 ;
and Substr(m.cstr,5,1) = '-' ;
and Substr(m.cstr,8,1) = '-' ;
and Substr(m.cstr,11,1) = 'T' ;
and Substr(m.cstr,14,1) = ':' ;
and Substr(m.cstr,17,1) = ':' ;
and Occurs('T',m.cstr) = 1 ;
and Occurs('-',m.cstr) = 2 ;
and Occurs(':',m.cstr) = 2 ,.T.,.F. )
*-----------------------------------
Procedure jsonDateToDT( cJsonDate )
*-----------------------------------
Return Eval("{^"+m.cJsonDate+"}")
******************************************
Define Class Stack As Collection
******************************************
*---------------------------
Function pushObject()
*---------------------------
This.Add('O')
*---------------------------
Function pushArray()
*---------------------------
This.Add('A')
*--------------------------------------
Function isObject()
*--------------------------------------
If This.Count > 0
Return This.Item( This.Count ) = 'O'
Else
Return .F.
Endif
*--------------------------------------
Function isArray()
*--------------------------------------
If This.Count > 0
Return This.Item( This.Count ) = 'A'
Else
Return .F.
Endif
*----------------------------
Function Pop()
*----------------------------
cret = This.Item( This.Count )
This.Remove( This.Count )
Return m.cret
******************************************
Enddefine
******************************************

View File

@@ -1,268 +0,0 @@
*!* CLEAR
*!* ?strtranx([ana are 1234567890.1234 lei], [\s\d+\.\d\s], [=TRANSFORM($1, "999 999 999 999.99")])
*?strtranx([ana are <<1234567890.1234>> lei], [<<], [=TRANSFORM($1, "AA")])
*!* RETURN
CLEAR
*-- http://www.cornerstonenw.com/article_id_parsing3.htm
SET STEP ON
lcSourceString = [ana are mere 123,345 678 ad]
LOCAL laItems[10]
lnResults = GetRegExpAll(lcSourceString, '\d+', @laItems)
SET STEP ON
RETURN
strTest = [ab cd2""$$<24>]
?strTest
?StripNonAscii(strTest)
*-- replace non a-z09 with "" case-insensitive
? strtranx([Ab ra /ca\d&abr'a],"[^a-z0-9]",[],1,,1)
RETURN
*-- count words
? OccursRegExp("\b(\w+)\b", [the then quick quick brown fox fox])
&& prints 7
*-- count repeatedwords
? OccursRegExp("\b(\w+)\s\1\b", [the then quick quick brown fox fox])
&& prints 2
*-- replace first and second lower-case "a"
? strtranx([Abracadabra],[a],[*],1,2)
&& prints Abr*c*dabra
*-- replace first and second "a" case-insensitive
? strtranx([Abracadabra],[a],[*],1,2,1)
&& prints *br*cadabra
*-- locate the replacement targets
? strtranx([Abracadabra],[^a|a$],[*],1,2,0)
&& Abracadabr*
? strtranx([Abracadabra],[^a|a$],[*],1,2,1)
&& *bracadabr*
lcText = "The cost, is $123,345.75. "
*-- convert the commas
lcText = strtranx( m.lcText, "(\d{1,3})\,(\d{1,}) ","$1 $2" )
*-- convert the decimals
? strtranx( m.lcText, "(\d{1,3})\.(\d{1,})", "$1,$2" )
** prints "The cost, is $123 345,75."
*-- add 1 to all digits
? strtranx( [ABC123], "(\d)", [=TRANSFORM(VAL($1)+1)] )
** prints "ABC234"
*-- convert all dates to long format
? strtranx( [the date is: 7/18/2004 ] , [(\d{1,2}/\d{1,2}/\d{4})], [=TRANSFORM(CTOD($1),"@YL")])
** prints "the date is: Sunday, July 18, 2004"
*----------------------------------------------------------
FUNCTION StrtranRegExp( tcSourceString, tcPattern, tcReplace )
LOCAL loRE
loRE = CREATEOBJECT("vbscript.regexp")
WITH loRE
.PATTERN = tcPattern
.GLOBAL = .T.
.multiline = .T.
RETURN .REPLACE( tcSourceString , tcReplace )
ENDWITH
ENDFUNC
*----------------------------------------------------------
FUNCTION OccursRegExp(tcPattern, tcText)
LOCAL loRE, loMatches, lnResult
loRE = CREATEOBJECT("vbscript.regexp")
WITH loRE
.PATTERN = m.tcPattern
.GLOBAL = .T.
.multiline = .T.
loMatches = loRE.Execute( m.tcText )
lnResult = loMatches.COUNT
loMatches = NULL
ENDWITH
RETURN m.lnResult
ENDFUNC
*----------------------------------------------------------
FUNCTION strtranx(tcSearched, ;
tcSearchFor, ;
tcReplacement, ;
tnStart, tnNumber, ;
tnFlag )
*-- the final version of the UDF
LOCAL loRE, lcText, lnShift, lcCommand,;
loMatch, loMatches, lnI, lnK, lcSubMatch,;
llevaluate, lcMatchDelim, lcReplaceText, lcReplacement,;
lnStart, lnNumber, loCol, lcKey
IF EMPTY(NVL(tcSearched, ''))
RETURN NVL(tcSearched, '')
ENDIF
loRE = CREATEOBJECT("vbscript.regexp")
WITH loRE
.PATTERN = m.tcSearchFor
.GLOBAL = .T.
.multiline = .T.
.ignorecase = IIF(VARTYPE(m.tnFlag)=[N],m.tnFlag = 1,.F.)
ENDWITH
lcReplacement = m.tcReplacement
*--- are we evaluating?
IF m.lcReplacement = [=]
llevaluate = .T.
lcReplacement = SUBSTR( m.lcReplacement, 2 )
ENDIF
IF VARTYPE( m.tnStart )=[N]
lnStart = m.tnStart
ELSE
lnStart = 1
ENDIF
IF VARTYPE( m.tnNumber) =[N]
lnNumber = m.tnNumber
ELSE
lnNumber = -1
ENDIF
IF m.lnStart>1 OR m.lnNumber#-1 OR m.llevaluate
lcText = m.tcSearched
lnShift = 1
loMatches = loRE.execute( m.lcText )
loCol = CREATEOBJECT([collection])
lnNumber = IIF( lnNumber=-1,loMatches.COUNT,MIN(lnNumber,loMatches.COUNT))
FOR lnK = m.lnStart TO m.lnNumber
loMatch = loMatches.ITEM(m.lnK-1) && zero based
lcCommand = m.lcReplacement
FOR lnI= 1 TO loMatch.submatches.COUNT
lcSubMatch = loMatch.submatches(m.lnI-1) && zero based
IF m.llevaluate
* "escape" the string we are about to use in an evaluation.
* it is important to escape due to possible delim chars (like ", ' etc)
* malicious content, or VFP line-length violations.
lcKey = ALLTRIM(TRANSFORM(m.lnK)+[_]+TRANSFORM(m.lnI))
loCol.ADD( m.lcSubMatch, m.lcKey )
lcSubMatch = [loCol.item(']+m.lcKey+[')]
ENDIF
lcCommand = STRTRAN( m.lcCommand, "$" + ALLTRIM( STR( m.lnI ) ) , m.lcSubMatch)
ENDFOR
IF m.llevaluate
TRY
lcReplaceText = EVALUATE( m.lcCommand )
CATCH TO loErr
lcReplaceText="[[ERROR #"+TRANSFORM(loErr.ERRORNO)+[ ]+loErr.MESSAGE+"]]"
ENDTRY
ELSE
lcReplaceText = m.lcCommand
ENDIF
lcText = STUFF( m.lcText, loMatch.FirstIndex + m.lnShift, m.loMatch.LENGTH, m.lcReplaceText )
lnShift = m.lnShift + LEN( m.lcReplaceText ) - m.loMatch.LENGTH
ENDFOR
ELSE
lcText = loRE.REPLACE( m.tcSearched, m.tcReplacement )
ENDIF
RETURN m.lcText
ENDFUNC
*=====================
FUNCTION StripNonAscii
LPARAMETERS tcSourceString, tcReplaceString
TEXT TO lcPattern NOSHOW
[^A-Za-z 0-9 \.,\?'""!@#\$%\^&\*\(\)-_=\+;:<>\/\\\|\}\{\[\]`~]
ENDTEXT
lcReplace = IIF(TYPE('tcReplaceString') <> 'C', "", tcReplaceString)
lcReturn = strtranx( m.tcSourceString, m.lcPattern, m.lcReplace,1,,1)
RETURN m.lcReturn
ENDFUNC && StripNonAscii
*=====================
* Intoarce un text care se potriveste cu pattern-ul
* Ex. Localitatea din textul: STRADA NR LOCALITATE
*=====================
FUNCTION GetRegExp
LPARAMETERS tcSourceString, tcPattern, tnOccurence
* tcSourceString: Bld. Stefan cel Mare 14 Tirgu Neamt
* tcPattern: [A-Za-z\s]+$ = (caracter sau spatiu) de cel putin o data la sfarsitul liniei = Tirgu Neamt
* tcPattern: \d+[A-Za-z\s]+$ = oricate cifre (caracter sau spatiu) de cel putin o data la sfarsitul liniei = 14 Tirgu Neamt
LOCAL loRE, loMatches, lcResult, lnOccurence
lcResult = ''
lnOccurence = IIF(!EMPTY(m.tnOccurence) and TYPE('tnOccurence') = 'N', m.tnOccurence, 1)
loRE = CREATEOBJECT("vbscript.regexp")
WITH loRE
.PATTERN = m.tcPattern
.GLOBAL = .T.
.multiline = .T.
loMatches = loRE.Execute( m.tcSourceString)
IF loMatches.COUNT >= m.lnOccurence
lcResult = loMatches.Item(m.lnOccurence - 1).Value
ENDIF
loMatches = NULL
ENDWITH
RETURN m.lcResult
ENDFUNC && GetRegExp
*=====================
* Intoarce numarul potrivirilor si un parametru OUT array sau lista de numere facturi separate prin ","
* Ex. Toate numerele dintr-un text lnMatches = GetRegExpAll(lcSourceString, '\d+', @loMatches)
*=====================
FUNCTION GetRegExpAll
LPARAMETERS tcSourceString, tcPattern, taItems
* tcSourceString: Bld. Stefan cel Mare 14 Tirgu Neamt
* tcPattern: [A-Za-z\s]+$ = (caracter sau spatiu) de cel putin o data la sfarsitul liniei = Tirgu Neamt
* tcPattern: \d+[A-Za-z\s]+$ = oricate cifre (caracter sau spatiu) de cel putin o data la sfarsitul liniei = 14 Tirgu Neamt
* taItems "A">taItems : array cu rezultatele (OUT) taItems[1..Result] sau taItems "C" lista facturi separate prin virgula
LOCAL loRE, loMatches, lnResults, lnItem
IF TYPE('taItems') = "A"
EXTERNAL ARRAY taItems
ELSE
taItems = ""
ENDIF
lnResult = 0
loRE = CREATEOBJECT("vbscript.regexp")
WITH loRE
.PATTERN = m.tcPattern
.GLOBAL = .T.
.multiline = .T.
loMatches = loRE.Execute( m.tcSourceString)
lnResults = loMatches.COUNT
IF TYPE('taItems') = "A"
DIMENSION taItems[m.lnResult]
FOR lnItem = 1 TO m.lnResult
taItems[m.lnItem] = loMatches.Item(m.lnItem-1).Value
ENDFOR
ELSE
FOR lnItem = 1 TO m.lnResults
taItems = taItems + IIF(m.lnItem > 1, ",", "") + loMatches.Item(m.lnItem-1).Value
ENDFOR
ENDIF
loMatches = NULL
ENDWITH
RETURN m.lnResults
ENDFUNC && GetRegExp

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +0,0 @@
@echo off
cd /d "%~dp0"
"C:\Program Files (x86)\Microsoft Visual FoxPro 9\vfp9.exe" -T "%~dp0sync-comenzi-web.prg"
pause

View File

@@ -1,70 +0,0 @@
[API]
ApiBaseUrl=https://api.gomag.ro/api/v1/product/read/json?enabled=1
OrderApiUrl=https://api.gomag.ro/api/v1/order/read/json
ApiKey=YOUR_API_KEY_HERE
ApiShop=https://yourstore.gomag.ro
UserAgent=Mozilla/5.0
ContentType=application/json
[PAGINATION]
Limit=100
[OPTIONS]
GetProducts=1
GetOrders=1
[FILTERS]
OrderDaysBack=7
[ORACLE]
OracleUser=MARIUSM_AUTO
OraclePassword=ROMFASTSOFT
OracleDSN=ROA_CENTRAL
[SYNC]
AdapterProgram=gomag-adapter.prg
JsonFilePattern=gomag_orders*.json
AutoRunAdapter=1
[ROA]
IdPol=39
IdGestiune=NULL
IdSectie=NULL
# ===============================================
# CONFIGURATIE SYNC COMENZI WEB → ORACLE ROA
# ===============================================
#
# [API] - Configurari pentru GoMag API
# - ApiKey: Cheia API de la GoMag (OBLIGATORIU)
# - ApiShop: URL-ul magazinului GoMag (OBLIGATORIU)
#
# [OPTIONS]
# - GetProducts: 1=descarca produse, 0=skip
# - GetOrders: 1=descarca comenzi, 0=skip
#
# [ORACLE] - Conexiune la database ROA
# - OracleUser: Utilizatorul Oracle (OBLIGATORIU)
# - OraclePassword: Parola Oracle (OBLIGATORIU)
# - OracleDSN: Data Source Name (OBLIGATORIU)
#
# [SYNC] - Configurari sincronizare
# - AdapterProgram: Numele programului adapter (ex: gomag-adapter.prg)
# - JsonFilePattern: Pattern pentru fisiere JSON (ex: gomag_orders*.json)
# - AutoRunAdapter: 1=ruleaza automat adapter, 0=foloseste doar JSON existent
#
# [ROA] - Configurari sistem ROA
# - IdPol: ID politica de preturi (NULL=fara politica, numar=ID specific)
# - IdGestiune: ID gestiune pentru comenzi (NULL=automat, numar=ID specific)
# - IdSectie: ID sectie pentru comenzi (NULL=automat, numar=ID specific)
#
# Pentru utilizare:
# 1. Copiaza settings.ini.example → settings.ini
# 2. Configureaza ApiKey si ApiShop pentru GoMag
# 3. Verifica datele Oracle (default: schema MARIUSM_AUTO)
# 4. Ruleaza sync-comenzi-web.prg
#
# Pentru scheduled task Windows:
# - Creeaza task care ruleaza sync-comenzi-web.prg la interval
# - Nu mai este nevoie de auto-sync-timer.prg
# - sync-comenzi-web.prg va apela automat gomag-adapter.prg

View File

@@ -1,682 +0,0 @@
*-- sync-comenzi-web.prg - Orchestrator pentru sincronizarea comenzilor web cu Oracle ROA
*-- Autor: Claude AI
*-- Data: 10 septembrie 2025
*-- Dependency: gomag-vending.prg trebuie rulat mai intai pentru generarea JSON-urilor
Set Safety Off
Set Century On
Set Date Dmy
Set Exact On
Set Ansi On
Set Deleted On
*-- Variabile globale
Private gcAppPath, gcLogFile, gnStartTime, gnOrdersProcessed, gnOrdersSuccess, gnOrdersErrors, gcFailedSKUs
Private goConnectie, goSettings, goAppSetup, gcStepError
Local lcJsonPattern, laJsonFiles[1], lnJsonFiles, lnIndex, lcJsonFile
Local loJsonData, lcJsonContent, lnOrderCount, lnOrderIndex
Local loOrder, lcResult, llProcessSuccess, lcPath
goConnectie = Null
gcStepError = ""
*-- Initializare
gcAppPath = Addbs(Justpath(Sys(16,0)))
Set Default To (m.gcAppPath)
lcPath = gcAppPath + 'nfjson;'
Set Path To &lcPath Additive
Set Procedure To utils.prg Additive
Set Procedure To ApplicationSetup.prg Additive
Set Procedure To nfjsonread.prg Additive
Set Procedure To nfjsoncreate.prg Additive
Set Procedure To regex.prg Additive
*-- Statistici
gnStartTime = Seconds()
gnOrdersProcessed = 0
gnOrdersSuccess = 0
gnOrdersErrors = 0
gcFailedSKUs = ""
*-- Initializare logging
gcLogFile = InitLog("sync_comenzi")
*-- Creare si initializare clasa setup aplicatie
goAppSetup = Createobject("ApplicationSetup", gcAppPath)
If !goAppSetup.Initialize()
LogMessage("EROARE: Setup-ul aplicatiei a esuat sau necesita configurare!", "ERROR", gcLogFile)
Return .F.
Endif
goSettings = goAppSetup.GetSettings()
*-- Verificare directoare necesare
If !Directory(gcAppPath + "output")
LogMessage("EROARE: Directorul output/ nu exista!", "ERROR", gcLogFile)
Return .F.
Endif
*-- Rulare automata adapter pentru obtinere comenzi
If goSettings.AutoRunAdapter
If !ExecuteAdapter()
LogMessage("EROARE adapter, continuez cu JSON existente", "WARN", gcLogFile)
Endif
Endif
*-- Gasire fisiere JSON comenzi
lcJsonPattern = gcAppPath + "output\" + goSettings.JsonFilePattern
lnJsonFiles = Adir(laJsonFiles, lcJsonPattern)
If lnJsonFiles = 0
LogMessage("Nu au fost gasite fisiere JSON cu comenzi web", "WARN", gcLogFile)
Return .T.
Endif
*-- Conectare Oracle
If !ConnectToOracle()
LogMessage("EROARE: Nu s-a putut conecta la Oracle ROA", "ERROR", gcLogFile)
Return .F.
Endif
*-- Header compact
LogMessage("SYNC START | " + goSettings.OracleDSN + " " + goSettings.OracleUser + " | " + Transform(lnJsonFiles) + " JSON files", "INFO", gcLogFile)
*-- Procesare fiecare fisier JSON gasit
For lnIndex = 1 To lnJsonFiles
lcJsonFile = gcAppPath + "output\" + laJsonFiles[lnIndex, 1]
Try
lcJsonContent = Filetostr(lcJsonFile)
If Empty(lcJsonContent)
Loop
Endif
loJsonData = nfjsonread(lcJsonContent)
If Isnull(loJsonData) Or Type('loJsonData') != 'O' Or Type('loJsonData.orders') != 'O'
LogMessage("EROARE JSON: " + laJsonFiles[lnIndex, 1], "ERROR", gcLogFile)
Loop
Endif
Local Array laOrderProps[1]
lnOrderCount = Amembers(laOrderProps, loJsonData.orders, 0)
*-- Procesare fiecare comanda
For lnOrderIndex = 1 To lnOrderCount
Local lcOrderId, loOrder
lcOrderId = laOrderProps[lnOrderIndex]
loOrder = Evaluate('loJsonData.orders.' + lcOrderId)
If Type('loOrder') = 'O'
gnOrdersProcessed = gnOrdersProcessed + 1
llProcessSuccess = ProcessWebOrder(loOrder, lnOrderIndex, lnOrderCount)
If llProcessSuccess
gnOrdersSuccess = gnOrdersSuccess + 1
Else
gnOrdersErrors = gnOrdersErrors + 1
Endif
Endif
If m.gnOrdersErrors > 10
Exit
Endif
Endfor
Catch To loError
LogMessage("EROARE fisier " + laJsonFiles[lnIndex, 1] + ": " + loError.Message, "ERROR", gcLogFile)
gnOrdersErrors = gnOrdersErrors + 1
Endtry
If m.gnOrdersErrors > 10
LogMessage("Peste 10 erori, stop import", "ERROR", gcLogFile)
Exit
Endif
Endfor
*-- Inchidere conexiune Oracle
DisconnectFromOracle()
*-- Sumar SKU-uri lipsa
If !Empty(gcFailedSKUs)
LogMessage("=== SKU-URI LIPSA ===", "INFO", gcLogFile)
Local lnSkuCount, lnSkuIdx
Local Array laSkus[1]
lnSkuCount = Alines(laSkus, gcFailedSKUs, .T., CHR(10))
For lnSkuIdx = 1 To lnSkuCount
If !Empty(laSkus[lnSkuIdx])
LogMessage(Alltrim(laSkus[lnSkuIdx]), "INFO", gcLogFile)
Endif
Endfor
LogMessage("=== SFARSIT SKU-URI LIPSA ===", "INFO", gcLogFile)
Endif
*-- Footer compact
Local lcStopped, lnSkuTotal, lnDuration
lnDuration = Int(Seconds() - gnStartTime)
lnSkuTotal = Iif(Empty(gcFailedSKUs), 0, Occurs(CHR(10), gcFailedSKUs) + 1)
lcStopped = Iif(gnOrdersErrors > 10, " (stopped early)", "")
LogMessage("SYNC END | " + Transform(gnOrdersProcessed) + " processed: " + Transform(gnOrdersSuccess) + " ok, " + Transform(gnOrdersErrors) + " err" + lcStopped + " | " + Transform(lnSkuTotal) + " SKUs lipsa | " + Transform(lnDuration) + "s", "INFO", gcLogFile)
CloseLog(gnStartTime, 0, gnOrdersProcessed, gcLogFile)
Return .T.
*-- ===================================================================
*-- HELPER FUNCTIONS
*-- ===================================================================
*-- Conectare la Oracle
Function ConnectToOracle
Local llSuccess, lnHandle
llSuccess = .F.
Try
lnHandle = SQLConnect(goSettings.OracleDSN, goSettings.OracleUser, goSettings.OraclePassword)
If lnHandle > 0
goConnectie = lnHandle
llSuccess = .T.
Else
LogMessage("EROARE conectare Oracle: Handle=" + Transform(lnHandle), "ERROR", gcLogFile)
Endif
Catch To loError
LogMessage("EROARE conectare Oracle: " + loError.Message, "ERROR", gcLogFile)
Endtry
Return llSuccess
Endfunc
*-- Deconectare de la Oracle
Function DisconnectFromOracle
If Type('goConnectie') = 'N' And goConnectie > 0
SQLDisconnect(goConnectie)
Endif
Return .T.
Endfunc
*-- Procesare comanda web - logeaza O SINGURA LINIE per comanda
*-- Format: [N/Total] OrderNumber P:PartnerID A:AddrFact/AddrLivr -> OK/ERR details
*-- NOTA: VFP nu permite RETURN in TRY/CATCH, se foloseste flag llContinue
Function ProcessWebOrder
Lparameters loOrder, tnIndex, tnTotal
Local llSuccess, lcOrderNumber, lcOrderDate, lnPartnerID, lcArticlesJSON
Local lcSQL, lnResult, lcErrorDetails, lnIdComanda
Local ldOrderDate, loError
Local lnIdAdresaFacturare, lnIdAdresaLivrare
Local lcPrefix, lcSummary, lcErrDetail, llContinue
lnIdAdresaLivrare = NULL
lnIdAdresaFacturare = NULL
lnIdComanda = 0
llSuccess = .F.
llContinue = .T.
lnPartnerID = 0
lcOrderNumber = "?"
*-- Prefix: [N/Total] OrderNumber
lcPrefix = "[" + Transform(tnIndex) + "/" + Transform(tnTotal) + "]"
Try
*-- Validare comanda
If !ValidateWebOrder(loOrder)
LogMessage(lcPrefix + " ? -> ERR VALIDARE: date obligatorii lipsa", "ERROR", gcLogFile)
llContinue = .F.
Endif
*-- Extragere date comanda
If llContinue
lcOrderNumber = CleanWebText(Transform(loOrder.Number))
lcOrderDate = ConvertWebDate(loOrder.Date)
ldOrderDate = String2Date(m.lcOrderDate, 'yyyymmdd')
lcPrefix = lcPrefix + " " + lcOrderNumber
*-- Procesare partener
gcStepError = ""
lnPartnerID = ProcessPartner(loOrder.billing)
If lnPartnerID <= 0
LogMessage(lcPrefix + " -> ERR PARTENER: " + Iif(Empty(gcStepError), "nu s-a putut procesa", gcStepError), "ERROR", gcLogFile)
llContinue = .F.
Endif
Endif
*-- Adrese + JSON articole
If llContinue
lnIdAdresaFacturare = ProcessAddress(m.lnPartnerID, loOrder.billing)
If Type('loOrder.shipping') = 'O'
lnIdAdresaLivrare = ProcessAddress(m.lnPartnerID, loOrder.shipping)
Endif
lcArticlesJSON = BuildArticlesJSON(loOrder.items)
If Empty(m.lcArticlesJSON)
LogMessage(lcPrefix + " P:" + Transform(lnPartnerID) + " -> ERR JSON_ARTICOLE", "ERROR", gcLogFile)
llContinue = .F.
Endif
Endif
*-- Import comanda in Oracle
If llContinue
lcSQL = "BEGIN PACK_IMPORT_COMENZI.importa_comanda(?lcOrderNumber, ?ldOrderDate, ?lnPartnerID, ?lcArticlesJSON, ?lnIdAdresaLivrare, ?lnIdAdresaFacturare, ?goSettings.IdPol, ?goSettings.IdSectie, ?@lnIdComanda); END;"
lnResult = SQLExec(goConnectie, lcSQL)
lcSummary = lcPrefix + " P:" + Transform(lnPartnerID) + ;
" A:" + Transform(Nvl(lnIdAdresaFacturare, 0)) + "/" + Transform(Nvl(lnIdAdresaLivrare, 0))
If lnResult > 0 And Nvl(m.lnIdComanda, 0) > 0
LogMessage(lcSummary + " -> OK ID:" + Transform(m.lnIdComanda), "INFO", gcLogFile)
llSuccess = .T.
Else
lcErrorDetails = GetOracleErrorDetails()
lcErrDetail = ClassifyImportError(lcErrorDetails)
CollectFailedSKUs(lcErrorDetails)
LogMessage(lcSummary + " -> ERR " + lcErrDetail, "ERROR", gcLogFile)
Endif
Endif
Catch To loError
LogMessage(lcPrefix + " -> ERR EXCEPTIE: " + loError.Message, "ERROR", gcLogFile)
Endtry
Return llSuccess
Endfunc
*-- Validare comanda web
Function ValidateWebOrder
Parameters loOrder
Local llValid
llValid = .T.
If Type('loOrder.number') != 'C' Or Empty(loOrder.Number)
llValid = .F.
Endif
If Type('loOrder.date') != 'C' Or Empty(loOrder.Date)
llValid = .F.
Endif
If Type('loOrder.billing') != 'O'
llValid = .F.
Endif
If Type('loOrder.items') != 'O'
llValid = .F.
Endif
Return llValid
Endfunc
*-- Procesare partener (fara logging, seteaza gcStepError la eroare)
Function ProcessPartner
Lparameters toBilling
Local lcDenumire, lcCodFiscal, lcRegistru, lcAdresa, lcTelefon, lcEmail, lcRegistru
Local lcSQL, lnResult
Local lnIdPart, lnIdAdresa, lnIsPersoanaJuridica
lnIdPart = 0
lnIdAdresa = 0
lcDenumire = ''
lcCodFiscal = ''
lcRegistru = ''
lnIsPersoanaJuridica = 0
lcCodFiscal = Null
Try
If Type('toBilling.company') = 'O' And !Empty(toBilling.company.Name)
loCompany = toBilling.company
lcDenumire = CleanWebText(loCompany.Name)
lcCodFiscal = Iif(Type('loCompany.code') = 'C', loCompany.Code, Null)
lcCodFiscal = CleanWebText(m.lcCodFiscal)
lcRegistru = Iif(Type('loCompany.registrationNo') = 'C', loCompany.registrationNo, Null)
lcRegistru = CleanWebText(m.lcRegistru)
lnIsPersoanaJuridica = 1
Else
If Type('toBilling.firstname') = 'C'
lcDenumire = CleanWebText(Alltrim(toBilling.firstname) + " " + Alltrim(toBilling.lastname))
lnIsPersoanaJuridica = 0
Endif
Endif
lcSQL = "BEGIN PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener(?lcCodFiscal, ?lcDenumire, ?lcRegistru, ?lnIsPersoanaJuridica, ?@lnIdPart); END;"
lnResult = SQLExec(goConnectie, lcSQL)
If lnResult <= 0
gcStepError = lcDenumire + " | " + GetOracleErrorDetails()
Endif
Catch To loError
gcStepError = loError.Message
Endtry
Return m.lnIdPart
Endfunc
*-- Procesare adresa (fara logging)
Function ProcessAddress
Lparameters tnIdPart, toAdresa
Local lcAdresa, lcTelefon, lcEmail, lcSQL, lnResult, lnIdAdresa
lnIdAdresa = 0
Try
If !Empty(Nvl(m.tnIdPart, 0))
lcAdresa = FormatAddressForOracle(toAdresa)
lcTelefon = Iif(Type('toAdresa.phone') = 'C', toAdresa.phone, "")
lcEmail = Iif(Type('toAdresa.email') = 'C', toAdresa.email, "")
lcSQL = "BEGIN PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa(?tnIdPart, ?lcAdresa, ?lcTelefon, ?lcEmail, ?@lnIdAdresa); END;"
lnResult = SQLExec(goConnectie, lcSQL)
Endif
Catch To loError
Endtry
Return m.lnIdAdresa
Endfunc
*-- Construire JSON articole
Function BuildArticlesJSON
Lparameters loItems
Local lcJSON, loError
lcJSON = ""
Try
lcJSON = nfjsoncreate(loItems)
Catch To loError
lcJSON = ""
Endtry
Return lcJSON
Endfunc
*-- Curatare text web (HTML entities -> ASCII simplu)
Function CleanWebText
Parameters tcText
Local lcResult
If Empty(tcText) Or Type('tcText') != 'C'
Return ""
Endif
lcResult = tcText
lcResult = Strtran(lcResult, '&#259;', 'a')
lcResult = Strtran(lcResult, '&#537;', 's')
lcResult = Strtran(lcResult, '&#539;', 't')
lcResult = Strtran(lcResult, '&#238;', 'i')
lcResult = Strtran(lcResult, '&#226;', 'a')
lcResult = Strtran(lcResult, '&amp;', '&')
lcResult = Strtran(lcResult, '&lt;', '<')
lcResult = Strtran(lcResult, '&gt;', '>')
lcResult = Strtran(lcResult, '&quot;', '"')
lcResult = Strtran(lcResult, '<br>', ' ')
lcResult = Strtran(lcResult, '<br/>', ' ')
lcResult = Strtran(lcResult, '<br />', ' ')
lcResult = Strtran(lcResult, '\/', '/')
Return Alltrim(lcResult)
Endfunc
*-- Conversie data web in format YYYYMMDD
Function ConvertWebDate
Parameters tcWebDate
Local lcResult
If Empty(tcWebDate) Or Type('tcWebDate') != 'C'
Return Dtos(Date())
Endif
lcResult = Strtran(Left(tcWebDate, 10), "-", "",1,10,1)
If Len(lcResult) = 8
Return lcResult
Else
Return Dtos(Date())
Endif
Endfunc
*-- Conversie string in Date
Function String2Date
Lparameters tcDate, tcFormat
Local lcAn, lcDate, lcFormat, lcLuna, lcZi, ldData, lnAn, lnLuna, lnZi, loEx
ldData = {}
lcDate = m.tcDate
lcFormat = Iif(!Empty(m.tcFormat), Alltrim(Lower(m.tcFormat)), 'yyyymmdd')
lcDate = Chrtran(m.lcDate, '-/\','...')
lcDate = Strtran(m.lcDate, '.', '', 1, 2, 1)
lcFormat = Chrtran(m.lcFormat, '.-/\','...')
lcFormat = Strtran(m.lcFormat, '.', '', 1, 2, 1)
Do Case
Case m.lcFormat = 'ddmmyyyy'
lcAn = Substr(m.tcDate, 5, 4)
lcLuna = Substr(m.tcDate, 3, 2)
lcZi = Substr(m.tcDate, 1, 2)
Otherwise
lcAn = Substr(m.tcDate, 1, 4)
lcLuna = Substr(m.tcDate, 5, 2)
lcZi = Substr(m.tcDate, 7, 2)
Endcase
lnAn = Int(Val(m.lcAn))
lnLuna = Int(Val(m.lcLuna))
lnZi = Int(Val(m.lcZi))
Try
ldData = Date(m.lnAn, m.lnLuna, m.lnZi)
Catch To loEx
ldData = {}
Endtry
Return m.ldData
Endfunc
*-- Formatare adresa in format semicolon pentru Oracle
Function FormatAddressForOracle
Parameters loBilling
Local lcAdresa, lcJudet, lcOras, lcStrada
lcJudet = Iif(Type('loBilling.region') = 'C', CleanWebText(loBilling.Region), "")
lcOras = Iif(Type('loBilling.city') = 'C', CleanWebText(loBilling.city), "")
lcStrada = Iif(Type('loBilling.address') = 'C', CleanWebText(loBilling.address), "")
lcAdresa = "JUD:" + lcJudet + ";" + lcOras + ";" + lcStrada
Return lcAdresa
Endfunc
*-- Construire observatii comanda
Function BuildOrderObservations
Parameters loOrder
Local lcObservatii
lcObservatii = ""
If Type('loOrder.payment') = 'O' And Type('loOrder.payment.name') = 'C'
lcObservatii = lcObservatii + "Payment: " + CleanWebText(loOrder.Payment.Name) + "; "
Endif
If Type('loOrder.delivery') = 'O' And Type('loOrder.delivery.name') = 'C'
lcObservatii = lcObservatii + "Delivery: " + CleanWebText(loOrder.delivery.Name) + "; "
Endif
If Type('loOrder.status') = 'C'
lcObservatii = lcObservatii + "Status: " + CleanWebText(loOrder.Status) + "; "
Endif
If Type('loOrder.source') = 'C'
lcObservatii = lcObservatii + "Source: " + CleanWebText(loOrder.Source)
If Type('loOrder.sales_channel') = 'C'
lcObservatii = lcObservatii + " " + CleanWebText(loOrder.sales_channel)
Endif
lcObservatii = lcObservatii + "; "
Endif
If Type('loOrder.shipping') = 'O' And Type('loOrder.billing') = 'O'
If Type('loOrder.shipping.address') = 'C' And Type('loOrder.billing.address') = 'C'
If !Alltrim(loOrder.shipping.address) == Alltrim(loOrder.billing.address)
lcObservatii = lcObservatii + "Shipping: " + CleanWebText(loOrder.shipping.address)
If Type('loOrder.shipping.city') = 'C'
lcObservatii = lcObservatii + ", " + CleanWebText(loOrder.shipping.city)
Endif
lcObservatii = lcObservatii + "; "
Endif
Endif
Endif
If Len(lcObservatii) > 500
lcObservatii = Left(lcObservatii, 497) + "..."
Endif
Return lcObservatii
Endfunc
*-- Obtinere detalii eroare Oracle (single-line, fara SQL)
Function GetOracleErrorDetails
Local lcError, laError[1], lnErrorLines, lnIndex
lcError = ""
lnErrorLines = Aerror(laError)
If lnErrorLines > 0
For lnIndex = 1 To lnErrorLines
If lnIndex > 1
lcError = lcError + " | "
Endif
lcError = lcError + Alltrim(Str(laError[lnIndex, 1])) + ": " + laError[lnIndex, 2]
Endfor
Endif
If Empty(lcError)
lcError = "Eroare Oracle nedefinita"
Endif
*-- Compact: inlocuieste newlines cu spatii
lcError = Strtran(lcError, CHR(13) + CHR(10), " ")
lcError = Strtran(lcError, CHR(10), " ")
lcError = Strtran(lcError, CHR(13), " ")
Return lcError
Endfunc
*-- Clasifica eroarea Oracle intr-un format compact
*-- Returneaza: "SKU_NOT_FOUND: sku" / "PRICE_POLICY: sku" / eroarea bruta
Function ClassifyImportError
Lparameters tcErrorDetails
Local lcText, lcSku, lnPos, lcSearch
lcText = Iif(Empty(tcErrorDetails), "", tcErrorDetails)
*-- SKU negasit
lcSearch = "NOM_ARTICOLE: "
lnPos = Atc(lcSearch, lcText)
If lnPos > 0
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
Return "SKU_NOT_FOUND: " + lcSku
Endif
*-- Eroare adaugare articol (include pretul)
lcSearch = "Eroare adaugare articol "
lnPos = Atc(lcSearch, lcText)
If lnPos > 0
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
Return "PRICE_POLICY: " + lcSku
Endif
*-- Eroare pret fara SKU (inainte de fix-ul Oracle)
If Atc("Pretul pentru acest articol", lcText) > 0
Return "PRICE_POLICY: (SKU necunoscut)"
Endif
*-- Eroare generica - primele 100 caractere
Return Left(lcText, 100)
Endfunc
*-- Colectare SKU-uri lipsa din mesajele de eroare Oracle
Function CollectFailedSKUs
Lparameters tcErrorDetails
Local lcSku, lnPos, lcSearch, lcText
If Empty(tcErrorDetails)
Return
Endif
lcText = tcErrorDetails
*-- Pattern 1: "SKU negasit in ARTICOLE_TERTI si NOM_ARTICOLE: XXXXX"
lcSearch = "NOM_ARTICOLE: "
lnPos = Atc(lcSearch, lcText)
If lnPos > 0
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
If !Empty(lcSku)
AddUniqueSKU(lcSku)
Endif
Endif
*-- Pattern 2: "Eroare adaugare articol XXXXX (CODMAT:" sau "Eroare adaugare articol XXXXX:"
lcSearch = "Eroare adaugare articol "
lnPos = Atc(lcSearch, lcText)
If lnPos > 0
lcSku = Alltrim(Getwordnum(Substr(lcText, lnPos + Len(lcSearch)), 1))
If !Empty(lcSku)
AddUniqueSKU(lcSku)
Endif
Endif
Return
Endfunc
*-- Adauga un SKU in gcFailedSKUs daca nu exista deja
Function AddUniqueSKU
Lparameters tcSku
Local lcSku
lcSku = Alltrim(tcSku)
If Empty(lcSku)
Return
Endif
If Empty(gcFailedSKUs)
gcFailedSKUs = lcSku
Else
If !(CHR(10) + lcSku + CHR(10)) $ (CHR(10) + gcFailedSKUs + CHR(10))
gcFailedSKUs = gcFailedSKUs + CHR(10) + lcSku
Endif
Endif
Return
Endfunc
*-- Executie adapter configurat
Function ExecuteAdapter
Local llSuccess, lcAdapterPath
llSuccess = .F.
Try
lcAdapterPath = gcAppPath + goSettings.AdapterProgram
If File(lcAdapterPath)
Do (lcAdapterPath)
llSuccess = .T.
Else
LogMessage("EROARE: Adapter negasit: " + lcAdapterPath, "ERROR", gcLogFile)
Endif
Catch To loError
LogMessage("EROARE adapter: " + loError.Message, "ERROR", gcLogFile)
Endtry
Return llSuccess
Endfunc

View File

@@ -1,203 +0,0 @@
*-- utils.prg - Functii utilitare generale
*-- Contine doar functii utilitare reutilizabile (INI, HTTP, logging, encoding)
*-- Autor: Claude AI
*-- Data: 10 septembrie 2025
*-- Functie pentru citirea fisierelor INI private
*-- Returneaza valoarea din sectiunea si intrarea specificata sau blank daca nu e gasita
FUNCTION ReadPini
PARAMETERS cSection, cEntry, cINIFile
LOCAL cDefault, cRetVal, nRetLen
cDefault = ""
cRetVal = SPACE(255)
nRetLen = LEN(cRetVal)
DECLARE INTEGER GetPrivateProfileString IN WIN32API ;
STRING cSection, ;
STRING cEntry, ;
STRING cDefault, ;
STRING @cRetVal, ;
INTEGER nRetLen, ;
STRING cINIFile
nRetLen = GetPrivateProfileString(cSection, ;
cEntry, ;
cDefault, ;
@cRetVal, ;
nRetLen, ;
cINIFile)
RETURN LEFT(cRetVal, nRetLen)
ENDFUNC
*-- Functie pentru scrierea in fisierele INI private
*-- Returneaza .T. daca e successful, .F. daca nu
FUNCTION WritePini
PARAMETERS cSection, cEntry, cValue, cINIFile
LOCAL nRetVal
DECLARE INTEGER WritePrivateProfileString IN WIN32API ;
STRING cSection, ;
STRING cEntry, ;
STRING cValue, ;
STRING cINIFile
nRetVal = WritePrivateProfileString(cSection, ;
cEntry, ;
cValue, ;
cINIFile)
RETURN nRetVal = 1
ENDFUNC
*-- Test conectivitate internet
FUNCTION TestConnectivity
LOCAL loHttp, llResult
llResult = .T.
TRY
loHttp = CREATEOBJECT("WinHttp.WinHttpRequest.5.1")
loHttp.Open("GET", "https://www.google.com", .F.)
loHttp.SetTimeouts(5000, 5000, 5000, 5000)
loHttp.Send()
IF loHttp.Status != 200
llResult = .F.
ENDIF
CATCH
llResult = .F.
ENDTRY
loHttp = NULL
RETURN llResult
ENDFUNC
*-- Functie pentru codificare URL
FUNCTION UrlEncode
PARAMETERS tcString
LOCAL lcResult, lcChar, lnI
lcResult = ""
FOR lnI = 1 TO LEN(tcString)
lcChar = SUBSTR(tcString, lnI, 1)
DO CASE
CASE ISALPHA(lcChar) OR ISDIGIT(lcChar) OR INLIST(lcChar, "-", "_", ".", "~")
lcResult = lcResult + lcChar
OTHERWISE
lcResult = lcResult + "%" + RIGHT("0" + TRANSFORM(ASC(lcChar), "@0"), 2)
ENDCASE
ENDFOR
RETURN lcResult
ENDFUNC
*-- Functie pentru verificarea existentei fisierului INI
FUNCTION CheckIniFile
PARAMETERS cINIFile
LOCAL llExists
TRY
llExists = FILE(cINIFile)
CATCH
llExists = .F.
ENDTRY
RETURN llExists
ENDFUNC
*-- Functie pentru initializarea logging-ului
FUNCTION InitLog
PARAMETERS cBaseName
LOCAL lcLogFile, lcStartTime, lcLogHeader, lcLogDir
*-- Cream directorul log daca nu existe
lcLogDir = gcAppPath + "log"
IF !DIRECTORY(lcLogDir)
MKDIR (lcLogDir)
ENDIF
*-- Generam numele fisierului log cu timestamp in directorul log
lcStartTime = DTOS(DATE()) + "_" + STRTRAN(TIME(), ":", "")
lcLogFile = lcLogDir + "\" + cBaseName + "_" + lcStartTime + ".log"
*-- Header pentru log
lcLogHeader = "[" + TIME() + "] [START] === GoMag Sync Started ===" + CHR(13) + CHR(10)
lcLogHeader = lcLogHeader + "[" + TIME() + "] [INFO ] Date: " + DTOC(DATE()) + " | VFP: " + VERSION() + CHR(13) + CHR(10)
*-- Cream fisierul log
STRTOFILE(lcLogHeader, lcLogFile)
RETURN lcLogFile
ENDFUNC
*-- Functie pentru logging cu nivel si timestamp
FUNCTION LogMessage
PARAMETERS cMessage, cLevel, cLogFile
LOCAL lcTimeStamp, lcLogEntry, lcExistingContent
*-- Setam nivel implicit daca nu e specificat
IF EMPTY(cLevel)
cLevel = "INFO "
ELSE
*-- Formatam nivelul pentru a avea 5 caractere
cLevel = LEFT(cLevel + " ", 5)
ENDIF
*-- Cream timestamp-ul
lcTimeStamp = TIME()
*-- Formatam mesajul pentru log
lcLogEntry = "[" + lcTimeStamp + "] [" + cLevel + "] " + cMessage + CHR(13) + CHR(10)
*-- Adaugam la fisierul existent
lcExistingContent = ""
IF FILE(cLogFile)
lcExistingContent = FILETOSTR(cLogFile)
ENDIF
STRTOFILE(lcExistingContent + lcLogEntry, cLogFile)
RETURN .T.
ENDFUNC
*-- Functie pentru inchiderea logging-ului cu statistici
FUNCTION CloseLog
PARAMETERS nStartTime, nProductsCount, nOrdersCount, cLogFile
LOCAL lcEndTime, lcDuration, lcStatsEntry
lcEndTime = TIME()
*-- Calculam durata in secunde
nDuration = SECONDS() - nStartTime
*-- Formatam statisticile finale
lcStatsEntry = "[" + lcEndTime + "] [END ] === GoMag Sync Completed ===" + CHR(13) + CHR(10)
lcStatsEntry = lcStatsEntry + "[" + lcEndTime + "] [STATS] Duration: " + TRANSFORM(INT(nDuration)) + "s"
IF nProductsCount > 0
lcStatsEntry = lcStatsEntry + " | Products: " + TRANSFORM(nProductsCount)
ENDIF
IF nOrdersCount > 0
lcStatsEntry = lcStatsEntry + " | Orders: " + TRANSFORM(nOrdersCount)
ENDIF
lcStatsEntry = lcStatsEntry + CHR(13) + CHR(10)
*-- Adaugam la log
LOCAL lcExistingContent
lcExistingContent = ""
IF FILE(cLogFile)
lcExistingContent = FILETOSTR(cLogFile)
ENDIF
STRTOFILE(lcExistingContent + lcStatsEntry, cLogFile)
RETURN .T.
ENDFUNC