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:
Claude Agent
2026-03-16 15:05:04 +00:00
parent ee60a17f00
commit a9d0cead79
16 changed files with 17798 additions and 329 deletions

View File

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

View File

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

1
.gitignore vendored
View File

@@ -24,6 +24,7 @@ __pycache__/
# Settings files with secrets
settings.ini
vfp/settings.ini
.gittoken
output/
vfp/*.json
*.~pck

View File

@@ -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

View File

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

View File

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

View File

@@ -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}

View File

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

View File

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

View File

@@ -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; }

View File

@@ -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)}&nbsp;<sup style="color:#6b7280;font-size:0.65rem">&#9650;</sup></td>`;
return `<td class="tooltip-cont fw-bold" data-tooltip="Cont: ${escHtml(billing)}">${escHtml(shipping)}&nbsp;<sup style="color:#6b7280;font-size:0.65rem">&#9650;</sup></td>`;
}
return `<td>${escHtml(shipping || billing || '\u2014')}</td>`;
return `<td class="fw-bold">${escHtml(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> &mdash; <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');
}

View File

@@ -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) {

View 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> &mdash; <span class="denumire">${escHtml(r.denumire)}</span>
</div>`
).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}, 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
}
function settSelectArticle(inputId, dropdownId, codmat) {
document.getElementById(inputId).value = codmat;
document.getElementById(dropdownId).classList.add('d-none');
}
function escHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

File diff suppressed because it is too large Load Diff

528
deploy.ps1 Normal file
View File

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

62
iis-web.config Normal file
View File

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