Compare commits
8 Commits
19834d193a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
371c73f5cf | ||
|
|
3b56029cf2 | ||
|
|
dcc5042586 | ||
|
|
ccc6a933fa | ||
|
|
698d036de9 | ||
|
|
5dd5acc25e | ||
|
|
df684b7183 | ||
|
|
2c3b35294c |
@@ -517,6 +517,7 @@ ssh -i ~/.ssh/id_ed25519 -p 22122 gomag@79.119.86.134 \
|
||||
| [docs/oracle-schema-notes.md](docs/oracle-schema-notes.md) | Schema Oracle: tabele comenzi, facturi, preturi, proceduri cheie |
|
||||
| [docs/pack_facturare_analysis.md](docs/pack_facturare_analysis.md) | Analiza flow facturare: call chain, parametri, STOC lookup, FACT-008 |
|
||||
| [docs/cautare-selectie-client-cod-fiscal-anaf.md](docs/cautare-selectie-client-cod-fiscal-anaf.md) | Cautare/selectie client dupa cod fiscal: normalizare CUI, verificare ANAF, gate, proceduri Oracle |
|
||||
| [docs/relink-facturi-manuale.md](docs/relink-facturi-manuale.md) | Reconciliere facturi manuale ROA ↔ comenzi GoMag dupa downtime (`relink_manual_invoices.py/.bat`) |
|
||||
| [scripts/HANDOFF_MAPPING.md](scripts/HANDOFF_MAPPING.md) | Matching GoMag SKU → ROA articole (strategie si rezultate) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
|
||||
from .config import settings
|
||||
@@ -19,8 +20,12 @@ _stream_handler.setFormatter(_formatter)
|
||||
|
||||
_log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_log_filename = f"sync_comenzi_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
_file_handler = logging.FileHandler(os.path.join(_log_dir, _log_filename), encoding='utf-8')
|
||||
# Rotating handler (10MB x 5 backups) instead of a new timestamped file per
|
||||
# start — caps log growth and stops file proliferation across restarts. Fixed
|
||||
# name still matches the QA glob `sync_comenzi_*.log`.
|
||||
_file_handler = logging.handlers.RotatingFileHandler(
|
||||
os.path.join(_log_dir, "sync_comenzi_current.log"),
|
||||
maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8')
|
||||
_file_handler.setFormatter(_formatter)
|
||||
|
||||
_root_logger = logging.getLogger()
|
||||
@@ -54,6 +59,15 @@ async def lifespan(app: FastAPI):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Daily DB/log maintenance (prune audit history + cleanup old logs) + a
|
||||
# one-shot catch-up so a long-down service reclaims immediately on start.
|
||||
try:
|
||||
from .services import maintenance_service
|
||||
scheduler_service.start_maintenance_job()
|
||||
asyncio.create_task(maintenance_service.run_daily_maintenance())
|
||||
except Exception as e:
|
||||
logger.warning(f"Maintenance scheduling failed: {e}")
|
||||
|
||||
logger.info("GoMag Import Manager started")
|
||||
yield
|
||||
|
||||
|
||||
75
api/app/services/maintenance_service.py
Normal file
75
api/app/services/maintenance_service.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Periodic maintenance: prune audit history + clean up old log files.
|
||||
|
||||
Keeps the SQLite DB and the logs/ directory from growing unbounded. The audit
|
||||
tables (sync_runs, sync_run_orders) were the only DB growth source under the
|
||||
1-minute scheduler; business tables (orders, order_items) are never touched.
|
||||
|
||||
The one-shot heavy reclaim (full VACUUM, run while the service is stopped) lives
|
||||
in scripts/db_maintenance.py and is invoked by deploy.ps1.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HISTORY_RETENTION_DAYS = 7
|
||||
DEFAULT_LOG_RETENTION_DAYS = 7
|
||||
|
||||
|
||||
def _logs_dir() -> str:
|
||||
"""Absolute path to the repo-root logs/ directory (matches main.py)."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(os.path.abspath(os.path.join(here, "..", "..", "..")), "logs")
|
||||
|
||||
|
||||
def cleanup_old_logs(retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
|
||||
log_dir: str | None = None) -> int:
|
||||
"""Delete log files older than `retention_days`. Returns count removed.
|
||||
|
||||
Targets any file with `.log` in its name (covers `sync_comenzi_current.log`,
|
||||
NSSM `service_stdout.log`, and rotated backups like `*.log.3`). The live
|
||||
rotating files stay fresh (recent mtime) so they fall inside the window.
|
||||
"""
|
||||
log_dir = log_dir or _logs_dir()
|
||||
if not os.path.isdir(log_dir):
|
||||
return 0
|
||||
cutoff = time.time() - retention_days * 86400
|
||||
removed = 0
|
||||
for name in os.listdir(log_dir):
|
||||
if ".log" not in name:
|
||||
continue
|
||||
path = os.path.join(log_dir, name)
|
||||
try:
|
||||
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
|
||||
os.remove(path)
|
||||
removed += 1
|
||||
except OSError as e:
|
||||
logger.warning(f"cleanup_old_logs: could not remove {name}: {e}")
|
||||
if removed:
|
||||
logger.info(f"cleanup_old_logs: removed {removed} file(s) older than "
|
||||
f"{retention_days}d from {log_dir}")
|
||||
return removed
|
||||
|
||||
|
||||
async def run_daily_maintenance(
|
||||
history_days: int = DEFAULT_HISTORY_RETENTION_DAYS,
|
||||
log_days: int = DEFAULT_LOG_RETENTION_DAYS) -> dict:
|
||||
"""Daily job: prune audit history (+reclaim pages) and clean old log files.
|
||||
|
||||
Each step is isolated — a failure in one does not skip the other.
|
||||
"""
|
||||
from . import sqlite_service
|
||||
|
||||
result: dict = {}
|
||||
try:
|
||||
result["db"] = await sqlite_service.prune_sync_history(history_days)
|
||||
except Exception as e:
|
||||
logger.warning(f"run_daily_maintenance: prune_sync_history failed: {e}")
|
||||
result["db_error"] = str(e)
|
||||
try:
|
||||
result["logs_removed"] = cleanup_old_logs(log_days)
|
||||
except Exception as e:
|
||||
logger.warning(f"run_daily_maintenance: cleanup_old_logs failed: {e}")
|
||||
result["logs_error"] = str(e)
|
||||
return result
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,6 +43,31 @@ def start_scheduler(interval_minutes: int = 10):
|
||||
logger.info(f"Scheduler started with interval {interval_minutes}min")
|
||||
|
||||
|
||||
def start_maintenance_job(hour: int = 3):
|
||||
"""Schedule the daily DB/log maintenance job (prune history + cleanup logs).
|
||||
|
||||
Runs independently of the sync job — starts the scheduler if it isn't already
|
||||
running so maintenance happens even when auto-sync is disabled.
|
||||
"""
|
||||
if _scheduler is None:
|
||||
init_scheduler()
|
||||
|
||||
from . import maintenance_service
|
||||
|
||||
_scheduler.add_job(
|
||||
maintenance_service.run_daily_maintenance,
|
||||
trigger=CronTrigger(hour=hour, minute=0),
|
||||
id="maintenance_job",
|
||||
name="Daily DB/Log Maintenance",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
if not _scheduler.running:
|
||||
_scheduler.start()
|
||||
|
||||
logger.info(f"Maintenance job scheduled daily at {hour:02d}:00")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""Stop the scheduler."""
|
||||
global _is_running
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from ..database import get_sqlite, get_sqlite_sync
|
||||
from ..constants import OrderStatus
|
||||
@@ -114,6 +114,45 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
|
||||
await db.close()
|
||||
|
||||
|
||||
async def prune_sync_history(retention_days: int = 7) -> dict:
|
||||
"""Delete sync_runs + sync_run_orders older than `retention_days`.
|
||||
|
||||
Audit-only tables — `orders`/`order_items` (business data) are never touched.
|
||||
Frees pages via incremental_vacuum (prod DB is auto_vacuum=INCREMENTAL after
|
||||
the initial reclaim). Returns counts for logging. See _SKIP_JUNCTION_STATUSES
|
||||
for the complementary write-side guard.
|
||||
"""
|
||||
cutoff = (datetime.now(_tz_bucharest).replace(tzinfo=None)
|
||||
- timedelta(days=retention_days)).strftime("%Y-%m-%d")
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
cur = await db.execute(
|
||||
"DELETE FROM sync_run_orders WHERE sync_run_id IN "
|
||||
"(SELECT run_id FROM sync_runs WHERE substr(started_at,1,10) < ?)",
|
||||
(cutoff,))
|
||||
junction_deleted = cur.rowcount
|
||||
cur = await db.execute(
|
||||
"DELETE FROM sync_runs WHERE substr(started_at,1,10) < ?", (cutoff,))
|
||||
runs_deleted = cur.rowcount
|
||||
# Drop phase-failure rows orphaned by the run deletion.
|
||||
await db.execute(
|
||||
"DELETE FROM sync_phase_failures "
|
||||
"WHERE run_id NOT IN (SELECT run_id FROM sync_runs)")
|
||||
await db.commit()
|
||||
try:
|
||||
await db.execute("PRAGMA incremental_vacuum")
|
||||
await db.commit()
|
||||
except Exception as e: # auto_vacuum may be OFF on a fresh dev DB
|
||||
logger.debug(f"prune_sync_history: incremental_vacuum skipped: {e}")
|
||||
logger.info(
|
||||
f"prune_sync_history: cutoff<{cutoff} runs_deleted={runs_deleted} "
|
||||
f"junction_deleted={junction_deleted}")
|
||||
return {"cutoff": cutoff, "runs_deleted": runs_deleted,
|
||||
"junction_deleted": junction_deleted}
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
||||
customer_name: str, status: str, id_comanda: int = None,
|
||||
id_partener: int = None, error_message: str = None,
|
||||
@@ -171,8 +210,28 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
|
||||
await db.close()
|
||||
|
||||
|
||||
# Audit junction policy (DB-size guard):
|
||||
# The sync_run_orders junction recorded EVERY order seen on EVERY run. Under the
|
||||
# 1-minute scheduler, ~98% of rows were no-op ALREADY_IMPORTED re-observations,
|
||||
# which grew the table to 21M+ rows / 2GB. We no longer record those: the order's
|
||||
# current state still lives in `orders`; the junction now only lists orders a run
|
||||
# actually touched (new / changed / skipped / errored / cancelled). Run-detail
|
||||
# views therefore show only meaningful orders per run.
|
||||
_SKIP_JUNCTION_STATUSES = {OrderStatus.ALREADY_IMPORTED.value}
|
||||
|
||||
|
||||
def _record_in_junction(status_at_run: str) -> bool:
|
||||
"""Whether this per-run status is worth persisting in sync_run_orders."""
|
||||
return status_at_run not in _SKIP_JUNCTION_STATUSES
|
||||
|
||||
|
||||
async def add_sync_run_order(sync_run_id: str, order_number: str, status_at_run: str):
|
||||
"""Record that this run processed this order (junction table)."""
|
||||
"""Record that this run processed this order (junction table).
|
||||
|
||||
No-op ALREADY_IMPORTED observations are skipped — see _SKIP_JUNCTION_STATUSES.
|
||||
"""
|
||||
if not _record_in_junction(status_at_run):
|
||||
return
|
||||
db = await get_sqlite()
|
||||
try:
|
||||
await db.execute("""
|
||||
@@ -258,9 +317,15 @@ async def _insert_orders_only(db, orders: list[dict]):
|
||||
if not orders:
|
||||
return
|
||||
await db.executemany(_ORDERS_UPSERT_SQL, [_orders_row(d) for d in orders])
|
||||
junction_rows = [
|
||||
(d["sync_run_id"], d["order_number"], d.get("status_at_run", d["status"]))
|
||||
for d in orders
|
||||
if _record_in_junction(d.get("status_at_run", d["status"]))
|
||||
]
|
||||
if junction_rows:
|
||||
await db.executemany(
|
||||
"INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) VALUES (?, ?, ?)",
|
||||
[(d["sync_run_id"], d["order_number"], d.get("status_at_run", d["status"])) for d in orders],
|
||||
junction_rows,
|
||||
)
|
||||
|
||||
|
||||
@@ -273,9 +338,15 @@ async def _insert_valid_batch(db, orders: list[dict]):
|
||||
if not orders:
|
||||
return
|
||||
await db.executemany(_ORDERS_UPSERT_SQL, [_orders_row(d) for d in orders])
|
||||
junction_rows = [
|
||||
(d["sync_run_id"], d["order_number"], d["status_at_run"])
|
||||
for d in orders
|
||||
if _record_in_junction(d["status_at_run"])
|
||||
]
|
||||
if junction_rows:
|
||||
await db.executemany(
|
||||
"INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) VALUES (?, ?, ?)",
|
||||
[(d["sync_run_id"], d["order_number"], d["status_at_run"]) for d in orders],
|
||||
junction_rows,
|
||||
)
|
||||
|
||||
all_items: list[tuple] = []
|
||||
@@ -314,6 +385,7 @@ async def _insert_single_order(db, d: dict):
|
||||
Caller wraps in SAVEPOINT so a per-row failure doesn't poison the batch.
|
||||
"""
|
||||
await db.execute(_ORDERS_UPSERT_SQL, _orders_row(d))
|
||||
if _record_in_junction(d["status_at_run"]):
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO sync_run_orders (sync_run_id, order_number, status_at_run) VALUES (?, ?, ?)",
|
||||
(d["sync_run_id"], d["order_number"], d["status_at_run"]),
|
||||
|
||||
101
deploy.ps1
101
deploy.ps1
@@ -35,9 +35,9 @@ param(
|
||||
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 }
|
||||
@@ -46,9 +46,9 @@ function Write-Info { param([string]$msg) Write-Host " $msg" -Foregroun
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. Citire token Gitea
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Citire token Gitea"
|
||||
|
||||
$TokenFile = Join-Path $ScriptDir ".gittoken"
|
||||
@@ -71,9 +71,9 @@ $RepoUrl = if ($GitToken) {
|
||||
"https://gitea.romfast.ro/romfast/gomag-vending.git"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. Git clone / pull
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Git clone / pull"
|
||||
|
||||
# Verifica git instalat
|
||||
@@ -106,9 +106,9 @@ if (Test-Path (Join-Path $RepoPath ".git")) {
|
||||
Write-OK "git clone OK"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. Verificare Python
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Verificare Python"
|
||||
|
||||
$PythonCmd = $null
|
||||
@@ -135,9 +135,9 @@ if (-not $PythonCmd) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 4. Creare venv si instalare dependinte
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Virtual environment + dependinte"
|
||||
|
||||
$VenvDir = Join-Path $RepoPath "venv"
|
||||
@@ -170,9 +170,9 @@ if ($needInstall) {
|
||||
Write-OK "Dependinte deja up-to-date"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. Detectare Oracle Home → sugestie INSTANTCLIENTPATH
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5. Detectare Oracle Home -> sugestie INSTANTCLIENTPATH
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Detectare Oracle"
|
||||
|
||||
$OracleHome = $env:ORACLE_HOME
|
||||
@@ -207,9 +207,9 @@ if ($OracleHome -and (Test-Path $OracleHome)) {
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 6. Creare .env din template daca lipseste
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Fisier configurare api\.env"
|
||||
|
||||
$EnvFile = Join-Path $RepoPath "api\.env"
|
||||
@@ -241,9 +241,9 @@ if (-not (Test-Path $EnvFile)) {
|
||||
Write-OK "api\.env exista deja"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 7. Creare directoare necesare
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Directoare date"
|
||||
|
||||
foreach ($dir in @("data", "output", "logs")) {
|
||||
@@ -256,9 +256,9 @@ foreach ($dir in @("data", "output", "logs")) {
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 8. Generare start.bat
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Generare start.bat"
|
||||
|
||||
$StartBat = Join-Path $RepoPath "start.bat"
|
||||
@@ -298,13 +298,13 @@ echo Starting GoMag Import Manager on http://0.0.0.0:$Port (prefix /gomag)
|
||||
Set-Content -Path $StartBat -Value $StartBatContent -Encoding UTF8
|
||||
Write-OK "start.bat generat: $StartBat"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 9. IIS — Verificare ARR + URL Rewrite
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# 9. IIS - Verificare ARR + URL Rewrite
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Step "Verificare module IIS"
|
||||
|
||||
if ($SkipIIS) {
|
||||
Write-Warn "SkipIIS activ — configurare IIS sarita"
|
||||
Write-Warn "SkipIIS activ - configurare IIS sarita"
|
||||
} else {
|
||||
$ArrPath = "$env:SystemRoot\System32\inetsrv\arr.dll"
|
||||
$UrlRewritePath = "$env:SystemRoot\System32\inetsrv\rewrite.dll"
|
||||
@@ -328,9 +328,9 @@ if ($SkipIIS) {
|
||||
Write-Info "Sau: winget install Microsoft.URLRewrite"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 10. Configurare IIS — copiere web.config
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# -------------------------------------------------------------------------
|
||||
# 10. Configurare IIS - copiere web.config
|
||||
# -------------------------------------------------------------------------
|
||||
if ($ArrOk -and $UrlRwOk) {
|
||||
Write-Step "Configurare IIS reverse proxy"
|
||||
|
||||
@@ -354,7 +354,7 @@ if ($SkipIIS) {
|
||||
}
|
||||
} 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"
|
||||
Write-Info "Activeaza manual din IIS Manager -> server root -> Application Request Routing Cache -> Enable Proxy"
|
||||
}
|
||||
|
||||
# Determina wwwroot site-ului IIS
|
||||
@@ -406,13 +406,13 @@ if ($SkipIIS) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "IIS nu e configurat complet — instaleaza ARR si URL Rewrite, apoi ruleaza deploy.ps1 din nou"
|
||||
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"
|
||||
@@ -431,25 +431,38 @@ if ($NssmExe) {
|
||||
|
||||
$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($existingService) {
|
||||
Write-Info "Serviciu existent, restarteaza..."
|
||||
& $NssmExe restart $ServiceName
|
||||
Write-OK "Serviciu $ServiceName restartat"
|
||||
} else {
|
||||
if (-not $existingService) {
|
||||
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
|
||||
} else {
|
||||
Write-Info "Serviciu existent, il opresc pentru mentenanta..."
|
||||
& $NssmExe stop $ServiceName 2>$null
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
|
||||
# Mentenanta DB + log-uri cu serviciul oprit: prune istoric (7z) + VACUUM
|
||||
# (reclaim disc) + cleanup log-uri (7z). Non-fatal daca esueaza.
|
||||
Write-Info "Mentenanta DB/log-uri (prune + VACUUM + cleanup)..."
|
||||
try {
|
||||
& $VenvPy (Join-Path $RepoPath "scripts\db_maintenance.py") --history-days 7 --log-days 7
|
||||
} catch {
|
||||
Write-Warn "Mentenanta DB a esuat (continui): $_"
|
||||
}
|
||||
|
||||
# (Re)aplica config log/rotatie de fiecare data - idempotent, astfel rotatia
|
||||
# ajunge si pe serviciile instalate inainte ca aceste setari sa existe.
|
||||
& $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"
|
||||
}
|
||||
Write-OK "Serviciu $ServiceName pornit (mentenanta + rotatie aplicate)"
|
||||
|
||||
} else {
|
||||
# Fallback: Task Scheduler
|
||||
@@ -498,13 +511,13 @@ if ($NssmExe) {
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sumar final
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host " GoMag Vending Deploy — Sumar" -ForegroundColor Cyan
|
||||
Write-Host "══════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
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
|
||||
@@ -512,13 +525,13 @@ 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
|
||||
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 " [!] api\.env contine valori placeholder - editeaza!" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
133
docs/relink-facturi-manuale.md
Normal file
133
docs/relink-facturi-manuale.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Reconciliere facturi manuale ROA ↔ comenzi GoMag
|
||||
|
||||
**Script:** [`scripts/relink_manual_invoices.py`](../scripts/relink_manual_invoices.py)
|
||||
**Launcher Windows:** [`scripts/relink_manual_invoices.bat`](../scripts/relink_manual_invoices.bat)
|
||||
|
||||
## Cand se foloseste
|
||||
|
||||
Cand Oracle pool / sync-ul **a fost cazut** (ex. dupa o pana de curent la VENDING)
|
||||
si operatorul a emis **facturi manual direct in ROA** in acel interval. Dupa ce
|
||||
aplicatia revine, importeaza aceleasi comenzi web in `COMENZI`, **dar** factura
|
||||
manuala nu se leaga de comanda → comanda ramane **nefacturata** in ROA si in
|
||||
dashboard, desi factura exista.
|
||||
|
||||
Semnal: in dashboard apar comenzi „nefacturate" pentru clienti care au fost de
|
||||
fapt facturati, iar in `VANZARI` exista facturi cu `ID_COMANDA IS NULL`.
|
||||
|
||||
## Cauza tehnica
|
||||
|
||||
| Flux | `VANZARI.ID_COMANDA` | `COMENZI.COMANDA_EXTERNA` |
|
||||
|------|----------------------|----------------------------|
|
||||
| Normal (app) | setat (leaga factura de comanda) | nr comanda GoMag |
|
||||
| Manual (operator, in downtime) | **NULL** | — |
|
||||
|
||||
Dashboard-ul / cache-ul SQLite marcheaza o comanda „Facturat" doar daca exista
|
||||
`VANZARI ... WHERE id_comanda = <comanda> AND sters = 0`
|
||||
(vezi `invoice_service.check_invoices_for_orders`). Factura manuala cu
|
||||
`ID_COMANDA NULL` nu e niciodata gasita → comanda apare nefacturata.
|
||||
|
||||
`ID_FACT` (documentul fiscal) si `COMENZI.COMANDA_EXTERNA` sunt deja completate;
|
||||
**singura piesa lipsa e legatura `VANZARI → COMANDA`.**
|
||||
|
||||
## ⚠️ Facturi de depozit (walk-in) — NU se ating
|
||||
|
||||
Operatorul emite zilnic si **facturi manuale legitime din depozit** (~20+/zi),
|
||||
fara nicio comanda online in spate. Acestea au tot `ID_COMANDA NULL`, dar **nu**
|
||||
trebuie legate de nimic. De aceea matching-ul e **conservator**: orice nu e o
|
||||
potrivire 1:1 sigura e raportat, niciodata legat automat.
|
||||
|
||||
## Cum potriveste
|
||||
|
||||
Pentru fiecare factura orfana (`VANZARI.ID_COMANDA NULL`, `sters=0`, in fereastra
|
||||
`--days`) cauta o comanda GoMag nefacturata (in SQLite, cu `id_comanda` setat) cu:
|
||||
|
||||
1. **Total identic** (`TOTAL_CU_TVA` ≈ `order_total`, toleranta 0.01 lei), apoi
|
||||
2. **acelasi partener** (`ID_PART` = `id_partener`) **SAU**
|
||||
3. **nume potrivit** (token-overlap, tolereaza SRL/SC si ordinea cuvintelor) —
|
||||
acopera cazul in care operatorul a creat un **partener duplicat** la facturare.
|
||||
|
||||
Verifica si ca acea comanda e activa in ROA (`sters=0`) si **nu** are deja factura
|
||||
(anti-dubla-facturare).
|
||||
|
||||
### Clasificare (in raport)
|
||||
|
||||
| Eticheta | Ce inseamna | Actiune |
|
||||
|----------|-------------|---------|
|
||||
| `LINK` | potrivire 1:1 sigura | leaga (la `--apply`) |
|
||||
| `SKIP_NOMATCH` | nicio comanda online cu acel total | **factura de depozit — lasata neatinsa** |
|
||||
| `SKIP_AMBIGUOUS` | mai multe comenzi plauzibile, sau total potrivit dar partener+nume diferit | raportat pentru verificare manuala |
|
||||
| `SKIP_ALREADY` | comanda nu mai e activa / are deja factura | sarit |
|
||||
|
||||
La aplicare: `UPDATE VANZARI SET ID_COMANDA = <comanda>` + populeaza
|
||||
`orders.factura_*` in SQLite, exact ca aplicatia (`update_order_invoice`).
|
||||
|
||||
### ⚠ Verificare reziduu de linie (legatura header nu e mereu suficienta)
|
||||
|
||||
Aplicatia / dashboard-ul marcheaza o comanda „Facturat" doar dupa **legatura header**
|
||||
(`VANZARI.ID_COMANDA`). **ROA insa verifica la nivel de linie**: in
|
||||
`PACK_FACTURARE.cursor_comanda`, cantitatea facturata se potriveste cu comanda pe
|
||||
**`ID_ARTICOL` + `PRET` exact**, iar o linie e „de facturat" cand
|
||||
`SIGN(CANTITATE) * (CANTITATE − NVL(facturat,0)) > 0`.
|
||||
|
||||
Daca factura manuala a reprezentat liniile **altfel** decat comanda — tipic discounturi
|
||||
comasate (ex. discounturile pe cote de TVA 11%/21% puse intr-o singura linie la 0% TVA) —
|
||||
preturile nu se mai potrivesc, deci ROA arata comanda **tot nefacturata** desi headerul
|
||||
e legat si dashboard-ul o vede facturata.
|
||||
|
||||
Scriptul **prezice** acest reziduu inainte de `--apply` (functia `order_line_residual`,
|
||||
simuland factura ce urmeaza a fi legata) si il **re-verifica** dupa legare. Cand exista,
|
||||
afiseaza `!! ATENTIE ...` cu liniile reziduale (ART / cantitate comanda / pret / facturat)
|
||||
si un contor in rezumat. **Scriptul NU atinge `COMENZI_ELEMENTE`** — aceste cazuri se
|
||||
corecteaza **manual in ROA** (aliniezi liniile comenzii la factura, ex. comasezi liniile
|
||||
de discount ca in factura, pastrand valoarea totala).
|
||||
|
||||
## Utilizare
|
||||
|
||||
Ruleaza **pe serverul de productie VENDING** (are nevoie de Oracle prod +
|
||||
`api/data/import.db` prod). Foloseste `app.config.settings` (deci `.env`-ul prod).
|
||||
|
||||
```bat
|
||||
REM din C:\gomag-vending\scripts
|
||||
relink_manual_invoices.bat REM dry-run, ultimele 3 zile (NU modifica)
|
||||
relink_manual_invoices.bat --apply REM aplica, cu confirmare
|
||||
relink_manual_invoices.bat --apply --yes REM aplica fara confirmare
|
||||
relink_manual_invoices.bat --days 7 REM alta fereastra
|
||||
```
|
||||
|
||||
Dublu-click pe `.bat` = dry-run. `.bat`-ul seteaza mediul Oracle thick-mode
|
||||
(`TNS_ADMIN` + PATH instant client) ca `start.bat`.
|
||||
|
||||
Direct cu Python (echivalent):
|
||||
|
||||
```bat
|
||||
set TNS_ADMIN=C:\roa\instantclient_11_2_0_2
|
||||
set PATH=C:\app\Server\product\18.0.0\dbhomeXE\bin;%PATH%
|
||||
C:\gomag-vending\venv\Scripts\python.exe scripts\relink_manual_invoices.py --apply
|
||||
```
|
||||
|
||||
Din containerul de dev, peste SSH:
|
||||
|
||||
```bash
|
||||
scp -P 22122 scripts/relink_manual_invoices.* gomag@79.119.86.134:C:/gomag-vending/scripts/
|
||||
ssh -p 22122 gomag@79.119.86.134 'cmd /c "C:\gomag-vending\scripts\relink_manual_invoices.bat --days 3 < nul"'
|
||||
```
|
||||
|
||||
**Workflow recomandat:** intai dry-run → verifica lista `LINK` si `SKIP_AMBIGUOUS`
|
||||
→ apoi `--apply`. Cazurile `SKIP_AMBIGUOUS` se rezolva manual in ROA.
|
||||
|
||||
## Istoric
|
||||
|
||||
Codifica reconcilierea din **2026-06-26** (pana de curent la VENDING): pool cazut
|
||||
~09:07–10:25; 12 facturi manuale `TIP=1` (IDV 138191–138203 → comenzi 5419–5430)
|
||||
legate; 3 facturi de depozit corect excluse (CRISS VENDING, COFEE SEVEN TO GO,
|
||||
PANDELE MIOARA); 2 parteneri duplicati semnalati (CERBU, MILITARU).
|
||||
|
||||
**Follow-up 2026-06-26 (reziduu de linie):** 2 din cele 12 comenzi (5419/web 492710430,
|
||||
5423/web 492710513) au ramas nefacturate **in ROA** desi headerul era legat — factura
|
||||
manuala comasase cele 2 linii de discount (ART 2077, split pe TVA 11%/21%) intr-una la
|
||||
0% TVA, deci nu se potriveau pe `ID_ARTICOL+PRET`. Reparate manual prin alinierea
|
||||
liniilor comenzii la factura (comasare in `COMENZI_ELEMENTE`, valoare discount pastrata).
|
||||
De aici provine verificarea de reziduu de linie adaugata in script.
|
||||
|
||||
Vezi si: [oracle-schema-notes.md](oracle-schema-notes.md) (tabele `COMENZI`/`VANZARI`),
|
||||
sectiunea „Facturi & Cache" din [README](../README.md).
|
||||
102
scripts/db_maintenance.py
Normal file
102
scripts/db_maintenance.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-shot SQLite + log maintenance, invoked by deploy.ps1 while the GoMagVending
|
||||
service is stopped.
|
||||
|
||||
What it does:
|
||||
1. Prune audit history older than --history-days (sync_runs, sync_run_orders,
|
||||
orphaned sync_phase_failures). Business tables (orders, order_items) are
|
||||
NEVER touched.
|
||||
2. Enable PRAGMA auto_vacuum=INCREMENTAL and run a full VACUUM to reclaim disk.
|
||||
3. Delete log files older than --log-days from logs/.
|
||||
|
||||
Plain sqlite3 only — no app imports, no Oracle, no event loop — so it runs even
|
||||
if the app/Oracle env isn't set up.
|
||||
|
||||
Usage:
|
||||
python scripts/db_maintenance.py # defaults: 7/7 days
|
||||
python scripts/db_maintenance.py --history-days 7 --log-days 7
|
||||
python scripts/db_maintenance.py --db C:\\path\\import.db
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_DB = os.path.join(REPO_ROOT, "api", "data", "import.db")
|
||||
DEFAULT_LOGS = os.path.join(REPO_ROOT, "logs")
|
||||
|
||||
|
||||
def prune_and_vacuum(db_path: str, history_days: int) -> None:
|
||||
cutoff = (datetime.now() - timedelta(days=history_days)).strftime("%Y-%m-%d")
|
||||
before = os.path.getsize(db_path) / 1048576.0
|
||||
conn = sqlite3.connect(db_path, timeout=120)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM sync_run_orders WHERE sync_run_id IN "
|
||||
"(SELECT run_id FROM sync_runs WHERE substr(started_at,1,10) < ?)",
|
||||
(cutoff,))
|
||||
junction = cur.rowcount
|
||||
cur.execute(
|
||||
"DELETE FROM sync_runs WHERE substr(started_at,1,10) < ?", (cutoff,))
|
||||
runs = cur.rowcount
|
||||
cur.execute(
|
||||
"DELETE FROM sync_phase_failures "
|
||||
"WHERE run_id NOT IN (SELECT run_id FROM sync_runs)")
|
||||
conn.commit()
|
||||
# auto_vacuum mode change only takes effect on the next VACUUM.
|
||||
conn.isolation_level = None
|
||||
conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
||||
t0 = time.time()
|
||||
conn.execute("VACUUM")
|
||||
vac = time.time() - t0
|
||||
finally:
|
||||
conn.close()
|
||||
after = os.path.getsize(db_path) / 1048576.0
|
||||
print(f"[db_maintenance] cutoff<{cutoff} runs_deleted={runs} "
|
||||
f"junction_deleted={junction} size {before:.1f}MB -> {after:.1f}MB "
|
||||
f"(VACUUM {vac:.1f}s)")
|
||||
|
||||
|
||||
def cleanup_logs(log_dir: str, log_days: int) -> None:
|
||||
if not os.path.isdir(log_dir):
|
||||
print(f"[db_maintenance] logs dir not found: {log_dir}")
|
||||
return
|
||||
cutoff = time.time() - log_days * 86400
|
||||
removed = 0
|
||||
for name in os.listdir(log_dir):
|
||||
if ".log" not in name:
|
||||
continue
|
||||
path = os.path.join(log_dir, name)
|
||||
try:
|
||||
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
|
||||
os.remove(path)
|
||||
removed += 1
|
||||
except OSError as e:
|
||||
print(f"[db_maintenance] could not remove {name}: {e}")
|
||||
print(f"[db_maintenance] removed {removed} log file(s) older than {log_days}d")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="SQLite + log maintenance")
|
||||
ap.add_argument("--db", default=DEFAULT_DB)
|
||||
ap.add_argument("--logs-dir", default=DEFAULT_LOGS)
|
||||
ap.add_argument("--history-days", type=int, default=7)
|
||||
ap.add_argument("--log-days", type=int, default=7)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(args.db):
|
||||
# Non-fatal: a fresh install may not have a DB yet.
|
||||
print(f"[db_maintenance] DB not found, skipping: {args.db}", file=sys.stderr)
|
||||
else:
|
||||
prune_and_vacuum(args.db, args.history_days)
|
||||
cleanup_logs(args.logs_dir, args.log_days)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
38
scripts/relink_manual_invoices.bat
Normal file
38
scripts/relink_manual_invoices.bat
Normal file
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Reconciliere facturi manuale ROA <-> comenzi GoMag (relink VANZARI.ID_COMANDA)
|
||||
REM Ruleaza pe serverul de productie VENDING. Seteaza mediul Oracle (thick mode)
|
||||
REM exact ca start.bat, apoi apeleaza scriptul Python.
|
||||
REM
|
||||
REM Utilizare (dublu-click = dry-run, sau din cmd):
|
||||
REM relink_manual_invoices.bat -> dry-run (ultimele 3 zile)
|
||||
REM relink_manual_invoices.bat --apply -> aplica (cu confirmare)
|
||||
REM relink_manual_invoices.bat --apply --yes -> aplica fara confirmare
|
||||
REM relink_manual_invoices.bat --days 7 -> alta fereastra
|
||||
REM relink_manual_invoices.bat --apply --days 7
|
||||
REM ============================================================================
|
||||
setlocal
|
||||
|
||||
REM --- Mediu Oracle (vezi start.bat) ---
|
||||
set "TNS_ADMIN=C:\roa\instantclient_11_2_0_2"
|
||||
set "PATH=C:\app\Server\product\18.0.0\dbhomeXE\bin;%PATH%"
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
|
||||
REM --- Cai relative la acest .bat (scripts\) ---
|
||||
set "PYEXE=%~dp0..\venv\Scripts\python.exe"
|
||||
set "PYSCRIPT=%~dp0relink_manual_invoices.py"
|
||||
|
||||
if not exist "%PYEXE%" (
|
||||
echo [EROARE] Nu gasesc venv-ul: "%PYEXE%"
|
||||
echo Ruleaza din C:\gomag-vending\scripts pe serverul VENDING.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
"%PYEXE%" "%PYSCRIPT%" %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
echo (cod iesire: %RC%)
|
||||
pause
|
||||
endlocal & exit /b %RC%
|
||||
410
scripts/relink_manual_invoices.py
Normal file
410
scripts/relink_manual_invoices.py
Normal file
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reconcile manual ROA invoices with GoMag orders left "nefacturate".
|
||||
|
||||
Context
|
||||
-------
|
||||
When the Oracle pool / sync is down (e.g. after a power loss) the warehouse
|
||||
operator emits invoices MANUALLY in ROA. Those land in `VANZARI` with
|
||||
`ID_COMANDA = NULL` (vs the app's normal flow which sets `ID_COMANDA` and links
|
||||
to `COMENZI.COMANDA_EXTERNA` = GoMag order no). Once the app recovers it imports
|
||||
the same web orders into `COMENZI`, but the manual invoice is never linked, so
|
||||
the order stays "nefacturat" in ROA and in the dashboard.
|
||||
|
||||
This script finds those orphan invoices (`VANZARI.ID_COMANDA IS NULL`, `sters=0`)
|
||||
and links each to its GoMag order, matching by **exact total + partner/name**,
|
||||
then populates the SQLite invoice cache (`orders.factura_*`) exactly like the app.
|
||||
|
||||
IMPORTANT — warehouse / walk-in invoices
|
||||
-----------------------------------------
|
||||
The operator ALSO emits genuine manual invoices directly from the warehouse,
|
||||
with NO online order behind them (~20+/day). Those have no matching uninvoiced
|
||||
GoMag order, so they get classified SKIP_NOMATCH and are LEFT UNTOUCHED. The
|
||||
matching is deliberately conservative: anything not an unambiguous 1:1 match is
|
||||
reported for manual review, never auto-linked.
|
||||
|
||||
Run it ON the production server (it needs prod Oracle + prod import.db):
|
||||
|
||||
# dry-run (default) — shows the plan, changes nothing
|
||||
C:\\gomag-vending\\venv\\Scripts\\python.exe scripts\\relink_manual_invoices.py
|
||||
|
||||
# apply, with confirmation
|
||||
... scripts\\relink_manual_invoices.py --apply
|
||||
|
||||
# apply without confirmation (automation)
|
||||
... scripts\\relink_manual_invoices.py --apply --yes
|
||||
|
||||
# widen / narrow the lookback window (default: last 3 days)
|
||||
... scripts\\relink_manual_invoices.py --days 5
|
||||
|
||||
From the dev container you can drive it over SSH:
|
||||
|
||||
scp -P 22122 scripts/relink_manual_invoices.py gomag@79.119.86.134:C:/gomag-vending/scripts/
|
||||
ssh -p 22122 gomag@79.119.86.134 "cd C:\\gomag-vending\\api; \
|
||||
$env:TNS_ADMIN='C:\\roa\\instantclient_11_2_0_2'; \
|
||||
C:\\gomag-vending\\venv\\Scripts\\python.exe ..\\scripts\\relink_manual_invoices.py"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
# Windows service console is cp1252; keep output robust regardless of code page.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Make the app package importable + load .env-backed settings (Oracle creds, SQLite path).
|
||||
_API_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "api")
|
||||
sys.path.insert(0, _API_ROOT)
|
||||
|
||||
import oracledb # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
|
||||
# Match tolerance for money comparison (lei). Totals are stored to 2 decimals.
|
||||
MONEY_EPS = 0.01
|
||||
|
||||
|
||||
# ─── Oracle ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def oracle_connect():
|
||||
if settings.TNS_ADMIN:
|
||||
os.environ.setdefault("TNS_ADMIN", settings.TNS_ADMIN)
|
||||
if settings.INSTANTCLIENTPATH:
|
||||
try:
|
||||
oracledb.init_oracle_client(lib_dir=settings.INSTANTCLIENTPATH)
|
||||
except Exception:
|
||||
pass # already initialized / thin mode
|
||||
return oracledb.connect(
|
||||
user=settings.ORACLE_USER,
|
||||
password=settings.ORACLE_PASSWORD,
|
||||
dsn=settings.ORACLE_DSN,
|
||||
)
|
||||
|
||||
|
||||
def fetch_orphan_invoices(cur, days):
|
||||
"""Manual invoices with no order link, created in the lookback window."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT v.ID_VANZARE, v.ID_PART, v.SERIE_ACT, v.NUMAR_ACT,
|
||||
v.TOTAL_FARA_TVA, v.TOTAL_TVA, v.TOTAL_CU_TVA,
|
||||
TO_CHAR(v.DATA_ACT, 'YYYY-MM-DD') AS data_act,
|
||||
TO_CHAR(v.DATAORA, 'YYYY-MM-DD HH24:MI') AS creat,
|
||||
v.TIP, p.DENUMIRE
|
||||
FROM VANZARI v
|
||||
LEFT JOIN NOM_PARTENERI p ON p.ID_PART = v.ID_PART
|
||||
WHERE v.STERS = 0
|
||||
AND v.ID_COMANDA IS NULL
|
||||
AND v.DATAORA >= TRUNC(SYSDATE) - :days
|
||||
ORDER BY v.DATAORA
|
||||
""",
|
||||
days=days,
|
||||
)
|
||||
cols = [d[0].lower() for d in cur.description]
|
||||
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def comanda_active(cur, id_comanda):
|
||||
cur.execute("SELECT COUNT(*) FROM COMENZI WHERE ID_COMANDA = :1 AND STERS = 0", [id_comanda])
|
||||
return cur.fetchone()[0] == 1
|
||||
|
||||
|
||||
def comanda_already_invoiced(cur, id_comanda):
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM VANZARI WHERE ID_COMANDA = :1 AND STERS = 0", [id_comanda]
|
||||
)
|
||||
return cur.fetchone()[0] > 0
|
||||
|
||||
|
||||
def order_line_residual(cur, id_comanda, extra_idv=None):
|
||||
"""COMENZI_ELEMENTE lines NOT covered by the linked invoice(s), per ROA's own
|
||||
line-level facturat test (`PACK_FACTURARE.cursor_comanda`): invoiced quantity is
|
||||
matched to the order on **ID_ARTICOL + exact PRET**, and a line is "still to
|
||||
invoice" when `SIGN(CANTITATE) * (CANTITATE - NVL(facturat, 0)) > 0`.
|
||||
|
||||
`extra_idv` simulates an invoice about to be linked, so the residual can be
|
||||
PREDICTED before `--apply` (when VANZARI.ID_COMANDA is not set yet).
|
||||
|
||||
A non-empty result means linking the VANZARI header is NOT enough — ROA will
|
||||
STILL show the order *nefacturat* (even though the app dashboard, which only
|
||||
checks the header link, shows it facturat). Typical cause: the manual invoice
|
||||
consolidated the order's discount lines (e.g. per-VAT-rate discounts merged into
|
||||
one 0%-TVA line), so the prices no longer match the order's COMENZI_ELEMENTE.
|
||||
Those need a manual line fix in ROA — the script never touches order lines.
|
||||
"""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT A.ID_COMANDA_ELEMENT, A.ID_ARTICOL, A.CANTITATE, A.PRET,
|
||||
NVL(D.CANTITATE, 0) AS FACTURAT
|
||||
FROM COMENZI_ELEMENTE A
|
||||
LEFT JOIN (SELECT B1.ID_ARTICOL, B1.PRET, SUM(B1.CANTITATE) AS CANTITATE
|
||||
FROM VANZARI A1
|
||||
JOIN VANZARI_DETALII B1
|
||||
ON A1.ID_VANZARE = B1.ID_VANZARE AND B1.STERS = 0
|
||||
WHERE A1.STERS = 0
|
||||
AND (A1.ID_COMANDA = :idc OR A1.ID_VANZARE = :idv)
|
||||
GROUP BY B1.ID_ARTICOL, B1.PRET) D
|
||||
ON A.ID_ARTICOL = D.ID_ARTICOL AND A.PRET = D.PRET
|
||||
WHERE A.STERS = 0
|
||||
AND A.ID_COMANDA = :idc
|
||||
AND SIGN(A.CANTITATE) * (A.CANTITATE - NVL(D.CANTITATE, 0)) > 0
|
||||
""",
|
||||
idc=id_comanda, idv=extra_idv,
|
||||
)
|
||||
cols = [d[0].lower() for d in cur.description]
|
||||
return [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
# ─── SQLite ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_uninvoiced_orders(db, days):
|
||||
"""Imported GoMag orders that have an id_comanda but no cached invoice yet."""
|
||||
cur = db.execute(
|
||||
"""
|
||||
SELECT order_number, id_comanda, id_partener, order_total,
|
||||
customer_name, shipping_name, billing_name
|
||||
FROM orders
|
||||
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
|
||||
AND id_comanda IS NOT NULL
|
||||
AND (factura_numar IS NULL OR factura_numar = '')
|
||||
AND order_date >= date('now', ?)
|
||||
""",
|
||||
(f'-{days} day',),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
# ─── Name matching (handles duplicate partner records) ────────────────────────
|
||||
|
||||
_NAME_NOISE = re.compile(
|
||||
r"\b(S\.?R\.?L\.?|S\.?C\.?|S\.?A\.?|P\.?F\.?A\.?|II|SRL|SC|SA)\b", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _tokens(name):
|
||||
if not name:
|
||||
return set()
|
||||
name = _NAME_NOISE.sub(" ", name.upper())
|
||||
name = re.sub(r"[^A-Z0-9 ]", " ", name)
|
||||
return {t for t in name.split() if len(t) >= 3}
|
||||
|
||||
|
||||
def name_match(a, b):
|
||||
"""Conservative name overlap — tolerant of word order and SRL/SC noise."""
|
||||
ta, tb = _tokens(a), _tokens(b)
|
||||
if not ta or not tb:
|
||||
return False
|
||||
shared = ta & tb
|
||||
return len(shared) >= 1 and len(shared) >= min(len(ta), len(tb)) * 0.5
|
||||
|
||||
|
||||
def money_eq(a, b):
|
||||
return a is not None and b is not None and abs(float(a) - float(b)) <= MONEY_EPS
|
||||
|
||||
|
||||
# ─── Matching ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def classify(inv, orders, cur):
|
||||
"""Decide what to do with one orphan invoice.
|
||||
|
||||
Returns (action, order_or_None, note). action in:
|
||||
LINK unambiguous match -> will link
|
||||
SKIP_NOMATCH no uninvoiced GoMag order with this total -> warehouse/walk-in invoice
|
||||
SKIP_AMBIGUOUS several plausible orders -> needs a human
|
||||
SKIP_ALREADY matched comanda already has an invoice / is gone
|
||||
"""
|
||||
total = inv["total_cu_tva"]
|
||||
cands = [o for o in orders if money_eq(o["order_total"], total)]
|
||||
|
||||
if not cands:
|
||||
return ("SKIP_NOMATCH", None, "fara comanda online cu acest total (factura depozit)")
|
||||
|
||||
def pick(subset, why):
|
||||
o = subset[0]
|
||||
if not comanda_active(cur, o["id_comanda"]):
|
||||
return ("SKIP_ALREADY", None, f"comanda {o['id_comanda']} nu mai e activa in ROA")
|
||||
if comanda_already_invoiced(cur, o["id_comanda"]):
|
||||
return ("SKIP_ALREADY", None, f"comanda {o['id_comanda']} are deja factura")
|
||||
return ("LINK", o, why)
|
||||
|
||||
by_partner = [o for o in cands if o["id_partener"] == inv["id_part"]]
|
||||
by_name = [
|
||||
o for o in cands
|
||||
if name_match(inv["denumire"], o["customer_name"])
|
||||
or name_match(inv["denumire"], o["shipping_name"])
|
||||
or name_match(inv["denumire"], o["billing_name"])
|
||||
]
|
||||
|
||||
if len(by_partner) == 1:
|
||||
return pick(by_partner, "potrivire partener+total")
|
||||
if len(by_partner) > 1:
|
||||
return ("SKIP_AMBIGUOUS", None,
|
||||
f"{len(by_partner)} comenzi acelasi partener+total: "
|
||||
+ ", ".join(o["order_number"] for o in by_partner))
|
||||
if len(by_name) == 1:
|
||||
return pick(by_name, "potrivire nume+total (partener dublat)")
|
||||
if len(by_name) > 1:
|
||||
return ("SKIP_AMBIGUOUS", None,
|
||||
f"{len(by_name)} comenzi nume+total: "
|
||||
+ ", ".join(o["order_number"] for o in by_name))
|
||||
if len(cands) == 1:
|
||||
return ("SKIP_AMBIGUOUS", None,
|
||||
f"total se potriveste cu {cands[0]['order_number']} dar partenerul si numele difera")
|
||||
return ("SKIP_AMBIGUOUS", None,
|
||||
f"{len(cands)} comenzi cu acelasi total, niciun partener/nume sigur")
|
||||
|
||||
|
||||
# ─── Apply ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def apply_link(ora_cur, db, inv, order):
|
||||
"""Link VANZARI -> COMANDA in Oracle and cache the invoice onto the SQLite order."""
|
||||
ora_cur.execute(
|
||||
"UPDATE VANZARI SET ID_COMANDA = :1 "
|
||||
"WHERE ID_VANZARE = :2 AND ID_COMANDA IS NULL AND STERS = 0",
|
||||
[order["id_comanda"], inv["id_vanzare"]],
|
||||
)
|
||||
linked = ora_cur.rowcount == 1
|
||||
if linked:
|
||||
numar = int(inv["numar_act"]) if inv["numar_act"] is not None else None
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE orders SET
|
||||
factura_serie = ?, factura_numar = ?,
|
||||
factura_total_fara_tva = ?, factura_total_tva = ?, factura_total_cu_tva = ?,
|
||||
factura_data = ?, invoice_checked_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE order_number = ? AND (factura_numar IS NULL OR factura_numar = '')
|
||||
""",
|
||||
(inv["serie_act"], numar,
|
||||
float(inv["total_fara_tva"] or 0), float(inv["total_tva"] or 0),
|
||||
float(inv["total_cu_tva"] or 0), inv["data_act"], order["order_number"]),
|
||||
)
|
||||
return linked
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Relink manual ROA invoices to GoMag orders.")
|
||||
ap.add_argument("--apply", action="store_true", help="apply changes (default: dry-run)")
|
||||
ap.add_argument("--yes", action="store_true", help="skip confirmation prompt")
|
||||
ap.add_argument("--days", type=int, default=3, help="lookback window in days (default 3)")
|
||||
args = ap.parse_args()
|
||||
|
||||
conn = oracle_connect()
|
||||
ora_cur = conn.cursor()
|
||||
db = sqlite3.connect(settings.SQLITE_DB_PATH)
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
invoices = fetch_orphan_invoices(ora_cur, args.days)
|
||||
orders = fetch_uninvoiced_orders(db, args.days)
|
||||
|
||||
print(f"Fereastra: ultimele {args.days} zile")
|
||||
print(f"Facturi orfane (VANZARI ID_COMANDA NULL, sters=0): {len(invoices)}")
|
||||
print(f"Comenzi GoMag nefacturate (cu id_comanda): {len(orders)}\n")
|
||||
|
||||
plans = []
|
||||
for inv in invoices:
|
||||
action, order, note = classify(inv, orders, ora_cur)
|
||||
plans.append((inv, action, order, note))
|
||||
# an order can only back one invoice
|
||||
if action == "LINK" and order is not None:
|
||||
orders = [o for o in orders if o["order_number"] != order["order_number"]]
|
||||
|
||||
# Predict ROA's line-level residual for each LINK. Linking the VANZARI header is
|
||||
# not always enough: if the manual invoice represented the lines differently than
|
||||
# the order (e.g. consolidated discounts), ROA still shows the order nefacturat.
|
||||
residuals = {}
|
||||
for inv, action, order, _note in plans:
|
||||
if action == "LINK" and order is not None:
|
||||
res = order_line_residual(ora_cur, order["id_comanda"], inv["id_vanzare"])
|
||||
if res:
|
||||
residuals[order["order_number"]] = res
|
||||
|
||||
def show(action, detailed=True):
|
||||
rows = [(i, o, n) for (i, a, o, n) in plans if a == action]
|
||||
if not rows:
|
||||
return
|
||||
print(f"-- {action} ({len(rows)}) --")
|
||||
if not detailed:
|
||||
print(" (facturi de depozit, fara comanda online — lasate neatinse)\n")
|
||||
return
|
||||
for inv, order, note in rows:
|
||||
tag = f"-> {order['order_number']} (idcom {order['id_comanda']})" if order else ""
|
||||
print(f" IDV={inv['id_vanzare']} {inv['serie_act']}{inv['numar_act']} "
|
||||
f"tot={inv['total_cu_tva']} [{inv['denumire']}] {tag} {note}")
|
||||
res = residuals.get(order["order_number"]) if order else None
|
||||
if res:
|
||||
print(f" !! ATENTIE: dupa legare ROA va arata comanda tot NEFACTURATA "
|
||||
f"({len(res)} linii reziduale la nivel de element — factura nu le acopera "
|
||||
f"pe ID_ARTICOL+PRET; probabil discount comasat/0% TVA). Necesita corectie "
|
||||
f"manuala a liniilor in ROA:")
|
||||
for r in res:
|
||||
print(f" ART={r['id_articol']} CANT_COMANDA={r['cantitate']} "
|
||||
f"PRET={r['pret']} facturat={r['facturat']}")
|
||||
print()
|
||||
|
||||
for a in ("LINK", "SKIP_AMBIGUOUS", "SKIP_ALREADY"):
|
||||
show(a)
|
||||
show("SKIP_NOMATCH", detailed=False)
|
||||
|
||||
to_link = [(i, o) for (i, a, o, n) in plans if a == "LINK"]
|
||||
ambiguous = sum(1 for (_, a, _, _) in plans if a == "SKIP_AMBIGUOUS")
|
||||
with_residual = sum(1 for (_, o) in to_link if o and o["order_number"] in residuals)
|
||||
print(f"De legat: {len(to_link)} | De verificat manual (AMBIGUOUS): {ambiguous} | "
|
||||
f"Neatinse (depozit): {sum(1 for (_, a, _, _) in plans if a == 'SKIP_NOMATCH')}")
|
||||
if with_residual:
|
||||
print(f"!! Din care {with_residual} raman NEFACTURATE in ROA dupa legare "
|
||||
f"(reziduu de linie — vezi ATENTIE mai sus; necesita corectie manuala a liniilor).")
|
||||
|
||||
if not args.apply:
|
||||
print("\n[DRY-RUN] nimic modificat. Reruleaza cu --apply ca sa aplici.")
|
||||
return
|
||||
|
||||
if not to_link:
|
||||
print("\nNimic de legat.")
|
||||
return
|
||||
|
||||
if not args.yes:
|
||||
resp = input(f"\nAplici {len(to_link)} legaturi? [y/N] ").strip().lower()
|
||||
if resp != "y":
|
||||
print("Anulat.")
|
||||
return
|
||||
|
||||
linked = 0
|
||||
for inv, order in to_link:
|
||||
if apply_link(ora_cur, db, inv, order):
|
||||
linked += 1
|
||||
print(f" OK IDV={inv['id_vanzare']} -> idcom {order['id_comanda']} "
|
||||
f"({order['order_number']})")
|
||||
else:
|
||||
print(f" SKIP IDV={inv['id_vanzare']} — ID_COMANDA nu mai era NULL (concurenta)")
|
||||
|
||||
conn.commit()
|
||||
db.commit()
|
||||
print(f"\nAplicat: {linked} facturi legate + cache SQLite actualizat.")
|
||||
|
||||
# Verifica reziduul REAL dupa legare. Daca > 0, ROA arata comanda tot nefacturata
|
||||
# desi headerul e legat (app dashboard o vede facturata). Liniile trebuie corectate
|
||||
# manual in ROA — scriptul nu atinge niciodata COMENZI_ELEMENTE.
|
||||
still = []
|
||||
for inv, order in to_link:
|
||||
res = order_line_residual(ora_cur, order["id_comanda"])
|
||||
if res:
|
||||
still.append((order, res))
|
||||
if still:
|
||||
print(f"\n!! ATENTIE — {len(still)} comenzi legate dar cu reziduu de linie in ROA "
|
||||
f"(raman NEFACTURATE pana corectezi liniile manual in ROA):")
|
||||
for order, res in still:
|
||||
print(f" {order['order_number']} (idcom {order['id_comanda']}): {len(res)} linii — "
|
||||
+ ", ".join(f"ART={r['id_articol']}@{r['pret']}" for r in res))
|
||||
|
||||
conn.close()
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user