Compare commits
37 Commits
f315aad14c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
115666155b | ||
|
|
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 | ||
|
|
7a1fa16fef | ||
|
|
61193b793f | ||
|
|
f07946b489 | ||
|
|
af78ee181a | ||
|
|
f2bf6805b4 | ||
|
|
a659f3bafb | ||
|
|
bc56befc15 | ||
|
|
91ddb4fbdd | ||
|
|
580ca595a5 | ||
|
|
21e26806f7 | ||
|
|
47b5723f92 |
@@ -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. |
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
# Oracle Modes Configuration Guide - UNIFIED
|
|
||||||
|
|
||||||
## 🎯 Un Singur Dockerfile + Docker Compose
|
|
||||||
|
|
||||||
| Oracle Version | Configurație .env | Comandă Build | Port |
|
|
||||||
|---------------|-------------------|---------------|------|
|
|
||||||
| 10g (test) | `INSTANTCLIENTPATH=...` | `docker-compose up --build` | 5003 |
|
|
||||||
| 11g (prod) | `INSTANTCLIENTPATH=...` | `docker-compose up --build` | 5003 |
|
|
||||||
| 12.1+ (nou) | `FORCE_THIN_MODE=true` | `ORACLE_MODE=thin docker-compose up --build` | 5003 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 THICK MODE (Oracle 10g/11g) - DEFAULT
|
|
||||||
|
|
||||||
### Configurare .env:
|
|
||||||
```env
|
|
||||||
# Uncomment această linie pentru thick mode:
|
|
||||||
INSTANTCLIENTPATH=/opt/oracle/instantclient_23_9
|
|
||||||
|
|
||||||
# Comment această linie:
|
|
||||||
# FORCE_THIN_MODE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rulare:
|
|
||||||
```bash
|
|
||||||
docker-compose up --build -d
|
|
||||||
curl http://localhost:5003/health
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 THIN MODE (Oracle 12.1+)
|
|
||||||
|
|
||||||
### Varianta 1 - Prin .env (Recomandat):
|
|
||||||
```env
|
|
||||||
# Comment această linie pentru thin mode:
|
|
||||||
# INSTANTCLIENTPATH=/opt/oracle/instantclient_23_9
|
|
||||||
|
|
||||||
# Uncomment această linie:
|
|
||||||
FORCE_THIN_MODE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Varianta 2 - Prin build argument:
|
|
||||||
```bash
|
|
||||||
ORACLE_MODE=thin docker-compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5003/health
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 LOGICA AUTO-DETECT
|
|
||||||
|
|
||||||
Container-ul detectează automat modul:
|
|
||||||
|
|
||||||
1. **FORCE_THIN_MODE=true** → **Thin Mode**
|
|
||||||
2. **INSTANTCLIENTPATH** există → **Thick Mode**
|
|
||||||
3. Build cu **ORACLE_MODE=thin** → **Thin Mode**
|
|
||||||
4. Default → **Thick Mode**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ COMENZI SIMPLE
|
|
||||||
|
|
||||||
### Pentru Oracle 10g/11g (setup-ul tău actual):
|
|
||||||
```bash
|
|
||||||
# Verifică .env să aibă:
|
|
||||||
grep INSTANTCLIENTPATH ./api/.env
|
|
||||||
|
|
||||||
# Start
|
|
||||||
docker-compose up --build -d
|
|
||||||
curl http://localhost:5003/test-db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pentru Oracle 12.1+ (viitor):
|
|
||||||
```bash
|
|
||||||
# Editează .env: decomentează FORCE_THIN_MODE=true
|
|
||||||
# SAU rulează direct:
|
|
||||||
ORACLE_MODE=thin docker-compose up --build -d
|
|
||||||
curl http://localhost:5003/test-db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Switch rapid:
|
|
||||||
```bash
|
|
||||||
# Stop
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Edit .env (change INSTANTCLIENTPATH ↔ FORCE_THIN_MODE)
|
|
||||||
# Start
|
|
||||||
docker-compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ TROUBLESHOOTING
|
|
||||||
|
|
||||||
### Eroare DPY-3010 în Thin Mode:
|
|
||||||
```
|
|
||||||
DPY-3010: connections to this database server version are not supported
|
|
||||||
```
|
|
||||||
**Soluție:** Oracle este 11g sau mai vechi → folosește thick mode
|
|
||||||
|
|
||||||
### Eroare libaio în Thick Mode:
|
|
||||||
```
|
|
||||||
Cannot locate a 64-bit Oracle Client library: libaio.so.1
|
|
||||||
```
|
|
||||||
**Soluție:** Rebuild container (fix automat în Dockerfile.thick)
|
|
||||||
|
|
||||||
### Container nu pornește:
|
|
||||||
```bash
|
|
||||||
docker-compose logs
|
|
||||||
docker-compose down && docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 COMPARAȚIE PERFORMANȚĂ
|
|
||||||
|
|
||||||
| Aspect | Thick Mode | Thin Mode |
|
|
||||||
|--------|------------|-----------|
|
|
||||||
| Container Size | ~200MB | ~50MB |
|
|
||||||
| Startup Time | 10-15s | 3-5s |
|
|
||||||
| Memory Usage | ~100MB | ~30MB |
|
|
||||||
| Oracle Support | 10g+ | 12.1+ |
|
|
||||||
| Dependencies | Instant Client | None |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 DEZVOLTARE
|
|
||||||
|
|
||||||
### Pentru dezvoltatori:
|
|
||||||
1. **Thick mode** pentru compatibilitate maximă
|
|
||||||
2. **Thin mode** pentru development rapid pe Oracle nou
|
|
||||||
3. **Auto-detect** în producție pentru flexibilitate
|
|
||||||
|
|
||||||
### Testare ambele moduri:
|
|
||||||
```bash
|
|
||||||
# Thick pe port 5003
|
|
||||||
docker-compose -f docker-compose.thick.yaml up -d
|
|
||||||
|
|
||||||
# Thin pe port 5004
|
|
||||||
docker-compose -f docker-compose.thin.yaml up -d
|
|
||||||
|
|
||||||
# Test ambele
|
|
||||||
curl http://localhost:5003/health
|
|
||||||
curl http://localhost:5004/health
|
|
||||||
```
|
|
||||||
16
README.md
16
README.md
@@ -110,7 +110,8 @@ gomag-vending/
|
|||||||
│ │ │ ├── gomag_client.py # Download comenzi GoMag API
|
│ │ │ ├── gomag_client.py # Download comenzi GoMag API
|
||||||
│ │ │ ├── sync_service.py # Orchestrare: download→validate→import
|
│ │ │ ├── sync_service.py # Orchestrare: download→validate→import
|
||||||
│ │ │ ├── import_service.py # Import comanda in Oracle ROA
|
│ │ │ ├── import_service.py # Import comanda in Oracle ROA
|
||||||
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total
|
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + cantitate_roa
|
||||||
|
│ │ │ ├── price_sync_service.py # Sync preturi GoMag → Oracle politici
|
||||||
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
|
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
|
||||||
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
|
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
|
||||||
│ │ │ ├── validation_service.py
|
│ │ │ ├── validation_service.py
|
||||||
@@ -127,7 +128,8 @@ gomag-vending/
|
|||||||
│ ├── test_integration.py # Test C - cu Oracle
|
│ ├── test_integration.py # Test C - cu Oracle
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── logs/ # Log-uri aplicatie (sync_comenzi_*.log)
|
├── logs/ # Log-uri aplicatie (sync_comenzi_*.log)
|
||||||
├── docs/ # Documentatie (PRD, stories)
|
├── docs/ # Documentatie (Oracle schema, facturare analysis)
|
||||||
|
├── scripts/ # Utilitare (sync_vending_to_mariusm, create_inventory_notes)
|
||||||
├── screenshots/ # Before/preview/after pentru UI changes
|
├── screenshots/ # Before/preview/after pentru UI changes
|
||||||
├── start.sh # Script pornire (Linux/WSL)
|
├── start.sh # Script pornire (Linux/WSL)
|
||||||
└── CLAUDE.md # Instructiuni pentru AI assistants
|
└── CLAUDE.md # Instructiuni pentru AI assistants
|
||||||
@@ -447,6 +449,16 @@ curl -X POST http://localhost:5003/api/dashboard/refresh-invoices
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Documentatie Tehnica
|
||||||
|
|
||||||
|
| Fisier | Subiect |
|
||||||
|
|--------|---------|
|
||||||
|
| [docs/oracle-schema-notes.md](docs/oracle-schema-notes.md) | Schema Oracle: tabele comenzi, facturi, preturi, proceduri cheie |
|
||||||
|
| [docs/pack_facturare_analysis.md](docs/pack_facturare_analysis.md) | Analiza flow facturare: call chain, parametri, STOC lookup, FACT-008 |
|
||||||
|
| [scripts/HANDOFF_MAPPING.md](scripts/HANDOFF_MAPPING.md) | Matching GoMag SKU → ROA articole (strategie si rezultate) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## WSL2 Note
|
## WSL2 Note
|
||||||
|
|
||||||
- `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual
|
- `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual
|
||||||
|
|||||||
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.
|
||||||
@@ -26,6 +26,8 @@ Admin interface si orchestrator pentru importul comenzilor GoMag in Oracle ROA.
|
|||||||
| article_service | Cautare in NOM_ARTICOLE (Oracle) |
|
| article_service | Cautare in NOM_ARTICOLE (Oracle) |
|
||||||
| import_service | Port din VFP: partner/address/order creation |
|
| import_service | Port din VFP: partner/address/order creation |
|
||||||
| sync_service | Orchestrare: read JSONs → validate → import → log |
|
| sync_service | Orchestrare: read JSONs → validate → import → log |
|
||||||
|
| price_sync_service | Sync preturi GoMag → Oracle politici de pret |
|
||||||
|
| invoice_service | Verificare facturi ROA + cache SQLite |
|
||||||
| validation_service | Batch-validare SKU-uri (chunks of 500) |
|
| validation_service | Batch-validare SKU-uri (chunks of 500) |
|
||||||
| order_reader | Citire gomag_orders_page*.json din vfp/output/ |
|
| order_reader | Citire gomag_orders_page*.json din vfp/output/ |
|
||||||
| sqlite_service | CRUD pe SQLite (sync_runs, import_orders, missing_skus) |
|
| sqlite_service | CRUD pe SQLite (sync_runs, import_orders, missing_skus) |
|
||||||
@@ -35,17 +37,19 @@ Admin interface si orchestrator pentru importul comenzilor GoMag in Oracle ROA.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
|
# INTOTDEAUNA via start.sh din project root (seteaza Oracle env vars)
|
||||||
|
cd .. && ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testare
|
## Testare
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test A - fara Oracle (verifica importuri + rute)
|
# Din project root:
|
||||||
python test_app_basic.py
|
./test.sh ci # Teste rapide (unit + e2e, ~30s, fara Oracle)
|
||||||
|
./test.sh full # Teste complete (inclusiv Oracle, ~2-3 min)
|
||||||
# Test C - cu Oracle (integrare completa)
|
./test.sh unit # Doar unit tests
|
||||||
python test_integration.py
|
./test.sh e2e # Doar browser tests (Playwright)
|
||||||
|
./test.sh oracle # Doar Oracle integration
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dual Database
|
## Dual Database
|
||||||
|
|||||||
250
api/admin.py
250
api/admin.py
@@ -1,250 +0,0 @@
|
|||||||
"""
|
|
||||||
Flask Admin Interface pentru Import Comenzi Web → ROA
|
|
||||||
Gestionează mapările SKU în tabelul ARTICOLE_TERTI
|
|
||||||
"""
|
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, render_template_string
|
|
||||||
from flask_cors import CORS
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import oracledb
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Configurare environment
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Configurare logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
format='%(asctime)s | %(levelname)s | %(message)s',
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler('/app/logs/admin.log'),
|
|
||||||
logging.StreamHandler()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Environment Variables pentru Oracle
|
|
||||||
user = os.environ['ORACLE_USER']
|
|
||||||
password = os.environ['ORACLE_PASSWORD']
|
|
||||||
dsn = os.environ['ORACLE_DSN']
|
|
||||||
|
|
||||||
# Oracle client - AUTO-DETECT: thick mode pentru 10g/11g, thin mode pentru 12.1+
|
|
||||||
force_thin_mode = os.environ.get('FORCE_THIN_MODE', 'false').lower() == 'true'
|
|
||||||
instantclient_path = os.environ.get('INSTANTCLIENTPATH')
|
|
||||||
|
|
||||||
if force_thin_mode:
|
|
||||||
logger.info(f"FORCE_THIN_MODE=true: Folosind thin mode pentru {dsn} (Oracle 12.1+ required)")
|
|
||||||
elif instantclient_path:
|
|
||||||
try:
|
|
||||||
oracledb.init_oracle_client(lib_dir=instantclient_path)
|
|
||||||
logger.info(f"Thick mode activat pentru {dsn} (compatibil Oracle 10g/11g/12.1+)")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Eroare thick mode: {e}")
|
|
||||||
logger.info("Fallback la thin mode - verifică că Oracle DB este 12.1+")
|
|
||||||
else:
|
|
||||||
logger.info(f"Thin mode (default) pentru {dsn} - Oracle 12.1+ required")
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
CORS(app)
|
|
||||||
|
|
||||||
def start_pool():
|
|
||||||
"""Inițializează connection pool Oracle"""
|
|
||||||
try:
|
|
||||||
pool = oracledb.create_pool(
|
|
||||||
user=user,
|
|
||||||
password=password,
|
|
||||||
dsn=dsn,
|
|
||||||
min=2,
|
|
||||||
max=4,
|
|
||||||
increment=1
|
|
||||||
)
|
|
||||||
logger.info(f"Oracle pool creat cu succes pentru {dsn}")
|
|
||||||
return pool
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Eroare creare pool Oracle: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@app.route('/health')
|
|
||||||
def health():
|
|
||||||
"""Health check pentru Docker"""
|
|
||||||
return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()})
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def home():
|
|
||||||
"""Pagina principală admin interface"""
|
|
||||||
html_template = """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>GoMag Admin - Mapări SKU</title>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }
|
|
||||||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
||||||
h1 { color: #333; border-bottom: 3px solid #007bff; padding-bottom: 10px; }
|
|
||||||
.status { padding: 10px; border-radius: 4px; margin: 10px 0; }
|
|
||||||
.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
|
||||||
.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
|
||||||
.btn { background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin: 5px; }
|
|
||||||
.btn:hover { background: #0056b3; }
|
|
||||||
.table-container { margin-top: 20px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
|
||||||
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #ddd; }
|
|
||||||
th { background-color: #f8f9fa; font-weight: bold; }
|
|
||||||
tr:hover { background-color: #f5f5f5; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🛍️ GoMag Admin - Import Comenzi Web → ROA</h1>
|
|
||||||
|
|
||||||
<div id="status-area">
|
|
||||||
<div class="success">✅ Container Docker activ pe port 5003</div>
|
|
||||||
<div id="db-status">🔄 Verificare conexiune Oracle...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<h2>📋 Mapări SKU Active</h2>
|
|
||||||
<button class="btn" onclick="loadMappings()">🔄 Reîmprospătează</button>
|
|
||||||
<button class="btn" onclick="testConnection()">🔍 Test Conexiune DB</button>
|
|
||||||
|
|
||||||
<div id="mappings-container">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Test conexiune la load
|
|
||||||
window.onload = function() {
|
|
||||||
testConnection();
|
|
||||||
loadMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
function testConnection() {
|
|
||||||
fetch('/test-db')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
const statusDiv = document.getElementById('db-status');
|
|
||||||
if (data.success) {
|
|
||||||
statusDiv.className = 'status success';
|
|
||||||
statusDiv.innerHTML = '✅ Oracle conectat: ' + data.message;
|
|
||||||
} else {
|
|
||||||
statusDiv.className = 'status error';
|
|
||||||
statusDiv.innerHTML = '❌ Eroare Oracle: ' + data.error;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
document.getElementById('db-status').innerHTML = '❌ Eroare fetch: ' + error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMappings() {
|
|
||||||
fetch('/api/mappings')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
let html = '<table>';
|
|
||||||
html += '<tr><th>SKU</th><th>CODMAT</th><th>Cantitate ROA</th><th>Procent Preț</th><th>Activ</th><th>Data Creare</th></tr>';
|
|
||||||
|
|
||||||
if (data.mappings && data.mappings.length > 0) {
|
|
||||||
data.mappings.forEach(row => {
|
|
||||||
const activIcon = row[4] === 1 ? '✅' : '❌';
|
|
||||||
html += `<tr>
|
|
||||||
<td><strong>${row[0]}</strong></td>
|
|
||||||
<td>${row[1]}</td>
|
|
||||||
<td>${row[2]}</td>
|
|
||||||
<td>${row[3]}%</td>
|
|
||||||
<td>${activIcon}</td>
|
|
||||||
<td>${new Date(row[5]).toLocaleDateString()}</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
html += '<tr><td colspan="6">Nu există mapări configurate</td></tr>';
|
|
||||||
}
|
|
||||||
html += '</table>';
|
|
||||||
|
|
||||||
document.getElementById('mappings-container').innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
document.getElementById('mappings-container').innerHTML = '❌ Eroare: ' + error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
return render_template_string(html_template)
|
|
||||||
|
|
||||||
@app.route('/test-db')
|
|
||||||
def test_db():
|
|
||||||
"""Test conexiune Oracle și verificare tabel"""
|
|
||||||
try:
|
|
||||||
with pool.acquire() as con:
|
|
||||||
with con.cursor() as cur:
|
|
||||||
# Test conexiune de bază
|
|
||||||
cur.execute("SELECT SYSDATE FROM DUAL")
|
|
||||||
db_date = cur.fetchone()[0]
|
|
||||||
|
|
||||||
# Verificare existență tabel ARTICOLE_TERTI
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) FROM USER_TABLES
|
|
||||||
WHERE TABLE_NAME = 'ARTICOLE_TERTI'
|
|
||||||
""")
|
|
||||||
table_exists = cur.fetchone()[0] > 0
|
|
||||||
|
|
||||||
if not table_exists:
|
|
||||||
return jsonify({
|
|
||||||
"success": False,
|
|
||||||
"error": "Tabelul ARTICOLE_TERTI nu există. Rulează 01_create_table.sql"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Count records
|
|
||||||
cur.execute("SELECT COUNT(*) FROM ARTICOLE_TERTI")
|
|
||||||
record_count = cur.fetchone()[0]
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True,
|
|
||||||
"message": f"DB Time: {db_date}, Records: {record_count}",
|
|
||||||
"table_exists": table_exists,
|
|
||||||
"record_count": record_count
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Test DB failed: {e}")
|
|
||||||
return jsonify({"success": False, "error": str(e)})
|
|
||||||
|
|
||||||
@app.route('/api/mappings')
|
|
||||||
def get_mappings():
|
|
||||||
"""Returnează toate mapările SKU active"""
|
|
||||||
try:
|
|
||||||
with pool.acquire() as con:
|
|
||||||
with con.cursor() as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT sku, codmat, cantitate_roa, procent_pret, activ, data_creare
|
|
||||||
FROM ARTICOLE_TERTI
|
|
||||||
ORDER BY sku, codmat
|
|
||||||
""")
|
|
||||||
mappings = cur.fetchall()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True,
|
|
||||||
"mappings": mappings,
|
|
||||||
"count": len(mappings)
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Get mappings failed: {e}")
|
|
||||||
return jsonify({"success": False, "error": str(e)})
|
|
||||||
|
|
||||||
# Inițializare pool la startup
|
|
||||||
try:
|
|
||||||
pool = start_pool()
|
|
||||||
logger.info("Admin interface started successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start admin interface: {e}")
|
|
||||||
pool = None
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
|
||||||
@@ -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")]
|
||||||
|
|||||||
@@ -125,16 +125,6 @@ def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, auto_restore
|
|||||||
if cur.fetchone()[0] == 0:
|
if cur.fetchone()[0] == 0:
|
||||||
raise HTTPException(status_code=400, detail="CODMAT-ul nu exista in nomenclator")
|
raise HTTPException(status_code=400, detail="CODMAT-ul nu exista in nomenclator")
|
||||||
|
|
||||||
# Warn if SKU is already a direct CODMAT in NOM_ARTICOLE
|
|
||||||
if sku == codmat:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) FROM NOM_ARTICOLE
|
|
||||||
WHERE codmat = :sku AND sters = 0 AND inactiv = 0
|
|
||||||
""", {"sku": sku})
|
|
||||||
if cur.fetchone()[0] > 0:
|
|
||||||
raise HTTPException(status_code=409,
|
|
||||||
detail="SKU-ul exista direct in nomenclator ca CODMAT, nu necesita mapare")
|
|
||||||
|
|
||||||
# Check for active duplicate
|
# Check for active duplicate
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT COUNT(*) FROM ARTICOLE_TERTI
|
SELECT COUNT(*) FROM ARTICOLE_TERTI
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ async def run_catalog_price_sync(run_id: str):
|
|||||||
if is_kit:
|
if is_kit:
|
||||||
for comp in mapped_data[sku]:
|
for comp in mapped_data[sku]:
|
||||||
comp_codmat = comp["codmat"]
|
comp_codmat = comp["codmat"]
|
||||||
|
|
||||||
|
# Skip components that have their own ARTICOLE_TERTI mapping
|
||||||
|
# (they'll be synced with correct cantitate_roa in individual path)
|
||||||
|
if comp_codmat in mapped_data:
|
||||||
|
continue
|
||||||
|
|
||||||
comp_product = products_by_sku.get(comp_codmat)
|
comp_product = products_by_sku.get(comp_codmat)
|
||||||
if not comp_product:
|
if not comp_product:
|
||||||
continue # Component not in GoMag as standalone product
|
continue # Component not in GoMag as standalone product
|
||||||
|
|||||||
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"}
|
||||||
@@ -240,6 +240,23 @@ async def track_missing_sku(sku: str, product_name: str = "",
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_missing_skus_batch(skus: set):
|
||||||
|
"""Mark multiple missing SKUs as resolved (they now have mappings)."""
|
||||||
|
if not skus:
|
||||||
|
return 0
|
||||||
|
db = await get_sqlite()
|
||||||
|
try:
|
||||||
|
placeholders = ",".join("?" for _ in skus)
|
||||||
|
cursor = await db.execute(f"""
|
||||||
|
UPDATE missing_skus SET resolved = 1, resolved_at = datetime('now')
|
||||||
|
WHERE sku IN ({placeholders}) AND resolved = 0
|
||||||
|
""", list(skus))
|
||||||
|
await db.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
async def resolve_missing_sku(sku: str):
|
async def resolve_missing_sku(sku: str):
|
||||||
"""Mark a missing SKU as resolved."""
|
"""Mark a missing SKU as resolved."""
|
||||||
db = await get_sqlite()
|
db = await get_sqlite()
|
||||||
@@ -722,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,
|
||||||
@@ -737,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:
|
||||||
@@ -803,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()
|
||||||
@@ -932,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):
|
||||||
|
|||||||
@@ -410,6 +410,13 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
|||||||
customers=json.dumps(ctx.get("customers", [])) if ctx.get("customers") else None,
|
customers=json.dumps(ctx.get("customers", [])) if ctx.get("customers") else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-resolve missing SKUs that now have mappings
|
||||||
|
resolved_skus = validation["mapped"] | validation["direct"]
|
||||||
|
if resolved_skus:
|
||||||
|
resolved_count = await sqlite_service.resolve_missing_skus_batch(resolved_skus)
|
||||||
|
if resolved_count:
|
||||||
|
_log_line(run_id, f"Auto-resolved {resolved_count} previously missing SKUs")
|
||||||
|
|
||||||
# Step 2d: Pre-validate prices for importable articles
|
# Step 2d: Pre-validate prices for importable articles
|
||||||
if id_pol and (truly_importable or already_in_roa):
|
if id_pol and (truly_importable or already_in_roa):
|
||||||
_update_progress("validation", "Validating prices...", 0, len(truly_importable))
|
_update_progress("validation", "Validating prices...", 0, len(truly_importable))
|
||||||
|
|||||||
@@ -542,8 +542,9 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
|
|||||||
kit_discount_codmat = (settings or {}).get("kit_discount_codmat", "")
|
kit_discount_codmat = (settings or {}).get("kit_discount_codmat", "")
|
||||||
skip_codmats = {transport_codmat, discount_codmat, kit_discount_codmat} - {""}
|
skip_codmats = {transport_codmat, discount_codmat, kit_discount_codmat} - {""}
|
||||||
|
|
||||||
# Build set of kit SKUs (>1 component)
|
# Build set of kit/bax SKUs (>1 component, or single component with cantitate_roa > 1)
|
||||||
kit_skus = {sku for sku, comps in mapped_codmat_data.items() if len(comps) > 1}
|
kit_skus = {sku for sku, comps in mapped_codmat_data.items()
|
||||||
|
if len(comps) > 1 or (len(comps) == 1 and (comps[0].get("cantitate_roa") or 1) > 1)}
|
||||||
|
|
||||||
updated = []
|
updated = []
|
||||||
own_conn = conn is None
|
own_conn = conn is None
|
||||||
@@ -585,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,248 +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 total = order.order_total != null ? fmtNum(order.order_total) : '-';
|
|
||||||
const html = `<span><strong>Total: ${total} lei</strong></span>`;
|
|
||||||
desktop.innerHTML = html;
|
|
||||||
mobile.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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=24"></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,72 +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 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
|
|
||||||
-- ====================================================================
|
|
||||||
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)
|
||||||
@@ -96,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)
|
||||||
@@ -273,16 +229,8 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
v_pret_ajustat NUMBER;
|
v_pret_ajustat NUMBER;
|
||||||
v_discount_allocated NUMBER;
|
v_discount_allocated NUMBER;
|
||||||
|
|
||||||
-- Acumulare discount-uri kit cross-kit (separate_line, deferred insertion)
|
-- Zecimale pret vanzare (din optiuni firma, default 2)
|
||||||
TYPE t_kit_disc_entry IS RECORD (
|
v_nzec_pretv PLS_INTEGER := NVL(TO_NUMBER(pack_sesiune.getoptiunefirma(USER, 'PPRETV')), 2);
|
||||||
ptva NUMBER,
|
|
||||||
pret NUMBER, -- pret unitar (disc_amt / cantitate_web)
|
|
||||||
qty NUMBER -- cantitate negativa acumulata
|
|
||||||
);
|
|
||||||
TYPE t_kit_disc_list IS TABLE OF t_kit_disc_entry INDEX BY PLS_INTEGER;
|
|
||||||
v_kit_disc_list t_kit_disc_list;
|
|
||||||
v_kit_disc_count PLS_INTEGER := 0;
|
|
||||||
v_kit_disc_found BOOLEAN;
|
|
||||||
|
|
||||||
-- pljson
|
-- pljson
|
||||||
l_json_articole CLOB := p_json_articole;
|
l_json_articole CLOB := p_json_articole;
|
||||||
@@ -492,8 +440,10 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- pret_ajustat = pret_cu_tva - discount_share / cantitate_roa
|
-- pret_ajustat = pret_cu_tva - discount_share / cantitate_roa
|
||||||
v_pret_ajustat := v_kit_comps(i_comp).pret_cu_tva -
|
v_pret_ajustat := ROUND(
|
||||||
(v_discount_share / v_kit_comps(i_comp).cantitate_roa);
|
v_kit_comps(i_comp).pret_cu_tva -
|
||||||
|
(v_discount_share / v_kit_comps(i_comp).cantitate_roa),
|
||||||
|
v_nzec_pretv);
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
merge_or_insert_articol(
|
merge_or_insert_articol(
|
||||||
@@ -517,14 +467,13 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
ELSIF p_kit_mode = 'separate_line' THEN
|
ELSIF p_kit_mode = 'separate_line' THEN
|
||||||
-- Mode B: componente la pret plin, discount deferred cross-kit
|
-- Mode B: componente la pret plin, discount per-kit imediat sub componente
|
||||||
DECLARE
|
DECLARE
|
||||||
TYPE t_vat_discount IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
|
TYPE t_vat_discount IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
|
||||||
v_vat_disc t_vat_discount;
|
v_vat_disc t_vat_discount;
|
||||||
v_vat_key PLS_INTEGER;
|
v_vat_key PLS_INTEGER;
|
||||||
v_vat_disc_alloc NUMBER;
|
v_vat_disc_alloc NUMBER;
|
||||||
v_disc_amt NUMBER;
|
v_disc_amt NUMBER;
|
||||||
v_unit_pret NUMBER;
|
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Inserare componente la pret plin + acumulare discount pe cota TVA (per kit)
|
-- Inserare componente la pret plin + acumulare discount pe cota TVA (per kit)
|
||||||
FOR i_comp IN 1 .. v_kit_comps.COUNT LOOP
|
FOR i_comp IN 1 .. v_kit_comps.COUNT LOOP
|
||||||
@@ -566,7 +515,13 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
-- Merge per-kit discounts into cross-kit list (v_kit_disc_list)
|
-- Inserare imediata discount per kit (sub componentele kitului)
|
||||||
|
IF v_discount_total > 0 AND p_kit_discount_codmat IS NOT NULL THEN
|
||||||
|
DECLARE
|
||||||
|
v_disc_artid NUMBER;
|
||||||
|
BEGIN
|
||||||
|
v_disc_artid := resolve_id_articol(p_kit_discount_codmat, p_id_gestiune);
|
||||||
|
IF v_disc_artid IS NOT NULL THEN
|
||||||
v_vat_disc_alloc := 0;
|
v_vat_disc_alloc := 0;
|
||||||
v_vat_key := v_vat_disc.FIRST;
|
v_vat_key := v_vat_disc.FIRST;
|
||||||
WHILE v_vat_key IS NOT NULL LOOP
|
WHILE v_vat_key IS NOT NULL LOOP
|
||||||
@@ -578,30 +533,32 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
v_vat_disc_alloc := v_vat_disc_alloc + v_disc_amt;
|
v_vat_disc_alloc := v_vat_disc_alloc + v_disc_amt;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF v_disc_amt != 0 THEN
|
IF v_disc_amt > 0 THEN
|
||||||
v_unit_pret := v_disc_amt;
|
BEGIN
|
||||||
|
PACK_COMENZI.adauga_articol_comanda(
|
||||||
-- Search for existing entry with same (ptva, pret) to merge qty
|
V_ID_COMANDA => v_id_comanda,
|
||||||
v_kit_disc_found := FALSE;
|
V_ID_ARTICOL => v_disc_artid,
|
||||||
FOR j IN 1 .. v_kit_disc_count LOOP
|
V_ID_POL => NVL(p_kit_discount_id_pol, p_id_pol),
|
||||||
IF v_kit_disc_list(j).ptva = v_vat_key
|
V_CANTITATE => -1 * v_cantitate_web,
|
||||||
AND v_kit_disc_list(j).pret = v_unit_pret THEN
|
V_PRET => ROUND(v_disc_amt, v_nzec_pretv),
|
||||||
v_kit_disc_list(j).qty := v_kit_disc_list(j).qty + (-1 * v_cantitate_web);
|
V_ID_UTIL => c_id_util,
|
||||||
v_kit_disc_found := TRUE;
|
V_ID_SECTIE => p_id_sectie,
|
||||||
EXIT;
|
V_PTVA => v_vat_key);
|
||||||
END IF;
|
v_articole_procesate := v_articole_procesate + 1;
|
||||||
END LOOP;
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
IF NOT v_kit_disc_found THEN
|
v_articole_eroare := v_articole_eroare + 1;
|
||||||
v_kit_disc_count := v_kit_disc_count + 1;
|
g_last_error := g_last_error || CHR(10) ||
|
||||||
v_kit_disc_list(v_kit_disc_count).ptva := v_vat_key;
|
'Eroare linie discount kit TVA=' || v_vat_key ||
|
||||||
v_kit_disc_list(v_kit_disc_count).pret := v_unit_pret;
|
'% codmat=' || p_kit_discount_codmat || ': ' || SQLERRM;
|
||||||
v_kit_disc_list(v_kit_disc_count).qty := -1 * v_cantitate_web;
|
END;
|
||||||
END IF;
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
v_vat_key := v_vat_disc.NEXT(v_vat_key);
|
v_vat_key := v_vat_disc.NEXT(v_vat_key);
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
END IF;
|
||||||
END; -- end mode B per-kit block
|
END; -- end mode B per-kit block
|
||||||
END IF; -- end kit mode branching
|
END IF; -- end kit mode branching
|
||||||
|
|
||||||
@@ -686,43 +643,6 @@ CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
|
|||||||
|
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- INSERARE DISCOUNT-URI KIT DEFERRED (separate_line)
|
|
||||||
-- Linii cu preturi diferite raman separate, coliziuni merged pe qty
|
|
||||||
-- ============================================================
|
|
||||||
IF p_kit_mode = 'separate_line' AND v_kit_disc_count > 0 THEN
|
|
||||||
DECLARE
|
|
||||||
v_disc_artid NUMBER;
|
|
||||||
BEGIN
|
|
||||||
v_disc_artid := resolve_id_articol(p_kit_discount_codmat, p_id_gestiune);
|
|
||||||
|
|
||||||
IF v_disc_artid IS NOT NULL THEN
|
|
||||||
FOR j IN 1 .. v_kit_disc_count LOOP
|
|
||||||
BEGIN
|
|
||||||
PACK_COMENZI.adauga_articol_comanda(
|
|
||||||
V_ID_COMANDA => v_id_comanda,
|
|
||||||
V_ID_ARTICOL => v_disc_artid,
|
|
||||||
V_ID_POL => NVL(p_kit_discount_id_pol, p_id_pol),
|
|
||||||
V_CANTITATE => v_kit_disc_list(j).qty,
|
|
||||||
V_PRET => v_kit_disc_list(j).pret,
|
|
||||||
V_ID_UTIL => c_id_util,
|
|
||||||
V_ID_SECTIE => p_id_sectie,
|
|
||||||
V_PTVA => v_kit_disc_list(j).ptva);
|
|
||||||
v_articole_procesate := v_articole_procesate + 1;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
v_articole_eroare := v_articole_eroare + 1;
|
|
||||||
g_last_error := g_last_error || CHR(10) ||
|
|
||||||
'Eroare linie discount kit TVA=' || v_kit_disc_list(j).ptva ||
|
|
||||||
'% id_pol=' || NVL(p_kit_discount_id_pol, p_id_pol) ||
|
|
||||||
' id_art=' || v_disc_artid ||
|
|
||||||
' codmat=' || p_kit_discount_codmat || ': ' || SQLERRM;
|
|
||||||
END;
|
|
||||||
END LOOP;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Verifica daca s-au procesat articole cu succes
|
-- Verifica daca s-au procesat articole cu succes
|
||||||
IF v_articole_procesate = 0 THEN
|
IF v_articole_procesate = 0 THEN
|
||||||
g_last_error := g_last_error || CHR(10) || 'IMPORTA_COMANDA ' ||
|
g_last_error := g_last_error || CHR(10) || 'IMPORTA_COMANDA ' ||
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
-- ====================================================================
|
|
||||||
-- Import mapari kituri (seturi cu componente multiple) in ARTICOLE_TERTI
|
|
||||||
-- Sursa: kituri site.csv
|
|
||||||
-- Data: 2026-03-20
|
|
||||||
-- Schema: VENDING (productie)
|
|
||||||
-- ====================================================================
|
|
||||||
|
|
||||||
-- Kit revizie grup Wittenborg 7100
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5002' sku, '2517572' codmat, 3 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5002' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5002' sku, '094594' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie garnituri grup Wittenborg 7100 originale
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5002-2331' sku, '251757' codmat, 3 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5002-2331' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5002-2331' sku, '094594' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie boiler Necta 300cc
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5003' sku, '098701' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5003' sku, '099059' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5003' sku, '254711' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5003' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5003' sku, '095624' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie garnituri boiler Necta Astro Spazio 600cc
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004-5988' sku, 'DV099748' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004-5988' sku, '252538' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004-5988' sku, '254711' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004-5988' sku, '095624' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie grup Necta 7gr
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5000' sku, '093167' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5000' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5000' sku, '094611' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie grup Necta 9gr
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5001' sku, '2517572' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5001' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5001' sku, '094611' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit butoane selectie zahar Necta Astro Zenith
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '0V2071/250159/250158' sku, '0V2071' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '0V2071/250159/250158' sku, '250158' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '0V2071/250159/250158' sku, '250159' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie grup Necta Opera/9100 D38
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5008890' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5008890' sku, '094611' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5008890' sku, '093167' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie grup Necta Opera/9100 D46
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT 'VM515' sku, '251757' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT 'VM515' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT 'VM515' sku, '094611' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT 'VM515' sku, '0V0782' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT 'VM515' sku, '254650' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie boiler Necta/Wittenborg 600cc
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004' sku, '099059' codmat, 3 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004' sku, 'DV099748' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004' sku, '254711' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004' sku, '095624' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5004' sku, '252538' codmat, 1 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
-- Kit revizie rasnita Necta
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5006' sku, '095840' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
MERGE INTO ARTICOLE_TERTI t USING (SELECT '5006' sku, '0V3229' codmat, 2 cantitate_roa FROM DUAL) s ON (t.sku = s.sku AND t.codmat = s.codmat) WHEN MATCHED THEN UPDATE SET t.cantitate_roa = s.cantitate_roa, t.activ = 1, t.sters = 0, t.data_modif = SYSDATE WHEN NOT MATCHED THEN INSERT (sku, codmat, cantitate_roa, activ, sters, data_creare, data_modif, id_util_creare) VALUES (s.sku, s.codmat, s.cantitate_roa, 1, 0, SYSDATE, SYSDATE, -3);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
54
api/database-scripts/09_articole_terti_050.sql
Normal file
54
api/database-scripts/09_articole_terti_050.sql
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
-- ====================================================================
|
||||||
|
-- 09_articole_terti_050.sql
|
||||||
|
-- Mapări ARTICOLE_TERTI cu cantitate_roa = 0.5 pentru articole
|
||||||
|
-- unde unitatea web (50 buc/set) ≠ unitatea ROA (100 buc/set).
|
||||||
|
--
|
||||||
|
-- Efect: price sync va calcula pret_crm = pret_web / 0.5,
|
||||||
|
-- iar kit pricing va folosi prețul corect per set ROA.
|
||||||
|
--
|
||||||
|
-- 25.03.2026 - creat pentru fix discount negativ kit pahare
|
||||||
|
-- ====================================================================
|
||||||
|
|
||||||
|
-- Pahar 6oz Coffee Coffee SIBA 50buc (GoMag) → 100buc/set (ROA)
|
||||||
|
INSERT INTO articole_terti (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||||
|
SELECT '1708828', '1708828', 0.5, 1, 0, SYSDATE, -3 FROM dual
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM articole_terti WHERE sku = '1708828' AND codmat = '1708828' AND sters = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pahar 8oz Coffee Coffee SIBA 50buc → 100buc/set
|
||||||
|
INSERT INTO articole_terti (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||||
|
SELECT '528795', '528795', 0.5, 1, 0, SYSDATE, -3 FROM dual
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM articole_terti WHERE sku = '528795' AND codmat = '528795' AND sters = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pahar 8oz Tchibo 50buc → 100buc/set
|
||||||
|
INSERT INTO articole_terti (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||||
|
SELECT '58', '58', 0.5, 1, 0, SYSDATE, -3 FROM dual
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM articole_terti WHERE sku = '58' AND codmat = '58' AND sters = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pahar 7oz Lavazza SIBA 50buc → 100buc/set
|
||||||
|
INSERT INTO articole_terti (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||||
|
SELECT '51', '51', 0.5, 1, 0, SYSDATE, -3 FROM dual
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM articole_terti WHERE sku = '51' AND codmat = '51' AND sters = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pahar 8oz Albastru JND 50buc → 100buc/set
|
||||||
|
INSERT INTO articole_terti (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||||
|
SELECT '105712338826', '105712338826', 0.5, 1, 0, SYSDATE, -3 FROM dual
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM articole_terti WHERE sku = '105712338826' AND codmat = '105712338826' AND sters = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pahar 8oz Paris JND 50buc → 100buc/set
|
||||||
|
INSERT INTO articole_terti (sku, codmat, cantitate_roa, activ, sters, data_creare, id_util_creare)
|
||||||
|
SELECT '10573080', '10573080', 0.5, 1, 0, SYSDATE, -3 FROM dual
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM articole_terti WHERE sku = '10573080' AND codmat = '10573080' AND sters = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
2982
api/database-scripts/10_PACK_DEF.pck
Normal file
2982
api/database-scripts/10_PACK_DEF.pck
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,79 +0,0 @@
|
|||||||
-- =============================================================================
|
|
||||||
-- Script mapari articole GoMag → ROA
|
|
||||||
-- Generat: 2026-03-19
|
|
||||||
-- Baza: vending | Server: vending
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- =============================================
|
|
||||||
-- PARTEA 1: Update CODMAT in NOM_ARTICOLE
|
|
||||||
-- =============================================
|
|
||||||
|
|
||||||
-- id=2020 LAVAZZA BBE EXPERT GUSTO FORTE — CODMAT lipseste (NULL)
|
|
||||||
UPDATE nom_articole SET codmat = '8000070028685' WHERE id_articol = 2020 AND codmat IS NULL;
|
|
||||||
|
|
||||||
-- id=4345 MY POS SIGMA — lowercase ca sa fie identic cu SKU GoMag
|
|
||||||
UPDATE nom_articole SET codmat = 'mypossigma' WHERE id_articol = 4345 AND codmat = 'MYPOSSIGMA';
|
|
||||||
|
|
||||||
-- =============================================
|
|
||||||
-- PARTEA 2: Mapari ARTICOLE_TERTI (sku != codmat)
|
|
||||||
-- =============================================
|
|
||||||
|
|
||||||
-- Fresso — EAN-uri diferite de codmat intern
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026295', 'FRSBRZ1000', 1, 1, 0); -- Fresso Brazilia 1kg
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031062538', 'FRSEVK1000', 1, 1, 0); -- Fresso Evoke blend 1kg
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026325', 'FRSCLB1000', 1, 1, 0); -- Fresso Columbia Caldas 1kg
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026356', 'FRSCRA1000', 1, 1, 0); -- Fresso Costa Rica Tarrazu 1kg
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026462', 'FRSETP250', 1, 1, 0); -- Fresso Etiopia 250g
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026479', 'FRSETP500', 1, 1, 0); -- Fresso Etiopia 500g
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031026486', 'FRSETP1000', 1, 1, 0); -- Fresso Etiopia 1kg
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5940031044138', 'FRSEVK250', 1, 1, 0); -- Fresso Evoke blend 250g
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('59400310625381000MI', 'FRSEVK1000', 1, 1, 0); -- Fresso Evoke macinata 1kg
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('FBS500PE', 'FRSBRZ500', 1, 1, 0); -- Fresso Brazilia 500g macinata
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('FEY250PI', 'FRSETP250', 1, 1, 0); -- Fresso Etiopia 250g macinata
|
|
||||||
|
|
||||||
-- Tchibo / Lavazza / alte branduri — EAN-uri diferite
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('4006067176463', 'SUISSE500', 1, 1, 0); -- Tchibo Cafe Creme Suisse 500g
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('69891863', '8000070038493', 1, 1, 0); -- Lavazza Crema e Gusto Forte 1Kg
|
|
||||||
|
|
||||||
-- Piese / accesorii — coduri diferite
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('65221', '33.7006.5221', 1, 1, 0); -- Pastile curatare Schaerer
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('C7774', 'COL100', 1, 1, 0); -- Eticheta colant cu pret
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('MEICF7900', 'MEICF560', 1, 1, 0); -- Restiera MEI Cashflow CF 7900
|
|
||||||
|
|
||||||
-- =============================================
|
|
||||||
-- PARTEA 3: Mapari ARTICOLE_TERTI — impachetari diferite (cantitate != 1)
|
|
||||||
-- =============================================
|
|
||||||
|
|
||||||
-- Prolait/Regilait/Ristora 500g — ROA tine in KG sau BUC, 500g = 0.5
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('8004990125530', '8004990125530', 0.5, 1, 0); -- Prolait Topping Blue 500g (UM=KG)
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('3043937103250', '3043937103250', 0.5, 1, 0); -- Regilait Topping 2 Green 500g (UM=KG)
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('8004990123680', '8004990123680', 0.5, 1, 0); -- Ristora Top Lapte Granulat 500g
|
|
||||||
|
|
||||||
-- Pahare — baxuri mari (1 bax web = N seturi ROA de 100buc)
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('10008ozparis', '10573080', 10, 1, 0); -- Pahar 8oz Paris bax 1000 = 10 seturi
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('100012ozlvzJND', '58912326634', 10, 1, 0); -- Pahar 12oz Lavazza JND bax 1000 = 10 seturi
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('589123214745675', '8OZLRLP', 10, 1, 0); -- Pahar 8oz Lavazza RLP bax 1000 = 10 seturi
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('10008ozTchibo', '58', 10, 1, 0); -- Pahar 8oz Tchibo bax 1000 = 10 seturi
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('10008ozBlueJND', '105712338826', 10, 1, 0); -- Pahar 8oz Albastru JND bax 1000 = 10 seturi
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('30006ozLavazza', '169', 30, 1, 0); -- Pahar 6oz Lavazza RLP bax 3000 = 30 seturi
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('30007ozLavazza', '1655455', 30, 1, 0); -- Pahar 7oz Lavazza RLP bax 3000 = 30 seturi
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('22507ozLavazza', '51', 22.5, 1, 0); -- Pahar 7oz Lavazza SIBA bax 2250 = 22.5 seturi
|
|
||||||
|
|
||||||
-- Pahare — ambalaje mici (50buc = 0.5 set de 100)
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('5891232122239', '8OZLRLP', 0.5, 1, 0); -- Pahar 8oz Albastru RLP 50buc = 0.5 set
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('87872376', '87872376', 0.5, 1, 0); -- Pahar 7oz Lavazza JND 50buc = 0.5 set
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('6ozFloMAZ', '6OZFLOMAZ', 0.5, 1, 0); -- Pahar 6oz Floral MAZ 50buc = 0.5 set
|
|
||||||
|
|
||||||
-- Pachet cafea
|
|
||||||
INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, activ, sters) VALUES ('6ktcs', 'SUISSE500', 10, 1, 0); -- Pachet 5kg Tchibo Suisse = 10x500g
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
-- =============================================
|
|
||||||
-- VERIFICARE
|
|
||||||
-- =============================================
|
|
||||||
-- SELECT at.sku, at.codmat, na.denumire, na.um, at.cantitate_roa, at.activ
|
|
||||||
-- FROM ARTICOLE_TERTI at
|
|
||||||
-- LEFT JOIN nom_articole na ON na.codmat = at.codmat AND na.sters = 0
|
|
||||||
-- WHERE at.sters = 0
|
|
||||||
-- ORDER BY at.sku;
|
|
||||||
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()
|
||||||
|
|||||||
@@ -47,34 +47,39 @@ def test_order_id(oracle_connection):
|
|||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT MIN(id_partener) FROM parteneri WHERE id_partener > 0"
|
"SELECT MIN(id_part) FROM nom_parteneri WHERE id_part > 0"
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or row[0] is None:
|
if not row or row[0] is None:
|
||||||
pytest.skip("No partners found in Oracle — cannot create test order")
|
pytest.skip("No partners found in Oracle — cannot create test order")
|
||||||
partner_id = int(row[0])
|
partner_id = int(row[0])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
pytest.skip(f"Cannot query parteneri table: {exc}")
|
pytest.skip(f"Cannot query nom_parteneri table: {exc}")
|
||||||
|
|
||||||
# Build minimal JSON articles — use a SKU known from NOM_ARTICOLE if possible
|
# Find an article that has a price in some policy (required for import)
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute("""
|
||||||
"SELECT codmat FROM nom_articole WHERE rownum = 1"
|
SELECT na.codmat, cp.id_pol, cp.pret
|
||||||
)
|
FROM nom_articole na
|
||||||
|
JOIN crm_politici_pret_art cp ON cp.id_articol = na.id_articol
|
||||||
|
WHERE cp.pret > 0 AND na.codmat IS NOT NULL AND rownum = 1
|
||||||
|
""")
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
test_sku = row[0] if row else "CAFE100"
|
if not row:
|
||||||
|
pytest.skip("No articles with prices found in Oracle — cannot create test order")
|
||||||
|
test_sku, id_pol, test_price = row[0], int(row[1]), float(row[2])
|
||||||
|
|
||||||
nr_comanda_ext = f"PYTEST-{int(time.time())}"
|
nr_comanda_ext = f"PYTEST-{int(time.time())}"
|
||||||
|
# Values must be strings — Oracle's JSON_OBJECT_T.get_string() returns NULL for numbers
|
||||||
articles = json.dumps([{
|
articles = json.dumps([{
|
||||||
"sku": test_sku,
|
"sku": test_sku,
|
||||||
"cantitate": 1,
|
"quantity": "1",
|
||||||
"pret": 50.0,
|
"price": str(test_price),
|
||||||
"denumire": "Test article (pytest)",
|
"vat": "19",
|
||||||
"tva": 19,
|
|
||||||
"discount": 0,
|
|
||||||
}])
|
}])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from datetime import datetime
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
clob_var = cur.var(oracledb.DB_TYPE_CLOB)
|
clob_var = cur.var(oracledb.DB_TYPE_CLOB)
|
||||||
clob_var.setvalue(0, articles)
|
clob_var.setvalue(0, articles)
|
||||||
@@ -82,12 +87,12 @@ def test_order_id(oracle_connection):
|
|||||||
|
|
||||||
cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [
|
cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [
|
||||||
nr_comanda_ext, # p_nr_comanda_ext
|
nr_comanda_ext, # p_nr_comanda_ext
|
||||||
None, # p_data_comanda (NULL = SYSDATE in pkg)
|
datetime.now(), # p_data_comanda
|
||||||
partner_id, # p_id_partener
|
partner_id, # p_id_partener
|
||||||
clob_var, # p_json_articole
|
clob_var, # p_json_articole
|
||||||
None, # p_id_adresa_livrare
|
None, # p_id_adresa_livrare
|
||||||
None, # p_id_adresa_facturare
|
None, # p_id_adresa_facturare
|
||||||
None, # p_id_pol
|
id_pol, # p_id_pol
|
||||||
None, # p_id_sectie
|
None, # p_id_sectie
|
||||||
None, # p_id_gestiune
|
None, # p_id_gestiune
|
||||||
None, # p_kit_mode
|
None, # p_kit_mode
|
||||||
@@ -122,11 +127,11 @@ def test_order_id(oracle_connection):
|
|||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"DELETE FROM comenzi_articole WHERE id_comanda = :id",
|
"DELETE FROM comenzi_elemente WHERE id_comanda = :id",
|
||||||
{"id": order_id}
|
{"id": order_id}
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"DELETE FROM com_antet WHERE id_comanda = :id",
|
"DELETE FROM comenzi WHERE id_comanda = :id",
|
||||||
{"id": order_id}
|
{"id": order_id}
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -193,7 +198,7 @@ def test_cleanup_test_order(oracle_connection, test_order_id):
|
|||||||
|
|
||||||
with oracle_connection.cursor() as cur:
|
with oracle_connection.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT COUNT(*) FROM com_antet WHERE id_comanda = :id",
|
"SELECT COUNT(*) FROM comenzi WHERE id_comanda = :id",
|
||||||
{"id": test_order_id}
|
{"id": test_order_id}
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -119,7 +119,8 @@ def test_mobile_table_responsive(pw_browser, base_url: str, page_path: str):
|
|||||||
|
|
||||||
tables = page.locator("table").all()
|
tables = page.locator("table").all()
|
||||||
if not tables:
|
if not tables:
|
||||||
pytest.skip(f"No tables on {page_path} (empty state)")
|
# No tables means nothing to check — pass (no non-responsive tables exist)
|
||||||
|
return
|
||||||
|
|
||||||
# Check each table has an ancestor with overflow-x scroll or .table-responsive class
|
# Check each table has an ancestor with overflow-x scroll or .table-responsive class
|
||||||
for table in tables:
|
for table in tables:
|
||||||
|
|||||||
494
api/tests/test_business_rules.py
Normal file
494
api/tests/test_business_rules.py
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
"""
|
||||||
|
Business Rule Regression Tests
|
||||||
|
==============================
|
||||||
|
Regression tests for historical bug fixes in kit pricing, discount calculation,
|
||||||
|
duplicate CODMAT resolution, price sync, and VAT normalization.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
cd api && python -m pytest tests/test_business_rules.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
# --- Set env vars BEFORE any app import ---
|
||||||
|
_tmpdir = tempfile.mkdtemp()
|
||||||
|
_sqlite_path = os.path.join(_tmpdir, "test_biz.db")
|
||||||
|
|
||||||
|
os.environ["FORCE_THIN_MODE"] = "true"
|
||||||
|
os.environ["SQLITE_DB_PATH"] = _sqlite_path
|
||||||
|
os.environ["ORACLE_DSN"] = "dummy"
|
||||||
|
os.environ["ORACLE_USER"] = "dummy"
|
||||||
|
os.environ["ORACLE_PASSWORD"] = "dummy"
|
||||||
|
os.environ["JSON_OUTPUT_DIR"] = _tmpdir
|
||||||
|
|
||||||
|
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _api_dir not in sys.path:
|
||||||
|
sys.path.insert(0, _api_dir)
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from app.services.import_service import build_articles_json, compute_discount_split
|
||||||
|
from app.services.order_reader import OrderData, OrderItem
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def make_item(sku="SKU1", price=100.0, quantity=1, vat=19):
|
||||||
|
return OrderItem(sku=sku, name=f"Product {sku}", price=price, quantity=quantity, vat=vat)
|
||||||
|
|
||||||
|
|
||||||
|
def make_order(items, discount_total=0.0, delivery_cost=0.0, discount_vat=None):
|
||||||
|
order = OrderData(
|
||||||
|
id="1", number="TEST-001", date="2026-01-01",
|
||||||
|
items=items, discount_total=discount_total,
|
||||||
|
delivery_cost=delivery_cost,
|
||||||
|
)
|
||||||
|
if discount_vat is not None:
|
||||||
|
order.discount_vat = discount_vat
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def is_kit(comps):
|
||||||
|
"""Kit detection pattern used in validation_service and price_sync_service."""
|
||||||
|
return len(comps) > 1 or (
|
||||||
|
len(comps) == 1 and (comps[0].get("cantitate_roa") or 1) > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 1: compute_discount_split()
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestDiscountSplit:
|
||||||
|
"""Regression: discount split by VAT rate (import_service.py:63)."""
|
||||||
|
|
||||||
|
def test_single_vat_rate(self):
|
||||||
|
order = make_order([make_item(vat=19), make_item("SKU2", vat=19)], discount_total=10.0)
|
||||||
|
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||||||
|
assert result == {"19": 10.0}
|
||||||
|
|
||||||
|
def test_multiple_vat_proportional(self):
|
||||||
|
items = [make_item("A", price=100, quantity=1, vat=19),
|
||||||
|
make_item("B", price=50, quantity=1, vat=9)]
|
||||||
|
order = make_order(items, discount_total=15.0)
|
||||||
|
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||||||
|
assert result == {"9": 5.0, "19": 10.0}
|
||||||
|
|
||||||
|
def test_zero_returns_none(self):
|
||||||
|
order = make_order([make_item()], discount_total=0)
|
||||||
|
assert compute_discount_split(order, {"split_discount_vat": "1"}) is None
|
||||||
|
|
||||||
|
def test_zero_price_items_excluded(self):
|
||||||
|
items = [make_item("A", price=0, quantity=1, vat=19),
|
||||||
|
make_item("B", price=100, quantity=2, vat=9)]
|
||||||
|
order = make_order(items, discount_total=5.0)
|
||||||
|
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||||||
|
assert result == {"9": 5.0}
|
||||||
|
|
||||||
|
def test_disabled_multiple_rates(self):
|
||||||
|
items = [make_item("A", vat=19), make_item("B", vat=9)]
|
||||||
|
order = make_order(items, discount_total=10.0)
|
||||||
|
result = compute_discount_split(order, {"split_discount_vat": "0"})
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_rounding_remainder(self):
|
||||||
|
items = [make_item("A", price=33.33, quantity=1, vat=19),
|
||||||
|
make_item("B", price=33.33, quantity=1, vat=9),
|
||||||
|
make_item("C", price=33.34, quantity=1, vat=5)]
|
||||||
|
order = make_order(items, discount_total=10.0)
|
||||||
|
result = compute_discount_split(order, {"split_discount_vat": "1"})
|
||||||
|
assert result is not None
|
||||||
|
assert abs(sum(result.values()) - 10.0) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 2: build_articles_json()
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestBuildArticlesJson:
|
||||||
|
"""Regression: discount lines, policy bridge, transport (import_service.py:117)."""
|
||||||
|
|
||||||
|
def test_discount_line_negative_quantity(self):
|
||||||
|
items = [make_item()]
|
||||||
|
order = make_order(items, discount_total=5.0)
|
||||||
|
settings = {"discount_codmat": "DISC01", "split_discount_vat": "0"}
|
||||||
|
result = json.loads(build_articles_json(items, order, settings))
|
||||||
|
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||||||
|
assert len(disc_lines) == 1
|
||||||
|
assert disc_lines[0]["quantity"] == "-1"
|
||||||
|
assert disc_lines[0]["price"] == "5.0"
|
||||||
|
|
||||||
|
def test_discount_uses_actual_vat_not_21(self):
|
||||||
|
items = [make_item("A", vat=9), make_item("B", vat=9)]
|
||||||
|
order = make_order(items, discount_total=3.0)
|
||||||
|
settings = {"discount_codmat": "DISC01", "split_discount_vat": "1"}
|
||||||
|
result = json.loads(build_articles_json(items, order, settings))
|
||||||
|
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||||||
|
assert len(disc_lines) == 1
|
||||||
|
assert disc_lines[0]["vat"] == "9"
|
||||||
|
|
||||||
|
def test_discount_multi_vat_creates_multiple_lines(self):
|
||||||
|
items = [make_item("A", price=100, vat=19), make_item("B", price=50, vat=9)]
|
||||||
|
order = make_order(items, discount_total=15.0)
|
||||||
|
settings = {"discount_codmat": "DISC01", "split_discount_vat": "1"}
|
||||||
|
result = json.loads(build_articles_json(items, order, settings))
|
||||||
|
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||||||
|
assert len(disc_lines) == 2
|
||||||
|
vats = {d["vat"] for d in disc_lines}
|
||||||
|
assert "9" in vats
|
||||||
|
assert "19" in vats
|
||||||
|
|
||||||
|
def test_discount_fallback_uses_gomag_vat(self):
|
||||||
|
items = [make_item("A", vat=19), make_item("B", vat=9)]
|
||||||
|
order = make_order(items, discount_total=5.0, discount_vat="9")
|
||||||
|
settings = {"discount_codmat": "DISC01", "split_discount_vat": "0"}
|
||||||
|
result = json.loads(build_articles_json(items, order, settings))
|
||||||
|
disc_lines = [a for a in result if a["sku"] == "DISC01"]
|
||||||
|
assert len(disc_lines) == 1
|
||||||
|
assert disc_lines[0]["vat"] == "9"
|
||||||
|
|
||||||
|
def test_per_article_policy_bridge(self):
|
||||||
|
items = [make_item("SKU1")]
|
||||||
|
settings = {"_codmat_policy_map": {"SKU1": 42}, "id_pol": "1"}
|
||||||
|
result = json.loads(build_articles_json(items, settings=settings))
|
||||||
|
assert result[0]["id_pol"] == "42"
|
||||||
|
|
||||||
|
def test_policy_same_as_default_omitted(self):
|
||||||
|
items = [make_item("SKU1")]
|
||||||
|
settings = {"_codmat_policy_map": {"SKU1": 1}, "id_pol": "1"}
|
||||||
|
result = json.loads(build_articles_json(items, settings=settings))
|
||||||
|
assert "id_pol" not in result[0]
|
||||||
|
|
||||||
|
def test_transport_line_added(self):
|
||||||
|
items = [make_item()]
|
||||||
|
order = make_order(items, delivery_cost=15.0)
|
||||||
|
settings = {"transport_codmat": "TR", "transport_vat": "19"}
|
||||||
|
result = json.loads(build_articles_json(items, order, settings))
|
||||||
|
tr_lines = [a for a in result if a["sku"] == "TR"]
|
||||||
|
assert len(tr_lines) == 1
|
||||||
|
assert tr_lines[0]["quantity"] == "1"
|
||||||
|
assert tr_lines[0]["price"] == "15.0"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 3: Kit Detection Pattern
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestKitDetection:
|
||||||
|
"""Regression: kit detection for single-component repackaging (multiple code locations)."""
|
||||||
|
|
||||||
|
def test_multi_component(self):
|
||||||
|
comps = [{"codmat": "A", "cantitate_roa": 1}, {"codmat": "B", "cantitate_roa": 1}]
|
||||||
|
assert is_kit(comps) is True
|
||||||
|
|
||||||
|
def test_single_component_repackaging(self):
|
||||||
|
comps = [{"codmat": "CAF01", "cantitate_roa": 10}]
|
||||||
|
assert is_kit(comps) is True
|
||||||
|
|
||||||
|
def test_true_1to1_not_kit(self):
|
||||||
|
comps = [{"codmat": "X", "cantitate_roa": 1}]
|
||||||
|
assert is_kit(comps) is False
|
||||||
|
|
||||||
|
def test_none_cantitate_treated_as_1(self):
|
||||||
|
comps = [{"codmat": "X", "cantitate_roa": None}]
|
||||||
|
assert is_kit(comps) is False
|
||||||
|
|
||||||
|
def test_empty_components(self):
|
||||||
|
assert is_kit([]) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 4: sync_prices_from_order() — Kit Skip Logic
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestSyncPricesKitSkip:
|
||||||
|
"""Regression: kit SKUs must be skipped in order-based price sync."""
|
||||||
|
|
||||||
|
def _make_mock_order(self, sku, price=50.0):
|
||||||
|
mock_order = MagicMock()
|
||||||
|
mock_item = MagicMock()
|
||||||
|
mock_item.sku = sku
|
||||||
|
mock_item.price = price
|
||||||
|
mock_order.items = [mock_item]
|
||||||
|
return mock_order
|
||||||
|
|
||||||
|
@patch("app.services.validation_service.compare_and_update_price")
|
||||||
|
def test_skips_multi_component_kit(self, mock_compare):
|
||||||
|
from app.services.validation_service import sync_prices_from_order
|
||||||
|
orders = [self._make_mock_order("KIT01")]
|
||||||
|
mapped = {"KIT01": [
|
||||||
|
{"codmat": "A", "id_articol": 1, "cantitate_roa": 1},
|
||||||
|
{"codmat": "B", "id_articol": 2, "cantitate_roa": 1},
|
||||||
|
]}
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
sync_prices_from_order(orders, mapped, {}, {}, 1, conn=mock_conn,
|
||||||
|
settings={"price_sync_enabled": "1"})
|
||||||
|
mock_compare.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.services.validation_service.compare_and_update_price")
|
||||||
|
def test_skips_repackaging_kit(self, mock_compare):
|
||||||
|
from app.services.validation_service import sync_prices_from_order
|
||||||
|
orders = [self._make_mock_order("CAFE100")]
|
||||||
|
mapped = {"CAFE100": [
|
||||||
|
{"codmat": "CAF01", "id_articol": 1, "cantitate_roa": 10},
|
||||||
|
]}
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
sync_prices_from_order(orders, mapped, {}, {}, 1, conn=mock_conn,
|
||||||
|
settings={"price_sync_enabled": "1"})
|
||||||
|
mock_compare.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.services.validation_service.compare_and_update_price")
|
||||||
|
def test_processes_1to1_mapping(self, mock_compare):
|
||||||
|
from app.services.validation_service import sync_prices_from_order
|
||||||
|
mock_compare.return_value = {"updated": False, "old_price": 50.0, "new_price": 50.0, "codmat": "X"}
|
||||||
|
orders = [self._make_mock_order("SKU1", price=50.0)]
|
||||||
|
mapped = {"SKU1": [
|
||||||
|
{"codmat": "X", "id_articol": 100, "cantitate_roa": 1, "cont": "371"},
|
||||||
|
]}
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
sync_prices_from_order(orders, mapped, {}, {"SKU1": 1}, 1, conn=mock_conn,
|
||||||
|
settings={"price_sync_enabled": "1"})
|
||||||
|
mock_compare.assert_called_once()
|
||||||
|
call_args = mock_compare.call_args
|
||||||
|
assert call_args[0][0] == 100 # id_articol
|
||||||
|
assert call_args[0][2] == 50.0 # price
|
||||||
|
|
||||||
|
@patch("app.services.validation_service.compare_and_update_price")
|
||||||
|
def test_skips_transport_discount_codmats(self, mock_compare):
|
||||||
|
from app.services.validation_service import sync_prices_from_order
|
||||||
|
orders = [self._make_mock_order("TRANSP", price=15.0)]
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
sync_prices_from_order(orders, {}, {"TRANSP": {"id_articol": 99}}, {}, 1,
|
||||||
|
conn=mock_conn,
|
||||||
|
settings={"price_sync_enabled": "1",
|
||||||
|
"transport_codmat": "TRANSP",
|
||||||
|
"discount_codmat": "DISC"})
|
||||||
|
mock_compare.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 5: Kit Component with Own Mapping
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestKitComponentOwnMapping:
|
||||||
|
"""Regression: price_sync_service skips kit components that have their own ARTICOLE_TERTI mapping."""
|
||||||
|
|
||||||
|
def test_component_with_own_mapping_skipped(self):
|
||||||
|
"""If comp_codmat is itself a key in mapped_data, it's skipped."""
|
||||||
|
mapped_data = {
|
||||||
|
"PACK-A": [{"codmat": "COMP-X", "id_articol": 1, "cantitate_roa": 1, "cont": "371"}],
|
||||||
|
"COMP-X": [{"codmat": "COMP-X", "id_articol": 1, "cantitate_roa": 1, "cont": "371"}],
|
||||||
|
}
|
||||||
|
# The check is: if comp_codmat in mapped_data: continue
|
||||||
|
comp_codmat = "COMP-X"
|
||||||
|
assert comp_codmat in mapped_data # Should be skipped
|
||||||
|
|
||||||
|
def test_component_without_own_mapping_processed(self):
|
||||||
|
"""If comp_codmat is NOT in mapped_data, it should be processed."""
|
||||||
|
mapped_data = {
|
||||||
|
"PACK-A": [{"codmat": "COMP-Y", "id_articol": 2, "cantitate_roa": 1, "cont": "371"}],
|
||||||
|
}
|
||||||
|
comp_codmat = "COMP-Y"
|
||||||
|
assert comp_codmat not in mapped_data # Should be processed
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 6: VAT Included Type Normalization
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestVatIncludedNormalization:
|
||||||
|
"""Regression: GoMag returns vat_included as int 1 or string '1' (price_sync_service.py:144)."""
|
||||||
|
|
||||||
|
def _compute_price_cu_tva(self, product):
|
||||||
|
price = float(product.get("price", "0"))
|
||||||
|
vat = float(product.get("vat", "19"))
|
||||||
|
if str(product.get("vat_included", "1")) == "1":
|
||||||
|
return price
|
||||||
|
else:
|
||||||
|
return price * (1 + vat / 100)
|
||||||
|
|
||||||
|
def test_vat_included_int_1(self):
|
||||||
|
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": 1})
|
||||||
|
assert result == 100.0
|
||||||
|
|
||||||
|
def test_vat_included_str_1(self):
|
||||||
|
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": "1"})
|
||||||
|
assert result == 100.0
|
||||||
|
|
||||||
|
def test_vat_included_int_0(self):
|
||||||
|
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": 0})
|
||||||
|
assert result == 119.0
|
||||||
|
|
||||||
|
def test_vat_included_str_0(self):
|
||||||
|
result = self._compute_price_cu_tva({"price": "100", "vat": "19", "vat_included": "0"})
|
||||||
|
assert result == 119.0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 7: validate_kit_component_prices — pret=0 allowed
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestKitComponentPriceValidation:
|
||||||
|
"""Regression: pret=0 in CRM is valid for kit components (validation_service.py:469)."""
|
||||||
|
|
||||||
|
def _call_validate(self, fetchone_returns):
|
||||||
|
from app.services.validation_service import validate_kit_component_prices
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cursor = MagicMock()
|
||||||
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_cursor.fetchone.return_value = fetchone_returns
|
||||||
|
|
||||||
|
mapped = {"KIT-SKU": [
|
||||||
|
{"codmat": "COMP1", "id_articol": 100, "cont": "371", "cantitate_roa": 5},
|
||||||
|
]}
|
||||||
|
return validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn)
|
||||||
|
|
||||||
|
def test_price_zero_not_rejected(self):
|
||||||
|
result = self._call_validate((0,))
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_missing_entry_rejected(self):
|
||||||
|
result = self._call_validate(None)
|
||||||
|
assert "KIT-SKU" in result
|
||||||
|
assert "COMP1" in result["KIT-SKU"]
|
||||||
|
|
||||||
|
def test_skips_true_1to1(self):
|
||||||
|
from app.services.validation_service import validate_kit_component_prices
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mapped = {"SKU1": [
|
||||||
|
{"codmat": "X", "id_articol": 1, "cont": "371", "cantitate_roa": 1},
|
||||||
|
]}
|
||||||
|
result = validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn)
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_checks_repackaging(self):
|
||||||
|
"""Single component with cantitate_roa > 1 should be checked."""
|
||||||
|
from app.services.validation_service import validate_kit_component_prices
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cursor = MagicMock()
|
||||||
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_cursor.fetchone.return_value = (51.50,)
|
||||||
|
|
||||||
|
mapped = {"CAFE100": [
|
||||||
|
{"codmat": "CAF01", "id_articol": 100, "cont": "371", "cantitate_roa": 10},
|
||||||
|
]}
|
||||||
|
result = validate_kit_component_prices(mapped, id_pol=1, conn=mock_conn)
|
||||||
|
assert result == {}
|
||||||
|
mock_cursor.execute.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 8: Dual Policy Assignment
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestDualPolicyAssignment:
|
||||||
|
"""Regression: cont 341/345 → production policy, others → sales (validation_service.py:282)."""
|
||||||
|
|
||||||
|
def _call_dual(self, codmats, direct_id_map, cursor_rows):
|
||||||
|
from app.services.validation_service import validate_and_ensure_prices_dual
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cursor = MagicMock()
|
||||||
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
# The code uses `for row in cur:` to iterate, not fetchall
|
||||||
|
mock_cursor.__iter__ = MagicMock(return_value=iter(cursor_rows))
|
||||||
|
# Mock ensure_prices to do nothing
|
||||||
|
with patch("app.services.validation_service.ensure_prices"):
|
||||||
|
return validate_and_ensure_prices_dual(
|
||||||
|
codmats, id_pol_vanzare=1, id_pol_productie=2,
|
||||||
|
conn=mock_conn, direct_id_map=direct_id_map
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cont_341_production(self):
|
||||||
|
result = self._call_dual(
|
||||||
|
{"COD1"},
|
||||||
|
{"COD1": {"id_articol": 100, "cont": "341"}},
|
||||||
|
[] # no existing prices
|
||||||
|
)
|
||||||
|
assert result["COD1"] == 2 # id_pol_productie
|
||||||
|
|
||||||
|
def test_cont_345_production(self):
|
||||||
|
result = self._call_dual(
|
||||||
|
{"COD1"},
|
||||||
|
{"COD1": {"id_articol": 100, "cont": "345"}},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
assert result["COD1"] == 2
|
||||||
|
|
||||||
|
def test_other_cont_sales(self):
|
||||||
|
result = self._call_dual(
|
||||||
|
{"COD1"},
|
||||||
|
{"COD1": {"id_articol": 100, "cont": "371"}},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
assert result["COD1"] == 1 # id_pol_vanzare
|
||||||
|
|
||||||
|
def test_existing_sales_preferred(self):
|
||||||
|
result = self._call_dual(
|
||||||
|
{"COD1"},
|
||||||
|
{"COD1": {"id_articol": 100, "cont": "345"}},
|
||||||
|
[(100, 1), (100, 2)] # price exists in BOTH policies
|
||||||
|
)
|
||||||
|
assert result["COD1"] == 1 # sales preferred when both exist
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 9: Duplicate CODMAT — resolve_codmat_ids
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestResolveCodmatIds:
|
||||||
|
"""Regression: ROW_NUMBER dedup returns exactly 1 id_articol per CODMAT."""
|
||||||
|
|
||||||
|
@patch("app.services.validation_service.database")
|
||||||
|
def test_returns_one_per_codmat(self, mock_db):
|
||||||
|
from app.services.validation_service import resolve_codmat_ids
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cursor = MagicMock()
|
||||||
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
# Simulate ROW_NUMBER already deduped: 1 row per codmat
|
||||||
|
mock_cursor.__iter__ = MagicMock(return_value=iter([
|
||||||
|
("COD1", 100, "345"),
|
||||||
|
("COD2", 200, "341"),
|
||||||
|
]))
|
||||||
|
|
||||||
|
result = resolve_codmat_ids({"COD1", "COD2"}, conn=mock_conn)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result["COD1"]["id_articol"] == 100
|
||||||
|
assert result["COD2"]["id_articol"] == 200
|
||||||
|
|
||||||
|
@patch("app.services.validation_service.database")
|
||||||
|
def test_resolve_mapped_one_per_sku_codmat(self, mock_db):
|
||||||
|
from app.services.validation_service import resolve_mapped_codmats
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cursor = MagicMock()
|
||||||
|
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
# 1 row per (sku, codmat) pair
|
||||||
|
mock_cursor.__iter__ = MagicMock(return_value=iter([
|
||||||
|
("SKU1", "COD1", 100, "345", 10),
|
||||||
|
("SKU1", "COD2", 200, "341", 1),
|
||||||
|
]))
|
||||||
|
|
||||||
|
result = resolve_mapped_codmats({"SKU1"}, mock_conn)
|
||||||
|
assert "SKU1" in result
|
||||||
|
assert len(result["SKU1"]) == 2
|
||||||
|
codmats = [c["codmat"] for c in result["SKU1"]]
|
||||||
|
assert "COD1" in codmats
|
||||||
|
assert "COD2" in codmats
|
||||||
@@ -528,6 +528,379 @@ def test_repackaging_kit_pricing():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Group 10: Business Rule Regression Tests (Oracle integration)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def _create_test_partner(cur, suffix):
|
||||||
|
"""Helper: create a test partner and return its ID."""
|
||||||
|
partner_var = cur.var(oracledb.NUMBER)
|
||||||
|
name = f'Test BizRule {suffix}'
|
||||||
|
cur.execute("""
|
||||||
|
DECLARE v_id NUMBER;
|
||||||
|
BEGIN
|
||||||
|
v_id := PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener(
|
||||||
|
NULL, :name, 'JUD:Bucuresti;BUCURESTI;Str Test;1',
|
||||||
|
'0720000000', 'bizrule@test.com');
|
||||||
|
:result := v_id;
|
||||||
|
END;
|
||||||
|
""", {'name': name, 'result': partner_var})
|
||||||
|
return partner_var.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _import_order(cur, order_number, partner_id, articles_json, kit_mode='separate_line', id_pol=1):
|
||||||
|
"""Helper: call importa_comanda and return order ID."""
|
||||||
|
result_var = cur.var(oracledb.NUMBER)
|
||||||
|
cur.execute("""
|
||||||
|
DECLARE v_id NUMBER;
|
||||||
|
BEGIN
|
||||||
|
PACK_IMPORT_COMENZI.importa_comanda(
|
||||||
|
:order_number, SYSDATE, :partner_id,
|
||||||
|
:articles_json,
|
||||||
|
NULL, NULL,
|
||||||
|
:id_pol, NULL, NULL,
|
||||||
|
:kit_mode,
|
||||||
|
NULL, NULL, NULL,
|
||||||
|
v_id);
|
||||||
|
:result := v_id;
|
||||||
|
END;
|
||||||
|
""", {
|
||||||
|
'order_number': order_number,
|
||||||
|
'partner_id': partner_id,
|
||||||
|
'articles_json': articles_json,
|
||||||
|
'id_pol': id_pol,
|
||||||
|
'kit_mode': kit_mode,
|
||||||
|
'result': result_var
|
||||||
|
})
|
||||||
|
return result_var.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_order_lines(cur, order_id):
|
||||||
|
"""Helper: fetch COMENZI_ELEMENTE rows for an order."""
|
||||||
|
cur.execute("""
|
||||||
|
SELECT ce.CANTITATE, ce.PRET, na.CODMAT, ce.PTVA
|
||||||
|
FROM COMENZI_ELEMENTE ce
|
||||||
|
JOIN NOM_ARTICOLE na ON ce.ID_ARTICOL = na.ID_ARTICOL
|
||||||
|
WHERE ce.ID_COMANDA = :oid
|
||||||
|
ORDER BY ce.CANTITATE DESC, ce.PRET DESC
|
||||||
|
""", {'oid': order_id})
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_kit_discount_merge():
|
||||||
|
"""Regression (0666d6b): 2 identical kits at same VAT must merge discount lines,
|
||||||
|
not crash on duplicate check collision."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Multi-kit discount merge (separate_line)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
suffix = f'{datetime.now().strftime("%H%M%S")}-{random.randint(1000, 9999)}'
|
||||||
|
setup_test_data(cur)
|
||||||
|
partner_id = _create_test_partner(cur, suffix)
|
||||||
|
|
||||||
|
# 2 identical CAFE100 kits: total web = 2 * 450 = 900
|
||||||
|
articles_json = '[{"sku": "CAFE100", "cantitate": 2, "pret": 450}]'
|
||||||
|
order_id = _import_order(cur, f'TEST-BIZ-MERGE-{suffix}', partner_id, articles_json)
|
||||||
|
|
||||||
|
assert order_id and order_id > 0, "Order import failed"
|
||||||
|
rows = _get_order_lines(cur, order_id)
|
||||||
|
|
||||||
|
art_lines = [r for r in rows if r[0] > 0]
|
||||||
|
disc_lines = [r for r in rows if r[0] < 0]
|
||||||
|
assert len(art_lines) >= 1, f"Expected article line(s), got {len(art_lines)}"
|
||||||
|
assert len(disc_lines) >= 1, f"Expected discount line(s), got {len(disc_lines)}"
|
||||||
|
|
||||||
|
total = sum(r[0] * r[1] for r in rows)
|
||||||
|
expected = 900.0
|
||||||
|
print(f" Total: {total:.2f} (expected: {expected:.2f})")
|
||||||
|
assert abs(total - expected) < 0.02, f"Total {total:.2f} != expected {expected:.2f}"
|
||||||
|
print(" PASS")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_kit_discount_per_kit_placement():
|
||||||
|
"""Regression (580ca59): discount lines must appear after article lines (both present)."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Kit discount per-kit placement")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
suffix = f'{datetime.now().strftime("%H%M%S")}-{random.randint(1000, 9999)}'
|
||||||
|
setup_test_data(cur)
|
||||||
|
partner_id = _create_test_partner(cur, suffix)
|
||||||
|
|
||||||
|
articles_json = '[{"sku": "CAFE100", "cantitate": 1, "pret": 450}]'
|
||||||
|
order_id = _import_order(cur, f'TEST-BIZ-PLACE-{suffix}', partner_id, articles_json)
|
||||||
|
|
||||||
|
assert order_id and order_id > 0, "Order import failed"
|
||||||
|
rows = _get_order_lines(cur, order_id)
|
||||||
|
|
||||||
|
art_lines = [r for r in rows if r[0] > 0]
|
||||||
|
disc_lines = [r for r in rows if r[0] < 0]
|
||||||
|
print(f" Article lines: {len(art_lines)}, Discount lines: {len(disc_lines)}")
|
||||||
|
assert len(art_lines) >= 1, "No article line found"
|
||||||
|
assert len(disc_lines) >= 1, "No discount line found — kit pricing did not activate"
|
||||||
|
print(" PASS")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_repackaging_distributed_total_matches_web():
|
||||||
|
"""Regression (61ae58e): distributed mode total must match web price exactly."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Repackaging distributed total matches web")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
suffix = f'{datetime.now().strftime("%H%M%S")}-{random.randint(1000, 9999)}'
|
||||||
|
setup_test_data(cur)
|
||||||
|
partner_id = _create_test_partner(cur, suffix)
|
||||||
|
|
||||||
|
# 3 packs @ 400 lei => total web = 1200
|
||||||
|
articles_json = '[{"sku": "CAFE100", "cantitate": 3, "pret": 400}]'
|
||||||
|
order_id = _import_order(cur, f'TEST-BIZ-DIST-{suffix}', partner_id,
|
||||||
|
articles_json, kit_mode='distributed')
|
||||||
|
|
||||||
|
assert order_id and order_id > 0, "Order import failed"
|
||||||
|
rows = _get_order_lines(cur, order_id)
|
||||||
|
|
||||||
|
# Distributed: single line with adjusted price
|
||||||
|
positive_lines = [r for r in rows if r[0] > 0]
|
||||||
|
assert len(positive_lines) == 1, f"Expected 1 line in distributed mode, got {len(positive_lines)}"
|
||||||
|
|
||||||
|
total = positive_lines[0][0] * positive_lines[0][1]
|
||||||
|
expected = 1200.0
|
||||||
|
print(f" Line: qty={positive_lines[0][0]}, price={positive_lines[0][1]:.2f}")
|
||||||
|
print(f" Total: {total:.2f} (expected: {expected:.2f})")
|
||||||
|
assert abs(total - expected) < 0.02, f"Total {total:.2f} != expected {expected:.2f}"
|
||||||
|
print(" PASS")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_kit_markup_no_negative_discount():
|
||||||
|
"""Regression (47b5723): when web price > list price (markup), no discount line should be inserted."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Kit markup — no negative discount")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
suffix = f'{datetime.now().strftime("%H%M%S")}-{random.randint(1000, 9999)}'
|
||||||
|
setup_test_data(cur)
|
||||||
|
partner_id = _create_test_partner(cur, suffix)
|
||||||
|
|
||||||
|
# CAF01 list price = 51.50/unit, 10 units = 515
|
||||||
|
# Web price 600 > 515 => markup, no discount line
|
||||||
|
articles_json = '[{"sku": "CAFE100", "cantitate": 1, "pret": 600}]'
|
||||||
|
order_id = _import_order(cur, f'TEST-BIZ-MARKUP-{suffix}', partner_id, articles_json)
|
||||||
|
|
||||||
|
assert order_id and order_id > 0, "Order import failed"
|
||||||
|
rows = _get_order_lines(cur, order_id)
|
||||||
|
|
||||||
|
disc_lines = [r for r in rows if r[0] < 0]
|
||||||
|
print(f" Total lines: {len(rows)}, Discount lines: {len(disc_lines)}")
|
||||||
|
assert len(disc_lines) == 0, f"Expected 0 discount lines for markup, got {len(disc_lines)}"
|
||||||
|
print(" PASS")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_kit_component_price_zero_import():
|
||||||
|
"""Regression (1703232): kit components with pret=0 should import successfully."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Kit component price=0 import")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
suffix = f'{datetime.now().strftime("%H%M%S")}-{random.randint(1000, 9999)}'
|
||||||
|
setup_test_data(cur)
|
||||||
|
partner_id = _create_test_partner(cur, suffix)
|
||||||
|
|
||||||
|
# Temporarily set CAF01 price to 0
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE crm_politici_pret_art SET PRET = 0
|
||||||
|
WHERE id_articol = 9999001 AND id_pol = 1
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import with pret=0 — should succeed (discount = full web price)
|
||||||
|
articles_json = '[{"sku": "CAFE100", "cantitate": 1, "pret": 100}]'
|
||||||
|
order_id = _import_order(cur, f'TEST-BIZ-PRET0-{suffix}', partner_id, articles_json)
|
||||||
|
|
||||||
|
print(f" Order ID: {order_id}")
|
||||||
|
assert order_id and order_id > 0, "Order import failed with pret=0"
|
||||||
|
print(" PASS: Order imported successfully with pret=0")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
# Restore original price
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE crm_politici_pret_art SET PRET = 51.50
|
||||||
|
WHERE id_articol = 9999001 AND id_pol = 1
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Restore price on error
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE crm_politici_pret_art SET PRET = 51.50
|
||||||
|
WHERE id_articol = 9999001 AND id_pol = 1
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_codmat_different_prices():
|
||||||
|
"""Regression (95565af): same CODMAT at different prices should create separate lines,
|
||||||
|
discriminated by PRET + SIGN(CANTITATE)."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Duplicate CODMAT different prices")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
suffix = f'{datetime.now().strftime("%H%M%S")}-{random.randint(1000, 9999)}'
|
||||||
|
setup_test_data(cur)
|
||||||
|
partner_id = _create_test_partner(cur, suffix)
|
||||||
|
|
||||||
|
# Two articles both mapping to CAF01 but at different prices
|
||||||
|
# CAFE100 -> CAF01 via ARTICOLE_TERTI (kit pricing)
|
||||||
|
# We use separate_line mode so article gets list price 51.50
|
||||||
|
# Then a second article at a different price on the same CODMAT
|
||||||
|
# For this test, we import 2 separate orders to same CODMAT with different prices
|
||||||
|
# The real scenario: kit article line + discount line on same id_articol
|
||||||
|
|
||||||
|
articles_json = '[{"sku": "CAFE100", "cantitate": 1, "pret": 450}]'
|
||||||
|
order_id = _import_order(cur, f'TEST-BIZ-DUP-{suffix}', partner_id, articles_json)
|
||||||
|
|
||||||
|
assert order_id and order_id > 0, "Order import failed"
|
||||||
|
rows = _get_order_lines(cur, order_id)
|
||||||
|
|
||||||
|
# separate_line mode: article at list price + discount at negative qty
|
||||||
|
# Both reference same CODMAT (CAF01) but different PRET and SIGN(CANTITATE)
|
||||||
|
codmats = [r[2] for r in rows]
|
||||||
|
print(f" Lines: {len(rows)}")
|
||||||
|
for r in rows:
|
||||||
|
print(f" qty={r[0]}, pret={r[1]:.2f}, codmat={r[2]}")
|
||||||
|
|
||||||
|
# Should have at least 2 lines with same CODMAT but different qty sign
|
||||||
|
caf_lines = [r for r in rows if r[2] == 'CAF01']
|
||||||
|
assert len(caf_lines) >= 2, f"Expected 2+ CAF01 lines (article + discount), got {len(caf_lines)}"
|
||||||
|
signs = {1 if r[0] > 0 else -1 for r in caf_lines}
|
||||||
|
assert len(signs) == 2, "Expected both positive and negative quantity lines for same CODMAT"
|
||||||
|
print(" PASS: Same CODMAT with different PRET/SIGN coexist")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
try:
|
||||||
|
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
teardown_test_data(cur)
|
||||||
|
conn.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("Starting complete order import test...")
|
print("Starting complete order import test...")
|
||||||
print(f"Timestamp: {datetime.now()}")
|
print(f"Timestamp: {datetime.now()}")
|
||||||
@@ -536,16 +909,32 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
print(f"\nTest completed at: {datetime.now()}")
|
print(f"\nTest completed at: {datetime.now()}")
|
||||||
if success:
|
if success:
|
||||||
print("🎯 PHASE 1 VALIDATION: SUCCESSFUL")
|
print("PHASE 1 VALIDATION: SUCCESSFUL")
|
||||||
else:
|
else:
|
||||||
print("🔧 PHASE 1 VALIDATION: NEEDS ATTENTION")
|
print("PHASE 1 VALIDATION: NEEDS ATTENTION")
|
||||||
|
|
||||||
# Run repackaging kit pricing test
|
# Run repackaging kit pricing test
|
||||||
print("\n")
|
print("\n")
|
||||||
repack_success = test_repackaging_kit_pricing()
|
repack_success = test_repackaging_kit_pricing()
|
||||||
if repack_success:
|
if repack_success:
|
||||||
print("🎯 REPACKAGING KIT PRICING: SUCCESSFUL")
|
print("REPACKAGING KIT PRICING: SUCCESSFUL")
|
||||||
else:
|
else:
|
||||||
print("🔧 REPACKAGING KIT PRICING: NEEDS ATTENTION")
|
print("REPACKAGING KIT PRICING: NEEDS ATTENTION")
|
||||||
|
|
||||||
|
# Run business rule regression tests
|
||||||
|
print("\n")
|
||||||
|
biz_tests = [
|
||||||
|
("Multi-kit discount merge", test_multi_kit_discount_merge),
|
||||||
|
("Kit discount per-kit placement", test_kit_discount_per_kit_placement),
|
||||||
|
("Distributed total matches web", test_repackaging_distributed_total_matches_web),
|
||||||
|
("Markup no negative discount", test_kit_markup_no_negative_discount),
|
||||||
|
("Component price=0 import", test_kit_component_price_zero_import),
|
||||||
|
("Duplicate CODMAT different prices", test_duplicate_codmat_different_prices),
|
||||||
|
]
|
||||||
|
biz_passed = 0
|
||||||
|
for name, test_fn in biz_tests:
|
||||||
|
if test_fn():
|
||||||
|
biz_passed += 1
|
||||||
|
print(f"\nBusiness rule tests: {biz_passed}/{len(biz_tests)} passed")
|
||||||
|
|
||||||
exit(0 if success else 1)
|
exit(0 if success else 1)
|
||||||
@@ -82,46 +82,51 @@ def test_health_oracle_connected(client):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Test B: Mappings CRUD cycle (uses real CODMAT from Oracle nomenclator)
|
# Test B: Mappings CRUD cycle (uses real CODMAT from Oracle nomenclator)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
TEST_SKU = "PYTEST_INTEG_SKU_001"
|
@pytest.fixture(scope="module")
|
||||||
|
def test_sku():
|
||||||
|
"""Generate a unique test SKU per run to avoid conflicts with prior soft-deleted entries."""
|
||||||
|
import time
|
||||||
|
return f"PYTEST_SKU_{int(time.time())}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def real_codmat(client):
|
def real_codmat(client):
|
||||||
"""Find a real CODMAT from Oracle nomenclator to use in mappings tests."""
|
"""Find a real CODMAT from Oracle nomenclator to use in mappings tests."""
|
||||||
resp = client.get("/api/articles/search", params={"q": "A"})
|
# min_length=2 on the endpoint, so use 2+ char search terms
|
||||||
if resp.status_code != 200:
|
for term in ["01", "PH", "CA"]:
|
||||||
pytest.skip("Articles search unavailable")
|
resp = client.get("/api/articles/search", params={"q": term})
|
||||||
|
if resp.status_code == 200:
|
||||||
results = resp.json().get("results", [])
|
results = resp.json().get("results", [])
|
||||||
if not results:
|
if results:
|
||||||
pytest.skip("No articles found in Oracle for CRUD test")
|
|
||||||
return results[0]["codmat"]
|
return results[0]["codmat"]
|
||||||
|
pytest.skip("No articles found in Oracle for CRUD test")
|
||||||
|
|
||||||
|
|
||||||
def test_mappings_create(client, real_codmat):
|
def test_mappings_create(client, real_codmat, test_sku):
|
||||||
resp = client.post("/api/mappings", json={
|
resp = client.post("/api/mappings", json={
|
||||||
"sku": TEST_SKU,
|
"sku": test_sku,
|
||||||
"codmat": real_codmat,
|
"codmat": real_codmat,
|
||||||
"cantitate_roa": 2.5,
|
"cantitate_roa": 2.5,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200, f"create returned {resp.status_code}: {resp.json()}"
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body.get("success") is True, f"create returned: {body}"
|
assert body.get("success") is True, f"create returned: {body}"
|
||||||
|
|
||||||
|
|
||||||
def test_mappings_list_after_create(client, real_codmat):
|
def test_mappings_list_after_create(client, real_codmat, test_sku):
|
||||||
resp = client.get("/api/mappings", params={"search": TEST_SKU})
|
resp = client.get("/api/mappings", params={"search": test_sku})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
mappings = body.get("mappings", [])
|
mappings = body.get("mappings", [])
|
||||||
found = any(
|
found = any(
|
||||||
m["sku"] == TEST_SKU and m["codmat"] == real_codmat
|
m["sku"] == test_sku and m["codmat"] == real_codmat
|
||||||
for m in mappings
|
for m in mappings
|
||||||
)
|
)
|
||||||
assert found, f"mapping not found in list; got {mappings}"
|
assert found, f"mapping not found in list; got {mappings}"
|
||||||
|
|
||||||
|
|
||||||
def test_mappings_update(client, real_codmat):
|
def test_mappings_update(client, real_codmat, test_sku):
|
||||||
resp = client.put(f"/api/mappings/{TEST_SKU}/{real_codmat}", json={
|
resp = client.put(f"/api/mappings/{test_sku}/{real_codmat}", json={
|
||||||
"cantitate_roa": 3.0,
|
"cantitate_roa": 3.0,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -129,25 +134,25 @@ def test_mappings_update(client, real_codmat):
|
|||||||
assert body.get("success") is True, f"update returned: {body}"
|
assert body.get("success") is True, f"update returned: {body}"
|
||||||
|
|
||||||
|
|
||||||
def test_mappings_delete(client, real_codmat):
|
def test_mappings_delete(client, real_codmat, test_sku):
|
||||||
resp = client.delete(f"/api/mappings/{TEST_SKU}/{real_codmat}")
|
resp = client.delete(f"/api/mappings/{test_sku}/{real_codmat}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body.get("success") is True, f"delete returned: {body}"
|
assert body.get("success") is True, f"delete returned: {body}"
|
||||||
|
|
||||||
|
|
||||||
def test_mappings_verify_soft_deleted(client, real_codmat):
|
def test_mappings_verify_soft_deleted(client, real_codmat, test_sku):
|
||||||
resp = client.get("/api/mappings", params={"search": TEST_SKU, "show_deleted": "true"})
|
resp = client.get("/api/mappings", params={"search": test_sku, "show_deleted": "true"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
mappings = body.get("mappings", [])
|
mappings = body.get("mappings", [])
|
||||||
deleted = any(
|
deleted = any(
|
||||||
m["sku"] == TEST_SKU and m["codmat"] == real_codmat and m.get("sters") == 1
|
m["sku"] == test_sku and m["codmat"] == real_codmat and m.get("sters") == 1
|
||||||
for m in mappings
|
for m in mappings
|
||||||
)
|
)
|
||||||
assert deleted, (
|
assert deleted, (
|
||||||
f"expected sters=1 for deleted mapping, got: "
|
f"expected sters=1 for deleted mapping, got: "
|
||||||
f"{[m for m in mappings if m['sku'] == TEST_SKU]}"
|
f"{[m for m in mappings if m['sku'] == test_sku]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
# LLM Project Manager Prompt
|
|
||||||
## Pentru Implementarea PRD: Import Comenzi Web → Sistem ROA
|
|
||||||
|
|
||||||
Tu ești un **Project Manager AI specializat** care urmărește implementarea unui PRD (Product Requirements Document) prin descompunerea în user stories executabile și urmărirea progresului.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Misiunea Ta
|
|
||||||
|
|
||||||
Implementezi sistemul de import automat comenzi web → ERP ROA Oracle conform PRD-ului furnizat. Vei coordona dezvoltarea în 4 faze distincte, urmărind fiecare story și asigurându-te că totul este livrat conform specificațiilor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Context PRD
|
|
||||||
|
|
||||||
**Sistem:** Import comenzi de pe platforme web (GoMag, etc.) în sistemul ERP ROA Oracle
|
|
||||||
**Tech Stack:** Oracle PL/SQL + Visual FoxPro 9 + FastApi (admin interface)
|
|
||||||
**Componente Principale:**
|
|
||||||
- Package Oracle pentru parteneri și comenzi
|
|
||||||
- Orchestrator VFP pentru sincronizare automată
|
|
||||||
- Interfață web pentru administrare mapări SKU
|
|
||||||
- Tabel nou ARTICOLE_TERTI pentru mapări complexe
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 User Stories Framework
|
|
||||||
|
|
||||||
Pentru fiecare story, vei genera:
|
|
||||||
|
|
||||||
### Story Template:
|
|
||||||
```
|
|
||||||
**Story ID:** [FASE]-[NR] (ex: P1-001)
|
|
||||||
**Titlu:** [Descriere concisă]
|
|
||||||
**As a:** [Utilizator/Sistem]
|
|
||||||
**I want:** [Funcționalitate dorită]
|
|
||||||
**So that:** [Beneficiul de business]
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
- [ ] Criteriu 1
|
|
||||||
- [ ] Criteriu 2
|
|
||||||
- [ ] Criteriu 3
|
|
||||||
|
|
||||||
**Technical Tasks:**
|
|
||||||
- [ ] Task tehnic 1
|
|
||||||
- [ ] Task tehnic 2
|
|
||||||
|
|
||||||
**Definition of Done:**
|
|
||||||
- [ ] Cod implementat și testat
|
|
||||||
- [ ] Documentație actualizată
|
|
||||||
- [ ] Error handling complet
|
|
||||||
- [ ] Logging implementat
|
|
||||||
- [ ] Review code efectuat
|
|
||||||
|
|
||||||
**Estimate:** [XS/S/M/L/XL] ([ore estimate])
|
|
||||||
**Dependencies:** [Alte story-uri necesare]
|
|
||||||
**Risk Level:** [Low/Medium/High]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Faze de Implementare
|
|
||||||
|
|
||||||
### **PHASE 1: Database Foundation (Ziua 1)**
|
|
||||||
Creează story-uri pentru:
|
|
||||||
- Tabel ARTICOLE_TERTI cu structura specificată
|
|
||||||
- Package IMPORT_PARTENERI complet funcțional
|
|
||||||
- Package IMPORT_COMENZI cu logica de mapare
|
|
||||||
- Teste unitare pentru package-uri
|
|
||||||
|
|
||||||
### **PHASE 2: VFP Integration (Ziua 2)**
|
|
||||||
Creează story-uri pentru:
|
|
||||||
- Adaptare gomag-adapter.prg pentru JSON output
|
|
||||||
- Orchestrator sync-comenzi-web.prg cu timer
|
|
||||||
- Integrare Oracle packages în VFP
|
|
||||||
- Sistem de logging cu rotație
|
|
||||||
|
|
||||||
### **PHASE 3: Web Admin Interface (Ziua 3)**
|
|
||||||
Creează story-uri pentru:
|
|
||||||
- Flask app cu Oracle connection pool
|
|
||||||
- HTML/CSS interface pentru admin mapări
|
|
||||||
- JavaScript pentru CRUD operații
|
|
||||||
- Validări client-side și server-side
|
|
||||||
|
|
||||||
### **PHASE 4: Testing & Deployment (Ziua 4)**
|
|
||||||
Creează story-uri pentru:
|
|
||||||
- Testare end-to-end cu comenzi reale
|
|
||||||
- Validare mapări complexe (seturi, reîmpachetări)
|
|
||||||
- Configurare environment production
|
|
||||||
- Documentație utilizare finală
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Workflow de Urmărire
|
|
||||||
|
|
||||||
### La început de sesiune:
|
|
||||||
1. **Prezintă status overview:** "PHASE X - Y% complete, Z stories remaining"
|
|
||||||
2. **Identifică story-ul curent** și dependencies
|
|
||||||
3. **Verifică blocaje** și propune soluții
|
|
||||||
4. **Actualizează planning-ul** dacă e nevoie
|
|
||||||
|
|
||||||
### Pe durata implementării:
|
|
||||||
1. **Urmărește progresul** fiecărui task în story
|
|
||||||
2. **Validează completion criteria** înainte să marchezi DONE
|
|
||||||
3. **Identifică riscos** și alertează proactiv
|
|
||||||
4. **Propune optimizări** de proces
|
|
||||||
|
|
||||||
### La finalizare story:
|
|
||||||
1. **Demo功能** implementată
|
|
||||||
2. **Confirmă acceptance criteria** îndeplinite
|
|
||||||
3. **Planifică next story** cu dependencies
|
|
||||||
4. **Actualizează overall progress**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Tracking & Reporting
|
|
||||||
|
|
||||||
### Daily Status Format:
|
|
||||||
```
|
|
||||||
📈 PROJECT STATUS - [DATA]
|
|
||||||
═══════════════════════════════════
|
|
||||||
|
|
||||||
🎯 Current Phase: [PHASE X]
|
|
||||||
📊 Overall Progress: [X]% ([Y]/[Z] stories done)
|
|
||||||
⏰ Current Story: [STORY-ID] - [TITLE]
|
|
||||||
🔄 Status: [IN PROGRESS/BLOCKED/READY FOR REVIEW]
|
|
||||||
|
|
||||||
📋 Today's Completed:
|
|
||||||
- ✅ [Story completă]
|
|
||||||
- ✅ [Task complet]
|
|
||||||
|
|
||||||
🚧 In Progress:
|
|
||||||
- 🔄 [Story în lucru]
|
|
||||||
- ⏳ [Task în progress]
|
|
||||||
|
|
||||||
⚠️ Blockers:
|
|
||||||
- 🚨 [Blocker 1]
|
|
||||||
- 🔍 [Issue necesitând decizie]
|
|
||||||
|
|
||||||
📅 Next Up:
|
|
||||||
- 📝 [Next story ready]
|
|
||||||
- 🔜 [Upcoming dependency]
|
|
||||||
|
|
||||||
🎯 Phase Target: [Data target] | Risk: [LOW/MED/HIGH]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Weekly Sprint Review:
|
|
||||||
- Retrospectivă story-uri complete vs planificate
|
|
||||||
- Analiza blockers întâlniți și soluții
|
|
||||||
- Ajustări planning pentru săptămâna următoare
|
|
||||||
- Identificare lesson learned
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Risk Management
|
|
||||||
|
|
||||||
### Categorii Risc:
|
|
||||||
- **HIGH:** Blockers care afectează multiple story-uri
|
|
||||||
- **MEDIUM:** Delay-uri care pot afecta phase target
|
|
||||||
- **LOW:** Issues locale care nu afectează planning-ul
|
|
||||||
|
|
||||||
### Escalation Matrix:
|
|
||||||
1. **Technical Issues:** Propui soluții alternative/workaround
|
|
||||||
2. **Dependency Blockers:** Replanifici priority și sequence
|
|
||||||
3. **Scope Changes:** Alertezi și ceri validare înainte de implementare
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎛️ Comenzi Disponibile
|
|
||||||
|
|
||||||
Răspunzi la comenzile:
|
|
||||||
- `status` - Overall progress și current story
|
|
||||||
- `stories` - Lista toate story-urile cu status
|
|
||||||
- `phase` - Detalii phase curentă
|
|
||||||
- `risks` - Identifică și prioritizează riscuri
|
|
||||||
- `demo [story-id]` - Demonstrație funcționalitate implementată
|
|
||||||
- `plan` - Re-planificare dacă apar schimbări
|
|
||||||
|
|
||||||
## 📋 User Stories Location
|
|
||||||
|
|
||||||
Toate story-urile sunt stocate în fișiere individuale în `docs/stories/` cu format:
|
|
||||||
- **P1-001-ARTICOLE_TERTI.md** - Story complet cu acceptance criteria
|
|
||||||
- **P1-002-Package-IMPORT_PARTENERI.md** - Detalii implementare parteneri
|
|
||||||
- **P1-003-Package-IMPORT_COMENZI.md** - Logică import comenzi
|
|
||||||
- **P1-004-Testing-Manual-Packages.md** - Plan testare
|
|
||||||
|
|
||||||
**Beneficii:**
|
|
||||||
- Nu mai regenerez story-urile la fiecare sesiune
|
|
||||||
- Persistența progresului și update-urilor
|
|
||||||
- Ușor de referenciat și de împărtășit cu stakeholders
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Success Criteria
|
|
||||||
|
|
||||||
### Technical KPIs:
|
|
||||||
- Import success rate > 95%
|
|
||||||
- Timp mediu procesare < 30s per comandă
|
|
||||||
- Zero downtime pentru ROA principal
|
|
||||||
- 100% log coverage
|
|
||||||
|
|
||||||
### Project KPIs:
|
|
||||||
- Stories delivered on time: >90%
|
|
||||||
- Zero blockers mai mult de 1 zi
|
|
||||||
- Code review coverage: 100%
|
|
||||||
- Documentation completeness: 100%
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤖 Personality & Communication Style
|
|
||||||
|
|
||||||
- **Proactiv:** Anticipezi probleme și propui soluții
|
|
||||||
- **Data-driven:** Folosești metrici concrete pentru tracking
|
|
||||||
- **Pragmatic:** Focusat pe delivery și rezultate practice
|
|
||||||
- **Comunicativ:** Updates clare și acționabile
|
|
||||||
- **Quality-focused:** Nu accepti compromisuri pe Definition of Done
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
|
||||||
|
|
||||||
**Primul tau task:**
|
|
||||||
1. Citește întregul PRD furnizat și verifică dacă există story-uri pentru fiecare fază și la care fază/story ai rămas
|
|
||||||
|
|
||||||
**Întreabă-mă dacă:**
|
|
||||||
- Necesită clarificări tehnice despre PRD
|
|
||||||
- Vrei să ajustez priority sau sequence
|
|
||||||
- Apare vreo dependency neidentificată
|
|
||||||
- Ai nevoie de input pentru estimări
|
|
||||||
|
|
||||||
**Întreabă-mă dacă:**
|
|
||||||
Afișează comenzile disponibile
|
|
||||||
- status - Progres overall
|
|
||||||
- stories - Lista story-uri
|
|
||||||
- phase - Detalii fază curentă
|
|
||||||
- risks - Identificare riscuri
|
|
||||||
- demo [story-id] - Demo funcționalitate
|
|
||||||
- plan - Re-planificare
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Acum începe cu:** "Am analizat PRD-ul și sunt gata să coordonez implementarea. Vrei să îți spun care a fost ultimul story si care este statusul său?"
|
|
||||||
610
docs/PRD.md
610
docs/PRD.md
@@ -1,610 +0,0 @@
|
|||||||
# Product Requirements Document (PRD)
|
|
||||||
## Import Comenzi Web → Sistem ROA
|
|
||||||
|
|
||||||
**Versiune:** 1.2
|
|
||||||
**Data:** 10 septembrie 2025
|
|
||||||
**Status:** Phase 1 - ✅ COMPLET | Ready for Phase 2 VFP Integration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Overview
|
|
||||||
|
|
||||||
Sistem ultra-minimal pentru importul comenzilor de pe platforme web (GoMag, etc.) în sistemul ERP ROA Oracle. Sistemul gestionează automat maparea produselor, crearea clienților și generarea comenzilor în ROA.
|
|
||||||
|
|
||||||
### Obiective Principale
|
|
||||||
- ✅ Import automat comenzi web → ROA
|
|
||||||
- ✅ Mapare flexibilă SKU → CODMAT (reîmpachetări + seturi)
|
|
||||||
- ✅ Crearea automată a partenerilor noi
|
|
||||||
- ✅ Interfață web pentru administrare mapări
|
|
||||||
- ✅ Logging complet pentru troubleshooting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Scope & Limitations
|
|
||||||
|
|
||||||
### În Scope
|
|
||||||
- Import comenzi din orice platformă web (nu doar GoMag)
|
|
||||||
- Mapare SKU complexe (1:1, 1:N, reîmpachetări, seturi)
|
|
||||||
- Crearea automată parteneri + adrese
|
|
||||||
- Interfață web admin pentru mapări
|
|
||||||
- Logging în fișiere text
|
|
||||||
|
|
||||||
### Out of Scope
|
|
||||||
- Modificarea comenzilor existente în ROA
|
|
||||||
- Sincronizare bidirectională
|
|
||||||
- Gestionarea stocurilor
|
|
||||||
- Interfață pentru utilizatori finali
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
[Web Platform API] → [VFP Orchestrator] → [Oracle PL/SQL] → [Web Admin Interface]
|
|
||||||
↓ ↓ ↑ ↑
|
|
||||||
JSON Orders Process & Log Store/Update Configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
- **Backend:** Oracle PL/SQL packages
|
|
||||||
- **Integration:** Visual FoxPro 9
|
|
||||||
- **Admin Interface:** Flask + Oracle
|
|
||||||
- **Data:** Oracle 11g/12c
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Data Model
|
|
||||||
|
|
||||||
### Tabel Nou: ARTICOLE_TERTI
|
|
||||||
```sql
|
|
||||||
CREATE TABLE ARTICOLE_TERTI (
|
|
||||||
sku VARCHAR2(100), -- SKU din platforma web
|
|
||||||
codmat VARCHAR2(50), -- CODMAT din nom_articole
|
|
||||||
cantitate_roa NUMBER(10,3), -- Câte unități ROA = 1 web
|
|
||||||
procent_pret NUMBER(5,2), -- % din preț pentru seturi
|
|
||||||
activ NUMBER(1), -- 1=activ, 0=inactiv
|
|
||||||
PRIMARY KEY (sku, codmat)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Exemple Mapări
|
|
||||||
- **Simplu:** SKU "CAF01" → caută direct în nom_articole (nu se stochează)
|
|
||||||
- **Reîmpachetare:** SKU "CAFE100" → CODMAT "CAF01", cantitate_roa=10
|
|
||||||
- **Set compus:**
|
|
||||||
- SKU "SET01" → CODMAT "CAF01", cantitate_roa=2, procent_pret=60
|
|
||||||
- SKU "SET01" → CODMAT "FILT01", cantitate_roa=1, procent_pret=40
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Components Specification
|
|
||||||
|
|
||||||
### 1. Package IMPORT_PARTENERI
|
|
||||||
|
|
||||||
**Funcții:**
|
|
||||||
- `cauta_sau_creeaza_partener()` - Găsește partener existent sau creează unul nou
|
|
||||||
- `parseaza_adresa_semicolon()` - Parsează adrese format: "JUD:București;BUCURESTI;Str.Victoriei;10"
|
|
||||||
|
|
||||||
**Logica Căutare Parteneri:**
|
|
||||||
1. Caută după cod_fiscal (dacă > 3 caractere)
|
|
||||||
2. Caută după denumire exactă
|
|
||||||
3. Creează partener nou folosind `pack_def.adauga_partener()`
|
|
||||||
4. Adaugă adresa folosind `pack_def.adauga_adresa_partener2()`
|
|
||||||
|
|
||||||
### 2. Package IMPORT_COMENZI
|
|
||||||
|
|
||||||
**Funcții:**
|
|
||||||
- `gaseste_articol_roa()` - Rezolvă SKU → articole ROA
|
|
||||||
- `importa_comanda_web()` - Import comandă completă
|
|
||||||
|
|
||||||
**Logica Articole:**
|
|
||||||
1. Verifică ARTICOLE_TERTI pentru SKU
|
|
||||||
2. Dacă nu există → caută direct în nom_articole (SKU = CODMAT)
|
|
||||||
3. Calculează cantități și prețuri conform mapărilor
|
|
||||||
4. Folosește `PACK_COMENZI.adauga_comanda()` și `PACK_COMENZI.adauga_articol_comanda()`
|
|
||||||
|
|
||||||
### 3. VFP Orchestrator (sync-comenzi-web.prg)
|
|
||||||
|
|
||||||
**Responsabilități:**
|
|
||||||
- Rulare automată (timer 5 minute)
|
|
||||||
- Citire comenzi din JSON-ul generat de gomag-adapter.prg
|
|
||||||
- Procesare comenzi GoMag cu mapare completă la Oracle
|
|
||||||
- Apelare package-uri Oracle pentru import
|
|
||||||
- Logging în fișiere text cu timestamp
|
|
||||||
|
|
||||||
**Fluxul complet de procesare:**
|
|
||||||
1. **Input:** Citește `output/gomag_orders_last7days_*.json`
|
|
||||||
2. **Pentru fiecare comandă:**
|
|
||||||
- Extrage date billing/shipping
|
|
||||||
- Procesează parteneri (persoane fizice vs companii)
|
|
||||||
- Mapează articole web → ROA
|
|
||||||
- Creează comandă în Oracle cu toate detaliile
|
|
||||||
3. **Output:** Log complet în `logs/sync_comenzi_YYYYMMDD.log`
|
|
||||||
|
|
||||||
**Funcții helper necesare:**
|
|
||||||
- `CleanGoMagText()` - Curățare HTML entities
|
|
||||||
- `ProcessGoMagOrder()` - Procesare comandă completă
|
|
||||||
- `BuildArticlesJSON()` - Transformare items → JSON Oracle
|
|
||||||
- `FormatAddressForOracle()` - Adrese în format semicolon
|
|
||||||
- `HandleSpecialCases()` - Shipping vs billing, discounts, etc.
|
|
||||||
|
|
||||||
**Procesare Date GoMag pentru IMPORT_PARTENERI:**
|
|
||||||
|
|
||||||
*Decodare HTML entities în caractere simple (fără diacritice):*
|
|
||||||
```foxpro
|
|
||||||
* Funcție de curățare text GoMag
|
|
||||||
FUNCTION CleanGoMagText(tcText)
|
|
||||||
LOCAL lcResult
|
|
||||||
lcResult = tcText
|
|
||||||
lcResult = STRTRAN(lcResult, 'ă', 'a') && ă → a
|
|
||||||
lcResult = STRTRAN(lcResult, 'ș', 's') && ș → s
|
|
||||||
lcResult = STRTRAN(lcResult, 'ț', 't') && ț → t
|
|
||||||
lcResult = STRTRAN(lcResult, 'î', 'i') && î → i
|
|
||||||
lcResult = STRTRAN(lcResult, 'â', 'a') && â → a
|
|
||||||
RETURN lcResult
|
|
||||||
ENDFUNC
|
|
||||||
```
|
|
||||||
|
|
||||||
*Pregătire date partener din billing GoMag:*
|
|
||||||
```foxpro
|
|
||||||
* Pentru persoane fizice (când billing.company e gol):
|
|
||||||
IF EMPTY(loBilling.company.name)
|
|
||||||
lcDenumire = CleanGoMagText(loBilling.firstname + ' ' + loBilling.lastname)
|
|
||||||
lcCodFiscal = NULL && persoane fizice nu au CUI în GoMag
|
|
||||||
ELSE
|
|
||||||
* Pentru companii:
|
|
||||||
lcDenumire = CleanGoMagText(loBilling.company.name)
|
|
||||||
lcCodFiscal = loBilling.company.code && CUI companie
|
|
||||||
ENDIF
|
|
||||||
|
|
||||||
* Formatare adresă pentru Oracle (format semicolon):
|
|
||||||
lcAdresa = "JUD:" + CleanGoMagText(loBilling.region) + ";" + ;
|
|
||||||
CleanGoMagText(loBilling.city) + ";" + ;
|
|
||||||
CleanGoMagText(loBilling.address)
|
|
||||||
|
|
||||||
* Date contact
|
|
||||||
lcTelefon = loBilling.phone
|
|
||||||
lcEmail = loBilling.email
|
|
||||||
```
|
|
||||||
|
|
||||||
*Apel package Oracle IMPORT_PARTENERI:*
|
|
||||||
```foxpro
|
|
||||||
* Apelare IMPORT_PARTENERI.cauta_sau_creeaza_partener
|
|
||||||
lcSQL = "SELECT IMPORT_PARTENERI.cauta_sau_creeaza_partener(?, ?, ?, ?, ?) AS ID_PART FROM dual"
|
|
||||||
|
|
||||||
* Executare cu parametri:
|
|
||||||
* p_cod_fiscal, p_denumire, p_adresa, p_telefon, p_email
|
|
||||||
lnIdPart = SQLEXEC(goConnectie, lcSQL, lcCodFiscal, lcDenumire, lcAdresa, lcTelefon, lcEmail, "cursor_result")
|
|
||||||
|
|
||||||
IF lnIdPart > 0 AND RECCOUNT("cursor_result") > 0
|
|
||||||
lnPartnerID = cursor_result.ID_PART
|
|
||||||
* Continuă cu procesarea comenzii...
|
|
||||||
ELSE
|
|
||||||
* Log eroare partener
|
|
||||||
WriteLog("ERROR: Nu s-a putut crea/găsi partenerul: " + lcDenumire)
|
|
||||||
ENDIF
|
|
||||||
```
|
|
||||||
|
|
||||||
**Procesare Articole pentru IMPORT_COMENZI:**
|
|
||||||
|
|
||||||
*Construire JSON articole din items GoMag:*
|
|
||||||
```foxpro
|
|
||||||
* Funcție BuildArticlesJSON - transformă items GoMag în format Oracle
|
|
||||||
FUNCTION BuildArticlesJSON(loItems)
|
|
||||||
LOCAL lcJSON, i, loItem
|
|
||||||
lcJSON = "["
|
|
||||||
|
|
||||||
FOR i = 1 TO loItems.Count
|
|
||||||
loItem = loItems.Item(i)
|
|
||||||
|
|
||||||
IF i > 1
|
|
||||||
lcJSON = lcJSON + ","
|
|
||||||
ENDIF
|
|
||||||
|
|
||||||
* Format JSON conform package Oracle: {"sku":"...", "cantitate":..., "pret":...}
|
|
||||||
lcJSON = lcJSON + "{" + ;
|
|
||||||
'"sku":"' + CleanGoMagText(loItem.sku) + '",' + ;
|
|
||||||
'"cantitate":' + TRANSFORM(VAL(loItem.quantity)) + ',' + ;
|
|
||||||
'"pret":' + TRANSFORM(VAL(loItem.price)) + ;
|
|
||||||
"}"
|
|
||||||
ENDFOR
|
|
||||||
|
|
||||||
lcJSON = lcJSON + "]"
|
|
||||||
RETURN lcJSON
|
|
||||||
ENDFUNC
|
|
||||||
```
|
|
||||||
|
|
||||||
*Gestionare cazuri speciale:*
|
|
||||||
```foxpro
|
|
||||||
* Informații adiționale pentru observații
|
|
||||||
lcObservatii = "Payment: " + CleanGoMagText(loOrder.payment.name) + "; " + ;
|
|
||||||
"Delivery: " + CleanGoMagText(loOrder.delivery.name) + "; " + ;
|
|
||||||
"Status: " + CleanGoMagText(loOrder.status) + "; " + ;
|
|
||||||
"Source: " + CleanGoMagText(loOrder.source) + " " + CleanGoMagText(loOrder.sales_channel)
|
|
||||||
|
|
||||||
* Adrese diferite shipping vs billing
|
|
||||||
IF NOT (CleanGoMagText(loOrder.shipping.address) == CleanGoMagText(loBilling.address))
|
|
||||||
lcObservatii = lcObservatii + "; Shipping: " + ;
|
|
||||||
CleanGoMagText(loOrder.shipping.address) + ", " + ;
|
|
||||||
CleanGoMagText(loOrder.shipping.city)
|
|
||||||
ENDIF
|
|
||||||
```
|
|
||||||
|
|
||||||
*Apel package Oracle IMPORT_COMENZI:*
|
|
||||||
```foxpro
|
|
||||||
* Conversie dată GoMag → Oracle
|
|
||||||
ldDataComanda = CTOD(SUBSTR(loOrder.date, 1, 10)) && "2025-08-27 16:32:43" → date
|
|
||||||
|
|
||||||
* JSON articole
|
|
||||||
lcArticoleJSON = BuildArticlesJSON(loOrder.items)
|
|
||||||
|
|
||||||
* Apelare IMPORT_COMENZI.importa_comanda_web
|
|
||||||
lcSQL = "SELECT IMPORT_COMENZI.importa_comanda_web(?, ?, ?, ?, ?, ?) AS ID_COMANDA FROM dual"
|
|
||||||
|
|
||||||
lnResult = SQLEXEC(goConnectie, lcSQL, ;
|
|
||||||
loOrder.number, ; && p_nr_comanda_ext
|
|
||||||
ldDataComanda, ; && p_data_comanda
|
|
||||||
lnPartnerID, ; && p_id_partener (din pas anterior)
|
|
||||||
lcArticoleJSON, ; && p_json_articole
|
|
||||||
NULL, ; && p_id_adresa_livrare (opțional)
|
|
||||||
lcObservatii, ; && p_observatii
|
|
||||||
"cursor_comanda")
|
|
||||||
|
|
||||||
IF lnResult > 0 AND cursor_comanda.ID_COMANDA > 0
|
|
||||||
WriteLog("SUCCESS: Comandă importată - ID: " + TRANSFORM(cursor_comanda.ID_COMANDA))
|
|
||||||
ELSE
|
|
||||||
WriteLog("ERROR: Import comandă eșuat pentru: " + loOrder.number)
|
|
||||||
ENDIF
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note Importante:**
|
|
||||||
- Toate caracterele HTML trebuie transformate în ASCII simplu (fără diacritice)
|
|
||||||
- Package-ul Oracle așteaptă text curat, fără entități HTML
|
|
||||||
- Adresa trebuie în format semicolon cu prefix "JUD:" pentru județ
|
|
||||||
- Cod fiscal NULL pentru persoane fizice este acceptabil
|
|
||||||
- JSON articole: exact formatul `{"sku":"...", "cantitate":..., "pret":...}`
|
|
||||||
- Conversie date GoMag: `"2025-08-27 16:32:43"` → `CTOD()` pentru Oracle
|
|
||||||
- Observații: concatenează payment/delivery/status/source pentru tracking
|
|
||||||
- Gestionează adrese diferite shipping vs billing în observații
|
|
||||||
- Utilizează conexiunea Oracle existentă (goConnectie)
|
|
||||||
|
|
||||||
### 4. Web Admin Interface
|
|
||||||
|
|
||||||
**Funcționalități:**
|
|
||||||
- Vizualizare mapări SKU existente
|
|
||||||
- Adăugare/editare/ștergere mapări
|
|
||||||
- Validare date înainte de salvare
|
|
||||||
- Interface responsive cu Flask
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Database Foundation (Ziua 1) - 🎯 75% COMPLET
|
|
||||||
- [x] ✅ **P1-001:** Creare tabel ARTICOLE_TERTI + Docker setup
|
|
||||||
- [x] ✅ **P1-002:** Package IMPORT_PARTENERI complet
|
|
||||||
- [x] ✅ **P1-003:** Package IMPORT_COMENZI complet
|
|
||||||
- [ ] 🔄 **P1-004:** Testare manuală package-uri (NEXT UP!)
|
|
||||||
|
|
||||||
### Phase 2: VFP Integration (Ziua 2)
|
|
||||||
- [ ] **P2-001:** Adaptare gomag-adapter.prg pentru output JSON (READY - doar activare GetOrders)
|
|
||||||
- [ ] **P2-002:** Creare sync-comenzi-web.prg cu toate helper functions
|
|
||||||
- [ ] **P2-003:** Testare import comenzi end-to-end cu date reale GoMag
|
|
||||||
- [ ] **P2-004:** Configurare logging și error handling complet
|
|
||||||
|
|
||||||
**Detalii P2-002 (sync-comenzi-web.prg):**
|
|
||||||
- `CleanGoMagText()` - HTML entities cleanup
|
|
||||||
- `ProcessGoMagOrder()` - Main orchestrator per order
|
|
||||||
- `BuildArticlesJSON()` - Items conversion for Oracle
|
|
||||||
- `FormatAddressForOracle()` - Semicolon format
|
|
||||||
- `HandleSpecialCases()` - Shipping/billing/discounts/payments
|
|
||||||
- Integration cu logging existent din utils.prg
|
|
||||||
- Timer-based execution (5 minute intervals)
|
|
||||||
- Complete error handling cu retry logic
|
|
||||||
|
|
||||||
### Phase 3: Web Admin Interface (Ziua 3)
|
|
||||||
- [ ] Flask app cu connection pool Oracle
|
|
||||||
- [ ] HTML/CSS pentru admin mapări
|
|
||||||
- [ ] JavaScript pentru CRUD operații
|
|
||||||
- [ ] Testare interfață web
|
|
||||||
|
|
||||||
### Phase 4: Testing & Deployment (Ziua 4)
|
|
||||||
- [ ] Testare integrată pe comenzi reale
|
|
||||||
- [ ] Validare mapări complexe (seturi)
|
|
||||||
- [ ] Configurare environment production
|
|
||||||
- [ ] Documentație utilizare
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/api/ # ✅ Flask Admin Interface
|
|
||||||
├── admin.py # ✅ Flask app cu Oracle pool
|
|
||||||
├── 01_create_table.sql # ✅ Tabel ARTICOLE_TERTI
|
|
||||||
├── 02_import_parteneri.sql # ✅ Package parteneri (COMPLET)
|
|
||||||
├── 03_import_comenzi.sql # ✅ Package comenzi (COMPLET)
|
|
||||||
├── Dockerfile # ✅ Container cu Oracle client
|
|
||||||
├── tnsnames.ora # ✅ Config Oracle ROA
|
|
||||||
├── .env # ✅ Environment variables
|
|
||||||
└── requirements.txt # ✅ Dependencies Python
|
|
||||||
|
|
||||||
/docs/ # 📋 Project Documentation
|
|
||||||
├── PRD.md # ✅ Product Requirements Document
|
|
||||||
├── LLM_PROJECT_MANAGER_PROMPT.md # ✅ Project Manager Prompt
|
|
||||||
└── stories/ # 📋 User Stories (Detailed)
|
|
||||||
├── P1-001-ARTICOLE_TERTI.md # ✅ Story P1-001 (COMPLET)
|
|
||||||
├── P1-002-Package-IMPORT_PARTENERI.md # ✅ Story P1-002 (COMPLET)
|
|
||||||
├── P1-003-Package-IMPORT_COMENZI.md # ✅ Story P1-003 (COMPLET)
|
|
||||||
└── P1-004-Testing-Manual-Packages.md # 📋 Story P1-004
|
|
||||||
|
|
||||||
/vfp/ # ⏳ VFP Integration (Phase 2)
|
|
||||||
└── sync-comenzi-web.prg # ⏳ Orchestrator principal
|
|
||||||
|
|
||||||
/docker-compose.yaml # ✅ Container orchestration
|
|
||||||
/logs/ # ✅ Logging directory
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Business Rules
|
|
||||||
|
|
||||||
### Parteneri
|
|
||||||
- Căutare prioritate: cod_fiscal → denumire → creare nou
|
|
||||||
- Persoane fizice (CUI 13 cifre): separă nume/prenume
|
|
||||||
- Adrese: defaultează la București Sectorul 1 dacă nu găsește
|
|
||||||
- Toate partenerele noi au ID_UTIL = -3 (sistem)
|
|
||||||
|
|
||||||
### Articole
|
|
||||||
- SKU simple (găsite direct în nom_articole): nu se stochează în ARTICOLE_TERTI
|
|
||||||
- Mapări speciale: doar reîmpachetări și seturi complexe
|
|
||||||
- Validare: suma procent_pret pentru același SKU să fie logic
|
|
||||||
- Articole inactive: activ=0 (nu se șterg)
|
|
||||||
|
|
||||||
### Comenzi
|
|
||||||
- Folosește package-urile existente (PACK_COMENZI)
|
|
||||||
- ID_GESTIUNE = 1, ID_SECTIE = 1, ID_POL = 0 (default)
|
|
||||||
- Data livrare = data comenzii + 1 zi
|
|
||||||
- Toate comenzile au INTERNA = 0 (externe)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Success Metrics
|
|
||||||
|
|
||||||
### Technical Metrics
|
|
||||||
- Import success rate > 95%
|
|
||||||
- Timpul mediu de procesare < 30s per comandă
|
|
||||||
- Zero downtime pentru sistemul principal ROA
|
|
||||||
- Log coverage 100% (toate operațiile logate)
|
|
||||||
|
|
||||||
### Business Metrics
|
|
||||||
- Reducerea timpului de introducere comenzi cu 90%
|
|
||||||
- Eliminarea erorilor manuale de transcriere
|
|
||||||
- Timpul de configurare mapări noi < 5 minute
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Error Handling
|
|
||||||
|
|
||||||
### Categorii Erori
|
|
||||||
1. **Erori conexiune Oracle:** Retry logic + alertă
|
|
||||||
2. **SKU not found:** Log warning + skip articol
|
|
||||||
3. **Partener invalid:** Tentativă creare + log detalii
|
|
||||||
4. **Comenzi duplicate:** Skip cu log info
|
|
||||||
|
|
||||||
### Logging Format
|
|
||||||
```
|
|
||||||
2025-09-08 14:30:25 | COMANDA-123 | OK | ID:456789
|
|
||||||
2025-09-08 14:30:26 | COMANDA-124 | ERROR | SKU 'XYZ' not found
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Environment Variables (.env)
|
|
||||||
```env
|
|
||||||
ORACLE_USER=MARIUSM_AUTO
|
|
||||||
ORACLE_PASSWORD=********
|
|
||||||
ORACLE_DSN=ROA_CENTRAL
|
|
||||||
TNS_ADMIN=/app
|
|
||||||
INSTANTCLIENTPATH=/opt/oracle/instantclient
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚠️ **CRITICAL: Oracle Schema Details**
|
|
||||||
|
|
||||||
**Test Schema:** `MARIUSM_AUTO` (nu CONTAFIN_ORACLE)
|
|
||||||
**Database:** Oracle 10g Enterprise Edition Release 10.2.0.4.0
|
|
||||||
**TNS Connection:** ROA_CENTRAL (nu ROA_ROMFAST)
|
|
||||||
|
|
||||||
**Structura Reală Tables:**
|
|
||||||
- `COMENZI` (nu `comenzi_antet`) - Comenzile principale
|
|
||||||
- `COMENZI_ELEMENTE` (nu `comenzi_articole`) - Articolele din comenzi
|
|
||||||
- `NOM_PARTENERI` - Partenerii
|
|
||||||
- `NOM_ARTICOLE` - Articolele
|
|
||||||
- `ARTICOLE_TERTI` - Mapările SKU (creat de noi)
|
|
||||||
|
|
||||||
**Foreign Key Constraints CRITICAL:**
|
|
||||||
```sql
|
|
||||||
-- Pentru COMENZI_ELEMENTE:
|
|
||||||
ID_POL = 2 (obligatoriu, nu NULL sau 0)
|
|
||||||
ID_VALUTA = 3 (obligatoriu, nu 1)
|
|
||||||
ID_ARTICOL - din NOM_ARTICOLE
|
|
||||||
ID_COMANDA - din COMENZI
|
|
||||||
```
|
|
||||||
|
|
||||||
**Package Status în MARIUSM_AUTO:**
|
|
||||||
- ✅ `PACK_IMPORT_PARTENERI` - VALID (header + body)
|
|
||||||
- ✅ `PACK_JSON` - VALID (header + body)
|
|
||||||
- ✅ `PACK_COMENZI` - VALID (header + body)
|
|
||||||
- ✅ `PACK_IMPORT_COMENZI` - header VALID, body FIXED în P1-004
|
|
||||||
|
|
||||||
### VFP Configuration
|
|
||||||
- Timer interval: 300 secunde (5 minute)
|
|
||||||
- Conexiune Oracle prin goExecutor existent
|
|
||||||
- Log files: sync_YYYYMMDD.log (rotație zilnică)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎛️ Admin Interface Specification
|
|
||||||
|
|
||||||
### Main Screen: SKU Mappings
|
|
||||||
- Tabel editabil cu coloane: SKU, CODMAT, Cantitate ROA, Procent Preț, Activ
|
|
||||||
- Inline editing cu auto-save
|
|
||||||
- Filtrare și căutare
|
|
||||||
- Export/Import mapări (CSV)
|
|
||||||
- Validare în timp real
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Bulk operations (activare/dezactivare multiple)
|
|
||||||
- Template mapări pentru tipuri comune
|
|
||||||
- Preview calcul preț pentru teste
|
|
||||||
- Audit trail (cine/când a modificat)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏁 Definition of Done
|
|
||||||
|
|
||||||
### Per Feature
|
|
||||||
- [ ] Cod implementat și testat
|
|
||||||
- [ ] Documentație actualizată
|
|
||||||
- [ ] Error handling complet
|
|
||||||
- [ ] Logging implementat
|
|
||||||
- [ ] Review code efectuat
|
|
||||||
|
|
||||||
### Per Phase
|
|
||||||
- [ ] Toate feature-urile Phase complete
|
|
||||||
- [ ] Testare integrată reușită
|
|
||||||
- [ ] Performance requirements îndeplinite
|
|
||||||
- [ ] Deployment verificat
|
|
||||||
- [ ] Sign-off stakeholder
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support & Maintenance
|
|
||||||
|
|
||||||
### Monitoring
|
|
||||||
- Log files în /logs/ cu rotație automată
|
|
||||||
- Alertă email pentru erori critice
|
|
||||||
- Dashboard cu statistici import (opcional Phase 2)
|
|
||||||
|
|
||||||
### Backup & Recovery
|
|
||||||
- Mapări ARTICOLE_TERTI incluse în backup-ul zilnic ROA
|
|
||||||
- Config files versionate în Git
|
|
||||||
- Procedură rollback pentru package-uri Oracle
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Progress Status - Phase 1 [🎯 100% COMPLET]
|
|
||||||
|
|
||||||
### ✅ P1-001 COMPLET: Tabel ARTICOLE_TERTI
|
|
||||||
- **Implementat:** 08 septembrie 2025, 22:30
|
|
||||||
- **Files:** `api/database-scripts/01_create_table.sql`, `api/admin.py`, `docker-compose.yaml`
|
|
||||||
- **Status:** ✅ Production ready
|
|
||||||
|
|
||||||
### ✅ P1-002 COMPLET: Package PACK_IMPORT_PARTENERI
|
|
||||||
- **Implementat:** 09 septembrie 2025, 10:30
|
|
||||||
- **Key Features:**
|
|
||||||
- `cauta_sau_creeaza_partener()` - Search priority: cod_fiscal → denumire → create
|
|
||||||
- `parseaza_adresa_semicolon()` - Flexible address parsing cu defaults
|
|
||||||
- Individual vs company logic (CUI 13 digits)
|
|
||||||
- Custom exceptions + autonomous transaction logging
|
|
||||||
- **Files:** `api/database-scripts/02_import_parteneri.sql`
|
|
||||||
- **Status:** ✅ Production ready - 100% tested
|
|
||||||
|
|
||||||
### ✅ P1-003 COMPLET: Package PACK_IMPORT_COMENZI
|
|
||||||
- **Implementat:** 09 septembrie 2025, 10:30 | **Finalizat:** 10 septembrie 2025, 12:30
|
|
||||||
- **Key Features:**
|
|
||||||
- `gaseste_articol_roa()` - Complex SKU mapping cu pipelined functions ✅ 100% tested
|
|
||||||
- Manual workflow validation - comenzi + articole ✅ 100% working
|
|
||||||
- Support mapări: simple, reîmpachetări, seturi complexe ✅
|
|
||||||
- Performance monitoring < 30s per comandă ✅
|
|
||||||
- Schema reală MARIUSM_AUTO validation ✅
|
|
||||||
- **Files:** `api/database-scripts/04_import_comenzi.sql` + `api/final_validation.py`
|
|
||||||
- **Status:** ✅ 100% Production ready cu componente validate
|
|
||||||
|
|
||||||
### ✅ P1-004 Testing Manual Packages - 100% COMPLET
|
|
||||||
- **Obiectiv:** Testare completă cu date reale ROA ✅
|
|
||||||
- **Dependencies:** P1-001 ✅, P1-002 ✅, P1-003 ✅
|
|
||||||
- **Rezultate Finale:**
|
|
||||||
- ✅ PACK_IMPORT_PARTENERI: 100% funcțional cu parteneri reali
|
|
||||||
- ✅ gaseste_articol_roa: 100% funcțional cu mapări CAFE100 → CAF01
|
|
||||||
- ✅ Oracle connection, FK constraints, schema MARIUSM_AUTO identificată
|
|
||||||
- ✅ Manual workflow: comenzi + articole complet funcțional
|
|
||||||
- **Status:** ✅ 100% COMPLET
|
|
||||||
|
|
||||||
### 🔍 **FOR LOOP Issue REZOLVAT - Root Cause Analysis:**
|
|
||||||
|
|
||||||
**PROBLEMA NU ERA CU FOR LOOP-ul!** For loop-ul era corect sintactic și logic.
|
|
||||||
|
|
||||||
**Problemele Reale Identificate:**
|
|
||||||
1. **Schema Incorectă:** Am presupus `comenzi_antet`/`comenzi_articole` dar schema reală folosește `COMENZI`/`COMENZI_ELEMENTE`
|
|
||||||
2. **FK Constraints:** ID_POL=2, ID_VALUTA=3 (obligatorii, nu NULL sau alte valori)
|
|
||||||
3. **JSON Parsing:** Probleme de conversie numerică în Oracle PL/SQL simplu
|
|
||||||
4. **Environment:** Schema `MARIUSM_AUTO` pe Oracle 10g, nu environment-ul presupus inițial
|
|
||||||
|
|
||||||
**Componente care funcționează 100%:**
|
|
||||||
- ✅ `PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener()`
|
|
||||||
- ✅ `PACK_IMPORT_COMENZI.gaseste_articol_roa()`
|
|
||||||
- ✅ Direct INSERT în `COMENZI`/`COMENZI_ELEMENTE`
|
|
||||||
- ✅ Mapări complexe prin `ARTICOLE_TERTI`
|
|
||||||
|
|
||||||
**Lecții Învățate:**
|
|
||||||
- Verifică întotdeauna schema reală înainte de implementare
|
|
||||||
- Testează FK constraints și valorile valide
|
|
||||||
- Environment discovery este crucial pentru debugging
|
|
||||||
- FOR LOOP logic era corect - problema era în presupuneri de structură
|
|
||||||
|
|
||||||
### 🚀 **Phase 2 Ready - Validated Components:**
|
|
||||||
Toate componentele individuale sunt validate și funcționează perfect pentru VFP integration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 User Stories Reference
|
|
||||||
|
|
||||||
Toate story-urile pentru fiecare fază sunt stocate în `docs/stories/` cu detalii complete:
|
|
||||||
|
|
||||||
### Phase 1 Stories [🎯 75% COMPLET]
|
|
||||||
- **P1-001:** [Tabel ARTICOLE_TERTI](stories/P1-001-ARTICOLE_TERTI.md) - ✅ COMPLET
|
|
||||||
- **P1-002:** [Package IMPORT_PARTENERI](stories/P1-002-Package-IMPORT_PARTENERI.md) - ✅ COMPLET
|
|
||||||
- **P1-003:** [Package IMPORT_COMENZI](stories/P1-003-Package-IMPORT_COMENZI.md) - ✅ COMPLET
|
|
||||||
- **P1-004:** [Testing Manual Packages](stories/P1-004-Testing-Manual-Packages.md) - 🔄 READY TO START
|
|
||||||
|
|
||||||
### Faze Viitoare
|
|
||||||
- **Phase 2:** VFP Integration (stories vor fi generate după P1 completion)
|
|
||||||
- **Phase 3:** Web Admin Interface
|
|
||||||
- **Phase 4:** Testing & Deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Document Owner:** Development Team
|
|
||||||
**Last Updated:** 10 septembrie 2025, 12:30 (Phase 1 COMPLET - schema MARIUSM_AUTO documented)
|
|
||||||
**Next Review:** Phase 2 VFP Integration planning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 **PHASE 1 COMPLETION SUMMARY**
|
|
||||||
|
|
||||||
**Date Completed:** 10 septembrie 2025, 12:30
|
|
||||||
**Final Status:** ✅ 100% COMPLET
|
|
||||||
|
|
||||||
**Critical Discoveries & Updates:**
|
|
||||||
- ✅ Real Oracle schema: `MARIUSM_AUTO` (not CONTAFIN_ORACLE)
|
|
||||||
- ✅ Real table names: `COMENZI`/`COMENZI_ELEMENTE` (not comenzi_antet/comenzi_articole)
|
|
||||||
- ✅ Required FK values: ID_POL=2, ID_VALUTA=3
|
|
||||||
- ✅ All core components validated with real data
|
|
||||||
- ✅ FOR LOOP issue resolved (was environment/schema mismatch)
|
|
||||||
|
|
||||||
**Ready for Phase 2 with validated components:**
|
|
||||||
- `PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener()`
|
|
||||||
- `PACK_IMPORT_COMENZI.gaseste_articol_roa()`
|
|
||||||
- Direct SQL workflow for COMENZI/COMENZI_ELEMENTE
|
|
||||||
- ARTICOLE_TERTI mappings system
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**SQL*Plus Access:**
|
|
||||||
```bash
|
|
||||||
docker exec -i gomag-admin sqlplus MARIUSM_AUTO/ROMFASTSOFT@ROA_CENTRAL
|
|
||||||
```
|
|
||||||
122
docs/oracle-schema-notes.md
Normal file
122
docs/oracle-schema-notes.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Oracle Schema Notes — MARIUSM_AUTO
|
||||||
|
|
||||||
|
Reference pentru tabelele, procedurile și relațiile Oracle descoperite în debugging.
|
||||||
|
|
||||||
|
## Tabele comenzi
|
||||||
|
|
||||||
|
### COMENZI
|
||||||
|
| Coloană | Tip | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| ID_COMANDA | NUMBER (PK) | Auto-generated |
|
||||||
|
| COMANDA_EXTERNA | VARCHAR2 | Nr. comandă GoMag (ex: 481588552) |
|
||||||
|
| DATA_COMANDA | DATE | |
|
||||||
|
| ID_PART | NUMBER | FK → NOM_PARTENERI |
|
||||||
|
| PROC_DISCOUNT | NUMBER(10,4) | Discount procentual pe comandă (setat 0 la import) |
|
||||||
|
| STERS | NUMBER | Soft-delete flag |
|
||||||
|
|
||||||
|
### COMENZI_ELEMENTE
|
||||||
|
| Coloană | Tip | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| ID_COMANDA_ELEMENT | NUMBER (PK) | Auto-generated |
|
||||||
|
| ID_COMANDA | NUMBER | FK → COMENZI |
|
||||||
|
| ID_ARTICOL | NUMBER | FK → NOM_ARTICOLE |
|
||||||
|
| ID_POL | NUMBER | FK → CRM_POLITICI_PRETURI |
|
||||||
|
| PRET | NUMBER(14,3) | Preț per unitate (cu/fără TVA per PRET_CU_TVA flag) |
|
||||||
|
| CANTITATE | NUMBER(14,3) | Cantitate (negativă pentru discount lines) |
|
||||||
|
| DISCOUNT_UNITAR | NUMBER(20,4) | Default 0 |
|
||||||
|
| PTVA | NUMBER | Procentul TVA (11, 21, etc.) |
|
||||||
|
| PRET_CU_TVA | NUMBER(1) | 1 = prețul include TVA |
|
||||||
|
| STERS | NUMBER | Soft-delete flag |
|
||||||
|
|
||||||
|
**Discount lines**: qty negativă, pret pozitiv. Ex: qty=-1, pret=51.56 → scade 51.56 din total.
|
||||||
|
|
||||||
|
## Tabele facturare
|
||||||
|
|
||||||
|
### VANZARI
|
||||||
|
| Coloană | Tip | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| ID_VANZARE | NUMBER (PK) | |
|
||||||
|
| NUMAR_ACT | NUMBER | Număr factură (nract) |
|
||||||
|
| SERIE_ACT | VARCHAR2 | Serie factură |
|
||||||
|
| TIP | NUMBER | 3=factură pe bază de comandă, 1=factură simplă |
|
||||||
|
| ID_COMANDA | NUMBER | FK → COMENZI (pentru TIP=3) |
|
||||||
|
| ID_PART | NUMBER | FK → NOM_PARTENERI |
|
||||||
|
| TOTAL_FARA_TVA | NUMBER | Total calculat de pack_facturare |
|
||||||
|
| TOTAL_TVA | NUMBER | |
|
||||||
|
| TOTAL_CU_TVA | NUMBER | |
|
||||||
|
| DIFTOTFTVA | NUMBER | Diferența față de totalul trimis de client ROAFACTUARE |
|
||||||
|
| DIFTOTTVA | NUMBER | |
|
||||||
|
| STERS | NUMBER | |
|
||||||
|
|
||||||
|
### VANZARI_DETALII
|
||||||
|
| Coloană | Tip | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| **ID_VANZARE_DET** | NUMBER (PK) | ⚠ NU `id_detaliu`! |
|
||||||
|
| ID_VANZARE | NUMBER | FK → VANZARI |
|
||||||
|
| ID_ARTICOL | NUMBER | FK → NOM_ARTICOLE |
|
||||||
|
| CANTITATE | NUMBER | |
|
||||||
|
| PRET | NUMBER | Preț de vânzare |
|
||||||
|
| PRET_ACHIZITIE | NUMBER | |
|
||||||
|
| PROC_TVAV | NUMBER | Coeficient TVA (1.21, 1.11, etc.) |
|
||||||
|
| ID_GESTIUNE | NUMBER | NULL pentru discount lines |
|
||||||
|
| CONT | VARCHAR2 | '371', NULL pentru discount lines |
|
||||||
|
| STERS | NUMBER | |
|
||||||
|
|
||||||
|
## Tabele prețuri
|
||||||
|
|
||||||
|
### CRM_POLITICI_PRETURI
|
||||||
|
| Coloană | Tip | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| ID_POL | NUMBER (PK) | ID politică de preț |
|
||||||
|
| PRETURI_CU_TVA | NUMBER | 1 = prețurile includ TVA |
|
||||||
|
|
||||||
|
### CRM_POLITICI_PRET_ART
|
||||||
|
| Coloană | Tip | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| ID_POL | NUMBER | FK → CRM_POLITICI_PRETURI |
|
||||||
|
| ID_ARTICOL | NUMBER | FK → NOM_ARTICOLE |
|
||||||
|
| PRET | NUMBER | Preț de listă (cu/fără TVA per PRETURI_CU_TVA din politică) |
|
||||||
|
| PROC_TVAV | NUMBER | Coeficient TVA |
|
||||||
|
|
||||||
|
Politici folosite: id_pol=39 (vânzare), id_pol=65 (transport).
|
||||||
|
|
||||||
|
### ARTICOLE_TERTI
|
||||||
|
| Coloană | Tip | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| SKU | VARCHAR2 | SKU din magazin web (GoMag) |
|
||||||
|
| CODMAT | VARCHAR2 | CODMAT în ROA (FK → NOM_ARTICOLE.CODMAT) |
|
||||||
|
| CANTITATE_ROA | NUMBER | Conversie: 1 web unit = X ROA units |
|
||||||
|
| ACTIV | NUMBER | |
|
||||||
|
| STERS | NUMBER | |
|
||||||
|
|
||||||
|
**cantitate_roa semnificații**:
|
||||||
|
- `1` → 1:1 (unitate identică web/ROA)
|
||||||
|
- `0.5` → 1 web unit (50 buc) = 0.5 ROA set (100 buc). Price sync: `pret_web / 0.5`
|
||||||
|
- `10` → bax 1000buc = 10 seturi ROA (100 buc). Kit pricing activ.
|
||||||
|
- `22.5` → bax 2250buc = 22.5 seturi ROA (100 buc). Kit pricing activ.
|
||||||
|
|
||||||
|
## Proceduri cheie
|
||||||
|
|
||||||
|
### PACK_COMENZI.adauga_articol_comanda
|
||||||
|
```
|
||||||
|
(V_ID_COMANDA, V_ID_ARTICOL, V_ID_POL, V_CANTITATE, V_PRET, V_ID_UTIL, V_ID_SECTIE, V_PTVA)
|
||||||
|
```
|
||||||
|
- Lookup pret din CRM_POLITICI_PRET_ART, dar dacă V_PRET IS NOT NULL → folosește V_PRET
|
||||||
|
- **NU inversează semnul prețului** — V_PRET se salvează ca atare
|
||||||
|
- Check duplicat: dacă există rând cu același (id_articol, ptva, pret, sign(cantitate)) → eroare
|
||||||
|
|
||||||
|
### PACK_FACTURARE flow (facturare pe bază de comandă, ntip=42)
|
||||||
|
1. `cursor_comanda` → citește COMENZI_ELEMENTE, filtrează `SIGN(A.CANTITATE) * (A.CANTITATE - NVL(D.CANTITATE, 0)) > 0`
|
||||||
|
2. `cursor_gestiuni_articol` → verifică stoc per articol
|
||||||
|
3. `initializeaza_date_factura` → setează sesiune facturare
|
||||||
|
4. `adauga_articol_factura` (×N) → inserează în VANZARI_DETALII_TEMP
|
||||||
|
5. `scrie_factura2` → procesează temp, contabilizează
|
||||||
|
6. `finalizeaza_scriere_verificare` → finalizează factura
|
||||||
|
|
||||||
|
### PACK_SESIUNE
|
||||||
|
- `nzecimale_pretv` — variabilă package, setată la login ROAFACTUARE
|
||||||
|
- Inițializare: `pack_sesiune.getoptiunefirma(USER, 'PPRETV')` = **2** (pe MARIUSM_AUTO)
|
||||||
|
- **Nu e setată** în context server-side (import comenzi) → folosim `getoptiunefirma` direct
|
||||||
|
|
||||||
|
### OPTIUNI (tabel configurare)
|
||||||
|
- Coloane: `VARNAME`, `VARVALUE` (⚠ NU `cod`/`valoare`)
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Story P1-001: Tabel ARTICOLE_TERTI ✅ COMPLET
|
|
||||||
|
|
||||||
**Story ID:** P1-001
|
|
||||||
**Titlu:** Creare infrastructură database și tabel ARTICOLE_TERTI
|
|
||||||
**As a:** Developer
|
|
||||||
**I want:** Să am tabelul ARTICOLE_TERTI funcțional cu Docker environment
|
|
||||||
**So that:** Să pot stoca mapările SKU complexe pentru import comenzi
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] ✅ Tabel ARTICOLE_TERTI cu structura specificată
|
|
||||||
- [x] ✅ Primary Key compus (sku, codmat)
|
|
||||||
- [x] ✅ Docker environment cu Oracle Instant Client
|
|
||||||
- [x] ✅ Flask admin interface cu test conexiune
|
|
||||||
- [x] ✅ Date test pentru mapări (reîmpachetare + set compus)
|
|
||||||
- [x] ✅ Configurare tnsnames.ora pentru ROA
|
|
||||||
|
|
||||||
## Technical Tasks
|
|
||||||
- [x] ✅ Creare fișier `01_create_table.sql`
|
|
||||||
- [x] ✅ Definire structură tabel cu validări
|
|
||||||
- [x] ✅ Configurare Docker cu Oracle client
|
|
||||||
- [x] ✅ Setup Flask admin interface
|
|
||||||
- [x] ✅ Test conexiune Oracle ROA
|
|
||||||
- [x] ✅ Insert date test pentru validare
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] ✅ Cod implementat și testat
|
|
||||||
- [x] ✅ Tabel creat în Oracle fără erori
|
|
||||||
- [x] ✅ Docker environment funcțional
|
|
||||||
- [x] ✅ Conexiune Oracle validată
|
|
||||||
- [x] ✅ Date test inserate cu succes
|
|
||||||
- [x] ✅ Documentație actualizată în PRD
|
|
||||||
|
|
||||||
**Estimate:** M (6-8 ore)
|
|
||||||
**Dependencies:** None
|
|
||||||
**Risk Level:** LOW
|
|
||||||
**Status:** ✅ COMPLET (08 septembrie 2025, 22:30)
|
|
||||||
|
|
||||||
## Deliverables
|
|
||||||
- **Files:** `api/01_create_table.sql`, `api/admin.py`, `docker-compose.yaml`
|
|
||||||
- **Status:** ✅ Ready pentru testare cu ROA (10.0.20.36)
|
|
||||||
- **Data completare:** 08 septembrie 2025, 22:30
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Story P1-002: Package IMPORT_PARTENERI
|
|
||||||
|
|
||||||
**Story ID:** P1-002
|
|
||||||
**Titlu:** Implementare Package IMPORT_PARTENERI complet funcțional
|
|
||||||
**As a:** System
|
|
||||||
**I want:** Să pot căuta și crea automat parteneri în ROA
|
|
||||||
**So that:** Comenzile web să aibă parteneri valizi în sistemul ERP
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] ✅ Funcția `cauta_sau_creeaza_partener()` implementată
|
|
||||||
- [x] ✅ Funcția `parseaza_adresa_semicolon()` implementată
|
|
||||||
- [x] ✅ Căutare parteneri după cod_fiscal (prioritate 1)
|
|
||||||
- [x] ✅ Căutare parteneri după denumire exactă (prioritate 2)
|
|
||||||
- [x] ✅ Creare partener nou cu `pack_def.adauga_partener()`
|
|
||||||
- [x] ✅ Adăugare adresă cu `pack_def.adauga_adresa_partener2()`
|
|
||||||
- [x] ✅ Separare nume/prenume pentru persoane fizice (CUI 13 cifre)
|
|
||||||
- [x] ✅ Default București Sectorul 1 pentru adrese incomplete
|
|
||||||
|
|
||||||
## Technical Tasks
|
|
||||||
- [x] ✅ Creare fișier `02_import_parteneri.sql`
|
|
||||||
- [x] ✅ Implementare function `cauta_sau_creeaza_partener`
|
|
||||||
- [x] ✅ Implementare function `parseaza_adresa_semicolon`
|
|
||||||
- [x] ✅ Adăugare validări pentru cod_fiscal
|
|
||||||
- [x] ✅ Integrare cu package-urile existente pack_def
|
|
||||||
- [x] ✅ Error handling pentru parteneri invalizi
|
|
||||||
- [x] ✅ Logging pentru operațiile de creare parteneri
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] ✅ Cod implementat și testat
|
|
||||||
- [x] ✅ Package compilat fără erori în Oracle
|
|
||||||
- [ ] 🔄 Test manual cu date reale (P1-004)
|
|
||||||
- [x] ✅ Error handling complet
|
|
||||||
- [x] ✅ Logging implementat
|
|
||||||
- [x] ✅ Documentație actualizată
|
|
||||||
|
|
||||||
**Estimate:** M (6-8 ore) - ACTUAL: 4 ore (parallel development)
|
|
||||||
**Dependencies:** P1-001 ✅
|
|
||||||
**Risk Level:** MEDIUM (integrare cu pack_def existent) - MITIGATED ✅
|
|
||||||
**Status:** ✅ COMPLET (09 septembrie 2025, 10:30)
|
|
||||||
|
|
||||||
## 🎯 Implementation Highlights
|
|
||||||
- **Custom Exceptions:** 3 specialized exceptions for different error scenarios
|
|
||||||
- **Autonomous Transaction Logging:** Non-blocking logging system
|
|
||||||
- **Flexible Address Parser:** Handles multiple address formats gracefully
|
|
||||||
- **Individual Detection:** Smart CUI-based logic for person vs company
|
|
||||||
- **Production-Ready:** Complete validation, error handling, and documentation
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Story P1-003: Package IMPORT_COMENZI
|
|
||||||
|
|
||||||
**Story ID:** P1-003
|
|
||||||
**Titlu:** Implementare Package IMPORT_COMENZI cu logică mapare
|
|
||||||
**As a:** System
|
|
||||||
**I want:** Să pot importa comenzi web complete în ROA
|
|
||||||
**So that:** Comenzile de pe platformele web să ajungă automat în ERP
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] ✅ Funcția `gaseste_articol_roa()` implementată
|
|
||||||
- [x] ✅ Funcția `importa_comanda_web()` implementată
|
|
||||||
- [x] ✅ Verificare mapări în ARTICOLE_TERTI
|
|
||||||
- [x] ✅ Fallback căutare directă în nom_articole
|
|
||||||
- [x] ✅ Calcul cantități pentru reîmpachetări
|
|
||||||
- [x] ✅ Calcul prețuri pentru seturi compuse
|
|
||||||
- [x] ✅ Integrare cu PACK_COMENZI.adauga_comanda()
|
|
||||||
- [x] ✅ Integrare cu PACK_COMENZI.adauga_articol_comanda()
|
|
||||||
|
|
||||||
## Technical Tasks
|
|
||||||
- [x] ✅ Creare fișier `03_import_comenzi.sql`
|
|
||||||
- [x] ✅ Implementare function `gaseste_articol_roa`
|
|
||||||
- [x] ✅ Implementare function `importa_comanda_web`
|
|
||||||
- [x] ✅ Logică mapare SKU → CODMAT
|
|
||||||
- [x] ✅ Calcul cantități cu cantitate_roa
|
|
||||||
- [x] ✅ Calcul prețuri cu procent_pret
|
|
||||||
- [x] ✅ Validare seturi (suma procent_pret = 100%)
|
|
||||||
- [x] ✅ Error handling pentru SKU not found
|
|
||||||
- [x] ✅ Logging pentru fiecare operație
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] ✅ Cod implementat și testat
|
|
||||||
- [x] ✅ Package compilat fără erori în Oracle
|
|
||||||
- [ ] 🔄 Test cu mapări simple și complexe (P1-004)
|
|
||||||
- [x] ✅ Error handling complet
|
|
||||||
- [x] ✅ Logging implementat
|
|
||||||
- [x] ✅ Performance < 30s per comandă (monitorizare implementată)
|
|
||||||
|
|
||||||
**Estimate:** L (8-12 ore) - ACTUAL: 5 ore (parallel development)
|
|
||||||
**Dependencies:** P1-001 ✅, P1-002 ✅
|
|
||||||
**Risk Level:** HIGH (logică complexă mapări + integrare PACK_COMENZI) - MITIGATED ✅
|
|
||||||
**Status:** ✅ COMPLET (09 septembrie 2025, 10:30)
|
|
||||||
|
|
||||||
## 🎯 Implementation Highlights
|
|
||||||
- **Pipelined Functions:** Memory-efficient processing of complex mappings
|
|
||||||
- **Smart Mapping Logic:** Handles simple, repackaging, and set scenarios
|
|
||||||
- **Set Validation:** 95-105% tolerance for percentage sum validation
|
|
||||||
- **Performance Monitoring:** Built-in timing for 30s target compliance
|
|
||||||
- **JSON Integration:** Ready for web platform order import
|
|
||||||
- **Enterprise Logging:** Comprehensive audit trail with import_log table
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# Story P1-004: Testing Manual Packages
|
|
||||||
|
|
||||||
**Story ID:** P1-004
|
|
||||||
**Titlu:** Testare manuală completă package-uri Oracle
|
|
||||||
**As a:** Developer
|
|
||||||
**I want:** Să verific că package-urile funcționează corect cu date reale
|
|
||||||
**So that:** Să am încredere în stabilitatea sistemului înainte de Phase 2
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] ✅ Test creare partener nou cu adresă completă
|
|
||||||
- [x] ✅ Test căutare partener existent după cod_fiscal
|
|
||||||
- [x] ✅ Test căutare partener existent după denumire
|
|
||||||
- [x] ✅ Test import comandă cu SKU simplu (error handling verificat)
|
|
||||||
- [x] ✅ Test import comandă cu reîmpachetare (CAFE100: 2→20 bucăți)
|
|
||||||
- [x] ✅ Test import comandă cu set compus (SET01: 2×CAF01+1×FILTRU01)
|
|
||||||
- [x] ⚠️ Verificare comenzi create corect în ROA (blocked by external dependency)
|
|
||||||
- [x] ✅ Verificare logging complet în toate scenariile
|
|
||||||
|
|
||||||
## Technical Tasks
|
|
||||||
- [x] ✅ Pregătire date test pentru parteneri (created test partners)
|
|
||||||
- [x] ✅ Pregătire date test pentru articole/mapări (created CAF01, FILTRU01 in nom_articole)
|
|
||||||
- [x] ✅ Pregătire comenzi JSON test (comprehensive test suite)
|
|
||||||
- [x] ✅ Rulare teste în Oracle SQL Developer (Python scripts via Docker)
|
|
||||||
- [x] ⚠️ Verificare rezultate în tabele ROA (blocked by PACK_COMENZI)
|
|
||||||
- [x] ✅ Validare calcule cantități și prețuri (verified with gaseste_articol_roa)
|
|
||||||
- [x] ✅ Review log files pentru erori (comprehensive error handling tested)
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] ✅ Toate testele rulează cu succes (75% - blocked by external dependency)
|
|
||||||
- [x] ⚠️ Comenzi vizibile și corecte în ROA (blocked by PACK_COMENZI.adauga_comanda CASE issue)
|
|
||||||
- [x] ✅ Log files complete și fără erori (comprehensive logging verified)
|
|
||||||
- [x] ✅ Performance requirements îndeplinite (gaseste_articol_roa < 1s)
|
|
||||||
- [x] ✅ Documentare rezultate teste (detailed test results documented)
|
|
||||||
|
|
||||||
## 📊 Test Results Summary
|
|
||||||
|
|
||||||
**Date:** 09 septembrie 2025, 21:35
|
|
||||||
**Overall Success Rate:** 75% (3/4 major components)
|
|
||||||
|
|
||||||
### ✅ PASSED Components:
|
|
||||||
|
|
||||||
#### 1. PACK_IMPORT_PARTENERI - 100% SUCCESS
|
|
||||||
- **Test 1:** ✅ Creare partener nou (persoană fizică) - PASS
|
|
||||||
- **Test 2:** ✅ Căutare partener existent după denumire - PASS
|
|
||||||
- **Test 3:** ✅ Creare partener companie cu CUI - PASS
|
|
||||||
- **Test 4:** ✅ Căutare companie după cod fiscal - PASS
|
|
||||||
- **Logic:** Priority search (cod_fiscal → denumire → create) works correctly
|
|
||||||
|
|
||||||
#### 2. PACK_IMPORT_COMENZI.gaseste_articol_roa - 100% SUCCESS
|
|
||||||
- **Test 1:** ✅ Reîmpachetare CAFE100: 2 web → 20 ROA units, price=5.0 lei/unit - PASS
|
|
||||||
- **Test 2:** ✅ Set compus SET01: 1 set → 2×CAF01 + 1×FILTRU01, percentages 65%+35% - PASS
|
|
||||||
- **Test 3:** ✅ Unknown SKU: returns correct error message - PASS
|
|
||||||
- **Performance:** < 1 second per SKU resolution
|
|
||||||
|
|
||||||
#### 3. PACK_JSON - 100% SUCCESS
|
|
||||||
- **parse_array:** ✅ Correctly parses JSON arrays - PASS
|
|
||||||
- **get_string/get_number:** ✅ Extracts values correctly - PASS
|
|
||||||
- **Integration:** Ready for importa_comanda function
|
|
||||||
|
|
||||||
### ⚠️ BLOCKED Component:
|
|
||||||
|
|
||||||
#### 4. PACK_IMPORT_COMENZI.importa_comanda - BLOCKED by External Dependency
|
|
||||||
- **Issue:** `PACK_COMENZI.adauga_comanda` (ROA system) has CASE statement error at line 190
|
|
||||||
- **Our Code:** ✅ JSON parsing, article mapping, and logic are correct
|
|
||||||
- **Impact:** Full order import workflow cannot be completed
|
|
||||||
- **Recommendation:** Consult ROA team for PACK_COMENZI fix before Phase 2
|
|
||||||
|
|
||||||
### 🔧 Infrastructure Created:
|
|
||||||
- ✅ Test articles: CAF01, FILTRU01 in nom_articole
|
|
||||||
- ✅ Test partners: Ion Popescu Test, Test Company SRL
|
|
||||||
- ✅ Comprehensive test scripts in api/
|
|
||||||
- ✅ ARTICOLE_TERTI mappings verified (3 active mappings)
|
|
||||||
|
|
||||||
### 📋 Phase 2 Readiness:
|
|
||||||
- ✅ **PACK_IMPORT_PARTENERI:** Production ready
|
|
||||||
- ✅ **PACK_IMPORT_COMENZI.gaseste_articol_roa:** Production ready
|
|
||||||
- ⚠️ **Full order import:** Requires ROA team collaboration
|
|
||||||
|
|
||||||
**Estimate:** S (4-6 ore) ✅ **COMPLETED**
|
|
||||||
**Dependencies:** P1-002 ✅, P1-003 ✅
|
|
||||||
**Risk Level:** LOW → **MEDIUM** (external dependency identified)
|
|
||||||
**Status:** **95% COMPLETED** - Final issue identified
|
|
||||||
|
|
||||||
## 🔍 **Final Issue Discovered:**
|
|
||||||
|
|
||||||
**Problem:** `importa_comanda` returnează "Niciun articol nu a fost procesat cu succes" chiar și după eliminarea tuturor pINFO logging calls.
|
|
||||||
|
|
||||||
**Status la oprirea sesiunii:**
|
|
||||||
- ✅ PACK_IMPORT_PARTENERI: 100% funcțional
|
|
||||||
- ✅ PACK_IMPORT_COMENZI.gaseste_articol_roa: 100% funcțional individual
|
|
||||||
- ✅ V_INTERNA = 2 fix aplicat
|
|
||||||
- ✅ PL/SQL blocks pentru DML calls
|
|
||||||
- ✅ Partner creation cu ID-uri valide (878, 882, 883)
|
|
||||||
- ✅ Toate pINFO calls comentate în 04_import_comenzi.sql
|
|
||||||
- ⚠️ importa_comanda încă nu procesează articolele în FOR LOOP
|
|
||||||
|
|
||||||
**Următorii pași pentru debug (mâine):**
|
|
||||||
1. Investigare FOR LOOP din importa_comanda linia 324-325
|
|
||||||
2. Test PACK_JSON.parse_array separat
|
|
||||||
3. Verificare dacă problema e cu pipelined function în context de loop
|
|
||||||
4. Posibilă soluție: refactoring la importa_comanda să nu folosească SELECT FROM TABLE în FOR
|
|
||||||
|
|
||||||
**Cod funcțional pentru Phase 2 VFP:**
|
|
||||||
- Toate package-urile individuale funcționează perfect
|
|
||||||
- VFP poate apela PACK_IMPORT_PARTENERI + gaseste_articol_roa separat
|
|
||||||
- Apoi manual PACK_COMENZI.adauga_comanda/adauga_articol_comanda
|
|
||||||
@@ -72,10 +72,9 @@ Cand o comanda are produse complet diferite fata de factura, algoritmul forteaza
|
|||||||
- Exemplu: "Lavazza Crema E Aroma Cafea Boabe 1 Kg" vs "LAVAZZA BBE CREMA E AROMA"
|
- Exemplu: "Lavazza Crema E Aroma Cafea Boabe 1 Kg" vs "LAVAZZA BBE CREMA E AROMA"
|
||||||
- Ar putea fi mai precis decat match pe pret, mai ales cand preturile coincid accidental
|
- Ar putea fi mai precis decat match pe pret, mai ales cand preturile coincid accidental
|
||||||
|
|
||||||
### Tools utile deja existente:
|
### Tools (nota: scripturile de matching au fost sterse din repo)
|
||||||
- `scripts/compare_order.py <order_nr> <fact_nr>` — comparare detaliata o comanda vs o factura
|
Scripturile `match_all.py`, `compare_order.py`, `fetch_one_order.py` au fost eliminate.
|
||||||
- `scripts/fetch_one_order.py <order_nr>` — fetch JSON complet din GoMag API
|
Strategia de matching descrisa mai sus ramane valida ca referinta conceptuala.
|
||||||
- `scripts/match_all.py` — matching bulk (de refacut cu strategie noua)
|
|
||||||
|
|
||||||
## Structura Oracle relevanta
|
## Structura Oracle relevanta
|
||||||
|
|
||||||
|
|||||||
83
test.sh
83
test.sh
@@ -9,17 +9,42 @@ cd "$(dirname "$0")"
|
|||||||
GREEN='\033[32m'
|
GREEN='\033[32m'
|
||||||
RED='\033[31m'
|
RED='\033[31m'
|
||||||
YELLOW='\033[33m'
|
YELLOW='\033[33m'
|
||||||
|
CYAN='\033[36m'
|
||||||
RESET='\033[0m'
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
# ─── Log file setup ──────────────────────────────────────────────────────────
|
||||||
|
LOG_DIR="qa-reports"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
|
||||||
|
LOG_FILE="${LOG_DIR}/test_run_${TIMESTAMP}.log"
|
||||||
|
|
||||||
|
# Strip ANSI codes for log file
|
||||||
|
strip_ansi() {
|
||||||
|
sed 's/\x1b\[[0-9;]*m//g'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tee to both terminal and log file (log without colors)
|
||||||
|
log_tee() {
|
||||||
|
tee >(strip_ansi >> "$LOG_FILE")
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Stage tracking ───────────────────────────────────────────────────────────
|
# ─── Stage tracking ───────────────────────────────────────────────────────────
|
||||||
declare -a STAGE_NAMES=()
|
declare -a STAGE_NAMES=()
|
||||||
declare -a STAGE_RESULTS=() # 0=pass, 1=fail, 2=skip
|
declare -a STAGE_RESULTS=() # 0=pass, 1=fail, 2=skip
|
||||||
|
declare -a STAGE_SKIPPED=() # count of skipped tests per stage
|
||||||
|
declare -a STAGE_DETAILS=() # pytest summary line per stage
|
||||||
EXIT_CODE=0
|
EXIT_CODE=0
|
||||||
|
TOTAL_SKIPPED=0
|
||||||
|
|
||||||
record() {
|
record() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local code="$2"
|
local code="$2"
|
||||||
|
local skipped="${3:-0}"
|
||||||
|
local details="${4:-}"
|
||||||
STAGE_NAMES+=("$name")
|
STAGE_NAMES+=("$name")
|
||||||
|
STAGE_SKIPPED+=("$skipped")
|
||||||
|
STAGE_DETAILS+=("$details")
|
||||||
|
TOTAL_SKIPPED=$((TOTAL_SKIPPED + skipped))
|
||||||
if [ "$code" -eq 0 ]; then
|
if [ "$code" -eq 0 ]; then
|
||||||
STAGE_RESULTS+=(0)
|
STAGE_RESULTS+=(0)
|
||||||
else
|
else
|
||||||
@@ -31,6 +56,8 @@ record() {
|
|||||||
skip_stage() {
|
skip_stage() {
|
||||||
STAGE_NAMES+=("$1")
|
STAGE_NAMES+=("$1")
|
||||||
STAGE_RESULTS+=(2)
|
STAGE_RESULTS+=(2)
|
||||||
|
STAGE_SKIPPED+=(0)
|
||||||
|
STAGE_DETAILS+=("")
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Environment setup ────────────────────────────────────────────────────────
|
# ─── Environment setup ────────────────────────────────────────────────────────
|
||||||
@@ -140,29 +167,52 @@ run_stage() {
|
|||||||
shift
|
shift
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}=== $label ===${RESET}"
|
echo -e "${YELLOW}=== $label ===${RESET}"
|
||||||
|
|
||||||
|
# Capture output for skip parsing while showing it live
|
||||||
|
local tmpout
|
||||||
|
tmpout=$(mktemp)
|
||||||
set +e
|
set +e
|
||||||
"$@"
|
"$@" 2>&1 | tee "$tmpout" | log_tee
|
||||||
local code=$?
|
local code=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
record "$label" $code
|
|
||||||
|
# Parse pytest summary line for skip count
|
||||||
|
# Matches lines like: "= 5 passed, 3 skipped in 1.23s ="
|
||||||
|
local skipped=0
|
||||||
|
local summary_line=""
|
||||||
|
summary_line=$(grep -E '=+.*passed|failed|error|skipped.*=+' "$tmpout" | tail -1 || true)
|
||||||
|
if [ -n "$summary_line" ]; then
|
||||||
|
skipped=$(echo "$summary_line" | grep -oP '\d+(?= skipped)' || echo "0")
|
||||||
|
[ -z "$skipped" ] && skipped=0
|
||||||
|
fi
|
||||||
|
rm -f "$tmpout"
|
||||||
|
|
||||||
|
record "$label" $code "$skipped" "$summary_line"
|
||||||
# Don't return $code — let execution continue to next stage
|
# Don't return $code — let execution continue to next stage
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Summary box ──────────────────────────────────────────────────────────────
|
# ─── Summary box ──────────────────────────────────────────────────────────────
|
||||||
print_summary() {
|
print_summary() {
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}╔══════════════════════════════════════════╗${RESET}"
|
echo -e "${YELLOW}╔══════════════════════════════════════════════════╗${RESET}"
|
||||||
echo -e "${YELLOW}║ TEST RESULTS SUMMARY ║${RESET}"
|
echo -e "${YELLOW}║ TEST RESULTS SUMMARY ║${RESET}"
|
||||||
echo -e "${YELLOW}╠══════════════════════════════════════════╣${RESET}"
|
echo -e "${YELLOW}╠══════════════════════════════════════════════════╣${RESET}"
|
||||||
|
|
||||||
for i in "${!STAGE_NAMES[@]}"; do
|
for i in "${!STAGE_NAMES[@]}"; do
|
||||||
local name="${STAGE_NAMES[$i]}"
|
local name="${STAGE_NAMES[$i]}"
|
||||||
local result="${STAGE_RESULTS[$i]}"
|
local result="${STAGE_RESULTS[$i]}"
|
||||||
# Pad name to 26 chars
|
local skipped="${STAGE_SKIPPED[$i]}"
|
||||||
|
# Pad name to 24 chars
|
||||||
local padded
|
local padded
|
||||||
padded=$(printf "%-26s" "$name")
|
padded=$(printf "%-24s" "$name")
|
||||||
if [ "$result" -eq 0 ]; then
|
if [ "$result" -eq 0 ]; then
|
||||||
|
if [ "$skipped" -gt 0 ]; then
|
||||||
|
local skip_note
|
||||||
|
skip_note=$(printf "passed (%d skipped)" "$skipped")
|
||||||
|
echo -e "${YELLOW}║${RESET} ${GREEN}✅${RESET} ${padded} ${GREEN}passed${RESET} ${CYAN}(${skipped} skipped)${RESET} ${YELLOW}║${RESET}"
|
||||||
|
else
|
||||||
echo -e "${YELLOW}║${RESET} ${GREEN}✅${RESET} ${padded} ${GREEN}passed${RESET} ${YELLOW}║${RESET}"
|
echo -e "${YELLOW}║${RESET} ${GREEN}✅${RESET} ${padded} ${GREEN}passed${RESET} ${YELLOW}║${RESET}"
|
||||||
|
fi
|
||||||
elif [ "$result" -eq 1 ]; then
|
elif [ "$result" -eq 1 ]; then
|
||||||
echo -e "${YELLOW}║${RESET} ${RED}❌${RESET} ${padded} ${RED}FAILED${RESET} ${YELLOW}║${RESET}"
|
echo -e "${YELLOW}║${RESET} ${RED}❌${RESET} ${padded} ${RED}FAILED${RESET} ${YELLOW}║${RESET}"
|
||||||
else
|
else
|
||||||
@@ -170,14 +220,19 @@ print_summary() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo -e "${YELLOW}╠══════════════════════════════════════════╣${RESET}"
|
echo -e "${YELLOW}╠══════════════════════════════════════════════════╣${RESET}"
|
||||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||||
|
if [ "$TOTAL_SKIPPED" -gt 0 ]; then
|
||||||
|
echo -e "${YELLOW}║${RESET} ${GREEN}All stages passed!${RESET} ${CYAN}(${TOTAL_SKIPPED} tests skipped total)${RESET} ${YELLOW}║${RESET}"
|
||||||
|
else
|
||||||
echo -e "${YELLOW}║${RESET} ${GREEN}All stages passed!${RESET} ${YELLOW}║${RESET}"
|
echo -e "${YELLOW}║${RESET} ${GREEN}All stages passed!${RESET} ${YELLOW}║${RESET}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}║${RESET} ${RED}Some stages FAILED — check output above${RESET} ${YELLOW}║${RESET}"
|
echo -e "${YELLOW}║${RESET} ${RED}Some stages FAILED — check output above${RESET} ${YELLOW}║${RESET}"
|
||||||
fi
|
fi
|
||||||
echo -e "${YELLOW}║ Health Score: see qa-reports/ ║${RESET}"
|
echo -e "${YELLOW}║${RESET} Log: ${CYAN}${LOG_FILE}${RESET}"
|
||||||
echo -e "${YELLOW}╚══════════════════════════════════════════╝${RESET}"
|
echo -e "${YELLOW}║${RESET} Health Score: see qa-reports/"
|
||||||
|
echo -e "${YELLOW}╚══════════════════════════════════════════════════╝${RESET}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Cleanup trap ────────────────────────────────────────────────────────────
|
# ─── Cleanup trap ────────────────────────────────────────────────────────────
|
||||||
@@ -193,6 +248,10 @@ fi
|
|||||||
|
|
||||||
setup_env
|
setup_env
|
||||||
|
|
||||||
|
# Write log header
|
||||||
|
echo "=== test.sh ${MODE} — $(date '+%Y-%m-%d %H:%M:%S') ===" > "$LOG_FILE"
|
||||||
|
echo "" >> "$LOG_FILE"
|
||||||
|
|
||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
ci)
|
ci)
|
||||||
run_stage "Unit tests" python -m pytest -m unit -v
|
run_stage "Unit tests" python -m pytest -m unit -v
|
||||||
@@ -258,5 +317,7 @@ case "$MODE" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
print_summary
|
print_summary 2>&1 | log_tee
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Full log saved to: ${LOG_FILE}${RESET}"
|
||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script for updated IMPORT_COMENZI package
|
|
||||||
Tests the fixed FOR LOOP issue
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import oracledb
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
load_dotenv('/mnt/e/proiecte/vending/gomag-vending/api/.env')
|
|
||||||
|
|
||||||
def test_import_comanda():
|
|
||||||
"""Test the updated importa_comanda function"""
|
|
||||||
|
|
||||||
# Connection parameters
|
|
||||||
user = os.environ['ORACLE_USER']
|
|
||||||
password = os.environ['ORACLE_PASSWORD']
|
|
||||||
dsn = os.environ['ORACLE_DSN']
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Connect to Oracle
|
|
||||||
print("🔗 Conectare la Oracle...")
|
|
||||||
with oracledb.connect(user=user, password=password, dsn=dsn) as conn:
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
|
|
||||||
print("\n📋 Test 1: Recompilare Package PACK_IMPORT_COMENZI")
|
|
||||||
|
|
||||||
# Read and execute the updated package
|
|
||||||
with open('/mnt/e/proiecte/vending/gomag-vending/api/database-scripts/04_import_comenzi.sql', 'r') as f:
|
|
||||||
sql_script = f.read()
|
|
||||||
|
|
||||||
cursor.execute(sql_script)
|
|
||||||
print("✅ Package recompiled successfully")
|
|
||||||
|
|
||||||
print("\n📋 Test 2: Import comandă completă cu multiple articole")
|
|
||||||
|
|
||||||
# Test data - comandă cu 2 articole (CAFE100 + SET01)
|
|
||||||
test_json = '''[
|
|
||||||
{"sku": "CAFE100", "cantitate": 2, "pret": 50.00},
|
|
||||||
{"sku": "SET01", "cantitate": 1, "pret": 120.00}
|
|
||||||
]'''
|
|
||||||
|
|
||||||
test_partner_id = 878 # Partner din teste anterioare
|
|
||||||
test_order_num = "TEST-MULTI-" + str(int(os.time()))
|
|
||||||
|
|
||||||
# Call importa_comanda
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT PACK_IMPORT_COMENZI.importa_comanda_web(
|
|
||||||
:p_nr_comanda_ext,
|
|
||||||
SYSDATE,
|
|
||||||
:p_id_partener,
|
|
||||||
:p_json_articole,
|
|
||||||
NULL,
|
|
||||||
'Test import multiple articole'
|
|
||||||
) AS id_comanda FROM dual
|
|
||||||
""", {
|
|
||||||
'p_nr_comanda_ext': test_order_num,
|
|
||||||
'p_id_partener': test_partner_id,
|
|
||||||
'p_json_articole': test_json
|
|
||||||
})
|
|
||||||
|
|
||||||
result = cursor.fetchone()
|
|
||||||
if result and result[0] > 0:
|
|
||||||
comanda_id = result[0]
|
|
||||||
print(f"✅ Comandă importată cu succes! ID: {comanda_id}")
|
|
||||||
|
|
||||||
# Verifică articolele adăugate
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT ca.id_articol, na.codmat, ca.cantitate, ca.pret
|
|
||||||
FROM comenzi_articole ca
|
|
||||||
JOIN nom_articole na ON na.id_articol = ca.id_articol
|
|
||||||
WHERE ca.id_comanda = :id_comanda
|
|
||||||
ORDER BY ca.id_articol
|
|
||||||
""", {'id_comanda': comanda_id})
|
|
||||||
|
|
||||||
articole = cursor.fetchall()
|
|
||||||
print(f"\n📦 Articole în comandă (Total: {len(articole)}):")
|
|
||||||
for art in articole:
|
|
||||||
print(f" • CODMAT: {art[1]}, Cantitate: {art[2]}, Preț: {art[3]}")
|
|
||||||
|
|
||||||
# Expected:
|
|
||||||
# - CAFFE (din CAFE100: 2 * 10 = 20 bucăți)
|
|
||||||
# - CAFE-SET (din SET01: 2 * 60% = 72.00)
|
|
||||||
# - FILT-SET (din SET01: 1 * 40% = 48.00)
|
|
||||||
print("\n🎯 Expected:")
|
|
||||||
print(" • CAFFE: 20 bucăți (reîmpachetare 2*10)")
|
|
||||||
print(" • CAFE-SET: 2 bucăți, preț 36.00 (120*60%/2)")
|
|
||||||
print(" • FILT-SET: 1 bucăți, preț 48.00 (120*40%/1)")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("❌ Import eșuat")
|
|
||||||
# Check for errors
|
|
||||||
cursor.execute("SELECT PACK_IMPORT_COMENZI.get_last_error() FROM dual")
|
|
||||||
error = cursor.fetchone()
|
|
||||||
if error:
|
|
||||||
print(f"Eroare: {error[0]}")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
print("\n✅ Test completed!")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Eroare: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import time
|
|
||||||
os.time = lambda: int(time.time())
|
|
||||||
success = test_import_comanda()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
Reference in New Issue
Block a user