chore: commit all pending changes including deploy scripts and Windows config
- deploy.ps1, iis-web.config: Windows Server deployment scripts - api/app/routers/sync.py, dashboard.py: router updates - api/app/services/import_service.py, sync_service.py: service updates - api/app/static/css/style.css, js/*.js: UI updates - api/database-scripts/08_PACK_FACTURARE.pck: Oracle package - .gitignore: add .gittoken - CLAUDE.md, agent configs: documentation updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: oracle-dba
|
||||
description: Oracle PL/SQL specialist for database scripts, packages, and schema changes in the ROA ERP system
|
||||
model: opus
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Oracle DBA Agent
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: python-backend
|
||||
description: FastAPI backend developer for services, routes, Oracle/SQLite integration, and API logic
|
||||
model: opus
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Python Backend Agent
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,6 +24,7 @@ __pycache__/
|
||||
# Settings files with secrets
|
||||
settings.ini
|
||||
vfp/settings.ini
|
||||
.gittoken
|
||||
output/
|
||||
vfp/*.json
|
||||
*.~pck
|
||||
|
||||
28
CLAUDE.md
28
CLAUDE.md
@@ -29,25 +29,19 @@ python api/test_app_basic.py # Test A - fara Oracle
|
||||
python api/test_integration.py # Test C - cu Oracle
|
||||
```
|
||||
|
||||
## UI Development Workflow: Before → Preview → After
|
||||
## UI Development Workflow: Preview → Implement → Verify
|
||||
|
||||
**OBLIGATORIU**: Respecta ordinea exacta. NU treci la pasul urmator fara aprobare explicita.
|
||||
|
||||
### 1. Before Screenshots
|
||||
Captureaza starea curenta cu Playwright MCP:
|
||||
- **Mobile:** 375x812
|
||||
- **Desktop:** 1440x900
|
||||
Salveaza in `screenshots/before/`
|
||||
|
||||
### 2. Plan & Preview — ASTEAPTA APROBARE
|
||||
### 1. Plan & Preview — ASTEAPTA APROBARE
|
||||
1. Citeste TOATE fisierele implicate
|
||||
2. Scrie planul de implementare cu decizii de design
|
||||
3. Genereaza mockup-uri HTML/CSS statice care arata rezultatul asteptat → salveaza in `screenshots/preview/`
|
||||
3. Genereaza **mockup-uri Markdown** care descriu rezultatul asteptat (tabele, liste, cod pseudo-CSS) — NU HTML static
|
||||
4. **Prezinta mockup-urile userului si ASTEAPTA aprobare explicita**
|
||||
5. Rafineaza planul daca userul cere modificari
|
||||
6. **NU trece la implementare pana userul nu spune explicit "ok", "aprob", "executa" sau similar**
|
||||
|
||||
### 3. Implementation cu TeamCreate (Agent Teams)
|
||||
### 2. Implementation cu TeamCreate (Agent Teams)
|
||||
|
||||
Folosim **TeamCreate** (team agents), NU superpowers subagents. Diferenta:
|
||||
- **TeamCreate**: agenti independenti cu task list partajat, comunicare directa intre ei, context propriu
|
||||
@@ -72,23 +66,21 @@ Folosim **TeamCreate** (team agents), NU superpowers subagents. Diferenta:
|
||||
|
||||
#### Teammate-ul de verificare (Task 3):
|
||||
1. Navigheaza la fiecare pagina cu Playwright MCP la 375x812 (mobile) si 1440x900 (desktop)
|
||||
2. Screenshot-uri → `screenshots/after/`
|
||||
3. Compara `after/` vs `preview/` vizual
|
||||
4. Raporteaza discrepante la team lead
|
||||
5. Verifica ca desktop-ul ramane neschimbat
|
||||
2. **Foloseste browser_snapshot** (NU screenshot-uri) pentru a inspecta structura DOM
|
||||
3. Verifica ca implementarea respecta fiecare punct din preview-ul aprobat (structura coloane, bold, dots, filtre etc.)
|
||||
4. Raporteaza discrepante concrete la team lead (ce e diferit fata de preview)
|
||||
5. NU salveaza screenshot-uri after/
|
||||
|
||||
#### Bucla de corectie (responsabilitatea team lead-ului):
|
||||
1. Dupa ce verify-agent raporteaza, **team lead-ul analizeaza discrepantele**
|
||||
2. Pentru fiecare discrepanta, creeaza un nou task de fix si spawneaza un agent sa-l rezolve
|
||||
3. Dupa fix, spawneaza din nou verify-agent pentru re-verificare
|
||||
4. **Repeta bucla** pana cand toate verificarile trec (after ≈ preview)
|
||||
4. **Repeta bucla** pana cand toate verificarile trec (implementare ≈ preview)
|
||||
5. Abia atunci declara task-ul complet
|
||||
|
||||
```
|
||||
screenshots/
|
||||
├── before/ # Starea inainte de modificari
|
||||
├── preview/ # Mockup-uri aprobate de user
|
||||
└── after/ # Verificare post-implementare
|
||||
└── preview/ # Mockup-uri Markdown aprobate de user (referinta pentru verificare)
|
||||
```
|
||||
|
||||
### Principii
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
SPECIFICATIE PROIECT - IMPORT COMENZI WEB IN ROA ORACLE
|
||||
Data: 5 martie 2026
|
||||
|
||||
================================================================================
|
||||
DESCRIERE SCOP
|
||||
================================================================================
|
||||
|
||||
Implementarea unui sistem automat de import a comenzilor de pe platforme web
|
||||
(GoMag si altele) in sistemul ERP ROA Oracle. Sistemul va prelua comenzi,
|
||||
va realiza mapari de articole, va converte unitati de masura si va crea
|
||||
comenzi in ROA automat.
|
||||
|
||||
|
||||
================================================================================
|
||||
DELIVERABLES
|
||||
================================================================================
|
||||
|
||||
1. Logica de import completa in baza de date ROA Oracle
|
||||
2. Orchestrator automat (cron job) pentru sincronizare comenzi
|
||||
3. Interfata web de configurare mapari SKU-uri
|
||||
4. Suport pentru articole compuse (mapari complexe)
|
||||
5. Conversii unitati de masura intre platforme
|
||||
6. Documentatie tehnica si handover
|
||||
7. Support 3 luni pentru bug fixes
|
||||
|
||||
|
||||
================================================================================
|
||||
EFORTURI SI COSTURI
|
||||
================================================================================
|
||||
|
||||
Lucrat deja: 20h 1,200 EUR
|
||||
De lucrat: 60h 3,600 EUR
|
||||
Support 3 luni: 24h 1,440 EUR
|
||||
|
||||
TOTAL IMPLEMENTARE: 80h 4,800 EUR
|
||||
TOTAL CU SUPPORT: 104h 6,240 EUR
|
||||
|
||||
Tarif orar: 60 EUR/h
|
||||
|
||||
|
||||
================================================================================
|
||||
INCLUS IN PRET
|
||||
================================================================================
|
||||
|
||||
- Analiza si integrare cu baza de date client
|
||||
- Testare completa cu date reale
|
||||
- Integrare in sistemul ROA Oracle
|
||||
- Validari si controale de integritate
|
||||
- Documentation si training
|
||||
- Support de 3 luni pentru probleme critice
|
||||
|
||||
|
||||
================================================================================
|
||||
CONDITII GENERALE
|
||||
================================================================================
|
||||
|
||||
Duratie proiect: 2-4 saptamani
|
||||
Payment terms: 50% avans, 50% la finalizare
|
||||
Garantie: 3 luni (bug fixes gratuit)
|
||||
Suport suplimentar: 60 EUR/h (dupa perioada garantie)
|
||||
|
||||
Buffer estimare: 50% (pentru integrare ROA + incertitudini)
|
||||
|
||||
|
||||
================================================================================
|
||||
RESPONSABILITATI CLIENT
|
||||
================================================================================
|
||||
|
||||
- Acces la baza de date client si ROA Oracle
|
||||
- Accesul la comenzile din platforma web
|
||||
- Clarificarea logicii maparii articole compuse
|
||||
- Testing si validare in mediu pilot
|
||||
|
||||
|
||||
================================================================================
|
||||
@@ -15,3 +15,7 @@ async def dashboard(request: Request):
|
||||
@router.get("/missing-skus", response_class=HTMLResponse)
|
||||
async def missing_skus_page(request: Request):
|
||||
return templates.TemplateResponse("missing_skus.html", {"request": request})
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
return templates.TemplateResponse("settings.html", {"request": request})
|
||||
|
||||
@@ -24,6 +24,11 @@ class AppSettingsUpdate(BaseModel):
|
||||
transport_codmat: str = ""
|
||||
transport_vat: str = "21"
|
||||
discount_codmat: str = ""
|
||||
transport_id_pol: str = ""
|
||||
discount_vat: str = "21"
|
||||
discount_id_pol: str = ""
|
||||
id_pol: str = ""
|
||||
id_sectie: str = ""
|
||||
|
||||
|
||||
# API endpoints
|
||||
@@ -332,11 +337,12 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
period_days=0 without dates means all time.
|
||||
"""
|
||||
is_uninvoiced_filter = (status == "UNINVOICED")
|
||||
is_invoiced_filter = (status == "INVOICED")
|
||||
|
||||
# For UNINVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
||||
fetch_status = "IMPORTED" if is_uninvoiced_filter else status
|
||||
fetch_per_page = 10000 if is_uninvoiced_filter else per_page
|
||||
fetch_page = 1 if is_uninvoiced_filter else page
|
||||
# For UNINVOICED/INVOICED: fetch all IMPORTED orders, then filter post-invoice-check
|
||||
fetch_status = "IMPORTED" if (is_uninvoiced_filter or is_invoiced_filter) else status
|
||||
fetch_per_page = 10000 if (is_uninvoiced_filter or is_invoiced_filter) else per_page
|
||||
fetch_page = 1 if (is_uninvoiced_filter or is_invoiced_filter) else page
|
||||
|
||||
result = await sqlite_service.get_orders(
|
||||
page=fetch_page, per_page=fetch_per_page, search=search,
|
||||
@@ -391,6 +397,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
1 for o in all_orders
|
||||
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
|
||||
))
|
||||
imported_total = counts.get("imported_all") or counts.get("imported", 0)
|
||||
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
|
||||
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
|
||||
|
||||
# For UNINVOICED filter: apply server-side filtering + pagination
|
||||
@@ -403,6 +411,15 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
|
||||
result["page"] = page
|
||||
result["per_page"] = per_page
|
||||
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
||||
elif is_invoiced_filter:
|
||||
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and o.get("invoice")]
|
||||
total = len(filtered)
|
||||
offset = (page - 1) * per_page
|
||||
result["orders"] = filtered[offset:offset + per_page]
|
||||
result["total"] = total
|
||||
result["page"] = page
|
||||
result["per_page"] = per_page
|
||||
result["pages"] = (total + per_page - 1) // per_page if total > 0 else 0
|
||||
|
||||
# Reshape response
|
||||
return {
|
||||
@@ -445,6 +462,11 @@ async def get_app_settings():
|
||||
"transport_codmat": settings.get("transport_codmat", ""),
|
||||
"transport_vat": settings.get("transport_vat", "21"),
|
||||
"discount_codmat": settings.get("discount_codmat", ""),
|
||||
"transport_id_pol": settings.get("transport_id_pol", ""),
|
||||
"discount_vat": settings.get("discount_vat", "19"),
|
||||
"discount_id_pol": settings.get("discount_id_pol", ""),
|
||||
"id_pol": settings.get("id_pol", ""),
|
||||
"id_sectie": settings.get("id_sectie", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -454,4 +476,9 @@ async def update_app_settings(config: AppSettingsUpdate):
|
||||
await sqlite_service.set_app_setting("transport_codmat", config.transport_codmat)
|
||||
await sqlite_service.set_app_setting("transport_vat", config.transport_vat)
|
||||
await sqlite_service.set_app_setting("discount_codmat", config.discount_codmat)
|
||||
await sqlite_service.set_app_setting("transport_id_pol", config.transport_id_pol)
|
||||
await sqlite_service.set_app_setting("discount_vat", config.discount_vat)
|
||||
await sqlite_service.set_app_setting("discount_id_pol", config.discount_id_pol)
|
||||
await sqlite_service.set_app_setting("id_pol", config.id_pol)
|
||||
await sqlite_service.set_app_setting("id_sectie", config.id_sectie)
|
||||
return {"success": True}
|
||||
|
||||
@@ -80,22 +80,29 @@ def build_articles_json(items, order=None, settings=None) -> str:
|
||||
|
||||
# Transport as article with quantity +1
|
||||
if order.delivery_cost > 0 and transport_codmat:
|
||||
articles.append({
|
||||
article_dict = {
|
||||
"sku": transport_codmat,
|
||||
"quantity": "1",
|
||||
"price": str(order.delivery_cost),
|
||||
"vat": transport_vat,
|
||||
"name": "Transport"
|
||||
})
|
||||
}
|
||||
if settings.get("transport_id_pol"):
|
||||
article_dict["id_pol"] = settings["transport_id_pol"]
|
||||
articles.append(article_dict)
|
||||
# Discount total with quantity -1 (positive price)
|
||||
if order.discount_total > 0 and discount_codmat:
|
||||
articles.append({
|
||||
discount_vat = settings.get("discount_vat", "19")
|
||||
article_dict = {
|
||||
"sku": discount_codmat,
|
||||
"quantity": "-1",
|
||||
"price": str(order.discount_total),
|
||||
"vat": "21",
|
||||
"vat": discount_vat,
|
||||
"name": "Discount"
|
||||
})
|
||||
}
|
||||
if settings.get("discount_id_pol"):
|
||||
article_dict["id_pol"] = settings["discount_id_pol"]
|
||||
articles.append(article_dict)
|
||||
|
||||
return json.dumps(articles)
|
||||
|
||||
|
||||
@@ -232,8 +232,10 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
)
|
||||
|
||||
# Step 2d: Pre-validate prices for importable articles
|
||||
id_pol = id_pol or settings.ID_POL
|
||||
id_sectie = id_sectie or settings.ID_SECTIE
|
||||
# Load app settings (for transport/discount CODMAT config AND id_pol/id_sectie override)
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
id_pol = id_pol or int(app_settings.get("id_pol") or 0) or settings.ID_POL
|
||||
id_sectie = id_sectie or int(app_settings.get("id_sectie") or 0) or settings.ID_SECTIE
|
||||
logger.info(f"Sync params: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
|
||||
_log_line(run_id, f"Parametri import: ID_POL={id_pol}, ID_SECTIE={id_sectie}")
|
||||
if id_pol and (truly_importable or already_in_roa):
|
||||
@@ -331,9 +333,6 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
|
||||
imported_count = 0
|
||||
error_count = 0
|
||||
|
||||
# Load app settings for transport/discount CODMAT config
|
||||
app_settings = await sqlite_service.get_app_settings()
|
||||
|
||||
for i, order in enumerate(truly_importable):
|
||||
shipping_name, billing_name, customer, payment_method, delivery_method = _derive_customer_info(order)
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ body {
|
||||
padding-right: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
min-height: 100vh;
|
||||
max-width: 1280px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────────────── */
|
||||
@@ -140,6 +143,10 @@ body {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Zebra striping */
|
||||
.table tbody tr:nth-child(even) td { background-color: #f7f8fa; }
|
||||
.table-hover tbody tr:hover td { background-color: #eef2ff !important; }
|
||||
|
||||
/* ── Badges — soft pill style ────────────────────── */
|
||||
.badge {
|
||||
font-size: 0.8125rem;
|
||||
@@ -736,3 +743,22 @@ tr.mapping-deleted td {
|
||||
gap: 1rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Clickable CODMAT link in order detail modal */
|
||||
.codmat-link { color: #0d6efd; cursor: pointer; text-decoration: underline; }
|
||||
.codmat-link:hover { color: #0a58ca; }
|
||||
|
||||
/* Mobile article flat list in order detail modal */
|
||||
.detail-item-flat { font-size: 0.85rem; }
|
||||
.detail-item-flat .dif-item { }
|
||||
.detail-item-flat .dif-item:nth-child(even) .dif-row { background: #f7f8fa; }
|
||||
.detail-item-flat .dif-row {
|
||||
display: flex; align-items: baseline; gap: 0.5rem;
|
||||
padding: 0.2rem 0.75rem; flex-wrap: wrap;
|
||||
}
|
||||
.dif-sku { font-family: monospace; font-size: 0.78rem; color: #6b7280; }
|
||||
.dif-name { font-weight: 500; flex: 1; }
|
||||
.dif-qty { white-space: nowrap; color: #6b7280; }
|
||||
.dif-val { white-space: nowrap; font-weight: 600; }
|
||||
.dif-codmat-link { color: #0d6efd; cursor: pointer; font-size: 0.78rem; font-family: monospace; }
|
||||
.dif-codmat-link:hover { color: #0a58ca; text-decoration: underline; }
|
||||
|
||||
@@ -281,42 +281,29 @@ async function loadDashOrders() {
|
||||
if (el('cntImp')) el('cntImp').textContent = c.imported_all || c.imported || 0;
|
||||
if (el('cntSkip')) el('cntSkip').textContent = c.skipped || 0;
|
||||
if (el('cntErr')) el('cntErr').textContent = c.error || c.errors || 0;
|
||||
if (el('cntNef')) el('cntNef').textContent = c.uninvoiced || c.nefacturate || 0;
|
||||
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||
|
||||
const tbody = document.getElementById('dashOrdersBody');
|
||||
const orders = data.orders || [];
|
||||
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = orders.map(o => {
|
||||
const dateStr = fmtDate(o.order_date);
|
||||
const statusBadge = orderStatusBadge(o.status);
|
||||
|
||||
// Invoice info
|
||||
let invoiceBadge = '';
|
||||
if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') {
|
||||
invoiceBadge = '<span class="text-muted">-</span>';
|
||||
} else if (o.invoice && o.invoice.facturat) {
|
||||
invoiceBadge = `<span style="color:#16a34a;font-weight:500">Facturat</span>`;
|
||||
if (o.invoice.serie_act || o.invoice.numar_act) {
|
||||
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
|
||||
}
|
||||
} else {
|
||||
invoiceBadge = `<span style="color:#dc2626">Nefacturat</span>`;
|
||||
}
|
||||
|
||||
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${dateStr}</td>
|
||||
<td>${statusDot(o.status)}</td>
|
||||
<td class="text-nowrap">${dateStr}</td>
|
||||
${renderClientCell(o)}
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td class="text-end">${orderTotal}</td>
|
||||
<td class="text-nowrap">${statusDot(o.status)} ${statusLabelText(o.status)}</td>
|
||||
<td>${o.id_comanda || '-'}</td>
|
||||
<td>${invoiceBadge}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||
<td class="text-end fw-bold">${orderTotal}</td>
|
||||
<td class="text-center">${invoiceDot(o)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -339,8 +326,8 @@ async function loadDashOrders() {
|
||||
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||
${statusDot(o.status)}
|
||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
||||
<span class="grow truncate">${esc(name)}</span>
|
||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + totalStr : ''}</span>
|
||||
<span class="grow truncate fw-bold">${esc(name)}</span>
|
||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -352,7 +339,8 @@ async function loadDashOrders() {
|
||||
{ label: 'Imp.', count: c.imported_all || c.imported || 0, value: 'IMPORTED', active: activeStatus === 'IMPORTED', colorClass: 'fc-green' },
|
||||
{ label: 'Omise', count: c.skipped || 0, value: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' },
|
||||
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
|
||||
{ label: 'Nefact.', count: c.uninvoiced || c.nefacturate || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-neutral' }
|
||||
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
|
||||
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' }
|
||||
], (val) => {
|
||||
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
||||
const pill = document.querySelector(`.filter-pill[data-status="${val}"]`);
|
||||
@@ -380,7 +368,7 @@ async function loadDashOrders() {
|
||||
});
|
||||
} catch (err) {
|
||||
document.getElementById('dashOrdersBody').innerHTML =
|
||||
`<tr><td colspan="8" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
`<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,9 +390,9 @@ function renderClientCell(order) {
|
||||
const billing = (order.billing_name || '').trim();
|
||||
const isDiff = order.is_different_person && billing && shipping !== billing;
|
||||
if (isDiff) {
|
||||
return `<td class="tooltip-cont" data-tooltip="Cont: ${escHtml(billing)}">${escHtml(shipping)} <sup style="color:#6b7280;font-size:0.65rem">▲</sup></td>`;
|
||||
return `<td class="tooltip-cont fw-bold" data-tooltip="Cont: ${escHtml(billing)}">${escHtml(shipping)} <sup style="color:#6b7280;font-size:0.65rem">▲</sup></td>`;
|
||||
}
|
||||
return `<td>${escHtml(shipping || billing || '\u2014')}</td>`;
|
||||
return `<td class="fw-bold">${escHtml(shipping || billing || '\u2014')}</td>`;
|
||||
}
|
||||
|
||||
// ── Helper functions ──────────────────────────────
|
||||
@@ -428,6 +416,10 @@ function escHtml(s) {
|
||||
// Alias kept for backward compat with inline handlers in modal
|
||||
function esc(s) { return escHtml(s); }
|
||||
|
||||
function fmtCost(v) {
|
||||
return v > 0 ? Number(v).toFixed(2) : '–';
|
||||
}
|
||||
|
||||
|
||||
function statusLabelText(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
@@ -449,6 +441,12 @@ function orderStatusBadge(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function invoiceDot(order) {
|
||||
if (order.status !== 'IMPORTED' && order.status !== 'ALREADY_IMPORTED') return '–';
|
||||
if (order.invoice && order.invoice.facturat) return '<span class="dot dot-green" title="Facturat"></span>';
|
||||
return '<span class="dot dot-red" title="Nefacturat"></span>';
|
||||
}
|
||||
|
||||
function renderCodmatCell(item) {
|
||||
if (!item.codmat_details || item.codmat_details.length === 0) {
|
||||
return `<code>${esc(item.codmat || '-')}</code>`;
|
||||
@@ -473,16 +471,12 @@ async function openDashOrderDetail(orderNumber) {
|
||||
document.getElementById('detailIdPartener').textContent = '-';
|
||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailError').style.display = 'none';
|
||||
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
||||
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
||||
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
||||
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
||||
const deliveryWrap = document.getElementById('detailDeliveryWrap');
|
||||
if (deliveryWrap) deliveryWrap.style.display = 'none';
|
||||
const discountWrap = document.getElementById('detailDiscountWrap');
|
||||
if (discountWrap) discountWrap.style.display = 'none';
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||
|
||||
@@ -514,25 +508,15 @@ async function openDashOrderDetail(orderNumber) {
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
|
||||
// Show delivery cost
|
||||
const dlvWrap = document.getElementById('detailDeliveryWrap');
|
||||
const dlvEl = document.getElementById('detailDeliveryCost');
|
||||
if (order.delivery_cost && Number(order.delivery_cost) > 0) {
|
||||
if (dlvEl) dlvEl.textContent = Number(order.delivery_cost).toFixed(2) + ' lei';
|
||||
if (dlvWrap) dlvWrap.style.display = '';
|
||||
}
|
||||
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–';
|
||||
|
||||
// Show discount
|
||||
const dscWrap = document.getElementById('detailDiscountWrap');
|
||||
const dscEl = document.getElementById('detailDiscount');
|
||||
if (order.discount_total && Number(order.discount_total) > 0) {
|
||||
if (dscEl) dscEl.textContent = '-' + Number(order.discount_total).toFixed(2) + ' lei';
|
||||
if (dscWrap) dscWrap.style.display = '';
|
||||
}
|
||||
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="8" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -541,53 +525,38 @@ async function openDashOrderDetail(orderNumber) {
|
||||
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 cards
|
||||
// Mobile article flat list
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) {
|
||||
mobileContainer.innerHTML = items.map(item => {
|
||||
let statusLabel = '';
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusLabel = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusLabel = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusLabel = '<span class="badge bg-warning">Lipsa</span>'; break;
|
||||
default: statusLabel = '<span class="badge bg-secondary">?</span>';
|
||||
}
|
||||
const codmat = item.codmat || '-';
|
||||
return `<div class="detail-item-card">
|
||||
<div class="card-sku">${esc(item.sku)}</div>
|
||||
<div class="card-name">${esc(item.product_name || '-')}</div>
|
||||
<div class="card-details">
|
||||
<span>x${item.quantity || 0}</span>
|
||||
<span>${item.price != null ? Number(item.price).toFixed(2) : '-'} lei</span>
|
||||
<span><code>${esc(codmat)}</code></span>
|
||||
<span>${statusLabel}</span>
|
||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
||||
const codmatList = item.codmat_details?.length
|
||||
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
|
||||
: `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '–')}</span>`;
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
return `<div class="dif-item">
|
||||
<div class="dif-row">
|
||||
<span class="dif-sku">${esc(item.sku)}</span>
|
||||
${codmatList}
|
||||
</div>
|
||||
<div class="dif-row">
|
||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
||||
<span class="dif-val">${valoare} lei</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
let statusBadge;
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusBadge = '<span class="badge bg-warning">Lipsa</span>'; break;
|
||||
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
||||
}
|
||||
|
||||
const action = item.mapping_status === 'missing'
|
||||
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
||||
: '';
|
||||
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
|
||||
return `<tr>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${codmatCell}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${action}</td>
|
||||
<td class="text-end">${valoare}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
@@ -730,80 +699,3 @@ async function saveQuickMapping() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── App Settings ─────────────────────────────────
|
||||
|
||||
let settAcTimeout = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAppSettings();
|
||||
wireSettingsAutocomplete('settTransportCodmat', 'settTransportAc');
|
||||
wireSettingsAutocomplete('settDiscountCodmat', 'settDiscountAc');
|
||||
});
|
||||
|
||||
async function loadAppSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
const el = (id) => document.getElementById(id);
|
||||
if (el('settTransportCodmat')) el('settTransportCodmat').value = data.transport_codmat || '';
|
||||
if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21';
|
||||
if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || '';
|
||||
} catch (err) {
|
||||
console.error('loadAppSettings error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAppSettings() {
|
||||
const transport_codmat = document.getElementById('settTransportCodmat')?.value?.trim() || '';
|
||||
const transport_vat = document.getElementById('settTransportVat')?.value || '21';
|
||||
const discount_codmat = document.getElementById('settDiscountCodmat')?.value?.trim() || '';
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transport_codmat, transport_vat, discount_codmat })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert('Setari salvate!');
|
||||
} else {
|
||||
alert('Eroare: ' + JSON.stringify(data));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare salvare setari: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function wireSettingsAutocomplete(inputId, dropdownId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
if (!input || !dropdown) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(settAcTimeout);
|
||||
settAcTimeout = setTimeout(async () => {
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="settSelectArticle('${inputId}', '${dropdownId}', '${esc(r.codmat)}')">
|
||||
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch { dropdown.classList.add('d-none'); }
|
||||
}, 250);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
function settSelectArticle(inputId, dropdownId, codmat) {
|
||||
document.getElementById(inputId).value = codmat;
|
||||
document.getElementById(dropdownId).classList.add('d-none');
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ let currentQmOrderNumber = '';
|
||||
let ordersSortColumn = 'order_date';
|
||||
let ordersSortDirection = 'desc';
|
||||
|
||||
function fmtCost(v) {
|
||||
return v > 0 ? Number(v).toFixed(2) : '–';
|
||||
}
|
||||
|
||||
function fmtDuration(startedAt, finishedAt) {
|
||||
if (!startedAt || !finishedAt) return '-';
|
||||
const diffMs = new Date(finishedAt) - new Date(startedAt);
|
||||
@@ -151,19 +155,21 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
const orders = data.orders || [];
|
||||
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" 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 {
|
||||
tbody.innerHTML = orders.map((o, i) => {
|
||||
const dateStr = fmtDate(o.order_date);
|
||||
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
|
||||
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
|
||||
<td>${statusDot(o.status)}</td>
|
||||
<td>${(ordersPage - 1) * 50 + i + 1}</td>
|
||||
<td>${dateStr}</td>
|
||||
<td class="text-nowrap">${dateStr}</td>
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td class="fw-bold">${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td class="text-end">${orderTotal}</td>
|
||||
<td class="text-nowrap">${statusDot(o.status)} ${logStatusText(o.status)}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.delivery_cost)}</td>
|
||||
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
|
||||
<td class="text-end fw-bold">${orderTotal}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -185,8 +191,8 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
|
||||
${statusDot(o.status)}
|
||||
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
|
||||
<span class="grow truncate">${esc(o.customer_name || '—')}</span>
|
||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + totalStr : ''}</span>
|
||||
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span>
|
||||
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -222,7 +228,7 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('runOrdersBody').innerHTML =
|
||||
`<tr><td colspan="6" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
`<tr><td colspan="9" class="text-center text-danger">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,16 +324,12 @@ async function openOrderDetail(orderNumber) {
|
||||
document.getElementById('detailIdPartener').textContent = '-';
|
||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="8" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
|
||||
document.getElementById('detailError').style.display = 'none';
|
||||
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
||||
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
||||
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
||||
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
||||
const deliveryWrap = document.getElementById('detailDeliveryWrap');
|
||||
if (deliveryWrap) deliveryWrap.style.display = 'none';
|
||||
const discountWrap = document.getElementById('detailDiscountWrap');
|
||||
if (discountWrap) discountWrap.style.display = 'none';
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||
|
||||
@@ -359,25 +361,15 @@ async function openOrderDetail(orderNumber) {
|
||||
document.getElementById('detailError').style.display = '';
|
||||
}
|
||||
|
||||
// Show delivery cost
|
||||
const dlvWrap = document.getElementById('detailDeliveryWrap');
|
||||
const dlvEl = document.getElementById('detailDeliveryCost');
|
||||
if (order.delivery_cost && Number(order.delivery_cost) > 0) {
|
||||
if (dlvEl) dlvEl.textContent = Number(order.delivery_cost).toFixed(2) + ' lei';
|
||||
if (dlvWrap) dlvWrap.style.display = '';
|
||||
}
|
||||
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–';
|
||||
|
||||
// Show discount
|
||||
const dscWrap = document.getElementById('detailDiscountWrap');
|
||||
const dscEl = document.getElementById('detailDiscount');
|
||||
if (order.discount_total && Number(order.discount_total) > 0) {
|
||||
if (dscEl) dscEl.textContent = '-' + Number(order.discount_total).toFixed(2) + ' lei';
|
||||
if (dscWrap) dscWrap.style.display = '';
|
||||
}
|
||||
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="8" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -386,53 +378,38 @@ async function openOrderDetail(orderNumber) {
|
||||
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 cards
|
||||
// Mobile article flat list
|
||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||
if (mobileContainer) {
|
||||
mobileContainer.innerHTML = items.map(item => {
|
||||
let statusLabel = '';
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusLabel = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusLabel = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusLabel = '<span class="badge bg-warning">Lipsa</span>'; break;
|
||||
default: statusLabel = '<span class="badge bg-secondary">?</span>';
|
||||
}
|
||||
const codmat = item.codmat || '-';
|
||||
return `<div class="detail-item-card">
|
||||
<div class="card-sku">${esc(item.sku)}</div>
|
||||
<div class="card-name">${esc(item.product_name || '-')}</div>
|
||||
<div class="card-details">
|
||||
<span>x${item.quantity || 0}</span>
|
||||
<span>${item.price != null ? Number(item.price).toFixed(2) : '-'} lei</span>
|
||||
<span><code>${esc(codmat)}</code></span>
|
||||
<span>${statusLabel}</span>
|
||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
||||
const codmatList = item.codmat_details?.length
|
||||
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
|
||||
: `<span class="dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '–')}</span>`;
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
return `<div class="dif-item">
|
||||
<div class="dif-row">
|
||||
<span class="dif-sku">${esc(item.sku)}</span>
|
||||
${codmatList}
|
||||
</div>
|
||||
<div class="dif-row">
|
||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
||||
<span class="dif-val">${valoare} lei</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
|
||||
let statusBadge;
|
||||
switch (item.mapping_status) {
|
||||
case 'mapped': statusBadge = '<span class="badge bg-success">Mapat</span>'; break;
|
||||
case 'direct': statusBadge = '<span class="badge bg-info">Direct</span>'; break;
|
||||
case 'missing': statusBadge = '<span class="badge bg-warning">Lipsa</span>'; break;
|
||||
default: statusBadge = '<span class="badge bg-secondary">?</span>';
|
||||
}
|
||||
|
||||
const action = item.mapping_status === 'missing'
|
||||
? `<a href="#" class="btn-map-icon" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}'); return false;" title="Mapeaza"><i class="bi bi-link-45deg"></i></a>`
|
||||
: '';
|
||||
|
||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
||||
const codmatCell = `<span class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
|
||||
return `<tr>
|
||||
<td><code>${esc(item.sku)}</code></td>
|
||||
<td>${esc(item.product_name || '-')}</td>
|
||||
<td>${codmatCell}</td>
|
||||
<td>${item.quantity || 0}</td>
|
||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
||||
<td>${item.vat != null ? Number(item.vat).toFixed(2) : '-'}</td>
|
||||
<td>${renderCodmatCell(item)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${action}</td>
|
||||
<td class="text-end">${valoare}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
|
||||
101
api/app/static/js/settings.js
Normal file
101
api/app/static/js/settings.js
Normal file
@@ -0,0 +1,101 @@
|
||||
let settAcTimeout = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSettings();
|
||||
wireAutocomplete('settTransportCodmat', 'settTransportAc');
|
||||
wireAutocomplete('settDiscountCodmat', 'settDiscountAc');
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
const el = (id) => document.getElementById(id);
|
||||
if (el('settTransportCodmat')) el('settTransportCodmat').value = data.transport_codmat || '';
|
||||
if (el('settTransportVat')) el('settTransportVat').value = data.transport_vat || '21';
|
||||
if (el('settTransportIdPol')) el('settTransportIdPol').value = data.transport_id_pol || '';
|
||||
if (el('settDiscountCodmat')) el('settDiscountCodmat').value = data.discount_codmat || '';
|
||||
if (el('settDiscountVat')) el('settDiscountVat').value = data.discount_vat || '19';
|
||||
if (el('settDiscountIdPol')) el('settDiscountIdPol').value = data.discount_id_pol || '';
|
||||
if (el('settIdPol')) el('settIdPol').value = data.id_pol || '';
|
||||
if (el('settIdSectie')) el('settIdSectie').value = data.id_sectie || '';
|
||||
} catch (err) {
|
||||
console.error('loadSettings error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const el = (id) => document.getElementById(id);
|
||||
const payload = {
|
||||
transport_codmat: el('settTransportCodmat')?.value?.trim() || '',
|
||||
transport_vat: el('settTransportVat')?.value || '21',
|
||||
transport_id_pol: el('settTransportIdPol')?.value?.trim() || '',
|
||||
discount_codmat: el('settDiscountCodmat')?.value?.trim() || '',
|
||||
discount_vat: el('settDiscountVat')?.value || '19',
|
||||
discount_id_pol: el('settDiscountIdPol')?.value?.trim() || '',
|
||||
id_pol: el('settIdPol')?.value?.trim() || '',
|
||||
id_sectie: el('settIdSectie')?.value?.trim() || '',
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
const resultEl = document.getElementById('settSaveResult');
|
||||
if (data.success) {
|
||||
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = '#16a34a'; }
|
||||
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
|
||||
} else {
|
||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = '#dc2626'; }
|
||||
}
|
||||
} catch (err) {
|
||||
const resultEl = document.getElementById('settSaveResult');
|
||||
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = '#dc2626'; }
|
||||
}
|
||||
}
|
||||
|
||||
function wireAutocomplete(inputId, dropdownId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
if (!input || !dropdown) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(settAcTimeout);
|
||||
settAcTimeout = setTimeout(async () => {
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) { dropdown.classList.add('d-none'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
||||
dropdown.innerHTML = data.results.map(r =>
|
||||
`<div class="autocomplete-item" onmousedown="settSelectArticle('${inputId}', '${dropdownId}', '${escHtml(r.codmat)}')">
|
||||
<span class="codmat">${escHtml(r.codmat)}</span> — <span class="denumire">${escHtml(r.denumire)}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
dropdown.classList.remove('d-none');
|
||||
} catch { dropdown.classList.add('d-none'); }
|
||||
}, 250);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
||||
});
|
||||
}
|
||||
|
||||
function settSelectArticle(inputId, dropdownId, codmat) {
|
||||
document.getElementById(inputId).value = codmat;
|
||||
document.getElementById(dropdownId).classList.add('d-none');
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
16928
api/database-scripts/08_PACK_FACTURARE.pck
Normal file
16928
api/database-scripts/08_PACK_FACTURARE.pck
Normal file
File diff suppressed because it is too large
Load Diff
528
deploy.ps1
Normal file
528
deploy.ps1
Normal file
@@ -0,0 +1,528 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploy / update GoMag Import Manager pe Windows Server cu IIS.
|
||||
|
||||
.DESCRIPTION
|
||||
- Prima rulare: clone repo, setup venv, genereaza start.bat, configureaza IIS
|
||||
- Rulari ulterioare: git pull, reinstaleaza deps, restarteaza serviciul
|
||||
|
||||
.PARAMETER RepoPath
|
||||
Calea locala unde se cloneaza repo-ul. Default: C:\gomag-vending
|
||||
|
||||
.PARAMETER Port
|
||||
Portul pe care ruleaza FastAPI. Default: 5003
|
||||
|
||||
.PARAMETER IisSiteName
|
||||
Numele site-ului IIS parinte. Default: "Default Web Site"
|
||||
|
||||
.PARAMETER SkipIIS
|
||||
Sarit configurarea IIS (util daca nu ai ARR/URLRewrite instalate inca)
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1
|
||||
.\deploy.ps1 -RepoPath "D:\apps\gomag-vending" -Port 5003
|
||||
.\deploy.ps1 -SkipIIS
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$RepoPath = "C:\gomag-vending",
|
||||
[int] $Port = 5003,
|
||||
[string]$IisSiteName = "Default Web Site",
|
||||
[switch]$SkipIIS
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
function Write-Step { param([string]$msg) Write-Host "`n==> $msg" -ForegroundColor Cyan }
|
||||
function Write-OK { param([string]$msg) Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||
function Write-Warn { param([string]$msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||
function Write-Fail { param([string]$msg) Write-Host " [FAIL] $msg" -ForegroundColor Red }
|
||||
function Write-Info { param([string]$msg) Write-Host " $msg" -ForegroundColor Gray }
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Citire token Gitea
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Citire token Gitea"
|
||||
|
||||
$TokenFile = Join-Path $ScriptDir ".gittoken"
|
||||
$GitToken = ""
|
||||
|
||||
if (Test-Path $TokenFile) {
|
||||
$GitToken = (Get-Content $TokenFile -Raw).Trim()
|
||||
Write-OK "Token citit din $TokenFile"
|
||||
} else {
|
||||
Write-Warn ".gittoken nu exista langa deploy.ps1"
|
||||
Write-Info "Creeaza fisierul $TokenFile cu token-ul tau Gitea (fara newline)"
|
||||
Write-Info "Ex: echo -n 'ghp_xxxx' > .gittoken"
|
||||
Write-Info ""
|
||||
Write-Info "Continui fara token (merge doar daca repo-ul e public sau deja clonat)"
|
||||
}
|
||||
|
||||
$RepoUrl = if ($GitToken) {
|
||||
"https://$GitToken@gitea.romfast.ro/romfast/gomag-vending.git"
|
||||
} else {
|
||||
"https://gitea.romfast.ro/romfast/gomag-vending.git"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Git clone / pull
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Git clone / pull"
|
||||
|
||||
# Verifica git instalat
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Fail "Git nu este instalat!"
|
||||
Write-Info "Descarca Git for Windows de la: https://git-scm.com/download/win"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $RepoPath ".git")) {
|
||||
Write-Info "Repo exista, fac git pull..."
|
||||
Push-Location $RepoPath
|
||||
try {
|
||||
# Update remote URL cu tokenul curent (in caz ca s-a schimbat)
|
||||
if ($GitToken) {
|
||||
git remote set-url origin $RepoUrl 2>$null
|
||||
}
|
||||
git pull --ff-only
|
||||
Write-OK "git pull OK"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Info "Clonez in $RepoPath ..."
|
||||
$ParentDir = Split-Path -Parent $RepoPath
|
||||
if (-not (Test-Path $ParentDir)) {
|
||||
New-Item -ItemType Directory -Path $ParentDir -Force | Out-Null
|
||||
}
|
||||
git clone $RepoUrl $RepoPath
|
||||
Write-OK "git clone OK"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. Verificare Python
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Verificare Python"
|
||||
|
||||
$PythonCmd = $null
|
||||
foreach ($candidate in @("python", "python3", "py")) {
|
||||
try {
|
||||
$ver = & $candidate --version 2>&1
|
||||
if ($ver -match "Python 3\.(\d+)") {
|
||||
$minor = [int]$Matches[1]
|
||||
if ($minor -ge 11) {
|
||||
$PythonCmd = $candidate
|
||||
Write-OK "Python gasit: $ver ($candidate)"
|
||||
break
|
||||
} else {
|
||||
Write-Warn "Python $ver prea vechi (necesar 3.11+)"
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (-not $PythonCmd) {
|
||||
Write-Fail "Python 3.11+ nu este instalat sau nu e in PATH!"
|
||||
Write-Info "Descarca de la: https://www.python.org/downloads/"
|
||||
Write-Info "IMPORTANT: Bifeaza 'Add Python to PATH' la instalare"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. Creare venv si instalare dependinte
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Virtual environment + dependinte"
|
||||
|
||||
$VenvDir = Join-Path $RepoPath "venv"
|
||||
$VenvPip = Join-Path $VenvDir "Scripts\pip.exe"
|
||||
$VenvPy = Join-Path $VenvDir "Scripts\python.exe"
|
||||
$ReqFile = Join-Path $RepoPath "api\requirements.txt"
|
||||
$DepsFlag = Join-Path $VenvDir ".deps_installed"
|
||||
|
||||
if (-not (Test-Path $VenvDir)) {
|
||||
Write-Info "Creez venv..."
|
||||
& $PythonCmd -m venv $VenvDir
|
||||
Write-OK "venv creat"
|
||||
}
|
||||
|
||||
# Reinstaleaza daca requirements.txt e mai nou decat flag-ul
|
||||
$needInstall = $true
|
||||
if (Test-Path $DepsFlag) {
|
||||
$reqTime = (Get-Item $ReqFile).LastWriteTime
|
||||
$flagTime = (Get-Item $DepsFlag).LastWriteTime
|
||||
if ($flagTime -ge $reqTime) { $needInstall = $false }
|
||||
}
|
||||
|
||||
if ($needInstall) {
|
||||
Write-Info "Instalez dependinte din requirements.txt..."
|
||||
& $VenvPip install --upgrade pip --quiet
|
||||
& $VenvPip install -r $ReqFile
|
||||
New-Item -ItemType File -Path $DepsFlag -Force | Out-Null
|
||||
Write-OK "Dependinte instalate"
|
||||
} else {
|
||||
Write-OK "Dependinte deja up-to-date"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. Detectare Oracle Home → sugestie INSTANTCLIENTPATH
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Detectare Oracle"
|
||||
|
||||
$OracleHome = $env:ORACLE_HOME
|
||||
$OracleBinPath = ""
|
||||
|
||||
if ($OracleHome -and (Test-Path $OracleHome)) {
|
||||
$OracleBinPath = Join-Path $OracleHome "bin"
|
||||
Write-OK "ORACLE_HOME detectat: $OracleHome"
|
||||
Write-Info "Seteaza in api\.env: INSTANTCLIENTPATH=$OracleBinPath"
|
||||
} else {
|
||||
# Cauta Oracle in locatii comune
|
||||
$commonPaths = @(
|
||||
"C:\oracle\product\19c\dbhome_1\bin",
|
||||
"C:\oracle\product\21c\dbhome_1\bin",
|
||||
"C:\app\oracle\product\19.0.0\dbhome_1\bin",
|
||||
"C:\oracle\instantclient_19_15",
|
||||
"C:\oracle\instantclient_21_3"
|
||||
)
|
||||
foreach ($p in $commonPaths) {
|
||||
if (Test-Path "$p\oci.dll") {
|
||||
$OracleBinPath = $p
|
||||
Write-OK "Oracle gasit la: $p"
|
||||
Write-Info "Seteaza in api\.env: INSTANTCLIENTPATH=$p"
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $OracleBinPath) {
|
||||
Write-Warn "Oracle Instant Client nu a fost gasit automat"
|
||||
Write-Info "Optiuni:"
|
||||
Write-Info " 1. Thick mode: seteaza INSTANTCLIENTPATH=<cale_oracle_bin> in api\.env"
|
||||
Write-Info " 2. Thin mode: seteaza FORCE_THIN_MODE=true in api\.env"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 6. Creare .env din template daca lipseste
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Fisier configurare api\.env"
|
||||
|
||||
$EnvFile = Join-Path $RepoPath "api\.env"
|
||||
$EnvExample = Join-Path $RepoPath "api\.env.example"
|
||||
|
||||
if (-not (Test-Path $EnvFile)) {
|
||||
if (Test-Path $EnvExample) {
|
||||
Copy-Item $EnvExample $EnvFile
|
||||
Write-OK "api\.env creat din .env.example"
|
||||
|
||||
# Actualizeaza TNS_ADMIN cu calea reala
|
||||
$ApiDir = Join-Path $RepoPath "api"
|
||||
(Get-Content $EnvFile) -replace "TNS_ADMIN=.*", "TNS_ADMIN=$ApiDir" |
|
||||
Set-Content $EnvFile
|
||||
|
||||
# Seteaza INSTANTCLIENTPATH daca am gasit Oracle
|
||||
if ($OracleBinPath) {
|
||||
(Get-Content $EnvFile) -replace "INSTANTCLIENTPATH=.*", "INSTANTCLIENTPATH=$OracleBinPath" |
|
||||
Set-Content $EnvFile
|
||||
}
|
||||
|
||||
Write-Warn "IMPORTANT: Editeaza $EnvFile cu credentialele Oracle si GoMag API!"
|
||||
Write-Info " ORACLE_USER, ORACLE_PASSWORD, ORACLE_DSN"
|
||||
Write-Info " GOMAG_API_KEY, GOMAG_API_SHOP"
|
||||
} else {
|
||||
Write-Warn ".env.example nu exista, sari pasul"
|
||||
}
|
||||
} else {
|
||||
Write-OK "api\.env exista deja"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 7. Creare directoare necesare
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Directoare date"
|
||||
|
||||
foreach ($dir in @("data", "output", "logs")) {
|
||||
$fullPath = Join-Path $RepoPath $dir
|
||||
if (-not (Test-Path $fullPath)) {
|
||||
New-Item -ItemType Directory -Path $fullPath -Force | Out-Null
|
||||
Write-OK "Creat: $dir\"
|
||||
} else {
|
||||
Write-OK "Exista: $dir\"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 8. Generare start.bat
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Generare start.bat"
|
||||
|
||||
$StartBat = Join-Path $RepoPath "start.bat"
|
||||
|
||||
# Citeste TNS_ADMIN si INSTANTCLIENTPATH din .env daca exista
|
||||
$TnsAdmin = Join-Path $RepoPath "api"
|
||||
$InstantClient = ""
|
||||
if (Test-Path $EnvFile) {
|
||||
Get-Content $EnvFile | ForEach-Object {
|
||||
if ($_ -match "^TNS_ADMIN=(.+)") {
|
||||
$TnsAdmin = $Matches[1].Trim()
|
||||
}
|
||||
if ($_ -match "^INSTANTCLIENTPATH=(.+)" -and $_ -notmatch "^#") {
|
||||
$InstantClient = $Matches[1].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$OraclePathLine = ""
|
||||
if ($InstantClient) {
|
||||
$OraclePathLine = "set PATH=$InstantClient;%PATH%"
|
||||
}
|
||||
|
||||
$StartBatContent = @"
|
||||
@echo off
|
||||
:: GoMag Import Manager - Windows Launcher
|
||||
:: Generat de deploy.ps1 - nu edita manual, ruleaza deploy.ps1 din nou
|
||||
|
||||
cd /d "$RepoPath"
|
||||
set TNS_ADMIN=$TnsAdmin
|
||||
$OraclePathLine
|
||||
|
||||
echo Starting GoMag Import Manager on http://0.0.0.0:$Port (prefix /gomag)
|
||||
"$VenvPy" -m uvicorn app.main:app --host 0.0.0.0 --port $Port --root-path /gomag --app-dir api
|
||||
"@
|
||||
|
||||
Set-Content -Path $StartBat -Value $StartBatContent -Encoding UTF8
|
||||
Write-OK "start.bat generat: $StartBat"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 9. IIS — Verificare ARR + URL Rewrite
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Verificare module IIS"
|
||||
|
||||
if ($SkipIIS) {
|
||||
Write-Warn "SkipIIS activ — configurare IIS sarita"
|
||||
} else {
|
||||
$ArrPath = "$env:SystemRoot\System32\inetsrv\arr.dll"
|
||||
$UrlRewritePath = "$env:SystemRoot\System32\inetsrv\rewrite.dll"
|
||||
|
||||
$ArrOk = Test-Path $ArrPath
|
||||
$UrlRwOk = Test-Path $UrlRewritePath
|
||||
|
||||
if ($ArrOk) {
|
||||
Write-OK "Application Request Routing (ARR) instalat"
|
||||
} else {
|
||||
Write-Warn "ARR 3.0 NU este instalat"
|
||||
Write-Info "Descarca: https://www.iis.net/downloads/microsoft/application-request-routing"
|
||||
Write-Info "Sau: winget install Microsoft.ARR"
|
||||
}
|
||||
|
||||
if ($UrlRwOk) {
|
||||
Write-OK "URL Rewrite 2.1 instalat"
|
||||
} else {
|
||||
Write-Warn "URL Rewrite 2.1 NU este instalat"
|
||||
Write-Info "Descarca: https://www.iis.net/downloads/microsoft/url-rewrite"
|
||||
Write-Info "Sau: winget install Microsoft.URLRewrite"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 10. Configurare IIS — copiere web.config
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if ($ArrOk -and $UrlRwOk) {
|
||||
Write-Step "Configurare IIS reverse proxy"
|
||||
|
||||
# Activeaza proxy in ARR (necesar o singura data)
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||
$proxyEnabled = (Get-WebConfigurationProperty `
|
||||
-pspath "MACHINE/WEBROOT/APPHOST" `
|
||||
-filter "system.webServer/proxy" `
|
||||
-name "enabled" `
|
||||
-ErrorAction SilentlyContinue).Value
|
||||
if (-not $proxyEnabled) {
|
||||
Set-WebConfigurationProperty `
|
||||
-pspath "MACHINE/WEBROOT/APPHOST" `
|
||||
-filter "system.webServer/proxy" `
|
||||
-name "enabled" `
|
||||
-value $true
|
||||
Write-OK "ARR proxy activat global"
|
||||
} else {
|
||||
Write-OK "ARR proxy deja activ"
|
||||
}
|
||||
} catch {
|
||||
Write-Warn "Nu am putut activa ARR proxy automat: $($_.Exception.Message)"
|
||||
Write-Info "Activeaza manual din IIS Manager → server root → Application Request Routing Cache → Enable Proxy"
|
||||
}
|
||||
|
||||
# Determina wwwroot site-ului IIS
|
||||
$IisRootPath = $null
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||
$site = Get-Website -Name $IisSiteName -ErrorAction SilentlyContinue
|
||||
if ($site) {
|
||||
$IisRootPath = [System.Environment]::ExpandEnvironmentVariables($site.PhysicalPath)
|
||||
Write-OK "Site IIS '$IisSiteName' gasit: $IisRootPath"
|
||||
} else {
|
||||
Write-Warn "Site IIS '$IisSiteName' nu a fost gasit"
|
||||
}
|
||||
} catch {
|
||||
# Fallback la locatia standard
|
||||
$IisRootPath = "$env:SystemDrive\inetpub\wwwroot"
|
||||
Write-Warn "WebAdministration unavailable, folosesc fallback: $IisRootPath"
|
||||
}
|
||||
|
||||
if ($IisRootPath) {
|
||||
$SourceWebConfig = Join-Path $RepoPath "iis-web.config"
|
||||
$DestWebConfig = Join-Path $IisRootPath "web.config"
|
||||
|
||||
if (Test-Path $SourceWebConfig) {
|
||||
# Inlocuieste portul in web.config cu cel configurat
|
||||
$wcContent = Get-Content $SourceWebConfig -Raw
|
||||
$wcContent = $wcContent -replace "localhost:5003", "localhost:$Port"
|
||||
|
||||
if (Test-Path $DestWebConfig) {
|
||||
# Backup web.config existent
|
||||
$backup = "$DestWebConfig.bak_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||||
Copy-Item $DestWebConfig $backup
|
||||
Write-Info "Backup web.config: $backup"
|
||||
}
|
||||
|
||||
Set-Content -Path $DestWebConfig -Value $wcContent -Encoding UTF8
|
||||
Write-OK "web.config copiat in $IisRootPath"
|
||||
} else {
|
||||
Write-Warn "iis-web.config nu exista in repo, sarit"
|
||||
}
|
||||
|
||||
# Restart IIS
|
||||
try {
|
||||
iisreset /noforce 2>&1 | Out-Null
|
||||
Write-OK "IIS restartat"
|
||||
} catch {
|
||||
Write-Warn "IIS restart esuat: $($_.Exception.Message)"
|
||||
Write-Info "Ruleaza manual: iisreset"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "IIS nu e configurat complet — instaleaza ARR si URL Rewrite, apoi ruleaza deploy.ps1 din nou"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 11. Serviciu Windows (NSSM sau Task Scheduler)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step "Serviciu Windows"
|
||||
|
||||
$ServiceName = "GoMagVending"
|
||||
$NssmExe = ""
|
||||
|
||||
# Cauta NSSM
|
||||
foreach ($p in @("nssm", "C:\nssm\win64\nssm.exe", "C:\tools\nssm\nssm.exe")) {
|
||||
if (Get-Command $p -ErrorAction SilentlyContinue) {
|
||||
$NssmExe = $p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($NssmExe) {
|
||||
Write-Info "NSSM gasit: $NssmExe"
|
||||
|
||||
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($existingService) {
|
||||
Write-Info "Serviciu existent, restarteaza..."
|
||||
& $NssmExe restart $ServiceName
|
||||
Write-OK "Serviciu $ServiceName restartat"
|
||||
} else {
|
||||
Write-Info "Instalez serviciu $ServiceName cu NSSM..."
|
||||
& $NssmExe install $ServiceName (Join-Path $RepoPath "start.bat")
|
||||
& $NssmExe set $ServiceName AppDirectory $RepoPath
|
||||
& $NssmExe set $ServiceName DisplayName "GoMag Vending Import Manager"
|
||||
& $NssmExe set $ServiceName Description "Import comenzi web GoMag -> ROA Oracle"
|
||||
& $NssmExe set $ServiceName Start SERVICE_AUTO_START
|
||||
& $NssmExe set $ServiceName AppStdout (Join-Path $RepoPath "logs\service_stdout.log")
|
||||
& $NssmExe set $ServiceName AppStderr (Join-Path $RepoPath "logs\service_stderr.log")
|
||||
& $NssmExe set $ServiceName AppRotateFiles 1
|
||||
& $NssmExe set $ServiceName AppRotateOnline 1
|
||||
& $NssmExe set $ServiceName AppRotateBytes 10485760
|
||||
& $NssmExe start $ServiceName
|
||||
Write-OK "Serviciu $ServiceName instalat si pornit"
|
||||
}
|
||||
|
||||
} else {
|
||||
# Fallback: Task Scheduler
|
||||
Write-Warn "NSSM nu este instalat"
|
||||
Write-Info "Optiuni:"
|
||||
Write-Info " 1. Descarca NSSM: https://nssm.cc/download si pune nssm.exe in PATH"
|
||||
Write-Info " 2. Sau foloseste Task Scheduler (creat mai jos)"
|
||||
|
||||
# Verifica daca task-ul exista deja
|
||||
$taskExists = Get-ScheduledTask -TaskName $ServiceName -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $taskExists) {
|
||||
Write-Info "Creez Task Scheduler task '$ServiceName'..."
|
||||
try {
|
||||
$action = New-ScheduledTaskAction -Execute (Join-Path $RepoPath "start.bat")
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Days 365) `
|
||||
-RestartCount 3 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1)
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
-UserId "SYSTEM" `
|
||||
-LogonType ServiceAccount `
|
||||
-RunLevel Highest
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $ServiceName `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Settings $settings `
|
||||
-Principal $principal `
|
||||
-Description "GoMag Vending Import Manager" `
|
||||
-Force | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $ServiceName
|
||||
Write-OK "Task Scheduler '$ServiceName' creat si pornit"
|
||||
} catch {
|
||||
Write-Warn "Task Scheduler esuat: $($_.Exception.Message)"
|
||||
Write-Info "Porneste manual: .\start.bat"
|
||||
}
|
||||
} else {
|
||||
# Restart task
|
||||
Stop-ScheduledTask -TaskName $ServiceName -ErrorAction SilentlyContinue
|
||||
Start-ScheduledTask -TaskName $ServiceName
|
||||
Write-OK "Task '$ServiceName' restartat"
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sumar final
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host " GoMag Vending Deploy — Sumar" -ForegroundColor Cyan
|
||||
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Repo: $RepoPath" -ForegroundColor White
|
||||
Write-Host " FastAPI: http://localhost:$Port/gomag" -ForegroundColor White
|
||||
Write-Host " start.bat generat" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $EnvFile)) {
|
||||
Write-Host " [!] api\.env lipseste — configureaza inainte de start!" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host " api\.env: OK" -ForegroundColor Green
|
||||
# Verifica daca mai are valori placeholder
|
||||
$envContent = Get-Content $EnvFile -Raw
|
||||
if ($envContent -match "your_api_key_here|USER_ORACLE|parola_oracle|TNS_ALIAS") {
|
||||
Write-Host " [!] api\.env contine valori placeholder — editeaza!" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Acces app: http://SERVER/gomag" -ForegroundColor Cyan
|
||||
Write-Host " Test local: http://localhost:$Port/gomag/health" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
62
iis-web.config
Normal file
62
iis-web.config
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
IIS web.config pentru GoMag Vending — URL Rewrite + ARR Reverse Proxy
|
||||
Copiat automat de deploy.ps1 in wwwroot site-ului IIS.
|
||||
|
||||
Prerequisite:
|
||||
- Application Request Routing (ARR) 3.0
|
||||
- URL Rewrite 2.1
|
||||
Ambele gratuite de la iis.net.
|
||||
|
||||
Configuratie:
|
||||
Browser → http://SERVER/gomag/...
|
||||
↓
|
||||
IIS (port 80)
|
||||
↓ (URL Rewrite)
|
||||
http://localhost:5003/...
|
||||
FastAPI/uvicorn
|
||||
-->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
|
||||
<!-- Activeaza proxy (ARR) -->
|
||||
<proxy enabled="true" preserveHostHeader="false" reverseRewriteHostInResponseHeaders="false" />
|
||||
|
||||
<rewrite>
|
||||
<rules>
|
||||
<!--
|
||||
Regula principala: /gomag/* → http://localhost:5003/*
|
||||
FastAPI ruleaza cu --root-path /gomag deci stie de prefix.
|
||||
-->
|
||||
<rule name="GoMag Reverse Proxy" stopProcessing="true">
|
||||
<match url="^gomag(.*)" />
|
||||
<conditions>
|
||||
<add input="{CACHE_URL}" pattern="^(https?)://" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="http://localhost:5003{R:1}" />
|
||||
</rule>
|
||||
</rules>
|
||||
|
||||
<!-- Rescrie Location header-ele din raspunsurile FastAPI -->
|
||||
<outboundRules>
|
||||
<rule name="GoMag Fix Location Header" preCondition="IsRedirect">
|
||||
<match serverVariable="RESPONSE_Location" pattern="^http://localhost:5003/(.*)" />
|
||||
<action type="Rewrite" value="/gomag/{R:1}" />
|
||||
</rule>
|
||||
<preConditions>
|
||||
<preCondition name="IsRedirect">
|
||||
<add input="{RESPONSE_STATUS}" pattern="3\d\d" />
|
||||
</preCondition>
|
||||
</preConditions>
|
||||
</outboundRules>
|
||||
</rewrite>
|
||||
|
||||
<!-- Securitate: ascunde versiunea IIS -->
|
||||
<httpProtocol>
|
||||
<customHeaders>
|
||||
<remove name="X-Powered-By" />
|
||||
</customHeaders>
|
||||
</httpProtocol>
|
||||
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user