Compare commits
25 Commits
7a1fa16fef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217fd1af3c | ||
|
|
6acb73b9ce | ||
|
|
b2745a9a64 | ||
|
|
f516bb5756 | ||
|
|
efb055c2be | ||
|
|
7e4bbabcae | ||
|
|
5a5ca63f92 | ||
|
|
35709cdc6e | ||
|
|
d3d1905b18 | ||
|
|
bd4524707e | ||
|
|
4a589aafeb | ||
|
|
b52313faf6 | ||
|
|
7a789b4fe7 | ||
|
|
1b2b1d8b24 | ||
|
|
a10a00aa4d | ||
|
|
3bd0556f73 | ||
|
|
f6b6b863bd | ||
|
|
ef996a45b2 | ||
|
|
2fabce7c5b | ||
|
|
60704d22c0 | ||
|
|
aacca13b85 | ||
|
|
a8292c2ef2 | ||
|
|
c5757b8322 | ||
|
|
c6d69ac0e0 | ||
|
|
9f2fd24d93 |
@@ -106,6 +106,13 @@ python3 scripts/sync_vending_to_mariusm.py --apply --yes
|
|||||||
Sincronizează via SSH din VENDING (prod Windows) în MARIUSM_AUTO (dev ROA_CENTRAL):
|
Sincronizează via SSH din VENDING (prod Windows) în MARIUSM_AUTO (dev ROA_CENTRAL):
|
||||||
nom_articole (noi by codmat, codmat updatat) + articole_terti (noi, modificate, soft-delete).
|
nom_articole (noi by codmat, codmat updatat) + articole_terti (noi, modificate, soft-delete).
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
Always read DESIGN.md before making any visual or UI decisions.
|
||||||
|
All font choices, colors, spacing, and aesthetic direction are defined there.
|
||||||
|
Do not deviate without explicit user approval.
|
||||||
|
In QA mode, flag any code that doesn't match DESIGN.md.
|
||||||
|
|
||||||
## Deploy Windows
|
## Deploy Windows
|
||||||
|
|
||||||
Vezi [README.md](README.md#deploy-windows)
|
Vezi [README.md](README.md#deploy-windows)
|
||||||
|
|||||||
324
DESIGN.md
Normal file
324
DESIGN.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# Design System — GoMag Vending
|
||||||
|
|
||||||
|
## Product Context
|
||||||
|
- **What this is:** Internal admin dashboard for importing web orders from GoMag e-commerce into ROA Oracle ERP
|
||||||
|
- **Who it's for:** Ops/admin team who monitor order sync daily, fix SKU mappings, check import errors
|
||||||
|
- **Space/industry:** Internal tools, B2B operations, ERP integration
|
||||||
|
- **Project type:** Data-heavy admin dashboard (tables, status indicators, sync controls)
|
||||||
|
|
||||||
|
## Aesthetic Direction
|
||||||
|
- **Direction:** Industrial/Utilitarian — function-first, data-dense, quietly confident
|
||||||
|
- **Decoration level:** Minimal — typography and color do the work. No illustrations, gradients, or decorative elements. The data IS the decoration.
|
||||||
|
- **Mood:** Command console. This tool says "built by someone who respects the operator." Serious, efficient, warm.
|
||||||
|
- **Anti-patterns:** No purple gradients, no 3-column icon grids, no centered-everything layouts, no decorative blobs, no stock-photo heroes
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Font Stack
|
||||||
|
- **Display/Headings:** Space Grotesk — geometric, slightly techy, distinctive `a` and `g`. Says "engineered."
|
||||||
|
- **Body/UI:** DM Sans — clean, excellent readability, good tabular-nums for inline numbers
|
||||||
|
- **Data/Tables:** JetBrains Mono — order IDs, CODMATs, status codes align perfectly. Tables become scannable.
|
||||||
|
- **Code:** JetBrains Mono
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Variables
|
||||||
|
```css
|
||||||
|
--font-display: 'Space Grotesk', sans-serif;
|
||||||
|
--font-body: 'DM Sans', sans-serif;
|
||||||
|
--font-data: 'JetBrains Mono', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Scale
|
||||||
|
| Level | Size | Weight | Font | Usage |
|
||||||
|
|-------|------|--------|------|-------|
|
||||||
|
| Page title | 18px | 600 | Display | "Panou de Comanda" |
|
||||||
|
| Section title | 16px | 600 | Display | Card headers |
|
||||||
|
| Label/uppercase | 12px | 500 | Display | Column headers, section labels (letter-spacing: 0.04em) |
|
||||||
|
| Body | 14px | 400 | Body | Paragraphs, descriptions |
|
||||||
|
| UI/Button | 13px | 500 | Body | Buttons, nav links, form labels |
|
||||||
|
| Data cell | 13px | 400 | Data | Codes, IDs, numbers, sums, dates (NOT text names — those use Body font) |
|
||||||
|
| Data small | 12px | 400 | Data | Timestamps, secondary data |
|
||||||
|
| Code/mono | 11px | 400 | Data | Inline code, debug info |
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
### Approach: Two-accent system (amber state + blue action)
|
||||||
|
Every admin tool is blue. This one uses amber — reads as "operational" and "attention-worthy."
|
||||||
|
- **Amber (--accent):** Navigation active state, filter pill active, accent backgrounds. "Where you are."
|
||||||
|
- **Blue (--info):** Primary buttons, CTAs, actionable links. "What you can do."
|
||||||
|
- Primary buttons (`btn-primary`) stay blue for clear action hierarchy.
|
||||||
|
|
||||||
|
### Light Mode (default)
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Surfaces */
|
||||||
|
--bg: #F8F7F5; /* warm off-white, not clinical gray */
|
||||||
|
--surface: #FFFFFF;
|
||||||
|
--surface-raised: #F3F2EF; /* hover states, table headers */
|
||||||
|
--card-shadow: 0 1px 3px rgba(28,25,23,0.1), 0 1px 2px rgba(28,25,23,0.06);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #1C1917; /* warm black */
|
||||||
|
--text-secondary: #57534E; /* warm gray */
|
||||||
|
--text-muted: #78716C; /* labels, timestamps */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #E7E5E4;
|
||||||
|
--border-subtle: #F0EFED;
|
||||||
|
|
||||||
|
/* Accent — amber */
|
||||||
|
--accent: #D97706;
|
||||||
|
--accent-hover: #B45309;
|
||||||
|
--accent-light: #FEF3C7; /* amber backgrounds */
|
||||||
|
--accent-text: #92400E; /* text on amber bg */
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--success: #16A34A;
|
||||||
|
--success-light: #DCFCE7;
|
||||||
|
--success-text: #166534;
|
||||||
|
|
||||||
|
--warning: #CA8A04;
|
||||||
|
--warning-light: #FEF9C3;
|
||||||
|
--warning-text: #854D0E;
|
||||||
|
|
||||||
|
--error: #DC2626;
|
||||||
|
--error-light: #FEE2E2;
|
||||||
|
--error-text: #991B1B;
|
||||||
|
|
||||||
|
--info: #2563EB;
|
||||||
|
--info-light: #DBEAFE;
|
||||||
|
--info-text: #1E40AF;
|
||||||
|
|
||||||
|
--cancelled: #78716C;
|
||||||
|
--cancelled-light: #F5F5F4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
Strategy: invert surfaces, reduce accent saturation ~15%, keep semantic colors recognizable.
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #121212;
|
||||||
|
--surface: #1E1E1E;
|
||||||
|
--surface-raised: #2A2A2A;
|
||||||
|
--card-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
|
||||||
|
|
||||||
|
--text-primary: #E8E4DD; /* warm bone white */
|
||||||
|
--text-secondary: #A8A29E;
|
||||||
|
--text-muted: #78716C;
|
||||||
|
|
||||||
|
--border: #333333;
|
||||||
|
--border-subtle: #262626;
|
||||||
|
|
||||||
|
--accent: #F59E0B;
|
||||||
|
--accent-hover: #D97706;
|
||||||
|
--accent-light: rgba(245,158,11,0.12);
|
||||||
|
--accent-text: #FCD34D;
|
||||||
|
|
||||||
|
--success: #16A34A;
|
||||||
|
--success-light: rgba(22,163,74,0.15);
|
||||||
|
--success-text: #4ADE80;
|
||||||
|
|
||||||
|
--warning: #CA8A04;
|
||||||
|
--warning-light: rgba(202,138,4,0.15);
|
||||||
|
--warning-text: #FACC15;
|
||||||
|
|
||||||
|
--error: #DC2626;
|
||||||
|
--error-light: rgba(220,38,38,0.15);
|
||||||
|
--error-text: #FCA5A5;
|
||||||
|
|
||||||
|
--info: #2563EB;
|
||||||
|
--info-light: rgba(37,99,235,0.15);
|
||||||
|
--info-text: #93C5FD;
|
||||||
|
|
||||||
|
--cancelled: #78716C;
|
||||||
|
--cancelled-light: rgba(120,113,108,0.15);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Color Mapping
|
||||||
|
| Status | Dot Color | Badge BG | Glow |
|
||||||
|
|--------|-----------|----------|------|
|
||||||
|
| IMPORTED | `--success` | `--success-light` | none (quiet when healthy) |
|
||||||
|
| ERROR | `--error` | `--error-light` | `0 0 8px 2px rgba(220,38,38,0.35)` |
|
||||||
|
| SKIPPED | `--warning` | `--warning-light` | `0 0 6px 2px rgba(202,138,4,0.3)` |
|
||||||
|
| ALREADY_IMPORTED | `--info` | `--info-light` | none |
|
||||||
|
| CANCELLED | `--cancelled` | `--cancelled-light` | none |
|
||||||
|
| DELETED_IN_ROA | `--cancelled` | `--cancelled-light` | none |
|
||||||
|
|
||||||
|
**Design rule:** Problems glow, success is calm. The operator's eye is pulled to rows that need action.
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
- **Base unit:** 4px
|
||||||
|
- **Density:** Comfortable — not cramped, not wasteful
|
||||||
|
- **Scale:**
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| 2xs | 2px | Tight internal gaps |
|
||||||
|
| xs | 4px | Icon-text gap, badge padding |
|
||||||
|
| sm | 8px | Compact card padding, table cell padding |
|
||||||
|
| md | 16px | Standard card padding, section gaps |
|
||||||
|
| lg | 24px | Section spacing |
|
||||||
|
| xl | 32px | Major section gaps |
|
||||||
|
| 2xl | 48px | Page-level spacing |
|
||||||
|
| 3xl | 64px | Hero spacing (rarely used) |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Approach: Grid-disciplined, full-width
|
||||||
|
Tables with 8+ columns and hundreds of rows need every pixel of width.
|
||||||
|
|
||||||
|
- **Nav:** Horizontal top bar, fixed, 48px height. Active tab has amber underline (2px).
|
||||||
|
- **Content max-width:** None on desktop (full-width for tables), 1200px for non-table content
|
||||||
|
- **Grid:** Single-column layout, cards stack vertically
|
||||||
|
- **Breakpoints:**
|
||||||
|
|
||||||
|
| Name | Width | Columns | Behavior |
|
||||||
|
|------|-------|---------|----------|
|
||||||
|
| Desktop | >= 1024px | Full width | All features visible |
|
||||||
|
| Tablet | 768-1023px | Full width | Nav labels abbreviated, tables scroll horizontally |
|
||||||
|
| Mobile | < 768px | Single column | Bottom nav, cards stack, condensed views |
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| sm | 4px | Buttons, inputs, badges, status dots |
|
||||||
|
| md | 8px | Cards, dropdowns, modals |
|
||||||
|
| lg | 12px | Large containers, mockup frames |
|
||||||
|
| full | 9999px | Pills, avatar circles |
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
- **Approach:** Minimal-functional — only transitions that aid comprehension
|
||||||
|
- **Easing:** enter(ease-out) exit(ease-in) move(ease-in-out)
|
||||||
|
- **Duration:**
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| micro | 50-100ms | Button hover, focus ring |
|
||||||
|
| short | 150-250ms | Dropdown open, tab switch, color transitions |
|
||||||
|
| medium | 250-400ms | Modal open/close, page transitions |
|
||||||
|
| long | 400-700ms | Only for sync pulse animation |
|
||||||
|
|
||||||
|
- **Sync pulse:** The live sync dot uses a 2s infinite pulse (opacity 1 → 0.4 → 1)
|
||||||
|
- **No:** entrance animations, scroll effects, decorative motion
|
||||||
|
|
||||||
|
## Mobile Design
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- **Bottom tab bar** replaces top horizontal nav on screens < 768px
|
||||||
|
- 5 tabs: Dashboard, Mapari, Lipsa, Jurnale, Setari
|
||||||
|
- Each tab: icon (Bootstrap Icons) + short label below
|
||||||
|
- Active tab: amber accent color, inactive: `--text-muted`
|
||||||
|
- Height: 56px, safe-area padding for notched devices
|
||||||
|
- Fixed position bottom, with `padding-bottom: env(safe-area-inset-bottom)`
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.top-navbar { display: none; }
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: 72px; /* clear bottom nav */
|
||||||
|
padding-top: 8px; /* no top navbar */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard — Mobile
|
||||||
|
- **Sync card:** Full width, stacked vertically
|
||||||
|
- Status + controls row wraps to 2 lines
|
||||||
|
- Sync button full-width at bottom of card
|
||||||
|
- Last sync info wraps naturally
|
||||||
|
- **Orders table:** Condensed card view instead of horizontal table
|
||||||
|
- Each order = a compact card showing: status dot + ID + client name + total
|
||||||
|
- Tap to expand: shows date, factura, full details
|
||||||
|
- Swipe left on card: quick action (view error details)
|
||||||
|
- **Filter bar:** Horizontal scrollable chips instead of dropdowns
|
||||||
|
- Period selector: pill chips (1zi, 7zi, 30zi, Toate)
|
||||||
|
- Status filter: colored chips matching status colors
|
||||||
|
- **Touch targets:** Minimum 44x44px for all interactive elements
|
||||||
|
|
||||||
|
### Orders Mobile Card Layout
|
||||||
|
```
|
||||||
|
┌────────────────────────────────┐
|
||||||
|
│ ● CMD-47832 2,450.00 RON│
|
||||||
|
│ SC Automate Express SRL │
|
||||||
|
│ 27.03.2026 · FCT-2026-1847 │
|
||||||
|
└────────────────────────────────┘
|
||||||
|
```
|
||||||
|
- Status dot (8px, left-aligned with glow for errors)
|
||||||
|
- Order ID in JetBrains Mono, amount right-aligned
|
||||||
|
- Client name in DM Sans
|
||||||
|
- Date + factura in muted data font
|
||||||
|
|
||||||
|
### SKU Mappings — Mobile
|
||||||
|
- Each mapping = expandable card
|
||||||
|
- Collapsed: SKU + product name + type badge (KIT/SIMPLU)
|
||||||
|
- Expanded: Full CODMAT list with quantities
|
||||||
|
- Search: Full-width sticky search bar at top
|
||||||
|
- Filter: Horizontal scrollable type chips
|
||||||
|
|
||||||
|
### Logs — Mobile
|
||||||
|
- Timeline view instead of table
|
||||||
|
- Each log entry = timestamp + status icon + summary
|
||||||
|
- Tap to expand full log details
|
||||||
|
- Infinite scroll with date separators
|
||||||
|
|
||||||
|
### Settings — Mobile
|
||||||
|
- Standard stacked form layout
|
||||||
|
- Full-width inputs
|
||||||
|
- Toggle switches for boolean settings (min 44px touch target)
|
||||||
|
- Save button sticky at bottom
|
||||||
|
|
||||||
|
### Gestures
|
||||||
|
- **Pull to refresh** on Dashboard: triggers sync status check
|
||||||
|
- **Swipe left** on order card: reveal quick actions
|
||||||
|
- **Long press** on SKU mapping: copy CODMAT to clipboard
|
||||||
|
- **No swipe navigation** between pages (use bottom tabs)
|
||||||
|
|
||||||
|
### Mobile Typography Adjustments
|
||||||
|
| Level | Desktop | Mobile |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| Page title | 18px | 16px |
|
||||||
|
| Body | 14px | 14px (no change) |
|
||||||
|
| Data cell | 13px | 13px (no change) |
|
||||||
|
| Data small | 12px | 12px (no change) |
|
||||||
|
| Table header | 12px | 11px |
|
||||||
|
|
||||||
|
### Responsive Images & Icons
|
||||||
|
- Use Bootstrap Icons throughout (already loaded via CDN)
|
||||||
|
- Icon size: 16px desktop, 20px mobile (larger touch targets)
|
||||||
|
- No images in the admin interface (data-only)
|
||||||
|
|
||||||
|
## Decisions Log
|
||||||
|
| Date | Decision | Rationale |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| 2026-03-27 | Initial design system created | Created by /design-consultation. Industrial/utilitarian aesthetic with amber accent, Space Grotesk + DM Sans + JetBrains Mono. |
|
||||||
|
| 2026-03-27 | Amber accent over blue | Every admin tool is blue. Amber reads as "operational" and gives the tool its own identity. Confirmed by Claude subagent ("Control Room Noir" also converged on amber). |
|
||||||
|
| 2026-03-27 | JetBrains Mono for data tables | Both primary analysis and subagent independently recommended monospace for data tables. Scannability win outweighs the ~15% wider columns. |
|
||||||
|
| 2026-03-27 | Warm tones throughout | Off-white (#F8F7F5) instead of clinical gray. Warm black text instead of blue-gray. Makes the tool feel handcrafted. |
|
||||||
|
| 2026-03-27 | Glowing status dots for errors | Problems glow (box-shadow), success is calm. Operator's eye is pulled to rows that need action. Inspired by subagent's "LED indicator" concept. |
|
||||||
|
| 2026-03-27 | Full mobile design | Bottom nav, card-based order views, touch-optimized gestures. Supports quick-glance usage from phone. |
|
||||||
|
| 2026-03-27 | Two-accent system | Blue = action (buttons, CTAs), amber = state (nav active, filter active). Clear hierarchy. |
|
||||||
|
| 2026-03-27 | JetBrains Mono selective | Mono font only for codes, IDs, numbers, sums, dates. Text names use DM Sans for readability. |
|
||||||
|
| 2026-03-27 | Dark mode in scope | CSS variables + toggle + localStorage. All DESIGN.md dark tokens implemented in Commit 0.5. |
|
||||||
15
TODOS.md
Normal file
15
TODOS.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# TODOS
|
||||||
|
|
||||||
|
## P2: Refactor sync_service.py in module separate
|
||||||
|
**What:** Split sync_service.py (870 linii) in: download_service, parse_service, sync_orchestrator.
|
||||||
|
**Why:** Faciliteza debugging si testare. Un bug in price sync nu ar trebui sa afecteze import flow.
|
||||||
|
**Effort:** M (human: ~1 sapt / CC: ~1-2h)
|
||||||
|
**Context:** Dupa implementarea planului Command Center (retry_service deja extras). sync_service face download + parse + validate + import + price sync + invoice check — prea multe responsabilitati.
|
||||||
|
**Depends on:** Finalizarea planului Command Center.
|
||||||
|
|
||||||
|
## P2: Email/webhook alert pe sync esuat
|
||||||
|
**What:** Cand sync-ul gaseste >5 erori sau esueaza complet, trimite un email/webhook.
|
||||||
|
**Why:** Post-lansare, cand app-ul ruleaza automat, nimeni nu sta sa verifice constant.
|
||||||
|
**Effort:** M (human: ~1 sapt / CC: ~1h)
|
||||||
|
**Context:** Depinde de infrastructura email/webhook disponibila la client. Implementare: SMTP simplu sau webhook URL configurabil in Settings.
|
||||||
|
**Depends on:** Lansare in productie + infrastructura email la client.
|
||||||
@@ -332,6 +332,7 @@ def init_sqlite():
|
|||||||
("discount_total", "REAL"),
|
("discount_total", "REAL"),
|
||||||
("web_status", "TEXT"),
|
("web_status", "TEXT"),
|
||||||
("discount_split", "TEXT"),
|
("discount_split", "TEXT"),
|
||||||
|
("price_match", "INTEGER"),
|
||||||
]:
|
]:
|
||||||
if col not in order_cols:
|
if col not in order_cols:
|
||||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -8,6 +9,7 @@ import os
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import init_oracle, close_oracle, init_sqlite
|
from .database import init_oracle, close_oracle, init_sqlite
|
||||||
|
from .routers.sync import backfill_price_match
|
||||||
|
|
||||||
# Configure logging with both stream and file handlers
|
# Configure logging with both stream and file handlers
|
||||||
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||||
@@ -56,6 +58,8 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
asyncio.create_task(backfill_price_match())
|
||||||
|
|
||||||
logger.info("GoMag Import Manager started")
|
logger.info("GoMag Import Manager started")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ async def create_batch_mapping(data: MappingBatchCreate):
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/mappings/{sku}/prices")
|
@router.get("/api/mappings/prices")
|
||||||
async def get_mapping_prices(sku: str):
|
async def get_mapping_prices(sku: str = Query(...)):
|
||||||
"""Get component prices from crm_politici_pret_art for a kit SKU."""
|
"""Get component prices from crm_politici_pret_art for a kit SKU."""
|
||||||
app_settings = await sqlite_service.get_app_settings()
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
id_pol = int(app_settings.get("id_pol") or 0) or None
|
id_pol = int(app_settings.get("id_pol") or 0) or None
|
||||||
|
|||||||
@@ -12,13 +12,81 @@ from pydantic import BaseModel
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
|
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service
|
||||||
from .. import database
|
from .. import database
|
||||||
|
|
||||||
router = APIRouter(tags=["sync"])
|
router = APIRouter(tags=["sync"])
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_items_with_codmat(items: list) -> None:
|
||||||
|
"""Enrich order items with codmat_details from ARTICOLE_TERTI + NOM_ARTICOLE fallback."""
|
||||||
|
skus = {item["sku"] for item in items if item.get("sku")}
|
||||||
|
if not skus:
|
||||||
|
return
|
||||||
|
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]
|
||||||
|
remaining_skus = {item["sku"] for item in items
|
||||||
|
if item.get("sku") and not item.get("codmat_details")}
|
||||||
|
if remaining_skus:
|
||||||
|
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_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,
|
||||||
|
"denumire": nom_map[sku], "direct": True}]
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill_price_match():
|
||||||
|
"""Background task: check prices for all imported orders without cached price_match."""
|
||||||
|
try:
|
||||||
|
from ..database import get_sqlite
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT order_number FROM orders
|
||||||
|
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
|
||||||
|
AND price_match IS NULL
|
||||||
|
ORDER BY order_date DESC
|
||||||
|
""")
|
||||||
|
rows = [r["order_number"] for r in await cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.info("backfill_price_match: no unchecked orders")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"backfill_price_match: checking {len(rows)} orders...")
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for order_number in rows:
|
||||||
|
try:
|
||||||
|
detail = await sqlite_service.get_order_detail(order_number)
|
||||||
|
if not detail:
|
||||||
|
continue
|
||||||
|
items = detail.get("items", [])
|
||||||
|
await _enrich_items_with_codmat(items)
|
||||||
|
price_data = await asyncio.to_thread(
|
||||||
|
validation_service.get_prices_for_order, items, app_settings
|
||||||
|
)
|
||||||
|
summary = price_data.get("summary", {})
|
||||||
|
if summary.get("oracle_available") is not False:
|
||||||
|
pm = summary.get("mismatches", 0) == 0
|
||||||
|
await sqlite_service.update_order_price_match(order_number, pm)
|
||||||
|
checked += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"backfill_price_match: order {order_number} failed: {e}")
|
||||||
|
|
||||||
|
logger.info(f"backfill_price_match: done, {checked}/{len(rows)} updated")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"backfill_price_match failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
class ScheduleConfig(BaseModel):
|
class ScheduleConfig(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
interval_minutes: int = 5
|
interval_minutes: int = 5
|
||||||
@@ -380,33 +448,36 @@ async def order_detail(order_number: str):
|
|||||||
if not detail:
|
if not detail:
|
||||||
return {"error": "Order not found"}
|
return {"error": "Order not found"}
|
||||||
|
|
||||||
# Enrich items with ARTICOLE_TERTI mappings from Oracle
|
|
||||||
items = detail.get("items", [])
|
items = detail.get("items", [])
|
||||||
skus = {item["sku"] for item in items if item.get("sku")}
|
await _enrich_items_with_codmat(items)
|
||||||
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 remaining SKUs via NOM_ARTICOLE (fallback for stale mapping_status)
|
# Price comparison against ROA Oracle
|
||||||
remaining_skus = {item["sku"] for item in items
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
if item.get("sku") and not item.get("codmat_details")}
|
try:
|
||||||
if remaining_skus:
|
price_data = await asyncio.to_thread(
|
||||||
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus)
|
validation_service.get_prices_for_order, items, app_settings
|
||||||
for item in items:
|
)
|
||||||
sku = item.get("sku")
|
price_items = price_data.get("items", {})
|
||||||
if sku and sku in nom_map and not item.get("codmat_details"):
|
for idx, item in enumerate(items):
|
||||||
item["codmat_details"] = [{
|
pi = price_items.get(idx)
|
||||||
"codmat": sku,
|
if pi:
|
||||||
"cantitate_roa": 1,
|
item["pret_roa"] = pi.get("pret_roa")
|
||||||
"denumire": nom_map[sku],
|
item["price_match"] = pi.get("match")
|
||||||
"direct": True
|
order_price_check = price_data.get("summary", {})
|
||||||
}]
|
# Cache price_match in SQLite if changed
|
||||||
|
if order_price_check.get("oracle_available") is not False:
|
||||||
|
pm = order_price_check.get("mismatches", 0) == 0
|
||||||
|
cached = detail.get("order", {}).get("price_match")
|
||||||
|
cached_bool = True if cached == 1 else (False if cached == 0 else None)
|
||||||
|
if cached_bool != pm:
|
||||||
|
await sqlite_service.update_order_price_match(order_number, pm)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Price comparison failed for order {order_number}: {e}")
|
||||||
|
order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False}
|
||||||
|
|
||||||
# Enrich with invoice data
|
# Enrich with invoice data
|
||||||
order = detail.get("order", {})
|
order = detail.get("order", {})
|
||||||
|
order["price_check"] = order_price_check
|
||||||
if order.get("factura_numar") and order.get("factura_data"):
|
if order.get("factura_numar") and order.get("factura_data"):
|
||||||
order["invoice"] = {
|
order["invoice"] = {
|
||||||
"facturat": True,
|
"facturat": True,
|
||||||
@@ -438,6 +509,19 @@ async def order_detail(order_number: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Invoice reconciliation
|
||||||
|
inv = order.get("invoice")
|
||||||
|
if inv and inv.get("facturat") and inv.get("total_cu_tva") is not None:
|
||||||
|
order_total = float(order.get("order_total") or 0)
|
||||||
|
inv_total = float(inv["total_cu_tva"])
|
||||||
|
difference = round(inv_total - order_total, 2)
|
||||||
|
inv["reconciliation"] = {
|
||||||
|
"order_total": order_total,
|
||||||
|
"invoice_total": inv_total,
|
||||||
|
"difference": difference,
|
||||||
|
"match": abs(difference) < 0.01,
|
||||||
|
}
|
||||||
|
|
||||||
# Parse discount_split JSON string
|
# Parse discount_split JSON string
|
||||||
if order.get("discount_split"):
|
if order.get("discount_split"):
|
||||||
try:
|
try:
|
||||||
@@ -445,8 +529,7 @@ async def order_detail(order_number: str):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add settings for receipt display
|
# Add settings for receipt display (app_settings already fetched above)
|
||||||
app_settings = await sqlite_service.get_app_settings()
|
|
||||||
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
||||||
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||||
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
||||||
@@ -454,6 +537,52 @@ async def order_detail(order_number: str):
|
|||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/orders/{order_number}/retry")
|
||||||
|
async def retry_order(order_number: str):
|
||||||
|
"""Retry importing a failed/skipped order."""
|
||||||
|
from ..services import retry_service
|
||||||
|
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
result = await retry_service.retry_single_order(order_number, app_settings)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/orders/by-sku/{sku}/pending")
|
||||||
|
async def get_pending_orders_for_sku(sku: str):
|
||||||
|
"""Get SKIPPED orders that contain the given SKU."""
|
||||||
|
order_numbers = await sqlite_service.get_skipped_orders_with_sku(sku)
|
||||||
|
return {"sku": sku, "order_numbers": order_numbers, "count": len(order_numbers)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/orders/batch-retry")
|
||||||
|
async def batch_retry_orders(request: Request):
|
||||||
|
"""Batch retry multiple orders."""
|
||||||
|
from ..services import retry_service
|
||||||
|
body = await request.json()
|
||||||
|
order_numbers = body.get("order_numbers", [])
|
||||||
|
if not order_numbers:
|
||||||
|
return {"success": False, "message": "No orders specified"}
|
||||||
|
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
results = {"imported": 0, "errors": 0, "messages": []}
|
||||||
|
|
||||||
|
for on in order_numbers[:20]: # Limit to 20 to avoid timeout
|
||||||
|
result = await retry_service.retry_single_order(str(on), app_settings)
|
||||||
|
if result.get("success"):
|
||||||
|
results["imported"] += 1
|
||||||
|
else:
|
||||||
|
results["errors"] += 1
|
||||||
|
results["messages"].append(f"{on}: {result.get('message', 'Error')}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": results["imported"] > 0,
|
||||||
|
"imported": results["imported"],
|
||||||
|
"errors": results["errors"],
|
||||||
|
"message": f"{results['imported']} importate, {results['errors']} erori" if results["errors"] else f"{results['imported']} importate cu succes",
|
||||||
|
"details": results["messages"][:5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/dashboard/orders")
|
@router.get("/api/dashboard/orders")
|
||||||
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||||
search: str = "", status: str = "all",
|
search: str = "", status: str = "all",
|
||||||
@@ -484,6 +613,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
|
||||||
all_orders = result["orders"]
|
all_orders = result["orders"]
|
||||||
for o in all_orders:
|
for o in all_orders:
|
||||||
|
# price_match: 1=OK, 0=mismatch, NULL=not checked yet
|
||||||
|
pm = o.get("price_match")
|
||||||
|
o["price_match"] = True if pm == 1 else (False if pm == 0 else None)
|
||||||
if o.get("factura_numar") and o.get("factura_data"):
|
if o.get("factura_numar") and o.get("factura_data"):
|
||||||
# Use cached invoice data from SQLite (only if complete)
|
# Use cached invoice data from SQLite (only if complete)
|
||||||
o["invoice"] = {
|
o["invoice"] = {
|
||||||
@@ -534,9 +666,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
|
|
||||||
# Use counts from sqlite_service (already period-scoped)
|
# Use counts from sqlite_service (already period-scoped)
|
||||||
counts = result.get("counts", {})
|
counts = result.get("counts", {})
|
||||||
# Count newly-cached invoices found during this request
|
# Adjust uninvoiced count for invoices discovered via Oracle during this request
|
||||||
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
|
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
|
||||||
# Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices
|
|
||||||
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
|
||||||
1 for o in all_orders
|
1 for o in all_orders
|
||||||
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
||||||
@@ -546,6 +677,13 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
|||||||
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
|
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
|
||||||
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
||||||
|
|
||||||
|
# Attention metrics: add unresolved SKUs count
|
||||||
|
try:
|
||||||
|
stats = await sqlite_service.get_dashboard_stats()
|
||||||
|
counts["unresolved_skus"] = stats.get("unresolved_skus", 0)
|
||||||
|
except Exception:
|
||||||
|
counts["unresolved_skus"] = 0
|
||||||
|
|
||||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||||
if is_uninvoiced_filter:
|
if is_uninvoiced_filter:
|
||||||
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]
|
||||||
|
|||||||
131
api/app/services/retry_service.py
Normal file
131
api/app/services/retry_service.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Retry service — re-import individual failed/skipped orders."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_single_order(order_number: str, app_settings: dict) -> dict:
|
||||||
|
"""Re-download and re-import a single order from GoMag.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Read order from SQLite to get order_date / customer_name
|
||||||
|
2. Check sync lock (no retry during active sync)
|
||||||
|
3. Download narrow date range from GoMag (order_date ± 1 day)
|
||||||
|
4. Find the specific order in downloaded data
|
||||||
|
5. Run import_single_order()
|
||||||
|
6. Update status in SQLite
|
||||||
|
|
||||||
|
Returns: {"success": bool, "message": str, "status": str|None}
|
||||||
|
"""
|
||||||
|
from . import sqlite_service, sync_service, gomag_client, import_service, order_reader
|
||||||
|
|
||||||
|
# Check sync lock
|
||||||
|
if sync_service._sync_lock.locked():
|
||||||
|
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
|
||||||
|
|
||||||
|
# Get order from SQLite
|
||||||
|
detail = await sqlite_service.get_order_detail(order_number)
|
||||||
|
if not detail:
|
||||||
|
return {"success": False, "message": "Comanda nu a fost gasita"}
|
||||||
|
|
||||||
|
order_data = detail["order"]
|
||||||
|
status = order_data.get("status", "")
|
||||||
|
if status not in ("ERROR", "SKIPPED"):
|
||||||
|
return {"success": False, "message": f"Retry permis doar pentru ERROR/SKIPPED (status actual: {status})"}
|
||||||
|
|
||||||
|
order_date_str = order_data.get("order_date", "")
|
||||||
|
customer_name = order_data.get("customer_name", "")
|
||||||
|
|
||||||
|
# Parse order date for narrow download window
|
||||||
|
try:
|
||||||
|
order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date()
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
order_date = datetime.now().date() - timedelta(days=1)
|
||||||
|
|
||||||
|
gomag_key = app_settings.get("gomag_api_key") or None
|
||||||
|
gomag_shop = app_settings.get("gomag_api_shop") or None
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
try:
|
||||||
|
today = datetime.now().date()
|
||||||
|
days_back = (today - order_date).days + 1
|
||||||
|
if days_back < 2:
|
||||||
|
days_back = 2
|
||||||
|
|
||||||
|
await gomag_client.download_orders(
|
||||||
|
tmp_dir, days_back=days_back,
|
||||||
|
api_key=gomag_key, api_shop=gomag_shop,
|
||||||
|
limit=200,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Retry download failed for {order_number}: {e}")
|
||||||
|
return {"success": False, "message": f"Eroare download GoMag: {e}"}
|
||||||
|
|
||||||
|
# Find the specific order in downloaded data
|
||||||
|
target_order = None
|
||||||
|
orders, _ = order_reader.read_json_orders(json_dir=tmp_dir)
|
||||||
|
for o in orders:
|
||||||
|
if str(o.number) == str(order_number):
|
||||||
|
target_order = o
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_order:
|
||||||
|
return {"success": False, "message": f"Comanda {order_number} nu a fost gasita in GoMag API"}
|
||||||
|
|
||||||
|
# Import the order
|
||||||
|
id_pol = int(app_settings.get("id_pol") or 0)
|
||||||
|
id_sectie = int(app_settings.get("id_sectie") or 0)
|
||||||
|
id_gestiune = app_settings.get("id_gestiune", "")
|
||||||
|
id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
import_service.import_single_order,
|
||||||
|
target_order, id_pol=id_pol, id_sectie=id_sectie,
|
||||||
|
app_settings=app_settings, id_gestiuni=id_gestiuni
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Retry import failed for {order_number}: {e}")
|
||||||
|
await sqlite_service.upsert_order(
|
||||||
|
sync_run_id="retry",
|
||||||
|
order_number=order_number,
|
||||||
|
order_date=order_date_str,
|
||||||
|
customer_name=customer_name,
|
||||||
|
status="ERROR",
|
||||||
|
error_message=f"Retry failed: {e}",
|
||||||
|
)
|
||||||
|
return {"success": False, "message": f"Eroare import: {e}"}
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
await sqlite_service.upsert_order(
|
||||||
|
sync_run_id="retry",
|
||||||
|
order_number=order_number,
|
||||||
|
order_date=order_date_str,
|
||||||
|
customer_name=customer_name,
|
||||||
|
status="IMPORTED",
|
||||||
|
id_comanda=result.get("id_comanda"),
|
||||||
|
id_partener=result.get("id_partener"),
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
if result.get("id_adresa_facturare") or result.get("id_adresa_livrare"):
|
||||||
|
await sqlite_service.update_import_order_addresses(
|
||||||
|
order_number=order_number,
|
||||||
|
id_adresa_facturare=result.get("id_adresa_facturare"),
|
||||||
|
id_adresa_livrare=result.get("id_adresa_livrare"),
|
||||||
|
)
|
||||||
|
logger.info(f"Retry successful for order {order_number} → IMPORTED")
|
||||||
|
return {"success": True, "message": "Comanda reimportata cu succes", "status": "IMPORTED"}
|
||||||
|
else:
|
||||||
|
error = result.get("error", "Unknown error")
|
||||||
|
await sqlite_service.upsert_order(
|
||||||
|
sync_run_id="retry",
|
||||||
|
order_number=order_number,
|
||||||
|
order_date=order_date_str,
|
||||||
|
customer_name=customer_name,
|
||||||
|
status="ERROR",
|
||||||
|
error_message=f"Retry: {error}",
|
||||||
|
)
|
||||||
|
return {"success": False, "message": f"Import esuat: {error}", "status": "ERROR"}
|
||||||
@@ -739,6 +739,16 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
|||||||
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
|
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
|
||||||
uninvoiced_sqlite = (await cursor.fetchone())[0]
|
uninvoiced_sqlite = (await cursor.fetchone())[0]
|
||||||
|
|
||||||
|
# Uninvoiced > 3 days old
|
||||||
|
uninv_old_clauses = list(base_clauses) + [
|
||||||
|
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
|
||||||
|
"(factura_numar IS NULL OR factura_numar = '')",
|
||||||
|
"order_date < datetime('now', '-3 days')",
|
||||||
|
]
|
||||||
|
uninv_old_where = "WHERE " + " AND ".join(uninv_old_clauses)
|
||||||
|
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
|
||||||
|
uninvoiced_old = (await cursor.fetchone())[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"orders": [dict(r) for r in rows],
|
"orders": [dict(r) for r in rows],
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -754,6 +764,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
|
|||||||
"cancelled": status_counts.get("CANCELLED", 0),
|
"cancelled": status_counts.get("CANCELLED", 0),
|
||||||
"total": sum(status_counts.values()),
|
"total": sum(status_counts.values()),
|
||||||
"uninvoiced_sqlite": uninvoiced_sqlite,
|
"uninvoiced_sqlite": uninvoiced_sqlite,
|
||||||
|
"uninvoiced_old": uninvoiced_old,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
@@ -820,6 +831,20 @@ async def update_order_invoice(order_number: str, serie: str = None,
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_order_price_match(order_number: str, match: bool | None):
|
||||||
|
"""Cache price_match result (True=OK, False=mismatch, None=unavailable)."""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
val = None if match is None else (1 if match else 0)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE orders SET price_match = ?, updated_at = datetime('now') WHERE order_number = ?",
|
||||||
|
(val, order_number),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
async def get_invoiced_imported_orders() -> list:
|
async def get_invoiced_imported_orders() -> list:
|
||||||
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
@@ -949,6 +974,24 @@ async def set_app_setting(key: str, value: str):
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── SKU-based order lookup ────────────────────────
|
||||||
|
|
||||||
|
async def get_skipped_orders_with_sku(sku: str) -> list[str]:
|
||||||
|
"""Get order_numbers of SKIPPED orders that contain the given SKU."""
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT DISTINCT oi.order_number
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN orders o ON o.order_number = oi.order_number
|
||||||
|
WHERE oi.sku = ? AND o.status = 'SKIPPED'
|
||||||
|
""", (sku,))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
# ── Price Sync Runs ───────────────────────────────
|
# ── Price Sync Runs ───────────────────────────────
|
||||||
|
|
||||||
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
|
async def get_price_sync_runs(page: int = 1, per_page: int = 20):
|
||||||
|
|||||||
@@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
|
|||||||
database.pool.release(conn)
|
database.pool.release(conn)
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict:
|
||||||
|
"""Compare GoMag prices with ROA prices for order items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details'
|
||||||
|
(codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}])
|
||||||
|
app_settings: dict with 'id_pol', 'id_pol_productie'
|
||||||
|
conn: Oracle connection (optional, will acquire if None)
|
||||||
|
|
||||||
|
Returns: {
|
||||||
|
"items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}},
|
||||||
|
"summary": {"mismatches": int, "checked": int, "oracle_available": bool}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
id_pol = int(app_settings.get("id_pol", 0) or 0)
|
||||||
|
id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
id_pol = 0
|
||||||
|
id_pol_productie = 0
|
||||||
|
|
||||||
|
def _empty_result(oracle_available: bool) -> dict:
|
||||||
|
return {
|
||||||
|
"items": {
|
||||||
|
idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)}
|
||||||
|
for idx, item in enumerate(items)
|
||||||
|
},
|
||||||
|
"summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available}
|
||||||
|
}
|
||||||
|
|
||||||
|
if not items or not id_pol:
|
||||||
|
return _empty_result(oracle_available=False)
|
||||||
|
|
||||||
|
own_conn = conn is None
|
||||||
|
try:
|
||||||
|
if own_conn:
|
||||||
|
conn = database.get_oracle_connection()
|
||||||
|
|
||||||
|
# Step 1: Collect codmats; use id_articol/cont from codmat_details when already known
|
||||||
|
pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}}
|
||||||
|
all_codmats = set()
|
||||||
|
for item in items:
|
||||||
|
for cd in (item.get("codmat_details") or []):
|
||||||
|
codmat = cd.get("codmat")
|
||||||
|
if not codmat:
|
||||||
|
continue
|
||||||
|
all_codmats.add(codmat)
|
||||||
|
if cd.get("id_articol") and codmat not in pre_resolved:
|
||||||
|
pre_resolved[codmat] = {
|
||||||
|
"id_articol": cd["id_articol"],
|
||||||
|
"cont": cd.get("cont") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 2: Resolve missing id_articols via nom_articole
|
||||||
|
need_resolve = all_codmats - set(pre_resolved.keys())
|
||||||
|
if need_resolve:
|
||||||
|
db_resolved = resolve_codmat_ids(need_resolve, conn=conn)
|
||||||
|
pre_resolved.update(db_resolved)
|
||||||
|
|
||||||
|
codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}}
|
||||||
|
|
||||||
|
# Step 3: Get PRETURI_CU_TVA flag once per policy
|
||||||
|
policies = {id_pol}
|
||||||
|
if id_pol_productie and id_pol_productie != id_pol:
|
||||||
|
policies.add(id_pol_productie)
|
||||||
|
|
||||||
|
pol_cu_tva = {} # {id_pol: bool}
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for pol in policies:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol",
|
||||||
|
{"pol": pol},
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False
|
||||||
|
|
||||||
|
# Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies
|
||||||
|
all_id_articols = list({
|
||||||
|
info["id_articol"]
|
||||||
|
for info in codmat_info.values()
|
||||||
|
if info.get("id_articol")
|
||||||
|
})
|
||||||
|
price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)}
|
||||||
|
|
||||||
|
if all_id_articols:
|
||||||
|
pol_list = list(policies)
|
||||||
|
pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))])
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for i in range(0, len(all_id_articols), 500):
|
||||||
|
batch = all_id_articols[i:i + 500]
|
||||||
|
art_placeholders = ",".join([f":a{j}" for j in range(len(batch))])
|
||||||
|
params = {f"a{j}": aid for j, aid in enumerate(batch)}
|
||||||
|
for k, pol in enumerate(pol_list):
|
||||||
|
params[f"p{k}"] = pol
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV
|
||||||
|
FROM CRM_POLITICI_PRET_ART
|
||||||
|
WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders})
|
||||||
|
""", params)
|
||||||
|
for row in cur:
|
||||||
|
price_map[(row[0], row[1])] = (row[2], row[3])
|
||||||
|
|
||||||
|
# Step 5: Compute pret_roa per item and compare with GoMag price
|
||||||
|
result_items = {}
|
||||||
|
mismatches = 0
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for idx, item in enumerate(items):
|
||||||
|
pret_gomag = float(item.get("price") or 0)
|
||||||
|
result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None}
|
||||||
|
|
||||||
|
codmat_details = item.get("codmat_details") or []
|
||||||
|
if not codmat_details:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_kit = len(codmat_details) > 1 or (
|
||||||
|
len(codmat_details) == 1
|
||||||
|
and float(codmat_details[0].get("cantitate_roa") or 1) > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
pret_roa_total = 0.0
|
||||||
|
all_resolved = True
|
||||||
|
|
||||||
|
for cd in codmat_details:
|
||||||
|
codmat = cd.get("codmat")
|
||||||
|
if not codmat:
|
||||||
|
all_resolved = False
|
||||||
|
break
|
||||||
|
|
||||||
|
info = codmat_info.get(codmat, {})
|
||||||
|
id_articol = info.get("id_articol")
|
||||||
|
if not id_articol:
|
||||||
|
all_resolved = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Dual-policy routing: cont 341/345 → production, else → sales
|
||||||
|
cont = str(info.get("cont") or cd.get("cont") or "").strip()
|
||||||
|
if cont in ("341", "345") and id_pol_productie:
|
||||||
|
pol = id_pol_productie
|
||||||
|
else:
|
||||||
|
pol = id_pol
|
||||||
|
|
||||||
|
price_entry = price_map.get((pol, id_articol))
|
||||||
|
if price_entry is None:
|
||||||
|
all_resolved = False
|
||||||
|
break
|
||||||
|
|
||||||
|
pret, proc_tvav = price_entry
|
||||||
|
proc_tvav = float(proc_tvav or 1.19)
|
||||||
|
|
||||||
|
if pol_cu_tva.get(pol):
|
||||||
|
pret_cu_tva = float(pret or 0)
|
||||||
|
else:
|
||||||
|
pret_cu_tva = float(pret or 0) * proc_tvav
|
||||||
|
|
||||||
|
cantitate_roa = float(cd.get("cantitate_roa") or 1)
|
||||||
|
if is_kit:
|
||||||
|
pret_roa_total += pret_cu_tva * cantitate_roa
|
||||||
|
else:
|
||||||
|
pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items
|
||||||
|
|
||||||
|
if not all_resolved:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pret_roa = round(pret_roa_total, 4)
|
||||||
|
match = abs(pret_gomag - pret_roa) < 0.01
|
||||||
|
result_items[idx]["pret_roa"] = pret_roa
|
||||||
|
result_items[idx]["match"] = match
|
||||||
|
checked += 1
|
||||||
|
if not match:
|
||||||
|
mismatches += 1
|
||||||
|
|
||||||
|
logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches")
|
||||||
|
return {
|
||||||
|
"items": result_items,
|
||||||
|
"summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_prices_for_order failed: {e}")
|
||||||
|
return _empty_result(oracle_available=False)
|
||||||
|
finally:
|
||||||
|
if own_conn and conn:
|
||||||
|
database.pool.release(conn)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
startSyncPolling();
|
startSyncPolling();
|
||||||
wireFilterBar();
|
wireFilterBar();
|
||||||
|
checkFirstTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function initPollInterval() {
|
async function initPollInterval() {
|
||||||
@@ -119,11 +120,33 @@ function updateSyncPanel(data) {
|
|||||||
}
|
}
|
||||||
if (st) {
|
if (st) {
|
||||||
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
|
||||||
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
|
st.style.color = lr.status === 'completed' ? 'var(--success)' : 'var(--error)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkFirstTime() {
|
||||||
|
const welcomeEl = document.getElementById('welcomeCard');
|
||||||
|
if (!welcomeEl) return;
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON('/api/sync/status');
|
||||||
|
if (!data.last_run) {
|
||||||
|
welcomeEl.innerHTML = `<div class="welcome-card">
|
||||||
|
<h5 style="font-family:var(--font-display);margin:0 0 8px">Bine ai venit!</h5>
|
||||||
|
<p class="text-muted mb-2" style="font-size:0.875rem">Configureaza si ruleaza primul sync:</p>
|
||||||
|
<div class="welcome-steps">
|
||||||
|
<span class="welcome-step"><b>1.</b> <a href="${window.ROOT_PATH||''}/settings">Verifica Settings</a></span>
|
||||||
|
<span class="welcome-step"><b>2.</b> Apasa "Start Sync"</span>
|
||||||
|
<span class="welcome-step"><b>3.</b> <a href="${window.ROOT_PATH||''}/missing-skus">Mapeaza SKU-urile lipsa</a></span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
welcomeEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
welcomeEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch(e) { welcomeEl.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
// Wire last-sync-row click → journal (use current running sync if active)
|
// Wire last-sync-row click → journal (use current running sync if active)
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
|
||||||
@@ -201,10 +224,14 @@ async function loadSchedulerStatus() {
|
|||||||
// ── Filter Bar wiring ─────────────────────────────
|
// ── Filter Bar wiring ─────────────────────────────
|
||||||
|
|
||||||
function wireFilterBar() {
|
function wireFilterBar() {
|
||||||
// Period dropdown
|
// Period preset buttons
|
||||||
document.getElementById('periodSelect')?.addEventListener('change', function () {
|
document.querySelectorAll('.preset-btn[data-days]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
const days = this.dataset.days;
|
||||||
const cr = document.getElementById('customRangeInputs');
|
const cr = document.getElementById('customRangeInputs');
|
||||||
if (this.value === 'custom') {
|
if (days === 'custom') {
|
||||||
cr?.classList.add('visible');
|
cr?.classList.add('visible');
|
||||||
} else {
|
} else {
|
||||||
cr?.classList.remove('visible');
|
cr?.classList.remove('visible');
|
||||||
@@ -212,6 +239,7 @@ function wireFilterBar() {
|
|||||||
loadDashOrders();
|
loadDashOrders();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Custom range inputs
|
// Custom range inputs
|
||||||
['periodStart', 'periodEnd'].forEach(id => {
|
['periodStart', 'periodEnd'].forEach(id => {
|
||||||
@@ -260,7 +288,8 @@ function dashSortBy(col) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashOrders() {
|
async function loadDashOrders() {
|
||||||
const periodVal = document.getElementById('periodSelect')?.value || '7';
|
const activePreset = document.querySelector('.preset-btn.active');
|
||||||
|
const periodVal = activePreset?.dataset.days || '3';
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (periodVal === 'custom') {
|
if (periodVal === 'custom') {
|
||||||
@@ -301,11 +330,29 @@ async function loadDashOrders() {
|
|||||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||||
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
|
||||||
|
|
||||||
|
// Attention card
|
||||||
|
const attnEl = document.getElementById('attentionCard');
|
||||||
|
if (attnEl) {
|
||||||
|
const errors = c.error || 0;
|
||||||
|
const unmapped = c.unresolved_skus || 0;
|
||||||
|
const nefact = c.nefacturate || 0;
|
||||||
|
|
||||||
|
if (errors === 0 && unmapped === 0 && nefact === 0) {
|
||||||
|
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
|
||||||
|
} else {
|
||||||
|
let items = [];
|
||||||
|
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
|
||||||
|
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
|
||||||
|
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
|
||||||
|
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById('dashOrdersBody');
|
const tbody = document.getElementById('dashOrdersBody');
|
||||||
const orders = data.orders || [];
|
const orders = data.orders || [];
|
||||||
|
|
||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = orders.map(o => {
|
tbody.innerHTML = orders.map(o => {
|
||||||
const dateStr = fmtDate(o.order_date);
|
const dateStr = fmtDate(o.order_date);
|
||||||
@@ -321,6 +368,7 @@ async function loadDashOrders() {
|
|||||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||||
<td class="text-end fw-bold">${orderTotal}</td>
|
<td class="text-end fw-bold">${orderTotal}</td>
|
||||||
<td class="text-center">${invoiceDot(o)}</td>
|
<td class="text-center">${invoiceDot(o)}</td>
|
||||||
|
<td class="text-center">${priceDot(o)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -340,11 +388,12 @@ async function loadDashOrders() {
|
|||||||
}
|
}
|
||||||
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
|
||||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
||||||
|
const priceMismatch = o.price_match === false ? '<span class="dot dot-red" style="width:6px;height:6px" title="Pret!="></span> ' : '';
|
||||||
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||||
${statusDot(o.status)}
|
${statusDot(o.status)}
|
||||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
||||||
<span class="grow truncate fw-bold">${esc(name)}</span>
|
<span class="grow truncate fw-bold">${esc(name)}</span>
|
||||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '<strong>' + totalStr + '</strong>' : ''}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -432,14 +481,6 @@ function escHtml(s) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
function statusLabelText(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED': return 'Importat';
|
case 'IMPORTED': return 'Importat';
|
||||||
@@ -450,16 +491,10 @@ function statusLabelText(status) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderStatusBadge(status) {
|
function priceDot(order) {
|
||||||
switch ((status || '').toUpperCase()) {
|
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
|
||||||
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
|
if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
|
||||||
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
|
return '<span class="dot dot-gray" title="Neverificat"></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) {
|
function invoiceDot(order) {
|
||||||
@@ -468,22 +503,6 @@ function invoiceDot(order) {
|
|||||||
return '<span class="dot dot-red" title="Nefacturat"></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}</span></div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Refresh Invoices ──────────────────────────────
|
// ── Refresh Invoices ──────────────────────────────
|
||||||
|
|
||||||
async function refreshInvoices() {
|
async function refreshInvoices() {
|
||||||
@@ -509,262 +528,12 @@ async function refreshInvoices() {
|
|||||||
|
|
||||||
// ── Order Detail Modal ────────────────────────────
|
// ── Order Detail Modal ────────────────────────────
|
||||||
|
|
||||||
async function openDashOrderDetail(orderNumber) {
|
function openDashOrderDetail(orderNumber) {
|
||||||
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
_sharedModalQuickMapFn = openDashQuickMap;
|
||||||
document.getElementById('detailCustomer').textContent = '...';
|
renderOrderDetailModal(orderNumber, {
|
||||||
document.getElementById('detailDate').textContent = '';
|
onQuickMap: openDashQuickMap,
|
||||||
document.getElementById('detailStatus').innerHTML = '';
|
onAfterRender: function() { /* nothing extra needed */ }
|
||||||
document.getElementById('detailIdComanda').textContent = '-';
|
|
||||||
document.getElementById('detailIdPartener').textContent = '-';
|
|
||||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center">Se incarca...</td></tr>';
|
|
||||||
document.getElementById('detailError').style.display = 'none';
|
|
||||||
document.getElementById('detailReceipt').innerHTML = '';
|
|
||||||
document.getElementById('detailReceiptMobile').innerHTML = '';
|
|
||||||
const invInfo = document.getElementById('detailInvoiceInfo');
|
|
||||||
if (invInfo) invInfo.style.display = 'none';
|
|
||||||
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 items = data.items || [];
|
|
||||||
if (items.length === 0) {
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Niciun articol</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store items for quick map pre-population
|
|
||||||
window._detailItems = items;
|
|
||||||
|
|
||||||
// Mobile article flat list
|
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
|
||||||
if (mobileContainer) {
|
|
||||||
let mobileHtml = 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));
|
|
||||||
return `<div class="dif-item">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-sku dif-codmat-link" onclick="openDashQuickMap('${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">${fmtNum(valoare)} lei</span>
|
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Transport row (mobile)
|
|
||||||
if (order.delivery_cost > 0) {
|
|
||||||
const tVat = order.transport_vat || '21';
|
|
||||||
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name text-muted">Transport</span>
|
|
||||||
<span class="dif-qty">x1</span>
|
|
||||||
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
|
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discount rows (mobile)
|
|
||||||
if (order.discount_total > 0) {
|
|
||||||
const discSplit = computeDiscountSplit(items, order);
|
|
||||||
if (discSplit) {
|
|
||||||
Object.entries(discSplit)
|
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
|
||||||
.forEach(([rate, amt]) => {
|
|
||||||
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name text-muted">Discount</span>
|
|
||||||
<span class="dif-qty">x\u20131</span>
|
|
||||||
<span class="dif-val">${fmtNum(amt)} lei</span>
|
|
||||||
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
|
||||||
<div class="dif-row">
|
|
||||||
<span class="dif-name text-muted">Discount</span>
|
|
||||||
<span class="dif-qty">x\u20131</span>
|
|
||||||
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
let tableHtml = items.map((item, idx) => {
|
|
||||||
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
|
||||||
return `<tr>
|
|
||||||
<td><code class="codmat-link" onclick="openDashQuickMap('${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 class="text-end">${item.quantity || 0}</td>
|
|
||||||
<td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
|
|
||||||
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
|
|
||||||
<td class="text-end">${fmtNum(valoare)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Transport row
|
|
||||||
if (order.delivery_cost > 0) {
|
|
||||||
const tVat = order.transport_vat || '21';
|
|
||||||
const tCodmat = order.transport_codmat || '';
|
|
||||||
tableHtml += `<tr class="table-light">
|
|
||||||
<td></td><td class="text-muted">Transport</td>
|
|
||||||
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
|
|
||||||
<td class="text-end">1</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
|
||||||
<td class="text-end">${tVat}</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discount rows (split by VAT rate)
|
|
||||||
if (order.discount_total > 0) {
|
|
||||||
const dCodmat = order.discount_codmat || '';
|
|
||||||
const discSplit = computeDiscountSplit(items, order);
|
|
||||||
if (discSplit) {
|
|
||||||
Object.entries(discSplit)
|
|
||||||
.sort(([a], [b]) => Number(a) - Number(b))
|
|
||||||
.forEach(([rate, amt]) => {
|
|
||||||
if (amt > 0) tableHtml += `<tr class="table-light">
|
|
||||||
<td></td><td class="text-muted">Discount</td>
|
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
|
||||||
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(amt)}</td>
|
|
||||||
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${fmtNum(amt)}</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tableHtml += `<tr class="table-light">
|
|
||||||
<td></td><td class="text-muted">Discount</td>
|
|
||||||
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
|
||||||
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(order.discount_total)}</td>
|
|
||||||
<td class="text-end">-</td><td class="text-end">\u2013${fmtNum(order.discount_total)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
|
||||||
|
|
||||||
// Receipt footer (just total)
|
|
||||||
renderReceipt(items, order);
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById('detailError').textContent = err.message;
|
|
||||||
document.getElementById('detailError').style.display = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtNum(v) {
|
|
||||||
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeDiscountSplit(items, order) {
|
|
||||||
if (order.discount_split && typeof order.discount_split === 'object')
|
|
||||||
return order.discount_split;
|
|
||||||
|
|
||||||
// Compute proportionally from items by VAT rate
|
|
||||||
const byRate = {};
|
|
||||||
items.forEach(item => {
|
|
||||||
const rate = item.vat != null ? Number(item.vat) : null;
|
|
||||||
if (rate === null) return;
|
|
||||||
if (!byRate[rate]) byRate[rate] = 0;
|
|
||||||
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
|
|
||||||
});
|
|
||||||
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
|
|
||||||
if (rates.length === 0) return null;
|
|
||||||
|
|
||||||
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
|
|
||||||
if (grandTotal <= 0) return null;
|
|
||||||
|
|
||||||
const split = {};
|
|
||||||
let remaining = order.discount_total;
|
|
||||||
rates.forEach((rate, i) => {
|
|
||||||
if (i === rates.length - 1) {
|
|
||||||
split[rate] = Math.round(remaining * 100) / 100;
|
|
||||||
} else {
|
|
||||||
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
|
|
||||||
split[rate] = amt;
|
|
||||||
remaining -= amt;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return split;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReceipt(items, order) {
|
|
||||||
const desktop = document.getElementById('detailReceipt');
|
|
||||||
const mobile = document.getElementById('detailReceiptMobile');
|
|
||||||
if (!items.length) {
|
|
||||||
desktop.innerHTML = '';
|
|
||||||
mobile.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
|
|
||||||
const discount = Number(order.discount_total || 0);
|
|
||||||
const transport = Number(order.delivery_cost || 0);
|
|
||||||
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
|
|
||||||
|
|
||||||
// Desktop: full labels
|
|
||||||
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
|
||||||
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
|
||||||
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
|
||||||
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
|
||||||
desktop.innerHTML = dHtml;
|
|
||||||
|
|
||||||
// Mobile: shorter labels
|
|
||||||
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
|
||||||
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
|
||||||
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
|
||||||
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
|
||||||
mobile.innerHTML = mHtml;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quick Map Modal (uses shared openQuickMap) ───
|
// ── Quick Map Modal (uses shared openQuickMap) ───
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ let ordersPage = 1;
|
|||||||
let ordersSortColumn = 'order_date';
|
let ordersSortColumn = 'order_date';
|
||||||
let ordersSortDirection = 'desc';
|
let ordersSortDirection = 'desc';
|
||||||
|
|
||||||
function fmtCost(v) {
|
|
||||||
return v > 0 ? Number(v).toFixed(2) : '–';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(startedAt, finishedAt) {
|
function fmtDuration(startedAt, finishedAt) {
|
||||||
if (!startedAt || !finishedAt) return '-';
|
if (!startedAt || !finishedAt) return '-';
|
||||||
const diffMs = new Date(finishedAt) - new Date(startedAt);
|
const diffMs = new Date(finishedAt) - new Date(startedAt);
|
||||||
@@ -23,24 +19,13 @@ function fmtDuration(startedAt, finishedAt) {
|
|||||||
|
|
||||||
function runStatusBadge(status) {
|
function runStatusBadge(status) {
|
||||||
switch ((status || '').toLowerCase()) {
|
switch ((status || '').toLowerCase()) {
|
||||||
case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
|
case 'completed': return '<span style="color:var(--success);font-weight:600">completed</span>';
|
||||||
case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
|
case 'running': return '<span style="color:var(--info);font-weight:600">running</span>';
|
||||||
case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
|
case 'failed': return '<span style="color:var(--error);font-weight:600">failed</span>';
|
||||||
default: return `<span style="font-weight:600">${esc(status)}</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) {
|
function logStatusText(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
case 'IMPORTED': return 'Importat';
|
case 'IMPORTED': return 'Importat';
|
||||||
@@ -156,7 +141,11 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = orders.map((o, i) => {
|
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
|
||||||
|
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
|
||||||
|
function orderRow(o, i) {
|
||||||
const dateStr = fmtDate(o.order_date);
|
const dateStr = fmtDate(o.order_date);
|
||||||
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
||||||
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
|
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
|
||||||
@@ -170,7 +159,31 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||||
<td class="text-end fw-bold">${orderTotal}</td>
|
<td class="text-end fw-bold">${orderTotal}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
// Show problem orders first (always visible)
|
||||||
|
problemOrders.forEach((o, i) => { html += orderRow(o, i); });
|
||||||
|
otherOrders.forEach((o, i) => { html += orderRow(o, problemOrders.length + i); });
|
||||||
|
|
||||||
|
// Collapsible OK orders
|
||||||
|
if (okOrders.length > 0) {
|
||||||
|
const toggleId = 'okOrdersCollapse_' + Date.now();
|
||||||
|
html += `<tr><td colspan="9" class="p-0">
|
||||||
|
<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
|
||||||
|
▶ ${okOrders.length} comenzi importate cu succes
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<tbody>
|
||||||
|
${okOrders.map((o, i) => orderRow(o, problemOrders.length + otherOrders.length + i)).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile flat rows
|
// Mobile flat rows
|
||||||
@@ -179,7 +192,11 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
if (orders.length === 0) {
|
if (orders.length === 0) {
|
||||||
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
|
||||||
} else {
|
} else {
|
||||||
mobileList.innerHTML = orders.map(o => {
|
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
|
||||||
|
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
|
||||||
|
|
||||||
|
function mobileRow(o) {
|
||||||
const d = o.order_date || '';
|
const d = o.order_date || '';
|
||||||
let dateFmt = '-';
|
let dateFmt = '-';
|
||||||
if (d.length >= 10) {
|
if (d.length >= 10) {
|
||||||
@@ -189,11 +206,26 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
|
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">
|
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||||
${statusDot(o.status)}
|
${statusDot(o.status)}
|
||||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
<span style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
|
||||||
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</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>
|
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}
|
||||||
|
|
||||||
|
let mobileHtml = '';
|
||||||
|
problemOrders.forEach(o => { mobileHtml += mobileRow(o); });
|
||||||
|
otherOrders.forEach(o => { mobileHtml += mobileRow(o); });
|
||||||
|
|
||||||
|
if (okOrders.length > 0) {
|
||||||
|
mobileHtml += `<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
|
||||||
|
▶ ${okOrders.length} comenzi importate cu succes
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
|
${okOrders.map(o => mobileRow(o)).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileList.innerHTML = mobileHtml;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,125 +328,17 @@ async function fetchTextLog(runId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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}</span></div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Order Detail Modal (R9) ─────────────────────
|
// ── Order Detail Modal (R9) ─────────────────────
|
||||||
|
|
||||||
async function openOrderDetail(orderNumber) {
|
function openOrderDetail(orderNumber) {
|
||||||
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
|
_sharedModalQuickMapFn = function(sku, productName, orderNum, itemIdx) {
|
||||||
document.getElementById('detailCustomer').textContent = '...';
|
openLogsQuickMap(sku, productName, orderNum);
|
||||||
document.getElementById('detailDate').textContent = '';
|
};
|
||||||
document.getElementById('detailStatus').innerHTML = '';
|
renderOrderDetailModal(orderNumber, {
|
||||||
document.getElementById('detailIdComanda').textContent = '-';
|
onQuickMap: function(sku, productName, orderNum, itemIdx) {
|
||||||
document.getElementById('detailIdPartener').textContent = '-';
|
openLogsQuickMap(sku, productName, orderNum);
|
||||||
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="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
|
|
||||||
: `<span class="dif-codmat-link" onclick="openLogsQuickMap('${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="openLogsQuickMap('${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 (uses shared openQuickMap) ───
|
// ── Quick Map Modal (uses shared openQuickMap) ───
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function renderTable(mappings, showDeleted) {
|
|||||||
? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></span>`
|
? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></span>`
|
||||||
: '';
|
: '';
|
||||||
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
|
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}">
|
html += `<div class="flat-row" style="background:var(--surface-raised);font-weight:600;border-top:1px solid var(--border);${inactiveStyle}">
|
||||||
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
<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})"`}
|
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
|
||||||
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
|
title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
|
||||||
@@ -135,7 +135,7 @@ function renderTable(mappings, showDeleted) {
|
|||||||
// After last CODMAT of a kit, add total row
|
// After last CODMAT of a kit, add total row
|
||||||
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
|
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
|
||||||
if (isLastOfKit) {
|
if (isLastOfKit) {
|
||||||
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed #e5e7eb"></div>`;
|
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed var(--border)"></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
prevSku = m.sku;
|
prevSku = m.sku;
|
||||||
@@ -176,7 +176,7 @@ async function loadKitPrices(sku, container) {
|
|||||||
if (spinner) spinner.style.display = '';
|
if (spinner) spinner.style.display = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/prices`);
|
const res = await fetch(`/api/mappings/prices?sku=${encodeURIComponent(sku)}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
|
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
|
||||||
@@ -523,7 +523,7 @@ function showInlineAddRow() {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.id = 'inlineAddRow';
|
row.id = 'inlineAddRow';
|
||||||
row.className = 'flat-row';
|
row.className = 'flat-row';
|
||||||
row.style.background = '#eff6ff';
|
row.style.background = 'var(--info-light)';
|
||||||
row.style.gap = '0.5rem';
|
row.style.gap = '0.5rem';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dark mode toggle
|
||||||
|
const darkToggle = document.getElementById('settDarkMode');
|
||||||
|
if (darkToggle) {
|
||||||
|
darkToggle.checked = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
darkToggle.addEventListener('change', () => {
|
||||||
|
if (typeof toggleDarkMode === 'function') toggleDarkMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Catalog sync toggle
|
// Catalog sync toggle
|
||||||
const catChk = document.getElementById('settCatalogSyncEnabled');
|
const catChk = document.getElementById('settCatalogSyncEnabled');
|
||||||
if (catChk) catChk.addEventListener('change', () => {
|
if (catChk) catChk.addEventListener('change', () => {
|
||||||
@@ -191,14 +200,14 @@ async function saveSettings() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const resultEl = document.getElementById('settSaveResult');
|
const resultEl = document.getElementById('settSaveResult');
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = '#16a34a'; }
|
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = 'var(--success)'; }
|
||||||
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
|
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
|
||||||
} else {
|
} else {
|
||||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = '#dc2626'; }
|
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = 'var(--error)'; }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const resultEl = document.getElementById('settSaveResult');
|
const resultEl = document.getElementById('settSaveResult');
|
||||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = '#dc2626'; }
|
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = 'var(--error)'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function renderMobileSegmented(containerId, pills, onSelect) {
|
|||||||
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';
|
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 => {
|
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 cls = p.active ? 'btn seg-active' : 'btn btn-outline-secondary';
|
||||||
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
|
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>`;
|
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>`;
|
}).join('')}</div>`;
|
||||||
@@ -344,6 +344,40 @@ async function saveQuickMapping() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
|
||||||
if (_qmOnSave) _qmOnSave(sku, mappings);
|
if (_qmOnSave) _qmOnSave(sku, mappings);
|
||||||
|
// Check for SKIPPED orders that can now be imported
|
||||||
|
try {
|
||||||
|
const pendingRes = await fetch(`/api/orders/by-sku/${encodeURIComponent(sku)}/pending`);
|
||||||
|
const pendingData = await pendingRes.json();
|
||||||
|
if (pendingData.count > 0) {
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.className = 'alert alert-info d-flex align-items-center gap-2 mt-2';
|
||||||
|
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
|
||||||
|
banner.innerHTML = `<i class="bi bi-arrow-clockwise"></i> <span>${pendingData.count} comenzi SKIPPED pot fi importate acum</span> <button class="btn btn-sm btn-primary ms-auto" id="batchRetryBtn">Importa</button> <button class="btn btn-sm btn-outline-secondary" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
|
||||||
|
document.getElementById('batchRetryBtn').onclick = async function() {
|
||||||
|
this.disabled = true;
|
||||||
|
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
try {
|
||||||
|
const retryRes = await fetch('/api/orders/batch-retry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({order_numbers: pendingData.order_numbers})
|
||||||
|
});
|
||||||
|
const retryData = await retryRes.json();
|
||||||
|
banner.className = retryData.errors > 0 ? 'alert alert-warning d-flex align-items-center gap-2 mt-2' : 'alert alert-success d-flex align-items-center gap-2 mt-2';
|
||||||
|
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
|
||||||
|
banner.innerHTML = `<i class="bi bi-check-circle"></i> ${esc(retryData.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
setTimeout(() => banner.remove(), 5000);
|
||||||
|
if (typeof loadDashOrders === 'function') loadDashOrders();
|
||||||
|
} catch(e) {
|
||||||
|
banner.innerHTML = `Eroare: ${esc(e.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => { if (banner.parentElement) banner.remove(); }, 15000);
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore */ }
|
||||||
} else {
|
} else {
|
||||||
alert('Eroare: ' + (data.error || 'Unknown'));
|
alert('Eroare: ' + (data.error || 'Unknown'));
|
||||||
}
|
}
|
||||||
@@ -352,6 +386,415 @@ async function saveQuickMapping() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers (moved from dashboard.js/logs.js) ─
|
||||||
|
|
||||||
|
function fmtCost(v) {
|
||||||
|
return v > 0 ? Number(v).toFixed(2) : '–';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(v) {
|
||||||
|
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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}</span></div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDiscountSplit(items, order) {
|
||||||
|
if (order.discount_split && typeof order.discount_split === 'object')
|
||||||
|
return order.discount_split;
|
||||||
|
|
||||||
|
const byRate = {};
|
||||||
|
items.forEach(item => {
|
||||||
|
const rate = item.vat != null ? Number(item.vat) : null;
|
||||||
|
if (rate === null) return;
|
||||||
|
if (!byRate[rate]) byRate[rate] = 0;
|
||||||
|
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
|
});
|
||||||
|
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
|
||||||
|
if (rates.length === 0) return null;
|
||||||
|
|
||||||
|
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
|
||||||
|
if (grandTotal <= 0) return null;
|
||||||
|
|
||||||
|
const split = {};
|
||||||
|
let remaining = order.discount_total;
|
||||||
|
rates.forEach((rate, i) => {
|
||||||
|
if (i === rates.length - 1) {
|
||||||
|
split[rate] = Math.round(remaining * 100) / 100;
|
||||||
|
} else {
|
||||||
|
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
|
||||||
|
split[rate] = amt;
|
||||||
|
remaining -= amt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderReceipt(items, order) {
|
||||||
|
const desktop = document.getElementById('detailReceipt');
|
||||||
|
const mobile = document.getElementById('detailReceiptMobile');
|
||||||
|
if (!desktop && !mobile) return;
|
||||||
|
if (!items.length) {
|
||||||
|
if (desktop) desktop.innerHTML = '';
|
||||||
|
if (mobile) mobile.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
|
||||||
|
const discount = Number(order.discount_total || 0);
|
||||||
|
const transport = Number(order.delivery_cost || 0);
|
||||||
|
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
|
||||||
|
|
||||||
|
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
||||||
|
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
||||||
|
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
||||||
|
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
||||||
|
if (desktop) desktop.innerHTML = dHtml;
|
||||||
|
|
||||||
|
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
|
||||||
|
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
|
||||||
|
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
|
||||||
|
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
|
||||||
|
if (mobile) mobile.innerHTML = mHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order Detail Modal (shared) ──────────────────
|
||||||
|
/**
|
||||||
|
* Render and show the order detail modal.
|
||||||
|
* @param {string} orderNumber
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {function} opts.onQuickMap - (sku, productName, orderNumber, itemIdx) => void
|
||||||
|
* @param {function} [opts.onAfterRender] - (order, items) => void
|
||||||
|
*/
|
||||||
|
async function renderOrderDetailModal(orderNumber, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
// Reset modal state
|
||||||
|
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="9" class="text-center">Se incarca...</td></tr>';
|
||||||
|
document.getElementById('detailError').style.display = 'none';
|
||||||
|
const retryBtn = document.getElementById('detailRetryBtn');
|
||||||
|
if (retryBtn) { retryBtn.style.display = 'none'; retryBtn.disabled = false; retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta'; retryBtn.className = 'btn btn-sm btn-outline-primary'; }
|
||||||
|
const receiptEl = document.getElementById('detailReceipt');
|
||||||
|
if (receiptEl) receiptEl.innerHTML = '';
|
||||||
|
const receiptMEl = document.getElementById('detailReceiptMobile');
|
||||||
|
if (receiptMEl) receiptMEl.innerHTML = '';
|
||||||
|
const invInfo = document.getElementById('detailInvoiceInfo');
|
||||||
|
if (invInfo) invInfo.style.display = 'none';
|
||||||
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||||
|
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||||
|
const priceCheckEl = document.getElementById('detailPriceCheck');
|
||||||
|
if (priceCheckEl) priceCheckEl.innerHTML = '';
|
||||||
|
const reconEl = document.getElementById('detailInvoiceRecon');
|
||||||
|
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Price check badge
|
||||||
|
const priceCheckEl = document.getElementById('detailPriceCheck');
|
||||||
|
if (priceCheckEl) {
|
||||||
|
const pc = order.price_check;
|
||||||
|
if (!pc || pc.oracle_available === false) {
|
||||||
|
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--cancelled-light);color:var(--text-muted)">Preturi ROA indisponibile</span>';
|
||||||
|
} else if (pc.mismatches === 0) {
|
||||||
|
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Preturi OK</span>';
|
||||||
|
} else {
|
||||||
|
priceCheckEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">${pc.mismatches} diferente de pret</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
|
||||||
|
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
|
||||||
|
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
|
||||||
|
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
|
||||||
|
|
||||||
|
// Invoice info
|
||||||
|
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 = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice reconciliation
|
||||||
|
const reconEl = document.getElementById('detailInvoiceRecon');
|
||||||
|
if (reconEl && inv && inv.reconciliation) {
|
||||||
|
const r = inv.reconciliation;
|
||||||
|
if (r.match) {
|
||||||
|
reconEl.innerHTML = `<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Total factura OK (${fmtNum(r.invoice_total)} lei)</span>`;
|
||||||
|
} else {
|
||||||
|
const sign = r.difference > 0 ? '+' : '';
|
||||||
|
reconEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">Diferenta: ${sign}${fmtNum(r.difference)} lei</span>
|
||||||
|
<small class="text-muted ms-2">Factura: ${fmtNum(r.invoice_total)} | Comanda: ${fmtNum(r.order_total)}</small>`;
|
||||||
|
}
|
||||||
|
reconEl.style.display = '';
|
||||||
|
} else if (reconEl) {
|
||||||
|
reconEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.error_message) {
|
||||||
|
document.getElementById('detailError').textContent = order.error_message;
|
||||||
|
document.getElementById('detailError').style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = data.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store items for quick map pre-population
|
||||||
|
window._detailItems = items;
|
||||||
|
|
||||||
|
const qmFn = opts.onQuickMap ? opts.onQuickMap.name || '_sharedQuickMap' : null;
|
||||||
|
|
||||||
|
// Mobile article flat list
|
||||||
|
if (mobileContainer) {
|
||||||
|
let mobileHtml = 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));
|
||||||
|
const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : '';
|
||||||
|
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
||||||
|
const priceMismatchHtml = priceInfo.match === false
|
||||||
|
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>`
|
||||||
|
: '';
|
||||||
|
return `<div class="dif-item">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
|
||||||
|
${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">${fmtNum(valoare)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
||||||
|
</div>
|
||||||
|
${priceMismatchHtml}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Transport row (mobile)
|
||||||
|
if (order.delivery_cost > 0) {
|
||||||
|
const tVat = order.transport_vat || '21';
|
||||||
|
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Transport</span>
|
||||||
|
<span class="dif-qty">x1</span>
|
||||||
|
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount rows (mobile)
|
||||||
|
if (order.discount_total > 0) {
|
||||||
|
const discSplit = computeDiscountSplit(items, order);
|
||||||
|
if (discSplit) {
|
||||||
|
Object.entries(discSplit)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.forEach(([rate, amt]) => {
|
||||||
|
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Discount</span>
|
||||||
|
<span class="dif-qty">x\u20131</span>
|
||||||
|
<span class="dif-val">${fmtNum(amt)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Discount</span>
|
||||||
|
<span class="dif-qty">x\u20131</span>
|
||||||
|
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop items table
|
||||||
|
const clickAttrFn = (item, idx) => opts.onQuickMap
|
||||||
|
? `onclick="_sharedModalQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare"`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
let tableHtml = items.map((item, idx) => {
|
||||||
|
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
|
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
|
||||||
|
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '–';
|
||||||
|
let matchDot, rowStyle;
|
||||||
|
if (priceInfo.pret_roa == null && priceInfo.match == null) {
|
||||||
|
matchDot = '<span class="dot dot-gray"></span>';
|
||||||
|
rowStyle = '';
|
||||||
|
} else if (priceInfo.match === false) {
|
||||||
|
matchDot = '<span class="dot dot-red"></span>';
|
||||||
|
rowStyle = ' style="background:var(--error-light)"';
|
||||||
|
} else {
|
||||||
|
matchDot = '<span class="dot dot-green"></span>';
|
||||||
|
rowStyle = '';
|
||||||
|
}
|
||||||
|
return `<tr${rowStyle}>
|
||||||
|
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
|
||||||
|
<td>${esc(item.product_name || '-')}</td>
|
||||||
|
<td>${renderCodmatCell(item)}</td>
|
||||||
|
<td class="text-end">${item.quantity || 0}</td>
|
||||||
|
<td class="text-end font-data">${item.price != null ? fmtNum(item.price) : '-'}</td>
|
||||||
|
<td class="text-end font-data">${pretRoaHtml}</td>
|
||||||
|
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
|
||||||
|
<td class="text-end font-data">${fmtNum(valoare)}</td>
|
||||||
|
<td class="text-center">${matchDot}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Transport row
|
||||||
|
if (order.delivery_cost > 0) {
|
||||||
|
const tVat = order.transport_vat || '21';
|
||||||
|
const tCodmat = order.transport_codmat || '';
|
||||||
|
tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Transport</td>
|
||||||
|
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount rows (split by VAT rate)
|
||||||
|
if (order.discount_total > 0) {
|
||||||
|
const dCodmat = order.discount_codmat || '';
|
||||||
|
const discSplit = computeDiscountSplit(items, order);
|
||||||
|
if (discSplit) {
|
||||||
|
Object.entries(discSplit)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.forEach(([rate, amt]) => {
|
||||||
|
if (amt > 0) tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Discount</td>
|
||||||
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Discount</td>
|
||||||
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(order.discount_total)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="text-end">-</td><td class="text-end font-data">\u2013${fmtNum(order.discount_total)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
||||||
|
_renderReceipt(items, order);
|
||||||
|
|
||||||
|
// Retry button (only for ERROR/SKIPPED orders)
|
||||||
|
const retryBtn = document.getElementById('detailRetryBtn');
|
||||||
|
if (retryBtn) {
|
||||||
|
const canRetry = ['ERROR', 'SKIPPED'].includes((order.status || '').toUpperCase());
|
||||||
|
retryBtn.style.display = canRetry ? '' : 'none';
|
||||||
|
if (canRetry) {
|
||||||
|
retryBtn.onclick = async () => {
|
||||||
|
retryBtn.disabled = true;
|
||||||
|
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
|
||||||
|
retryBtn.className = 'btn btn-sm btn-success';
|
||||||
|
// Refresh modal after short delay
|
||||||
|
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
|
||||||
|
} else {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
|
||||||
|
retryBtn.className = 'btn btn-sm btn-danger';
|
||||||
|
setTimeout(() => {
|
||||||
|
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
|
||||||
|
retryBtn.className = 'btn btn-sm btn-outline-primary';
|
||||||
|
retryBtn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
retryBtn.innerHTML = 'Eroare: ' + err.message;
|
||||||
|
retryBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.onAfterRender) opts.onAfterRender(order, items);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('detailError').textContent = err.message;
|
||||||
|
document.getElementById('detailError').style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global quick map dispatcher — set by each page
|
||||||
|
let _sharedModalQuickMapFn = null;
|
||||||
|
function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) {
|
||||||
|
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dot helper ────────────────────────────────────
|
// ── Dot helper ────────────────────────────────────
|
||||||
function statusDot(status) {
|
function statusDot(status) {
|
||||||
switch ((status || '').toUpperCase()) {
|
switch ((status || '').toUpperCase()) {
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ro" style="color-scheme: light">
|
<html lang="ro">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}GoMag Import Manager{% endblock %}</title>
|
<title>{% block title %}GoMag Import Manager{% endblock %}</title>
|
||||||
|
<!-- FOUC prevention: apply saved theme before any rendering -->
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('theme');
|
||||||
|
if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
} catch(e) {}
|
||||||
|
</script>
|
||||||
|
<!-- Fonts (DESIGN.md) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
{% set rp = request.scope.get('root_path', '') %}
|
{% set rp = request.scope.get('root_path', '') %}
|
||||||
<link href="{{ rp }}/static/css/style.css?v=17" rel="stylesheet">
|
<link href="{{ rp }}/static/css/style.css?v=25" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Navbar -->
|
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||||
<nav class="top-navbar">
|
<nav class="top-navbar">
|
||||||
<div class="navbar-brand">GoMag Import</div>
|
<div class="navbar-brand">GoMag Import</div>
|
||||||
<div class="navbar-links">
|
<div class="navbar-links">
|
||||||
@@ -20,10 +32,22 @@
|
|||||||
<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 }}/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>
|
<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>
|
</div>
|
||||||
|
<button class="dark-toggle" onclick="toggleDarkMode()" title="Comuta tema" aria-label="Comuta tema intunecata">
|
||||||
|
<i class="bi bi-sun-fill"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Bottom Nav (mobile only, shown via CSS) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<a href="{{ rp }}/" class="bottom-nav-item {% block bnav_dashboard %}{% endblock %}"><i class="bi bi-speedometer2"></i><span>Dashboard</span></a>
|
||||||
|
<a href="{{ rp }}/mappings" class="bottom-nav-item {% block bnav_mappings %}{% endblock %}"><i class="bi bi-arrow-left-right"></i><span>Mapari</span></a>
|
||||||
|
<a href="{{ rp }}/missing-skus" class="bottom-nav-item {% block bnav_missing %}{% endblock %}"><i class="bi bi-exclamation-triangle"></i><span>Lipsa</span></a>
|
||||||
|
<a href="{{ rp }}/logs" class="bottom-nav-item {% block bnav_logs %}{% endblock %}"><i class="bi bi-journal-text"></i><span>Jurnale</span></a>
|
||||||
|
<a href="{{ rp }}/settings" class="bottom-nav-item {% block bnav_settings %}{% endblock %}"><i class="bi bi-gear"></i><span>Setari</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="main-content">
|
<main class="main-content {% block main_class %}{% endblock %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -39,7 +63,7 @@
|
|||||||
<div style="margin-bottom:8px; font-size:0.85rem">
|
<div style="margin-bottom:8px; font-size:0.85rem">
|
||||||
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
|
<div class="qm-row" style="font-size:0.7rem; color:var(--text-muted); padding:0 0 2px">
|
||||||
<span style="flex:1">CODMAT</span>
|
<span style="flex:1">CODMAT</span>
|
||||||
<span style="width:70px">Cant.</span>
|
<span style="width:70px">Cant.</span>
|
||||||
<span style="width:30px"></span>
|
<span style="width:30px"></span>
|
||||||
@@ -59,9 +83,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared 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><span id="detailPriceCheck" class="ms-2"></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 id="detailInvoiceRecon" class="mt-1" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 class="text-end">Cant.</th>
|
||||||
|
<th class="text-end">Pret GoMag</th>
|
||||||
|
<th class="text-end">Pret ROA</th>
|
||||||
|
<th class="text-end">TVA%</th>
|
||||||
|
<th class="text-end">Valoare</th>
|
||||||
|
<th class="text-center">✓</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="detailItemsBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-md-none" id="detailItemsMobile"></div>
|
||||||
|
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
||||||
|
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="detailRetryBtn" class="btn btn-sm btn-outline-primary" style="display:none"><i class="bi bi-arrow-clockwise"></i> Reimporta</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="{{ rp }}/static/js/shared.js?v=12"></script>
|
<script src="{{ rp }}/static/js/shared.js?v=20"></script>
|
||||||
|
<script>
|
||||||
|
// Dark mode toggle
|
||||||
|
function toggleDarkMode() {
|
||||||
|
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
var newTheme = isDark ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', newTheme);
|
||||||
|
try { localStorage.setItem('theme', newTheme); } catch(e) {}
|
||||||
|
updateDarkToggleIcon();
|
||||||
|
// Sync settings page toggle if present
|
||||||
|
var settToggle = document.getElementById('settDarkMode');
|
||||||
|
if (settToggle) settToggle.checked = (newTheme === 'dark');
|
||||||
|
}
|
||||||
|
function updateDarkToggleIcon() {
|
||||||
|
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
document.querySelectorAll('.dark-toggle i').forEach(function(el) {
|
||||||
|
el.className = isDark ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateDarkToggleIcon();
|
||||||
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dashboard - GoMag Import{% endblock %}
|
{% block title %}Dashboard - GoMag Import{% endblock %}
|
||||||
{% block nav_dashboard %}active{% endblock %}
|
{% block nav_dashboard %}active{% endblock %}
|
||||||
|
{% block bnav_dashboard %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-4">Panou de Comanda</h4>
|
<h4 class="mb-4">Panou de Comanda</h4>
|
||||||
|
|
||||||
|
<div id="welcomeCard" style="display:none"></div>
|
||||||
|
|
||||||
<!-- Sync Card (unified two-row panel) -->
|
<!-- Sync Card (unified two-row panel) -->
|
||||||
<div class="sync-card">
|
<div class="sync-card">
|
||||||
<!-- TOP ROW: Status + Controls -->
|
<!-- TOP ROW: Status + Controls -->
|
||||||
@@ -48,19 +51,17 @@
|
|||||||
<span>Comenzi</span>
|
<span>Comenzi</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body py-2 px-3">
|
<div class="card-body py-2 px-3">
|
||||||
|
<div id="attentionCard"></div>
|
||||||
<div class="filter-bar" id="ordersFilterBar">
|
<div class="filter-bar" id="ordersFilterBar">
|
||||||
<!-- Period dropdown -->
|
<!-- Period preset buttons -->
|
||||||
<select id="periodSelect" class="select-compact">
|
<div class="period-presets">
|
||||||
<option value="1">1 zi</option>
|
<button class="preset-btn" data-days="1">Azi</button>
|
||||||
<option value="2">2 zile</option>
|
<button class="preset-btn active" data-days="3">3 zile</button>
|
||||||
<option value="3">3 zile</option>
|
<button class="preset-btn" data-days="7">7 zile</button>
|
||||||
<option value="7" selected>7 zile</option>
|
<button class="preset-btn" data-days="30">30 zile</button>
|
||||||
<option value="30">30 zile</option>
|
<button class="preset-btn" data-days="custom">Custom</button>
|
||||||
<option value="90">3 luni</option>
|
</div>
|
||||||
<option value="0">Toate</option>
|
<!-- Custom date range (hidden until 'Custom' clicked) -->
|
||||||
<option value="custom">Perioada personalizata...</option>
|
|
||||||
</select>
|
|
||||||
<!-- Custom date range (hidden until 'custom' selected) -->
|
|
||||||
<div class="period-custom-range" id="customRangeInputs">
|
<div class="period-custom-range" id="customRangeInputs">
|
||||||
<input type="date" id="periodStart" class="select-compact">
|
<input type="date" id="periodStart" class="select-compact">
|
||||||
<span>—</span>
|
<span>—</span>
|
||||||
@@ -77,8 +78,8 @@
|
|||||||
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
|
<button class="filter-pill d-none d-md-inline-flex" data-status="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">↻</button>
|
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">↻</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-md-none mb-2 d-flex align-items-center gap-2">
|
<div class="d-md-none mb-2 d-flex align-items-center gap-2" style="max-width:100%;overflow:hidden">
|
||||||
<div class="flex-grow-1" id="dashMobileSeg"></div>
|
<div class="flex-grow-1" id="dashMobileSeg" style="min-width:0;overflow-x:auto"></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">↻</button>
|
<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">↻</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +99,11 @@
|
|||||||
<th class="text-end">Discount</th>
|
<th class="text-end">Discount</th>
|
||||||
<th class="text-end">Total</th>
|
<th class="text-end">Total</th>
|
||||||
<th style="width:28px" title="Facturat">F</th>
|
<th style="width:28px" title="Facturat">F</th>
|
||||||
|
<th class="text-center" style="width:30px" title="Preturi ROA">₽</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="dashOrdersBody">
|
<tbody id="dashOrdersBody">
|
||||||
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
|
<tr><td colspan="10" class="text-center text-muted py-3">Se incarca...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,64 +111,8 @@
|
|||||||
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
|
<div id="dashPagination" class="pag-strip pag-strip-bottom"></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 id="detailInvoiceInfo" style="display:none; margin-top:4px;">
|
|
||||||
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
|
|
||||||
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div 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 class="text-end">Cant.</th>
|
|
||||||
<th class="text-end">Pret</th>
|
|
||||||
<th class="text-end">TVA%</th>
|
|
||||||
<th class="text-end">Valoare</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="detailItemsBody">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
|
|
||||||
</div>
|
|
||||||
<div class="d-md-none" id="detailItemsMobile"></div>
|
|
||||||
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
|
||||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
<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) -->
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=25"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=32"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Jurnale Import - GoMag Import{% endblock %}
|
{% block title %}Jurnale Import - GoMag Import{% endblock %}
|
||||||
{% block nav_logs %}active{% endblock %}
|
{% block nav_logs %}active{% endblock %}
|
||||||
|
{% block bnav_logs %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-4">Jurnale Import</h4>
|
<h4 class="mb-4">Jurnale Import</h4>
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
<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="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
|
||||||
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
|
<button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
|
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
|
||||||
|
|
||||||
<!-- Orders table -->
|
<!-- Orders table -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
@@ -96,65 +97,10 @@
|
|||||||
</div>
|
</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) -->
|
|
||||||
<!-- Hidden field for pre-selected run from URL/server -->
|
<!-- Hidden field for pre-selected run from URL/server -->
|
||||||
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=11"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=14"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Mapari SKU - GoMag Import{% endblock %}
|
{% block title %}Mapari SKU - GoMag Import{% endblock %}
|
||||||
{% block nav_mappings %}active{% endblock %}
|
{% block nav_mappings %}active{% endblock %}
|
||||||
|
{% block bnav_mappings %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h4 class="mb-0">Mapari SKU</h4>
|
<h4 class="mb-0">Mapari SKU</h4>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<!-- Desktop buttons -->
|
<!-- Desktop Import/Export dropdown -->
|
||||||
<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>
|
<div class="dropdown d-none d-md-inline-block">
|
||||||
<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-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
<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>
|
<i class="bi bi-file-earmark-spreadsheet"></i> Import/Export
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="downloadTemplate(); return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Download 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><hr class="dropdown-divider"></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>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<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-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>
|
<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 -->
|
<!-- Mobile ⋯ dropdown -->
|
||||||
@@ -150,5 +159,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=11"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=14"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
|
||||||
{% block nav_missing %}active{% endblock %}
|
{% block nav_missing %}active{% endblock %}
|
||||||
|
{% block bnav_missing %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Setari - GoMag Import{% endblock %}
|
{% block title %}Setari - GoMag Import{% endblock %}
|
||||||
{% block nav_settings %}active{% endblock %}
|
{% block nav_settings %}active{% endblock %}
|
||||||
|
{% block bnav_settings %}active{% endblock %}
|
||||||
|
{% block main_class %}constrained{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-3">Setari</h4>
|
<h4 class="mb-3">Setari</h4>
|
||||||
|
|
||||||
|
<!-- Dark mode toggle -->
|
||||||
|
<div class="theme-toggle-card">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-moon-fill me-2"></i>
|
||||||
|
<label for="settDarkMode">Mod intunecat</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="settDarkMode" style="width:2.5rem;height:1.25rem">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<!-- GoMag API card -->
|
<!-- GoMag API card -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -144,7 +157,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="mt-4">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSettings" aria-expanded="false" title="Modificati doar la indicatia echipei tehnice">
|
||||||
|
<i class="bi bi-gear"></i> Setari avansate
|
||||||
|
</button>
|
||||||
|
<div class="collapse mt-2" id="advancedSettings">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header py-2 px-3 fw-semibold">Dashboard</div>
|
<div class="card-header py-2 px-3 fw-semibold">Dashboard</div>
|
||||||
@@ -193,9 +211,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
|
<div class="card-header py-2 px-3 fw-semibold">Sincronizare Prețuri</div>
|
||||||
@@ -223,6 +241,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -233,5 +253,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=7"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=9"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,74 +1,3 @@
|
|||||||
-- ====================================================================
|
|
||||||
-- PACK_IMPORT_COMENZI
|
|
||||||
-- Package pentru importul comenzilor din platforme web (GoMag, etc.)
|
|
||||||
-- in sistemul ROA Oracle.
|
|
||||||
--
|
|
||||||
-- Dependinte:
|
|
||||||
-- Packages: PACK_COMENZI (adauga_comanda, adauga_articol_comanda)
|
|
||||||
-- pljson (pljson_list, pljson) - instalat in CONTAFIN_ORACLE,
|
|
||||||
-- accesat prin PUBLIC SYNONYM
|
|
||||||
-- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT)
|
|
||||||
-- NOM_ARTICOLE (nomenclator articole ROA)
|
|
||||||
-- COMENZI (verificare duplicat comanda_externa)
|
|
||||||
-- CRM_POLITICI_PRETURI (flag PRETURI_CU_TVA per politica)
|
|
||||||
-- CRM_POLITICI_PRET_ART (preturi componente kituri)
|
|
||||||
--
|
|
||||||
-- Proceduri publice:
|
|
||||||
--
|
|
||||||
-- importa_comanda(...)
|
|
||||||
-- Importa o comanda completa: creeaza comanda + adauga articolele.
|
|
||||||
-- 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).
|
|
||||||
-- Returneaza v_id_comanda (OUT) = ID-ul comenzii create.
|
|
||||||
--
|
|
||||||
-- Parametri kit pricing:
|
|
||||||
-- p_kit_mode — 'distributed' | 'separate_line' | NULL
|
|
||||||
-- distributed: discountul fata de suma componentelor se distribuie
|
|
||||||
-- proportional in pretul fiecarei componente
|
|
||||||
-- separate_line: componentele se insereaza la pret plin +
|
|
||||||
-- linii discount separate per-kit sub componente, grupate pe cota TVA
|
|
||||||
-- p_id_pol_productie — politica de pret pentru articole de productie
|
|
||||||
-- (cont in 341/345); NULL = nu se foloseste
|
|
||||||
-- p_kit_discount_codmat — CODMAT-ul articolului discount (Mode separate_line)
|
|
||||||
-- p_kit_discount_id_pol — id_pol pentru liniile discount (Mode separate_line)
|
|
||||||
--
|
|
||||||
-- Logica cautare articol per SKU:
|
|
||||||
-- 1. Mapari speciale din ARTICOLE_TERTI (reimpachetare, seturi compuse)
|
|
||||||
-- - daca SKU are >1 rand si p_kit_mode IS NOT NULL: kit pricing logic
|
|
||||||
-- - altfel (1 rand sau kit_mode NULL): pret web / cantitate_roa direct
|
|
||||||
-- 2. Fallback: cautare directa in NOM_ARTICOLE dupa CODMAT = SKU
|
|
||||||
--
|
|
||||||
-- get_last_error / clear_error
|
|
||||||
-- Management erori pentru orchestratorul VFP.
|
|
||||||
--
|
|
||||||
-- Exemplu utilizare:
|
|
||||||
-- DECLARE
|
|
||||||
-- v_id NUMBER;
|
|
||||||
-- BEGIN
|
|
||||||
-- PACK_IMPORT_COMENZI.importa_comanda(
|
|
||||||
-- p_nr_comanda_ext => '479317993',
|
|
||||||
-- p_data_comanda => SYSDATE,
|
|
||||||
-- p_id_partener => 1424,
|
|
||||||
-- p_json_articole => '[{"sku":"5941623003366","quantity":"1.00","price":"40.99","vat":"21"}]',
|
|
||||||
-- p_id_pol => 39,
|
|
||||||
-- v_id_comanda => v_id);
|
|
||||||
-- DBMS_OUTPUT.PUT_LINE('ID comanda: ' || v_id);
|
|
||||||
-- END;
|
|
||||||
-- 20.03.2026 - dual policy vanzare/productie, kit pricing distributed/separate_line, SKU→CODMAT via ARTICOLE_TERTI
|
|
||||||
-- 20.03.2026 - kit discount deferred cross-kit (separate_line, merge-on-collision)
|
|
||||||
-- 20.03.2026 - merge_or_insert_articol: merge cantitati cand kit+individual au acelasi articol/pret
|
|
||||||
-- 20.03.2026 - kit pricing extins pt reambalari single-component (cantitate_roa > 1)
|
|
||||||
-- 21.03.2026 - diagnostic detaliat discount kit (id_pol, id_art, codmat in eroare)
|
|
||||||
-- 21.03.2026 - fix discount amount: v_disc_amt e per-kit, nu se imparte la v_cantitate_web
|
|
||||||
-- 25.03.2026 - skip negative kit discount (markup), ROUND prices to nzecimale_pretv
|
|
||||||
-- 25.03.2026 - kit discount inserat per-kit sub componente (nu deferred cross-kit)
|
|
||||||
-- ====================================================================
|
|
||||||
CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS
|
CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS
|
||||||
|
|
||||||
-- Variabila package pentru ultima eroare (pentru orchestrator VFP)
|
-- Variabila package pentru ultima eroare (pentru orchestrator VFP)
|
||||||
@@ -98,6 +27,31 @@ END PACK_IMPORT_COMENZI;
|
|||||||
/
|
/
|
||||||
CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
||||||
|
|
||||||
|
-- ====================================================================
|
||||||
|
-- PACK_IMPORT_COMENZI
|
||||||
|
-- Package pentru importul comenzilor din platforme web (GoMag, etc.)
|
||||||
|
-- in sistemul ROA Oracle.
|
||||||
|
--
|
||||||
|
-- Dependinte:
|
||||||
|
-- Packages: PACK_COMENZI (adauga_comanda, adauga_articol_comanda)
|
||||||
|
-- pljson (pljson_list, pljson) - instalat in CONTAFIN_ORACLE,
|
||||||
|
-- accesat prin PUBLIC SYNONYM
|
||||||
|
-- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT)
|
||||||
|
-- NOM_ARTICOLE (nomenclator articole ROA)
|
||||||
|
-- COMENZI (verificare duplicat comanda_externa)
|
||||||
|
-- CRM_POLITICI_PRETURI (flag PRETURI_CU_TVA per politica)
|
||||||
|
-- CRM_POLITICI_PRET_ART (preturi componente kituri)
|
||||||
|
|
||||||
|
-- 20.03.2026 - dual policy vanzare/productie, kit pricing distributed/separate_line, SKU→CODMAT via ARTICOLE_TERTI
|
||||||
|
-- 20.03.2026 - kit discount deferred cross-kit (separate_line, merge-on-collision)
|
||||||
|
-- 20.03.2026 - merge_or_insert_articol: merge cantitati cand kit+individual au acelasi articol/pret
|
||||||
|
-- 20.03.2026 - kit pricing extins pt reambalari single-component (cantitate_roa > 1)
|
||||||
|
-- 21.03.2026 - diagnostic detaliat discount kit (id_pol, id_art, codmat in eroare)
|
||||||
|
-- 21.03.2026 - fix discount amount: v_disc_amt e per-kit, nu se imparte la v_cantitate_web
|
||||||
|
-- 25.03.2026 - skip negative kit discount (markup), ROUND prices to nzecimale_pretv
|
||||||
|
-- 25.03.2026 - kit discount inserat per-kit sub componente (nu deferred cross-kit)
|
||||||
|
-- ====================================================================
|
||||||
|
|
||||||
-- Constante pentru configurare
|
-- Constante pentru configurare
|
||||||
c_id_util CONSTANT NUMBER := -3; -- Sistem
|
c_id_util CONSTANT NUMBER := -3; -- Sistem
|
||||||
c_interna CONSTANT NUMBER := 2; -- Comenzi de la client (web)
|
c_interna CONSTANT NUMBER := 2; -- Comenzi de la client (web)
|
||||||
|
|||||||
105
api/tests/e2e/test_design_system_e2e.py
Normal file
105
api/tests/e2e/test_design_system_e2e.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
E2E tests for DESIGN.md migration (Commit 0.5).
|
||||||
|
Tests: dark toggle, FOUC prevention, bottom nav, active tab amber, dark contrast.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.e2e]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dark_mode_toggle(page, app_url):
|
||||||
|
"""Dark toggle switches theme and persists in localStorage."""
|
||||||
|
page.goto(f"{app_url}/settings")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Settings page has the dark mode toggle
|
||||||
|
toggle = page.locator("#settDarkMode")
|
||||||
|
assert toggle.is_visible()
|
||||||
|
|
||||||
|
# Start in light mode
|
||||||
|
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
|
||||||
|
if theme == "dark":
|
||||||
|
toggle.click()
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
|
||||||
|
# Toggle to dark
|
||||||
|
toggle.click()
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark"
|
||||||
|
assert page.evaluate("localStorage.getItem('theme')") == "dark"
|
||||||
|
|
||||||
|
# Toggle back to light
|
||||||
|
toggle.click()
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
assert page.evaluate("document.documentElement.getAttribute('data-theme')") != "dark"
|
||||||
|
assert page.evaluate("localStorage.getItem('theme')") == "light"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fouc_prevention(page, app_url):
|
||||||
|
"""Theme is applied before CSS loads (inline script in <head>)."""
|
||||||
|
# Set dark theme in localStorage before navigation
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
page.evaluate("localStorage.setItem('theme', 'dark')")
|
||||||
|
|
||||||
|
# Navigate fresh — the inline script should apply dark before paint
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
# Check immediately (before networkidle) that data-theme is set
|
||||||
|
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
|
||||||
|
assert theme == "dark", "FOUC: dark theme not applied before first paint"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
page.evaluate("localStorage.removeItem('theme')")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bottom_nav_visible_on_mobile(page, app_url):
|
||||||
|
"""Bottom nav is visible on mobile viewport, top navbar is hidden."""
|
||||||
|
page.set_viewport_size({"width": 375, "height": 812})
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
bottom_nav = page.locator(".bottom-nav")
|
||||||
|
top_navbar = page.locator(".top-navbar")
|
||||||
|
|
||||||
|
assert bottom_nav.is_visible(), "Bottom nav should be visible on mobile"
|
||||||
|
assert not top_navbar.is_visible(), "Top navbar should be hidden on mobile"
|
||||||
|
|
||||||
|
# Check 5 tabs exist
|
||||||
|
tabs = page.locator(".bottom-nav-item")
|
||||||
|
assert tabs.count() == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_tab_amber_accent(page, app_url):
|
||||||
|
"""Active nav tab uses amber accent color, not blue."""
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
active_tab = page.locator(".nav-tab.active")
|
||||||
|
assert active_tab.count() >= 1
|
||||||
|
|
||||||
|
# Get computed color of active tab
|
||||||
|
color = page.evaluate("""
|
||||||
|
() => getComputedStyle(document.querySelector('.nav-tab.active')).color
|
||||||
|
""")
|
||||||
|
# Amber #D97706 = rgb(217, 119, 6)
|
||||||
|
assert "217" in color and "119" in color, f"Active tab color should be amber, got: {color}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dark_mode_contrast(page, app_url):
|
||||||
|
"""Dark mode has proper contrast — bg is dark, text is light."""
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Enable dark mode
|
||||||
|
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
|
||||||
|
page.wait_for_timeout(100)
|
||||||
|
|
||||||
|
bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
|
||||||
|
color = page.evaluate("getComputedStyle(document.body).color")
|
||||||
|
|
||||||
|
# bg should be dark (#121212 = rgb(18, 18, 18))
|
||||||
|
assert "18" in bg, f"Dark mode bg should be dark, got: {bg}"
|
||||||
|
# text should be light (#E8E4DD = rgb(232, 228, 221))
|
||||||
|
assert "232" in color or "228" in color, f"Dark mode text should be light, got: {color}"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
page.evaluate("document.documentElement.removeAttribute('data-theme')")
|
||||||
@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
|
|||||||
texts = headers.all_text_contents()
|
texts = headers.all_text_contents()
|
||||||
|
|
||||||
# Current columns (may evolve — check dashboard.html for source of truth)
|
# Current columns (may evolve — check dashboard.html for source of truth)
|
||||||
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret", "Valoare"]
|
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "Pret ROA", "Valoare"]
|
||||||
for col in required_columns:
|
for col in required_columns:
|
||||||
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
|
||||||
|
|
||||||
@@ -51,5 +51,5 @@ def test_dashboard_navigates_to_logs(page: Page, app_url: str):
|
|||||||
page.goto(f"{app_url}/")
|
page.goto(f"{app_url}/")
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
logs_link = page.locator("a[href='/logs']")
|
logs_link = page.locator(".top-navbar a[href='/logs'], .bottom-nav a[href='/logs']")
|
||||||
expect(logs_link).to_be_visible()
|
expect(logs_link.first).to_be_visible()
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ def test_responsive_page(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_mobile_navbar_visible(pw_browser, base_url: str):
|
def test_mobile_navbar_visible(pw_browser, base_url: str):
|
||||||
"""Mobile viewport: navbar should still be visible and functional."""
|
"""Mobile viewport: bottom nav should be visible (top navbar hidden on mobile)."""
|
||||||
context = pw_browser.new_context(viewport=VIEWPORTS["mobile"])
|
context = pw_browser.new_context(viewport=VIEWPORTS["mobile"])
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
try:
|
try:
|
||||||
page.goto(base_url, wait_until="networkidle", timeout=15_000)
|
page.goto(base_url, wait_until="networkidle", timeout=15_000)
|
||||||
# Custom navbar: .top-navbar with .navbar-brand
|
# On mobile, top-navbar is hidden and bottom-nav is shown
|
||||||
navbar = page.locator(".top-navbar")
|
bottom_nav = page.locator(".bottom-nav")
|
||||||
expect(navbar).to_be_visible()
|
expect(bottom_nav).to_be_visible()
|
||||||
finally:
|
finally:
|
||||||
context.close()
|
context.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user