sync efactura-generator -> 0.9-beta-14
Mirror sincronizat cu repo canonic /workspace/efactura-generator. CLAUDE.md: documentat workflow sync + exclude config.json din rsync deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
CLAUDE.md
22
CLAUDE.md
@@ -9,7 +9,10 @@ Romfast company website (2025 version) for a Romanian ERP software company. Prom
|
|||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rsync -avz --exclude='.git' --exclude='.superdesign' --exclude='.claude' --exclude='verify-cleanup.sh' \
|
rsync -avz \
|
||||||
|
--exclude='.git' --exclude='.superdesign' --exclude='.claude' \
|
||||||
|
--exclude='verify-cleanup.sh' \
|
||||||
|
--exclude='efactura-generator/config.json' \
|
||||||
-e "ssh -p 7822" \
|
-e "ssh -p 7822" \
|
||||||
/home/moltbot/workspace/romfast-website/ \
|
/home/moltbot/workspace/romfast-website/ \
|
||||||
romfastr@nl1-ss18.a2hosting.com:~/public_html/
|
romfastr@nl1-ss18.a2hosting.com:~/public_html/
|
||||||
@@ -17,6 +20,19 @@ rsync -avz --exclude='.git' --exclude='.superdesign' --exclude='.claude' --exclu
|
|||||||
|
|
||||||
- **Host:** nl1-ss18.a2hosting.com | **Port:** 7822 | **User:** romfastr
|
- **Host:** nl1-ss18.a2hosting.com | **Port:** 7822 | **User:** romfastr
|
||||||
- **Document root:** `~/public_html/` — SSH key auth (no password)
|
- **Document root:** `~/public_html/` — SSH key auth (no password)
|
||||||
|
- **Important:** rsync-ul NU folosește `--delete`, deci nu șterge nimic pe prod. `efactura-generator/config.json` e exclus explicit ca să nu poată fi suprascris niciodată din repo (conține `api_key` și e gestionat doar pe server).
|
||||||
|
|
||||||
|
## Sub-proiectul `efactura-generator/`
|
||||||
|
|
||||||
|
Directorul `efactura-generator/` din acest repo este o **oglindă** a proiectului canonic `/workspace/efactura-generator/` (repo separat: `git@gitea.romfast.ro:romfast/efactura-generator.git`). Sursa de adevăr e acolo.
|
||||||
|
|
||||||
|
**NU edita direct fișierele din `efactura-generator/` aici** — modificările se pierd la următorul sync. În schimb:
|
||||||
|
|
||||||
|
1. Editează în `/workspace/efactura-generator/` (repo canonic).
|
||||||
|
2. Rulează `/workspace/efactura-generator/sync-to-website.sh` care propagă schimbările în `efactura-generator/` din acest repo cu excluderile potrivite (fără `config.json`, fără Dockerfile, fără docs interne etc.).
|
||||||
|
3. Commit aici (`romfast-website`) și deploy cu rsync-ul de mai sus.
|
||||||
|
|
||||||
|
`config.json` de pe server (`~/public_html/efactura-generator/config.json`) conține `api_key` și nu e nici în repo, nici în sync — se gestionează manual pe a2hosting.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -27,10 +43,10 @@ python3 -m http.server 8000
|
|||||||
# Access at http://localhost:8000
|
# Access at http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
The `efactura-generator/` sub-app has its own Node.js server:
|
The `efactura-generator/` sub-app has its own Node.js server (rulează din repo-ul canonic, nu de aici):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd efactura-generator && node js/server.js
|
cd /workspace/efactura-generator && node js/server.js
|
||||||
# Access at http://localhost:3000
|
# Access at http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
2
efactura-generator/.gitattributes
vendored
Normal file
2
efactura-generator/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
<FilesMatch "\.(html|php|css|js|ico|xml)$">
|
<FilesMatch "\.(html|php|css|js|mjs|ico|xml)$">
|
||||||
Order Allow,Deny
|
Order Allow,Deny
|
||||||
Allow from all
|
Allow from all
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Native ESM (.mjs) require JavaScript MIME type pe Apache.
|
||||||
|
<IfModule mod_mime.c>
|
||||||
|
AddType text/javascript .mjs
|
||||||
|
</IfModule>
|
||||||
|
|||||||
@@ -1,35 +1,115 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.9-beta-4 - 07.02.2025
|
## 0.9-beta-14 - 05.05.2026
|
||||||
|
|
||||||
### Bugfixes
|
### Bugfixes
|
||||||
- Fixed: Al doilea tag TaxTotal se generează doar când valuta documentului diferă de valuta TVA.
|
- Fixed: pe găzduirea cu PHP 7.3 (a2hosting), `receiver.php` returna 500 Internal Server Error la upload XML din cauza unei sintaxe noi (arrow function `fn() =>`) introduse în beta-13. Înlocuită cu funcție anonimă clasică, compatibilă cu PHP 7.3+.
|
||||||
|
|
||||||
## 0.9-beta-3 - 14.01.2025
|
## 0.9-beta-13 - 04.05.2026
|
||||||
|
|
||||||
### Bugfixes
|
### Bugfixes
|
||||||
- Fixed: Discounturile de pe articole nu se mai cumulează în discount global pe factură.
|
- Fixed: la încărcarea unui XML cu furnizor sau client neplătitor de TVA, codul fiscal apărea în câmpul „Nr. înregistrare" iar „Cod TVA" rămânea gol. Acum, dacă firma nu e plătitoare TVA, CIF-ul se completează în „Cod TVA" și numărul de la Registrul Comerțului în „Nr. înregistrare" (simetric cu modul în care se salvează XML-ul).
|
||||||
|
- Fixed: deploy în Docker/Dokploy returna „Acces interzis" la căutare CIF și validare ANAF, deoarece request-urile veneau prin reverse proxy și apăreau ca venind dintr-un IP intern, nu de la utilizator. Lista de IP-uri permise se poate dezactiva acum (gol sau `*`).
|
||||||
### New Features
|
|
||||||
- Added: Se completează valoare reducere și cod reducere pe fiecare articol.
|
### Modifications
|
||||||
|
- Configurare receiver prin variabile de mediu (`ANAF_API_KEY`, `ANAF_ALLOWED_IPS`, `ANAF_TOKEN`, `ANAF_TEMP_LIFETIME`) — suprascriu valorile din `config.json`. Util pentru deploy în container fără rebuild la schimbare configurație.
|
||||||
## 0.9-beta-2 - 07.01.2025
|
- Dockerfile: setează implicit `ANAF_ALLOWED_IPS=*` (verificare IP dezactivată), potrivit pentru deploy behind reverse proxy unde same-origin asigură deja protecția.
|
||||||
|
|
||||||
### Bugfixes
|
## 0.9-beta-12 - 04.05.2026
|
||||||
- Fixed: Selecția judet București și afișare oraș.
|
|
||||||
|
### Bugfixes
|
||||||
### New Features
|
- Fixed: eroare BR-CO-15 falsă după click pe „Recalculează Totaluri" — defalcarea TVA părea să nu corespundă cu Total TVA, deși matematica era corectă.
|
||||||
- Added: Se completează codul și motivul scutirii în secțiunea "Defalcare TVA" pentru articole cu Tip TVA "E" Neimpozabil.
|
- Fixed: eroare BR-16 falsă pe linii cu cantitate fracționară (ex. 1,000 buc) — totalul liniei era marcat greșit ca incorect.
|
||||||
- Added: S-a tratat cazul în care furnizorul nu este plătitor de TVA (codul fiscal nu are atributul fiscal "RO").
|
- Fixed: la salvare apărea „completați toate câmpurile obligatorii" chiar când nu se referențiază altă factură. Câmpul „Data factură referită" e opțional și nu mai blochează salvarea când e gol.
|
||||||
- Added: Citire din xml coduri identificare articole (vânzător, cumpărător, cod de bare, CPV, NC8, vamal).
|
- Fixed: „Factură Nouă" — eroare XML declaration duplicată la parsare (`XMLSerializer` include deja `<?xml?>`, codul o prefixa din nou). Strip declaration înainte de concatenare.
|
||||||
- Added: Editare, adăugare, ștergere coduri identificare articole.
|
|
||||||
|
### Modifications
|
||||||
### Modifications
|
- ANAF lookup CIF: completare automată oraș, județ, telefon și prefix `RO` la CIF-ul plătitorilor TVA. Toast indică acum statusul `Plătitor/Neplătitor TVA · Înregistrat eFactura`. Câmpurile `CountrySubentity` (SELECT RO-XX) și `Country` se populează automat.
|
||||||
- Modified: Afișare responsive pentru ecrane de diferite dimensiuni.
|
- Număr factură: pre-populare din secvența localStorage la deschiderea aplicației (fără incrementare contor).
|
||||||
|
- Număr factură: format configurabil — serie + spațiu + an opțional + contor cu N cifre (1–8). Exemple: `RFT 20260001` (cu an) sau `RFT 0001` (fără an). Modal „Factură Nouă" extins cu checkbox „Include an în număr" și input „Cifre contor".
|
||||||
### TODO
|
|
||||||
- Implement: citire și editare referință factura originală pentru factura storno.
|
## 0.9-beta-11 - 04.05.2026
|
||||||
- Implement: citire și editare modalități de plată.
|
|
||||||
|
### Bugfixes
|
||||||
## 0.9-beta-1 - 06.01.2025
|
- Fixed: Lookup CIF ANAF nu mai funcționa — migrat la API v9 (PlatitorTvaRest) care a înlocuit v8-ul async.
|
||||||
- Initial beta release.
|
|
||||||
|
### Modifications
|
||||||
|
- Added: Documentație rulare locală și Docker în README.
|
||||||
|
- Added: Script `start.sh` pentru pornire dev (Node :3000 + PHP :8000) cu auto-stop al proceselor existente pe aceste porturi. Banner-ul indică explicit `:8000` pentru testare cu ANAF/receiver, `:3000` pentru testare statică.
|
||||||
|
|
||||||
|
## 0.9-beta-10 - 30.04.2026
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Import bulk — se pot încărca mai multe fișiere XML (sau ZIP) simultan. Un sidebar afișează lista fișierelor deschise; fișierele modificate sunt marcate vizual. Limită 50 fișiere.
|
||||||
|
|
||||||
|
## 0.9-beta-9 - 30.04.2026
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Catalog produse local — articolele pot fi salvate în catalogul browserului și refolosite prin autocomplete la câmpul „Denumire" pe orice linie factură.
|
||||||
|
|
||||||
|
## 0.9-beta-8 - 30.04.2026
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Numerotare automată facturi — buton „Factură Nouă" generează numărul următor din serie configurată (ex. `RFT2026-0042`). La trecerea anului, aplicația întreabă dacă se continuă seria sau se resetează contorul la 1.
|
||||||
|
|
||||||
|
## 0.9-beta-7 - 30.04.2026
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Descărcare PDF direct din browser, fără server — buton „Descarcă PDF" în header și în paginile de printare.
|
||||||
|
|
||||||
|
## 0.9-beta-6 - 30.04.2026
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Validare XML la ANAF — buton „Validare ANAF" trimite factura la serverul ANAF și afișează erorile returnate (disponibil doar când receiver.php este activ).
|
||||||
|
- Added: Lookup CIF ANAF — buton „Caută CIF" lângă câmpurile cod TVA completează automat numele, adresa și numărul de înregistrare al firmei.
|
||||||
|
|
||||||
|
## 0.9-beta-5 - 30.04.2026
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Încărcare ZIP — fișierele ZIP cu XML eFactura pot fi încărcate direct (prin buton sau drag-and-drop); primul XML din arhivă este extras automat.
|
||||||
|
- Added: Redesign vizual complet — font Geist, paletă warm-paper cu header slate, spațiere și contrast îmbunătățite.
|
||||||
|
- Added: Profil furnizor — datele furnizorului pot fi salvate în browser și refolosite la facturi noi cu un singur click.
|
||||||
|
- Added: Tip factură — câmp nou pentru tipul documentului: factură comercială, notă de credit, factură corectată sau autofactură.
|
||||||
|
- Added: Modalități de plată — secțiune nouă cu unul sau mai multe rânduri cod plată + IBAN.
|
||||||
|
- Added: Referință factură originală — câmpuri pentru numărul și data facturii la care se referă storno-ul; completate automat la apăsarea „Stornează".
|
||||||
|
- Added: Validare matematică inline — badge verde/roșu pe fiecare linie și la total, arată dacă valorile sunt consistente. La salvare apare un avertisment dacă există diferențe.
|
||||||
|
- Added: Validare CIF/CUI pe blur — eroare inline dacă cifra de control nu este corectă.
|
||||||
|
- Added: Validare IBAN pe blur — eroare inline dacă IBAN-ul are lungime sau check digits incorecte.
|
||||||
|
- Added: Panel reguli CIUS-RO — panou flotant cu lista erorilor de conformitate față de standardul eFactura, actualizat în timp real. Click pe o eroare navighează la câmpul problematic.
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
- Fixed: Funcția „Stornează" lăsa valorile interne inconsistente față de ce era afișat în formular.
|
||||||
|
|
||||||
|
## 0.9-beta-4 - 07.02.2025
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
- Fixed: Al doilea tag TaxTotal se generează doar când valuta documentului diferă de valuta TVA.
|
||||||
|
|
||||||
|
## 0.9-beta-3 - 14.01.2025
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
- Fixed: Discounturile de pe articole nu se mai cumulează în discount global pe factură.
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Se completează valoare reducere și cod reducere pe fiecare articol.
|
||||||
|
|
||||||
|
## 0.9-beta-2 - 07.01.2025
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
- Fixed: Selecția judet București și afișare oraș.
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- Added: Se completează codul și motivul scutirii în secțiunea "Defalcare TVA" pentru articole cu Tip TVA "E" Neimpozabil.
|
||||||
|
- Added: S-a tratat cazul în care furnizorul nu este plătitor de TVA (codul fiscal nu are atributul fiscal "RO").
|
||||||
|
- Added: Citire din xml coduri identificare articole (vânzător, cumpărător, cod de bare, CPV, NC8, vamal).
|
||||||
|
- Added: Editare, adăugare, ștergere coduri identificare articole.
|
||||||
|
|
||||||
|
### Modifications
|
||||||
|
- Modified: Afișare responsive pentru ecrane de diferite dimensiuni.
|
||||||
|
|
||||||
|
### TODO
|
||||||
|
- Implement: citire și editare referință factura originală pentru factura storno.
|
||||||
|
- Implement: citire și editare modalități de plată.
|
||||||
|
|
||||||
|
## 0.9-beta-1 - 06.01.2025
|
||||||
|
- Initial beta release.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,172 @@
|
|||||||
# eFactura Editor / Editor Facturi Electronice
|
# Editor eFactura
|
||||||
|
|
||||||
## Demo
|
Editor static în browser pentru fișiere XML eFactura (UBL 2.1) conforme cu standardul ANAF.
|
||||||
https://romfast.github.io/efactura-generator/
|
|
||||||
|
## Demo
|
||||||
## Overview / Prezentare
|
|
||||||
Romanian electronic invoice (eFactura) editor - loads XML, allows editing, printing invoices and generates new XML files.
|
- GitHub Pages: https://romfast.github.io/efactura-generator/
|
||||||
|
|
||||||
Editor pentru facturi electronice (eFactura) - încarcă fișiere XML, permite editarea, printarea facturilor și generează fișiere XML noi.
|
## Scop
|
||||||
|
|
||||||

|
Aplicația este intenționat **statică** (HTML + CSS + JavaScript, fără build, fără backend obligatoriu) astfel încât să poată fi găzduită direct pe GitHub Pages sau pe orice server web simplu.
|
||||||

|
|
||||||

|
Cazurile principale de utilizare:
|
||||||
|
- **Corecții minore** într-un XML eFactura existent (date factură, furnizor, client, articole, TVA).
|
||||||
## Installation & Usage / Instalare & Utilizare
|
- **Generare factură storno** pornind de la o factură existentă, prin butonul „Stornează”.
|
||||||
|
|
||||||
### Option 1: Web Server / Opțiunea 1: Server Web
|
Nu este un sistem complet de facturare — lucrează cu câte un singur XML, încărcat și salvat local.
|
||||||
- Copy all project files to your web server maintaining the directory structure.
|
|
||||||
- Access through your web server URL.
|

|
||||||
- Use "Printează" button to print the invoice.
|

|
||||||
|

|
||||||
- Copiați toate fișierele pe server păstrând structura directoarelor.
|
|
||||||
- Accesați prin URL-ul serverului.
|
## Funcționalități
|
||||||
- Folosiți butonul "Printează" pentru a printa factura.
|
|
||||||
|
### Încărcare XML
|
||||||
### Option 2: Local Development / Opțiunea 2: Dezvoltare Locală
|
- Încărcare fișier XML local prin butonul „Alege Fișier XML”.
|
||||||
1. Install Node.js / Instalați Node.js
|
- Încărcare automată dintr-un fișier temporar prin parametru URL (`index.html?xml=<nume_fisier>`), folosit împreună cu `receiver.php` pentru integrare cu sisteme externe.
|
||||||
2. Clone/download repository / Clonați/descărcați repository-ul
|
|
||||||
3. Run / Rulați: `node server.js`
|
### Detalii factură
|
||||||
4. Open / Deschideți: http://localhost:3000
|
- Număr factură, data emiterii, data scadentă (cu calendar Pikaday și format `dd.mm.yyyy`).
|
||||||
|
- Monedă document și monedă TVA opțională (cu generarea automată a celui de-al doilea tag `TaxTotal` când monedele diferă).
|
||||||
## Project Structure / Structura Proiect
|
- Câmp text adițional (notă) cu limită de 900 de caractere și împărțire automată.
|
||||||
```
|
|
||||||
project/
|
### Furnizor și client
|
||||||
├── index.html
|
- Nume, cod TVA, număr de înregistrare, adresă, oraș, județ, țară, telefon, persoană de contact, email.
|
||||||
├── styles/
|
- Selector de țară conform ISO 3166-1 și selector de județ conform codurilor `RO-XX`.
|
||||||
│ ├── main.css
|
- Tratare specială pentru București și pentru furnizori neplătitori de TVA (cod fiscal fără atributul `RO`).
|
||||||
├── js/
|
|
||||||
│ ├── script.js
|
### Articole factură
|
||||||
│ ├── formatter.js
|
- Adăugare, editare și ștergere articole.
|
||||||
│ └── print.js
|
- Cantitate, preț, cotă TVA, unitate de măsură (EA, XPP, H87, KGM, MTR, LTR, MTQ).
|
||||||
├── templates/
|
- Reduceri pe linie cu cod și motiv reducere.
|
||||||
│ └── print.html
|
- Coduri de identificare articole multiple per linie: cod vânzător, cod cumpărător, cod de bare, CPV, NC8, cod vamal.
|
||||||
└── server.js
|
|
||||||
```
|
### TVA
|
||||||
|
- Tipuri TVA suportate: `S` (Standard), `AE` (Taxare Inversă), `O` (Neplătitor TVA), `Z` (Cotă 0%), `E` (Neimpozabil).
|
||||||
## License / Licență
|
- Coduri de scutire `VATEX-EU-*` cu motiv corespunzător, completate automat în funcție de tipul TVA și editabile manual.
|
||||||
AGPL-3.0-or-later
|
- Defalcare TVA cu mai multe cote, editabilă inline (bază impozabilă și valoare TVA).
|
||||||
|
|
||||||
If you use this software, even as a web service, you must:
|
### Reduceri și taxe la nivel de factură
|
||||||
1. Give credit to the original project
|
- Adăugare/editare/ștergere reduceri și taxe suplimentare la nivel de document.
|
||||||
2. Share all your modifications
|
- Coduri motiv pentru reduceri (95, 41, 42, 60, 62 etc.) și pentru taxe (TV, FC, ZZZ).
|
||||||
3. Use the same AGPL-3 license
|
|
||||||
|
### Totaluri
|
||||||
Dacă folosiți acest software, chiar și ca serviciu web, trebuie să:
|
- Recalculare automată: subtotal, total reduceri, total taxe, valoare netă, TVA, total.
|
||||||
1. Menționați proiectul original
|
- Editare inline a totalurilor (click pe valoare) cu păstrarea totalurilor originale din XML-ul încărcat.
|
||||||
2. Partajați toate modificările făcute
|
- Buton „Recalculează Totaluri” pentru regenerare din articole.
|
||||||
3. Folosiți aceeași licență AGPL-3
|
|
||||||
|
### Storno
|
||||||
## Changelog
|
- Buton „Stornează” care convertește factura curentă într-o factură storno (cantități și valori negative) gata de salvat.
|
||||||
[Istoric modificări](CHANGELOG.md)
|
|
||||||
|
### Salvare și printare
|
||||||
|
- Buton „Salvează XML” care generează și descarcă un XML UBL conform.
|
||||||
|
- Vizualizare printabilă în două formate (standard și compact) prin șabloanele din `templates/`.
|
||||||
|
|
||||||
|
### Formatare
|
||||||
|
- Numere, cantități și sume formatate conform locale-ului browserului, cu conversie automată la punct decimal pentru XML.
|
||||||
|
|
||||||
|
## Instalare și utilizare
|
||||||
|
|
||||||
|
### Opțiunea 1: Server web static
|
||||||
|
Copiați toate fișierele pe serverul web păstrând structura directoarelor și accesați `index.html` prin URL-ul serverului. Funcționează pe orice server static (Apache, nginx, GitHub Pages etc.).
|
||||||
|
|
||||||
|
### Opțiunea 2: Dezvoltare locală cu Node.js
|
||||||
|
```bash
|
||||||
|
node js/server.js
|
||||||
|
```
|
||||||
|
Apoi deschideți http://localhost:3000
|
||||||
|
|
||||||
|
Serverul Node este minimal (fără dependințe) și servește doar fișierele statice.
|
||||||
|
|
||||||
|
### Opțiunea 3: Script `start.sh` (Node + PHP simultan)
|
||||||
|
|
||||||
|
Pentru testare vizuală manuală cu receiver-ul PHP activ:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Pornește în paralel:
|
||||||
|
- Node static server pe `http://localhost:3000` (frontend, fără PHP — simulează GitHub Pages)
|
||||||
|
- PHP built-in server pe `http://localhost:8000` (servește atât frontend cât și `receiver.php`, `test-config.php`)
|
||||||
|
|
||||||
|
**Pentru testarea funcționalităților ANAF (lookup CIF, validare, PDF) deschideți `http://localhost:8000/` — frontend-ul apelează `./receiver.php` pe același origin, deci pe `:3000` (Node nu execută PHP) veți primi 404.** Folosiți `:3000` doar pentru a verifica comportamentul fără PHP.
|
||||||
|
|
||||||
|
Dacă porturile sunt deja ocupate, scriptul oprește procesele existente înainte de pornire. `Ctrl+C` oprește ambele servere. Loguri în `logs/dev-node.log` și `logs/dev-php.log`. Override porturi: `NODE_PORT=4000 PHP_PORT=9000 ./start.sh`.
|
||||||
|
|
||||||
|
### Opțiunea 4: Docker (local sau CI)
|
||||||
|
|
||||||
|
Imaginea include Apache + PHP 8.2 cu `mod_rewrite` și `mod_headers`, identic cu un hosting de producție.
|
||||||
|
|
||||||
|
**Build și run:**
|
||||||
|
```bash
|
||||||
|
docker build -t efactura-generator .
|
||||||
|
docker run --rm -p 8080:80 efactura-generator
|
||||||
|
```
|
||||||
|
Apoi deschideți http://localhost:8080
|
||||||
|
|
||||||
|
**Cu PHP receiver activ** (opțional — doar dacă integrați cu un sistem extern):
|
||||||
|
```bash
|
||||||
|
# Copiați și editați config.json înainte de build
|
||||||
|
cp config.json config.local.json
|
||||||
|
# Editați config.local.json: api_key, allowed_ips
|
||||||
|
|
||||||
|
docker build -t efactura-generator .
|
||||||
|
docker run --rm -p 8080:80 \
|
||||||
|
-v "$(pwd)/config.local.json:/var/www/html/config.json:ro" \
|
||||||
|
efactura-generator
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testare rapidă (fără PHP):**
|
||||||
|
```bash
|
||||||
|
# Nici o dependință — server Node minimal, doar fișiere statice
|
||||||
|
node js/server.js # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Notă:** Directorul `temp/` din container este volatil — se șterge la `docker run --rm`. Dacă vreți persistență, montați un volum: `-v "$(pwd)/temp:/var/www/html/temp"`.
|
||||||
|
|
||||||
|
### Opțiunea 5: Integrare cu sistem extern (PHP)
|
||||||
|
Pentru a primi un XML dintr-o aplicație externă și a-l deschide direct în editor:
|
||||||
|
1. Configurați `config.json` (cheie API, IP-uri permise, durată de viață fișiere temporare).
|
||||||
|
2. Sistemul extern face POST cu conținutul XML către `receiver.php`, transmițând antetul `X-Api-Key`.
|
||||||
|
3. `receiver.php` validează XML-ul și namespace-urile UBL, salvează în `temp/` și returnează numele fișierului.
|
||||||
|
4. Utilizatorul este redirecționat către `index.html?xml=<nume_fisier>`, care încarcă XML-ul automat.
|
||||||
|
5. Fișierul temporar este șters după încărcare; fișierele mai vechi decât `temp_file_lifetime` ore sunt curățate automat.
|
||||||
|
|
||||||
|
Pentru diagnosticare există pagina `test-config.php`.
|
||||||
|
|
||||||
|
## Structura proiectului
|
||||||
|
|
||||||
|
```
|
||||||
|
efactura-generator/
|
||||||
|
├── index.html # Pagina principală
|
||||||
|
├── styles/main.css
|
||||||
|
├── js/
|
||||||
|
│ ├── script.js # Logica completă: parsare/generare XML, formular, totaluri
|
||||||
|
│ ├── formatter.js # Formatare numere/sume/cantități
|
||||||
|
│ ├── print.js # Generare vizualizare printabilă
|
||||||
|
│ └── server.js # Server static minimal pentru dezvoltare locală
|
||||||
|
├── templates/
|
||||||
|
│ ├── print.html # Șablon printare standard
|
||||||
|
│ └── print-compact.html # Șablon printare compact
|
||||||
|
├── receiver.php # Endpoint opțional pentru încărcare XML din sisteme externe
|
||||||
|
├── test-config.php # Pagina de diagnosticare pentru receiver
|
||||||
|
├── config.json # Configurație receiver (API key, IP-uri permise)
|
||||||
|
└── .htaccess.template # Configurație Apache pentru hosting în producție
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licență
|
||||||
|
|
||||||
|
[AGPL-3.0-or-later](LICENSE.md)
|
||||||
|
|
||||||
|
Dacă folosiți acest software, chiar și ca serviciu web, trebuie să:
|
||||||
|
1. Menționați proiectul original.
|
||||||
|
2. Partajați toate modificările făcute.
|
||||||
|
3. Folosiți aceeași licență AGPL-3.
|
||||||
|
|
||||||
|
## Linkuri
|
||||||
|
|
||||||
|
- [Istoric modificări](CHANGELOG.md)
|
||||||
|
- [De făcut](TODO.md)
|
||||||
|
- [www.romfast.ro](https://www.romfast.ro)
|
||||||
|
|||||||
@@ -1,242 +1,294 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ro">
|
<html lang="ro">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Editor Factură Electronică</title>
|
<title>Editor Factură Electronică</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pikaday/1.8.0/css/pikaday.min.css">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link rel="stylesheet" href="./styles/main.css">
|
<link href="https://fonts.bunny.net/css?family=geist:400,500,600,700|geist-mono:400,500,600&display=swap" rel="stylesheet">
|
||||||
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pikaday/1.8.0/css/pikaday.min.css">
|
||||||
</head>
|
<link rel="stylesheet" href="./styles/main.css">
|
||||||
</head>
|
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
|
||||||
<body>
|
</head>
|
||||||
<div class="container">
|
<body>
|
||||||
<div class="header">
|
<div class="container">
|
||||||
<div>
|
<div class="header">
|
||||||
<h1>Editor eFactura</h1>
|
<div>
|
||||||
<div class="app-author">Romfast SRL</div>
|
<h1>Editor eFactura</h1>
|
||||||
</div>
|
<div class="app-author">Romfast SRL</div>
|
||||||
<div class="button-group">
|
</div>
|
||||||
<input type="file" id="fileInput" class="file-input" accept=".xml">
|
<div class="button-group">
|
||||||
<button onclick="document.getElementById('fileInput').click()" class="button">Alege Fișier XML</button>
|
<input type="file" id="fileInput" class="file-input" accept=".xml,.zip,application/zip,application/x-zip-compressed" multiple>
|
||||||
<button onclick="handleStorno()" class="button button-danger">Stornează</button>
|
<button onclick="document.getElementById('fileInput').click()" class="button">Alege Fișier XML / ZIP</button>
|
||||||
<button onclick="saveXML()" class="button button-secondary">Salvează XML</button>
|
<button onclick="window.openNewInvoiceModal()" class="button" title="Factură nouă cu numerotare automată">Factură Nouă</button>
|
||||||
</div>
|
<button id="btnDownloadPDF" onclick="window.downloadPDF()" class="button button-secondary" title="Descarcă factură în format PDF (client-side)">Descarcă PDF</button>
|
||||||
</div>
|
<button id="btnValidateAnaf" onclick="window.validateAnaf()" class="button button-success" style="display:none" title="Validare prin API ANAF (necesită receiver.php)">Validare ANAF</button>
|
||||||
|
<button onclick="handleStorno()" class="button button-danger">Stornează</button>
|
||||||
<form id="invoiceForm">
|
<button onclick="saveXML()" class="button button-secondary">Salvează XML</button>
|
||||||
<!-- Details Grid -->
|
</div>
|
||||||
<div class="details-grid">
|
</div>
|
||||||
<!-- Invoice Details -->
|
|
||||||
<div class="form-section invoice-details">
|
<form id="invoiceForm">
|
||||||
<h2 class="section-title">Detalii Factură</h2>
|
<!-- Details Grid -->
|
||||||
<div class="compact-grid">
|
<div class="details-grid">
|
||||||
<div class="form-group">
|
<!-- Invoice Details -->
|
||||||
<label class="form-label">Număr Factură</label>
|
<div class="form-section invoice-details">
|
||||||
<input type="text" class="form-input" name="invoiceNumber">
|
<h2 class="section-title">Detalii Factură</h2>
|
||||||
</div>
|
<div class="compact-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Data Emiterii</label>
|
<label class="form-label">Număr Factură</label>
|
||||||
<div class="date-input-container">
|
<input type="text" class="form-input mono" name="invoiceNumber" style="text-align:left">
|
||||||
<input type="text" class="form-input date-input" name="issueDate" placeholder="dd.mm.yyyy" maxlength="10" pattern="\d{2}.\d{2}.\d{4}">
|
</div>
|
||||||
<button type="button" class="calendar-button" tabindex="-1">📅</button>
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Tip Factură</label>
|
||||||
</div>
|
<select class="form-input" name="invoiceTypeCode">
|
||||||
<div class="form-group">
|
<option value="380">380 — Factură comercială</option>
|
||||||
<label class="form-label">Data Scadentă</label>
|
<option value="381">381 — Notă de credit</option>
|
||||||
<div class="date-input-container">
|
<option value="384">384 — Factură corectată</option>
|
||||||
<input type="text" class="form-input date-input" name="dueDate" placeholder="dd.mm.yyyy" maxlength="10" pattern="\d{2}.\d{2}.\d{4}">
|
<option value="389">389 — Autofactură</option>
|
||||||
<button type="button" class="calendar-button" tabindex="-1">📅</button>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label class="form-label">Data Emiterii</label>
|
||||||
<label class="form-label">Monedă Factură</label>
|
<div class="date-input-container">
|
||||||
<input type="text" class="form-input" name="documentCurrencyCode" value="RON" maxlength="3">
|
<input type="text" class="form-input date-input num" name="issueDate" placeholder="dd.mm.yyyy" maxlength="10" pattern="\d{2}.\d{2}.\d{4}">
|
||||||
</div>
|
<button type="button" class="calendar-button" tabindex="-1">📅</button>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label class="form-label">Monedă TVA (opțional)</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="taxCurrencyCode" maxlength="3">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Data Scadentă</label>
|
||||||
</div>
|
<div class="date-input-container">
|
||||||
</div>
|
<input type="text" class="form-input date-input num" name="dueDate" placeholder="dd.mm.yyyy" maxlength="10" pattern="\d{2}.\d{2}.\d{4}">
|
||||||
|
<button type="button" class="calendar-button" tabindex="-1">📅</button>
|
||||||
<!-- Supplier Details -->
|
</div>
|
||||||
<div class="form-section party-details">
|
</div>
|
||||||
<h2 class="section-title">Detalii Furnizor</h2>
|
<div class="form-group">
|
||||||
<div class="compact-grid">
|
<label class="form-label">Monedă Factură</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input mono" name="documentCurrencyCode" value="RON" maxlength="3" style="text-align:left">
|
||||||
<label class="form-label">Nume</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="supplierName">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Monedă TVA (opțional)</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input mono" name="taxCurrencyCode" maxlength="3" style="text-align:left">
|
||||||
<label class="form-label">Cod TVA</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="supplierVAT">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Factură Referită (nr.)</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input mono" name="billingRefId" placeholder="Nr. factură originală" style="text-align:left">
|
||||||
<label class="form-label">Nr. înregistrare</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="supplierCompanyId">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Data Factură Referită</label>
|
||||||
<div class="form-group">
|
<div class="date-input-container">
|
||||||
<label class="form-label">Adresă</label>
|
<input type="text" class="form-input date-input num" name="billingRefDate" placeholder="dd.mm.yyyy" maxlength="10" pattern="\d{2}.\d{2}.\d{4}">
|
||||||
<input type="text" class="form-input" name="supplierAddress">
|
<button type="button" class="calendar-button" tabindex="-1">📅</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label class="form-label">Oraș</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="supplierCity">
|
</div>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<!-- Supplier Details -->
|
||||||
<label class="form-label">Județ</label>
|
<div class="form-section party-details">
|
||||||
<input type="text" class="form-input" name="supplierCountrySubentity">
|
<h2 class="section-title">
|
||||||
</div>
|
Detalii Furnizor
|
||||||
<div class="form-group">
|
<span class="card-actions" id="supplierProfileActions">
|
||||||
<label class="form-label">Țară</label>
|
<button type="button" class="button button-secondary button-small"
|
||||||
<input type="text" class="form-input" name="supplierCountry">
|
id="btnSaveProfile" onclick="window.saveSupplierProfile()">Salvează profil</button>
|
||||||
</div>
|
<button type="button" class="button button-secondary button-small"
|
||||||
<div class="form-group">
|
id="btnUseProfile" onclick="window.useSupplierProfile()">Folosește profil</button>
|
||||||
<label class="form-label">Telefon</label>
|
<button type="button" class="button button-secondary button-small"
|
||||||
<input type="tel" class="form-input" name="supplierPhone" pattern="[0-9]*">
|
id="btnDeleteProfile" onclick="window.deleteSupplierProfile()">Șterge profil</button>
|
||||||
</div>
|
</span>
|
||||||
<div class="form-group">
|
</h2>
|
||||||
<label class="form-label">Persoană Contact</label>
|
<div class="compact-grid">
|
||||||
<input type="text" class="form-input" name="supplierContactName">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Nume</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input" name="supplierName">
|
||||||
<label class="form-label">Email</label>
|
</div>
|
||||||
<input type="email" class="form-input" name="supplierEmail">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Cod TVA</label>
|
||||||
</div>
|
<div class="input-with-action">
|
||||||
</div>
|
<input type="text" class="form-input mono" name="supplierVAT" style="text-align:left">
|
||||||
|
<button type="button" id="btnLookupSupplierCif" class="button button-secondary button-small anaf-cif-btn" style="display:none" onclick="window.lookupCif('supplier')" title="Caută date firmă din ANAF">Caută</button>
|
||||||
<!-- Customer Details -->
|
</div>
|
||||||
<div class="form-section party-details">
|
</div>
|
||||||
<h2 class="section-title">Detalii Client</h2>
|
<div class="form-group">
|
||||||
<div class="compact-grid">
|
<label class="form-label">Nr. înregistrare</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input mono" name="supplierCompanyId" style="text-align:left">
|
||||||
<label class="form-label">Nume</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="customerName">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Adresă</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input" name="supplierAddress">
|
||||||
<label class="form-label">Cod TVA</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="customerVAT">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Oraș</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input" name="supplierCity">
|
||||||
<label class="form-label">Nr. înregistrare</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="customerCompanyId">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Județ</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input" name="supplierCountrySubentity">
|
||||||
<label class="form-label">Adresă</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="customerAddress">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Țară</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input" name="supplierCountry">
|
||||||
<label class="form-label">Oraș</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="customerCity">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Telefon</label>
|
||||||
<div class="form-group">
|
<input type="tel" class="form-input mono" name="supplierPhone" pattern="[0-9]*" style="text-align:left">
|
||||||
<label class="form-label">Județ</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="customerCountrySubentity">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Persoană Contact</label>
|
||||||
<div class="form-group">
|
<input type="text" class="form-input" name="supplierContactName">
|
||||||
<label class="form-label">Țară</label>
|
</div>
|
||||||
<input type="text" class="form-input" name="customerCountry">
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Email</label>
|
||||||
<div class="form-group">
|
<input type="email" class="form-input" name="supplierEmail">
|
||||||
<label class="form-label">Telefon</label>
|
</div>
|
||||||
<input type="tel" class="form-input" name="customerPhone" pattern="[0-9]*">
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Persoană Contact</label>
|
<!-- Customer Details -->
|
||||||
<input type="text" class="form-input" name="customerContactName">
|
<div class="form-section party-details">
|
||||||
</div>
|
<h2 class="section-title">Detalii Client</h2>
|
||||||
<div class="form-group">
|
<div class="compact-grid">
|
||||||
<label class="form-label">Email</label>
|
<div class="form-group">
|
||||||
<input type="email" class="form-input" name="customerEmail">
|
<label class="form-label">Nume</label>
|
||||||
</div>
|
<input type="text" class="form-input" name="customerName">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Cod TVA</label>
|
||||||
|
<div class="input-with-action">
|
||||||
<!-- Note field -->
|
<input type="text" class="form-input mono" name="customerVAT" style="text-align:left">
|
||||||
<div class="form-section">
|
<button type="button" id="btnLookupCustomerCif" class="button button-secondary button-small anaf-cif-btn" style="display:none" onclick="window.lookupCif('customer')" title="Caută date firmă din ANAF">Caută</button>
|
||||||
<h2 class="section-title">Text Adițional</h2>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<textarea class="form-input note-input" name="invoiceNote" rows="4" maxlength="900"></textarea>
|
<div class="form-group">
|
||||||
<div class="note-counter">0/900 caractere</div>
|
<label class="form-label">Nr. înregistrare</label>
|
||||||
</div>
|
<input type="text" class="form-input mono" name="customerCompanyId" style="text-align:left">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<!-- Allowances and Charges -->
|
<label class="form-label">Adresă</label>
|
||||||
<div class="allowance-charges" id="allowanceCharges">
|
<input type="text" class="form-input" name="customerAddress">
|
||||||
<h2 class="section-title">
|
</div>
|
||||||
Reduceri și Taxe Suplimentare
|
<div class="form-group">
|
||||||
<button type="button" class="button button-small" onclick="addAllowanceCharge()">
|
<label class="form-label">Oraș</label>
|
||||||
Adaugă Reducere/Taxă
|
<input type="text" class="form-input" name="customerCity">
|
||||||
</button>
|
</div>
|
||||||
</h2>
|
<div class="form-group">
|
||||||
</div>
|
<label class="form-label">Județ</label>
|
||||||
|
<input type="text" class="form-input" name="customerCountrySubentity">
|
||||||
<!-- Line Items -->
|
</div>
|
||||||
<div class="line-items" id="lineItems">
|
<div class="form-group">
|
||||||
<h2 class="section-title">
|
<label class="form-label">Țară</label>
|
||||||
Articole Factură
|
<input type="text" class="form-input" name="customerCountry">
|
||||||
<button type="button" class="button button-small" onclick="addLineItem()">
|
</div>
|
||||||
Adaugă Articol
|
<div class="form-group">
|
||||||
</button>
|
<label class="form-label">Telefon</label>
|
||||||
</h2>
|
<input type="tel" class="form-input mono" name="customerPhone" pattern="[0-9]*" style="text-align:left">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<!-- Totals Section -->
|
<label class="form-label">Persoană Contact</label>
|
||||||
<div class="totals">
|
<input type="text" class="form-input" name="customerContactName">
|
||||||
<div class="total-row">
|
</div>
|
||||||
<span>Subtotal:</span>
|
<div class="form-group">
|
||||||
<span id="subtotal" class="editable-total">0.00</span>
|
<label class="form-label">Email</label>
|
||||||
</div>
|
<input type="email" class="form-input" name="customerEmail">
|
||||||
<div class="total-row">
|
</div>
|
||||||
<span>Total Reduceri:</span>
|
</div>
|
||||||
<span id="totalAllowances" class="editable-total">0.00</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="total-row">
|
|
||||||
<span>Total Taxe:</span>
|
<!-- Note field -->
|
||||||
<span id="totalCharges" class="editable-total">0.00</span>
|
<div class="form-section">
|
||||||
</div>
|
<h2 class="section-title">Text Adițional</h2>
|
||||||
<div class="total-row">
|
<div class="form-group">
|
||||||
<span>Valoare Netă:</span>
|
<textarea class="form-input note-input" name="invoiceNote" rows="4" maxlength="900"></textarea>
|
||||||
<span id="netAmount" class="editable-total">0.00</span>
|
<div class="note-counter">0/900 caractere</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- VAT Breakdown -->
|
|
||||||
<div id="vatBreakdown">
|
<!-- Payment Means (A5) -->
|
||||||
<h3 class="section-title">Defalcare TVA</h3>
|
<div class="form-section" id="paymentMeansSection">
|
||||||
<div id="vatBreakdownRows"></div>
|
<h2 class="section-title">
|
||||||
<button type="button" class="button button-small" onclick="window.addVATRate()">
|
Modalități de Plată
|
||||||
Adaugă Cotă TVA
|
<button type="button" class="button button-small" onclick="window.addPaymentMeansRow()">+ Adaugă</button>
|
||||||
</button>
|
</h2>
|
||||||
<div class="vat-total-group">
|
<div id="paymentMeansRows"></div>
|
||||||
<div class="total-row">
|
</div>
|
||||||
<span>Total TVA:</span>
|
|
||||||
<span id="vat" class="editable-total">0.00</span>
|
<!-- Allowances and Charges -->
|
||||||
</div>
|
<div class="allowance-charges" id="allowanceCharges">
|
||||||
</div>
|
<h2 class="section-title">
|
||||||
</div>
|
Reduceri și Taxe Suplimentare
|
||||||
|
<button type="button" class="button button-small" onclick="addAllowanceCharge()">
|
||||||
<div class="total-row total-row-final">
|
Adaugă Reducere/Taxă
|
||||||
<span>Total:</span>
|
</button>
|
||||||
<span id="total" class="editable-total">0.00</span>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="total-row">
|
|
||||||
<button type="button" class="button" onclick="refreshTotals()">Recalculează Totaluri</button>
|
<!-- Line Items -->
|
||||||
</div>
|
<div class="line-items" id="lineItems">
|
||||||
</div>
|
<h2 class="section-title">
|
||||||
</form>
|
Articole Factură
|
||||||
|
<button type="button" class="button button-small" onclick="addLineItem()">
|
||||||
<footer class="app-footer">
|
Adaugă Articol
|
||||||
<span id="app-version">v0.9-beta-4</span>
|
</button>
|
||||||
<a href="https://www.romfast.ro">www.romfast.ro</a>
|
</h2>
|
||||||
</footer>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Totals Section -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pikaday/1.8.0/pikaday.min.js"></script>
|
<div class="totals">
|
||||||
<script type="module" src="./js/script.js"></script>
|
<div class="total-row">
|
||||||
|
<span>Subtotal:</span>
|
||||||
|
<span id="subtotal" class="editable-total">0.00</span>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
<div class="total-row">
|
||||||
|
<span>Total Reduceri:</span>
|
||||||
|
<span id="totalAllowances" class="editable-total">0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
|
<span>Total Taxe:</span>
|
||||||
|
<span id="totalCharges" class="editable-total">0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
|
<span>Valoare Netă:</span>
|
||||||
|
<span id="netAmount" class="editable-total">0.00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VAT Breakdown -->
|
||||||
|
<div id="vatBreakdown">
|
||||||
|
<h3 class="section-title">Defalcare TVA</h3>
|
||||||
|
<div id="vatBreakdownRows"></div>
|
||||||
|
<button type="button" class="button button-small" onclick="window.addVATRate()">
|
||||||
|
Adaugă Cotă TVA
|
||||||
|
</button>
|
||||||
|
<div class="vat-total-group">
|
||||||
|
<div class="total-row">
|
||||||
|
<span>Total TVA:</span>
|
||||||
|
<span id="vat" class="editable-total">0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-row total-row-final">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span class="total-with-badge">
|
||||||
|
<span id="total" class="editable-total">0.00</span>
|
||||||
|
<span id="total-badge" class="badge"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
|
<button type="button" class="button" onclick="refreshTotals()">Recalculează Totaluri</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<span id="app-version">v0.9-beta-14</span>
|
||||||
|
<a href="https://www.romfast.ro">www.romfast.ro</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pikaday/1.8.0/pikaday.min.js"></script>
|
||||||
|
<script type="module" src="./js/script.js"></script>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
129
efactura-generator/js/anaf.js
Normal file
129
efactura-generator/js/anaf.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* js/anaf.js — Proxy ANAF APIs prin receiver.php
|
||||||
|
*
|
||||||
|
* Toate apelurile merg prin receiver.php (CORS proxy server-side).
|
||||||
|
* Pe hosting static (GitHub Pages, fără PHP), apelurile vor eșua cu eroare
|
||||||
|
* "receiver indisponibil" — verificați cu probeReceiver() la inițializare.
|
||||||
|
*
|
||||||
|
* Configurare necesară în config.json (server-side):
|
||||||
|
* "anaf_token": "<Bearer token OAuth ANAF>" — necesar pentru validate + pdf
|
||||||
|
*
|
||||||
|
* Endpoints ANAF (proxied):
|
||||||
|
* Validate : POST https://api.anaf.ro/prod/FCTEL/rest/validare/FACT1
|
||||||
|
* PDF/HTML : POST https://api.anaf.ro/prod/FCTEL/rest/transformare/FACT1/DA
|
||||||
|
* CIF info : POST https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RECEIVER = './receiver.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifică dacă receiver.php este disponibil pe server.
|
||||||
|
* Returnează true dacă poate răspunde la ?action=ping.
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function probeReceiver() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${RECEIVER}?action=ping`, { method: 'GET' });
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
return json?.pong === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validează un XML eFactura prin API-ul ANAF (necesită Bearer token în config.json).
|
||||||
|
* @param {string} xmlContent - XML ca string UTF-8
|
||||||
|
* @returns {Promise<{valid: boolean, messages: Array<{message:string, severity:string, xpathLocation?:string}>}>}
|
||||||
|
*/
|
||||||
|
export async function anafValidate(xmlContent) {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${RECEIVER}?action=validate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
body: xmlContent
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Receiver.php indisponibil — ' + e.message);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `ANAF validare: HTTP ${res.status}`;
|
||||||
|
try { const t = await res.text(); if (t) msg += ' — ' + t.slice(0, 200); } catch { /* ok */ }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
// Normalizare răspuns ANAF: { Messages: [{message, severity, xpathLocation}] }
|
||||||
|
const messages = (data.Messages || data.messages || []).map(m => ({
|
||||||
|
message: m.message || m.Message || String(m),
|
||||||
|
severity: (m.severity || m.Severity || 'ERROR').toUpperCase(),
|
||||||
|
xpathLocation: m.xpathLocation || m.XpathLocation || ''
|
||||||
|
}));
|
||||||
|
const valid = messages.filter(m => m.severity === 'ERROR' || m.severity === 'FATAL').length === 0;
|
||||||
|
return { valid, messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obține vizualizarea ANAF a facturii (ZIP cu HTML).
|
||||||
|
* Notă: ANAF /transformare returnează ZIP+HTML, nu PDF direct.
|
||||||
|
* PDF-ul real este generat client-side prin PR-PDF / html2pdf.js.
|
||||||
|
* @param {string} xmlContent - XML ca string UTF-8
|
||||||
|
* @returns {Promise<Blob>} ZIP blob
|
||||||
|
*/
|
||||||
|
export async function anafPdf(xmlContent) {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${RECEIVER}?action=pdf`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
body: xmlContent
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Receiver.php indisponibil — ' + e.message);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`ANAF vizualizare: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caută informații contribuabil după CIF prin ANAF.
|
||||||
|
* Folosește API-ul sincron ANAF v9 (webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva).
|
||||||
|
* Nu necesită token OAuth.
|
||||||
|
* @param {string|number} cif - CIF/CUI (cu sau fără prefix RO)
|
||||||
|
* @returns {Promise<{
|
||||||
|
* found: boolean,
|
||||||
|
* denumire?: string,
|
||||||
|
* adresa?: string,
|
||||||
|
* nrRegCom?: string,
|
||||||
|
* cui?: number,
|
||||||
|
* tvaActiv?: boolean,
|
||||||
|
* strada?: string,
|
||||||
|
* oras?: string,
|
||||||
|
* judetCod?: string,
|
||||||
|
* codPostal?: string,
|
||||||
|
* telefon?: string,
|
||||||
|
* statusEFactura?: boolean
|
||||||
|
* }>}
|
||||||
|
* @property {string} strada - Strada + număr din adresa_sediu_social ANAF
|
||||||
|
* @property {string} oras - Localitatea (fără prefix MUN./ORS./COM.)
|
||||||
|
* @property {string} judetCod - Cod județ ISO format RO-XX (ex: RO-B, RO-CJ)
|
||||||
|
* @property {string} codPostal - Cod poștal
|
||||||
|
* @property {string} telefon - Număr telefon din date_generale ANAF
|
||||||
|
* @property {boolean} statusEFactura - Înregistrat în sistemul eFactura
|
||||||
|
*/
|
||||||
|
export async function anafCifLookup(cif) {
|
||||||
|
const cifNum = String(cif).replace(/^RO\s*/i, '').trim();
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${RECEIVER}?action=cif&cif=${encodeURIComponent(cifNum)}`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Receiver.php indisponibil — ' + e.message);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`ANAF CIF lookup: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
129
efactura-generator/js/catalog.js
Normal file
129
efactura-generator/js/catalog.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* js/catalog.js — Catalog produse/servicii în IndexedDB (PR-A13)
|
||||||
|
*
|
||||||
|
* Folosește `openCatalog()` din storage.js (DB `efactura` v1, store `products`,
|
||||||
|
* indexes: `name`, `sellerItemID`, `cpvCode`).
|
||||||
|
*
|
||||||
|
* Schema produs (v1):
|
||||||
|
* id: string (UUID v4)
|
||||||
|
* name: string — denumire produs/serviciu (indexed, searched by prefix)
|
||||||
|
* unit: string — cod UM (EA, KGM, etc.)
|
||||||
|
* price: string — preț unitar canonical decimal
|
||||||
|
* vatType: string — cod tip TVA (S, AE, O, Z, E)
|
||||||
|
* vatRate: string — cotă TVA (19, 9, 5, 0)
|
||||||
|
* description: string — descriere detaliată (opțional)
|
||||||
|
* sellerItemID: string — cod articol furnizor (opțional, indexed)
|
||||||
|
* cpvCode: string — cod CPV (opțional, indexed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { openCatalog } from './storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaugă sau actualizează un produs în catalog.
|
||||||
|
* Dacă `product.id` lipsește, generează UUID nou.
|
||||||
|
* @param {Object} product
|
||||||
|
* @returns {Promise<string>} ID-ul produsului salvat
|
||||||
|
*/
|
||||||
|
export async function catalogAdd(product) {
|
||||||
|
const db = await openCatalog();
|
||||||
|
const entry = {
|
||||||
|
id: product.id || _uuid(),
|
||||||
|
name: (product.name || '').trim(),
|
||||||
|
unit: (product.unit || 'EA').trim(),
|
||||||
|
price: (product.price || '0').trim(),
|
||||||
|
vatType: (product.vatType || 'S').trim(),
|
||||||
|
vatRate: (product.vatRate || '19').trim(),
|
||||||
|
description: (product.description || '').trim(),
|
||||||
|
sellerItemID: (product.sellerItemID || '').trim(),
|
||||||
|
cpvCode: (product.cpvCode || '').trim(),
|
||||||
|
};
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction('products', 'readwrite');
|
||||||
|
const store = tx.objectStore('products');
|
||||||
|
const req = store.put(entry);
|
||||||
|
req.onsuccess = () => resolve(entry.id);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caută produse după prefix de denumire (case-insensitive prefix match).
|
||||||
|
* Returnează max `limit` rezultate, sortate alfabetic.
|
||||||
|
* @param {string} prefix
|
||||||
|
* @param {number} limit
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function catalogSearch(prefix, limit = 8) {
|
||||||
|
if (!prefix || !prefix.trim()) return [];
|
||||||
|
const db = await openCatalog();
|
||||||
|
const lower = prefix.trim().toLowerCase();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction('products', 'readonly');
|
||||||
|
const store = tx.objectStore('products');
|
||||||
|
const index = store.index('name');
|
||||||
|
|
||||||
|
// Interval IDB: [lower, lower + '') pentru prefix match
|
||||||
|
const range = IDBKeyRange.bound(lower, lower + '', false, false);
|
||||||
|
|
||||||
|
// Scanăm cu cursor pe index name (lowercase nu e direct în IDB —
|
||||||
|
// folosim open cursor pe tot și filtrăm client-side pentru robustețe)
|
||||||
|
const allReq = index.openCursor();
|
||||||
|
allReq.onsuccess = (e) => {
|
||||||
|
const cursor = e.target.result;
|
||||||
|
if (!cursor || results.length >= limit) {
|
||||||
|
resolve(results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = (cursor.value.name || '').toLowerCase();
|
||||||
|
if (name.startsWith(lower)) {
|
||||||
|
results.push(cursor.value);
|
||||||
|
}
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
allReq.onerror = () => reject(allReq.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Șterge un produs din catalog după ID.
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function catalogDelete(id) {
|
||||||
|
const db = await openCatalog();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction('products', 'readwrite');
|
||||||
|
const store = tx.objectStore('products');
|
||||||
|
const req = store.delete(id);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listează toate produsele (pentru management catalog).
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function catalogList() {
|
||||||
|
const db = await openCatalog();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction('products', 'readonly');
|
||||||
|
const store = tx.objectStore('products');
|
||||||
|
const req = store.getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result || []);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generează un UUID v4 simplu (crypto.randomUUID dacă disponibil, fallback manual). */
|
||||||
|
function _uuid() {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,71 +1,71 @@
|
|||||||
export class InvoiceFormatter {
|
// js/formatter.js
|
||||||
constructor() {
|
//
|
||||||
this.locale = navigator.language;
|
// Compatibility-layer formatter folosit de print template + script.js
|
||||||
|
// pentru afișare. Internal delegate la js/numeric.js (PR-E E1+E3+E4).
|
||||||
this.currencyFormatter = new Intl.NumberFormat(this.locale, {
|
//
|
||||||
minimumFractionDigits: 2,
|
// E2: locale hardcoded "ro-RO" (înlocuit `navigator.language`). Audiența
|
||||||
maximumFractionDigits: 2,
|
// țintă e RO; print PDF / display formular trebuie să fie consistent
|
||||||
useGrouping: true
|
// între browsere și OS-uri.
|
||||||
});
|
|
||||||
|
import { RO_LOCALE, parseStrict, parseStrictOr, format2, format3, format4 } from './numeric.js';
|
||||||
this.quantityFormatter = new Intl.NumberFormat(this.locale, {
|
|
||||||
minimumFractionDigits: 3,
|
export class InvoiceFormatter {
|
||||||
maximumFractionDigits: 3,
|
constructor() {
|
||||||
useGrouping: true
|
this.locale = RO_LOCALE;
|
||||||
});
|
|
||||||
|
this.currencyFormatter = new Intl.NumberFormat(this.locale, {
|
||||||
this.numberFormatter = new Intl.NumberFormat(this.locale, {
|
minimumFractionDigits: 2,
|
||||||
minimumFractionDigits: 4,
|
maximumFractionDigits: 2,
|
||||||
maximumFractionDigits: 4,
|
useGrouping: true
|
||||||
useGrouping: true
|
});
|
||||||
});
|
|
||||||
}
|
this.quantityFormatter = new Intl.NumberFormat(this.locale, {
|
||||||
|
minimumFractionDigits: 3,
|
||||||
formatCurrency(value) {
|
maximumFractionDigits: 3,
|
||||||
const numValue = parseFloat(value);
|
useGrouping: true
|
||||||
return isNaN(numValue) ? '0,00' : this.currencyFormatter.format(numValue);
|
});
|
||||||
}
|
|
||||||
|
this.numberFormatter = new Intl.NumberFormat(this.locale, {
|
||||||
formatQuantity(value) {
|
minimumFractionDigits: 4,
|
||||||
const numValue = parseFloat(value);
|
maximumFractionDigits: 4,
|
||||||
return isNaN(numValue) ? '0,000' : this.quantityFormatter.format(numValue);
|
useGrouping: true
|
||||||
}
|
});
|
||||||
|
}
|
||||||
formatNumber(value) {
|
|
||||||
const numValue = parseFloat(value);
|
formatCurrency(value) {
|
||||||
return isNaN(numValue) ? '0,0000' : this.numberFormatter.format(numValue);
|
const big = (value === '' || value === null || value === undefined)
|
||||||
}
|
? null
|
||||||
|
: parseStrict(value);
|
||||||
parseCurrency(value) {
|
return big === null ? '0,00' : format2(big);
|
||||||
if (typeof value !== 'string') {
|
}
|
||||||
value = value.toString();
|
|
||||||
}
|
formatQuantity(value) {
|
||||||
// Remove all non-digit characters except decimal and minus
|
const big = (value === '' || value === null || value === undefined)
|
||||||
const normalized = value.replace(/[^\d\-.,]/g, '')
|
? null
|
||||||
// Replace thousands separator
|
: parseStrict(value);
|
||||||
.replace(/[.,](?=.*[.,])/g, '')
|
return big === null ? '0,000' : format3(big);
|
||||||
// Last dot/comma is decimal separator
|
}
|
||||||
.replace(/[.,]/, '.');
|
|
||||||
return parseFloat(normalized) || 0;
|
formatNumber(value) {
|
||||||
}
|
const big = (value === '' || value === null || value === undefined)
|
||||||
|
? null
|
||||||
parseQuantity(value) {
|
: parseStrict(value);
|
||||||
if (typeof value !== 'string') {
|
return big === null ? '0,0000' : format4(big);
|
||||||
value = value.toString();
|
}
|
||||||
}
|
|
||||||
const normalized = value.replace(/[^\d\-.,]/g, '')
|
/**
|
||||||
.replace(/[.,](?=.*[.,])/g, '')
|
* Strict-but-pragmatic parsing → number (pentru consumatorii vechi).
|
||||||
.replace(/[.,]/, '.');
|
* Pentru cod nou, preferă `parseStrict` din numeric.js (returnează Big).
|
||||||
return parseFloat(normalized) || 0;
|
*/
|
||||||
}
|
parseCurrency(value) {
|
||||||
|
return Number(parseStrictOr(value, '0').toString());
|
||||||
parseNumber(value) {
|
}
|
||||||
if (typeof value !== 'string') {
|
|
||||||
value = value.toString();
|
parseQuantity(value) {
|
||||||
}
|
return Number(parseStrictOr(value, '0').toString());
|
||||||
const normalized = value.replace(/[^\d\-.,]/g, '')
|
}
|
||||||
.replace(/[.,](?=.*[.,])/g, '')
|
|
||||||
.replace(/[.,]/, '.');
|
parseNumber(value) {
|
||||||
return parseFloat(normalized) || 0;
|
return Number(parseStrictOr(value, '0').toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
273
efactura-generator/js/numeric.js
Normal file
273
efactura-generator/js/numeric.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// js/numeric.js
|
||||||
|
//
|
||||||
|
// Numeric pipeline canonică pentru editor eFactura (PR-E / Track 1).
|
||||||
|
//
|
||||||
|
// Trei reguli fundamentale:
|
||||||
|
//
|
||||||
|
// 1. `input.dataset.raw` este unica sursă de adevăr numerică (canonical
|
||||||
|
// decimal-dot string). `input.value` este display-only — locale
|
||||||
|
// "ro-RO" cu virgulă decimală.
|
||||||
|
//
|
||||||
|
// 2. Toate calculele folosesc Big.js (precizie arbitrară), niciodată
|
||||||
|
// Number. Rounding mode: HALF_UP (standard fiscal RO).
|
||||||
|
//
|
||||||
|
// 3. Parserul este strict-but-pragmatic: acceptă atât canonicul XML
|
||||||
|
// ("1234.56") cât și displayul RO ("1234,56" / "1.234,56"). Refuză
|
||||||
|
// formele EN ambigue ("1,234.56").
|
||||||
|
//
|
||||||
|
// Prefix module exports:
|
||||||
|
// - `Big` re-export pentru consumeri (single source of truth pentru
|
||||||
|
// pin-ul vendored).
|
||||||
|
// - `parseStrict(value)` → Big | null. null pentru NaN / empty / format
|
||||||
|
// ambiguu.
|
||||||
|
// - `parseStrictOr(value, fallback)` → Big. Fallback la "0" dacă invalid.
|
||||||
|
// - `format2`, `format3`, `format4` → string ro-RO display cu zecimale fix.
|
||||||
|
// - `formatRaw(big, decimals)` → string canonical decimal-dot pentru XML.
|
||||||
|
// - `setRaw(input, value)` → setează dataset.raw + input.value formatted.
|
||||||
|
// - `getRaw(input)` → Big citit din dataset.raw, fallback la parseStrict
|
||||||
|
// pe input.value.
|
||||||
|
// - `lineTotal(qty, price, discount, vatRate)` → { net, vat, gross } cu Big.
|
||||||
|
|
||||||
|
import Big from './vendor/big.mjs';
|
||||||
|
|
||||||
|
// HALF_UP = 1 în big.js. (HALF_EVEN = 2, HALF_DOWN = 3 — vezi big.mjs).
|
||||||
|
Big.RM = 1;
|
||||||
|
// Default decimal places pentru division (suficient pentru calcul intermediar).
|
||||||
|
Big.DP = 20;
|
||||||
|
|
||||||
|
export { Big };
|
||||||
|
|
||||||
|
// Locale hardcoded pentru proiectul RO. NU folosim navigator.language —
|
||||||
|
// vezi DESIGN.md / E2.
|
||||||
|
export const RO_LOCALE = 'ro-RO';
|
||||||
|
|
||||||
|
const _displayFmt = {
|
||||||
|
2: new Intl.NumberFormat(RO_LOCALE, { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true }),
|
||||||
|
3: new Intl.NumberFormat(RO_LOCALE, { minimumFractionDigits: 3, maximumFractionDigits: 3, useGrouping: true }),
|
||||||
|
4: new Intl.NumberFormat(RO_LOCALE, { minimumFractionDigits: 4, maximumFractionDigits: 4, useGrouping: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser strict-but-pragmatic.
|
||||||
|
*
|
||||||
|
* Acceptă:
|
||||||
|
* - canonical XML / număr cu zecimală pe punct: "1234.56", "0.001"
|
||||||
|
* - RO display cu zecimală pe virgulă: "1234,56", "1.234,56", "1.234.567,89"
|
||||||
|
* - integer: "0", "-12", " 42 "
|
||||||
|
* - Big sau Number: returnate direct (Number → Big via toString).
|
||||||
|
*
|
||||||
|
* Refuză (returnează null):
|
||||||
|
* - empty string / null / undefined
|
||||||
|
* - NaN (după ce s-a încercat normalizarea)
|
||||||
|
* - format EN cu thousands separator pe virgulă: "1,234.56" (ambiguu pentru RO)
|
||||||
|
* - alte caractere non-numerice: "abc", "1.2.3" cu mai multe puncte și fără virgulă
|
||||||
|
*
|
||||||
|
* @param {string|number|Big|null|undefined} value
|
||||||
|
* @returns {Big|null}
|
||||||
|
*/
|
||||||
|
export function parseStrict(value) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (value instanceof Big) return value;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
return new Big(value.toString());
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
|
||||||
|
let s = value.trim();
|
||||||
|
if (s === '') return null;
|
||||||
|
|
||||||
|
// Optional leading minus.
|
||||||
|
let sign = '';
|
||||||
|
if (s.startsWith('-')) { sign = '-'; s = s.slice(1); }
|
||||||
|
else if (s.startsWith('+')) { s = s.slice(1); }
|
||||||
|
if (s === '') return null;
|
||||||
|
|
||||||
|
const dotCount = (s.match(/\./g) || []).length;
|
||||||
|
const commaCount = (s.match(/,/g) || []).length;
|
||||||
|
|
||||||
|
let canonical;
|
||||||
|
if (commaCount === 0 && dotCount === 0) {
|
||||||
|
// integer
|
||||||
|
if (!/^\d+$/.test(s)) return null;
|
||||||
|
canonical = s;
|
||||||
|
} else if (commaCount === 0 && dotCount === 1) {
|
||||||
|
// canonical decimal-dot: "1234.56"
|
||||||
|
if (!/^\d+\.\d+$/.test(s)) return null;
|
||||||
|
canonical = s;
|
||||||
|
} else if (commaCount === 0 && dotCount > 1) {
|
||||||
|
// ambigu: "1.2.3" — refuz
|
||||||
|
return null;
|
||||||
|
} else if (commaCount === 1) {
|
||||||
|
// RO: virgula = decimală; punctele = thousands.
|
||||||
|
// Forma așteptată: cifre[.cifre[.cifre]]*,cifre+
|
||||||
|
if (!/^\d{1,3}(?:\.\d{3})*,\d+$/.test(s) && !/^\d+,\d+$/.test(s)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
canonical = s.replace(/\./g, '').replace(',', '.');
|
||||||
|
} else {
|
||||||
|
// commaCount > 1 — nu e RO valid. Refuz (ar putea fi EN "1,234,567.89"
|
||||||
|
// dar asta e ambiguu pentru audiența RO).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Big(sign + canonical);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variantă "or fallback" pentru cazurile unde un fallback la zero e
|
||||||
|
* acceptabil (display, sumare). NU folosi pentru validare.
|
||||||
|
*
|
||||||
|
* @param {*} value
|
||||||
|
* @param {string|number|Big} fallback
|
||||||
|
* @returns {Big}
|
||||||
|
*/
|
||||||
|
export function parseStrictOr(value, fallback = '0') {
|
||||||
|
const parsed = parseStrict(value);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
if (fallback instanceof Big) return fallback;
|
||||||
|
return new Big(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format Big → string display ro-RO cu N zecimale fixe. */
|
||||||
|
function _format(value, decimals) {
|
||||||
|
const big = (value instanceof Big) ? value : parseStrictOr(value);
|
||||||
|
const fmt = _displayFmt[decimals] || _displayFmt[2];
|
||||||
|
// Big.toFixed(decimals) → canonical decimal-dot. Convert la Number
|
||||||
|
// doar pentru Intl format (number passes through cu precizie suficientă
|
||||||
|
// pentru valori fiscale practice).
|
||||||
|
return fmt.format(Number(big.toFixed(decimals)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function format2(value) { return _format(value, 2); }
|
||||||
|
export function format3(value) { return _format(value, 3); }
|
||||||
|
export function format4(value) { return _format(value, 4); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format pentru ieșirea XML: canonical decimal-dot, fix N zecimale,
|
||||||
|
* fără thousands separator. Folosit la serializare UBL.
|
||||||
|
*
|
||||||
|
* @param {*} value
|
||||||
|
* @param {number} decimals
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatRaw(value, decimals = 2) {
|
||||||
|
const big = (value instanceof Big) ? value : parseStrictOr(value);
|
||||||
|
return big.toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setează valoarea unui input numeric:
|
||||||
|
* - `dataset.raw` ← canonical decimal-dot (sursa de adevăr)
|
||||||
|
* - `input.value` ← display ro-RO cu N zecimale
|
||||||
|
*
|
||||||
|
* Folosit la populare din XML și la commit-ul user-editat (post-blur).
|
||||||
|
*
|
||||||
|
* @param {HTMLInputElement} input
|
||||||
|
* @param {*} value Big | string | number
|
||||||
|
* @param {number} decimals decimale display (2 = currency, 3 = qty, 4 = price)
|
||||||
|
*/
|
||||||
|
export function setRaw(input, value, decimals = 2) {
|
||||||
|
const big = (value instanceof Big) ? value : parseStrictOr(value);
|
||||||
|
input.dataset.raw = big.toFixed(decimals);
|
||||||
|
// type="number" acceptă doar punct decimal; type="text" primește display ro-RO
|
||||||
|
input.value = (input.type === 'number') ? big.toFixed(decimals) : _format(big, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Citește valoarea numerică canonică a unui input.
|
||||||
|
* - Preferă `dataset.raw` (set de noi pe populate / blur).
|
||||||
|
* - Fallback la `parseStrict(input.value)` dacă raw absent.
|
||||||
|
* - Fallback final la Big("0").
|
||||||
|
*
|
||||||
|
* @param {HTMLInputElement} input
|
||||||
|
* @returns {Big}
|
||||||
|
*/
|
||||||
|
export function getRaw(input) {
|
||||||
|
if (!input) return new Big('0');
|
||||||
|
if (input.dataset && input.dataset.raw !== undefined && input.dataset.raw !== '') {
|
||||||
|
const parsed = parseStrict(input.dataset.raw);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
return parseStrictOr(input.value, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marchează un input ca dirty (editat de user). PR-A11 va folosi acest
|
||||||
|
* flag pentru tolerance switching (zero pe row dirty, ±0.01 RON pe row
|
||||||
|
* loaded).
|
||||||
|
*/
|
||||||
|
export function markDirty(input) {
|
||||||
|
if (input && input.dataset) input.dataset.dirty = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atașează handler-ul de blur care:
|
||||||
|
* 1. parseStrict pe input.value
|
||||||
|
* 2. setRaw cu valoarea normalizată (sau lasă raw existent dacă parse eșuează
|
||||||
|
* și marchează vizual ca invalid).
|
||||||
|
* 3. markDirty.
|
||||||
|
*
|
||||||
|
* @param {HTMLInputElement} input
|
||||||
|
* @param {number} decimals
|
||||||
|
*/
|
||||||
|
export function wireDatasetRaw(input, decimals = 2) {
|
||||||
|
if (!input || input.dataset.rawWired === '1') return;
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
const parsed = parseStrict(input.value);
|
||||||
|
if (parsed === null && input.value.trim() !== '') {
|
||||||
|
input.classList.add('invalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.classList.remove('invalid');
|
||||||
|
if (parsed !== null) {
|
||||||
|
setRaw(input, parsed, decimals);
|
||||||
|
markDirty(input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// La input change, marchează dirty (dar nu reformatează — lasă user să tasteze).
|
||||||
|
input.addEventListener('input', () => markDirty(input));
|
||||||
|
input.dataset.rawWired = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculează totalul pe linia de factură.
|
||||||
|
*
|
||||||
|
* net = (qty * price) - lineDiscount
|
||||||
|
* vat = round2(net * vatRate / 100)
|
||||||
|
* gross = net + vat
|
||||||
|
*
|
||||||
|
* @param {*} qty
|
||||||
|
* @param {*} price
|
||||||
|
* @param {*} discount
|
||||||
|
* @param {*} vatRate procent (ex. 19 pentru 19%)
|
||||||
|
* @returns {{net: Big, vat: Big, gross: Big}}
|
||||||
|
*/
|
||||||
|
export function lineTotal(qty, price, discount, vatRate) {
|
||||||
|
const q = parseStrictOr(qty, '0');
|
||||||
|
const p = parseStrictOr(price, '0');
|
||||||
|
const d = parseStrictOr(discount, '0');
|
||||||
|
const r = parseStrictOr(vatRate, '0');
|
||||||
|
|
||||||
|
const gross = q.times(p);
|
||||||
|
const net = gross.minus(d);
|
||||||
|
const vat = net.times(r).div(100).round(2, 1); // HALF_UP
|
||||||
|
const total = net.plus(vat);
|
||||||
|
|
||||||
|
return { net, vat, gross: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: a.eq(b) cu toleranță. Returnează true dacă |a - b| ≤ epsilon.
|
||||||
|
* Pentru A11 reconciliation legacy: ±0.01 RON.
|
||||||
|
*/
|
||||||
|
export function withinTolerance(a, b, epsilon) {
|
||||||
|
const aB = (a instanceof Big) ? a : parseStrictOr(a);
|
||||||
|
const bB = (b instanceof Big) ? b : parseStrictOr(b);
|
||||||
|
const eB = (epsilon instanceof Big) ? epsilon : parseStrictOr(epsilon);
|
||||||
|
return aB.minus(bB).abs().lte(eB);
|
||||||
|
}
|
||||||
@@ -1,251 +1,251 @@
|
|||||||
import { InvoiceFormatter } from './formatter.js';
|
import { InvoiceFormatter } from './formatter.js';
|
||||||
|
|
||||||
export class InvoicePrintHandler {
|
export class InvoicePrintHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.printWindow = null;
|
this.printWindow = null;
|
||||||
this.formatter = new InvoiceFormatter();
|
this.formatter = new InvoiceFormatter();
|
||||||
this.templates = {
|
this.templates = {
|
||||||
standard: './templates/print.html',
|
standard: './templates/print.html',
|
||||||
compact: './templates/print-compact.html'
|
compact: './templates/print-compact.html'
|
||||||
};
|
};
|
||||||
this.currentTemplate = 'standard';
|
this.currentTemplate = 'standard';
|
||||||
}
|
}
|
||||||
|
|
||||||
setTemplate(templateName) {
|
setTemplate(templateName) {
|
||||||
if (this.templates[templateName]) {
|
if (this.templates[templateName]) {
|
||||||
this.currentTemplate = templateName;
|
this.currentTemplate = templateName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
collectInvoiceData() {
|
collectInvoiceData() {
|
||||||
return {
|
return {
|
||||||
// Basic details
|
// Basic details
|
||||||
invoiceNumber: document.querySelector('[name="invoiceNumber"]').value,
|
invoiceNumber: document.querySelector('[name="invoiceNumber"]').value,
|
||||||
issueDate: document.querySelector('[name="issueDate"]').value,
|
issueDate: document.querySelector('[name="issueDate"]').value,
|
||||||
dueDate: document.querySelector('[name="dueDate"]').value,
|
dueDate: document.querySelector('[name="dueDate"]').value,
|
||||||
documentCurrencyCode: document.querySelector('[name="documentCurrencyCode"]').value.toUpperCase() || 'RON',
|
documentCurrencyCode: document.querySelector('[name="documentCurrencyCode"]').value.toUpperCase() || 'RON',
|
||||||
taxCurrencyCode: document.querySelector('[name="taxCurrencyCode"]').value.toUpperCase(),
|
taxCurrencyCode: document.querySelector('[name="taxCurrencyCode"]').value.toUpperCase(),
|
||||||
exchangeRate: parseFloat(document.querySelector('[name="exchangeRate"]')?.value || 1),
|
exchangeRate: parseFloat(document.querySelector('[name="exchangeRate"]')?.value || 1),
|
||||||
|
|
||||||
// Supplier details
|
// Supplier details
|
||||||
supplier: {
|
supplier: {
|
||||||
name: document.querySelector('[name="supplierName"]').value,
|
name: document.querySelector('[name="supplierName"]').value,
|
||||||
vat: document.querySelector('[name="supplierVAT"]').value,
|
vat: document.querySelector('[name="supplierVAT"]').value,
|
||||||
companyId: document.querySelector('[name="supplierCompanyId"]').value,
|
companyId: document.querySelector('[name="supplierCompanyId"]').value,
|
||||||
address: document.querySelector('[name="supplierAddress"]').value,
|
address: document.querySelector('[name="supplierAddress"]').value,
|
||||||
city: document.querySelector('[name="supplierCity"]').value,
|
city: document.querySelector('[name="supplierCity"]').value,
|
||||||
county: document.querySelector('[name="supplierCountrySubentity"]').value,
|
county: document.querySelector('[name="supplierCountrySubentity"]').value,
|
||||||
country: document.querySelector('[name="supplierCountry"]').value,
|
country: document.querySelector('[name="supplierCountry"]').value,
|
||||||
phone: document.querySelector('[name="supplierPhone"]').value,
|
phone: document.querySelector('[name="supplierPhone"]').value,
|
||||||
contactName: document.querySelector('[name="supplierContactName"]').value,
|
contactName: document.querySelector('[name="supplierContactName"]').value,
|
||||||
email: document.querySelector('[name="supplierEmail"]').value
|
email: document.querySelector('[name="supplierEmail"]').value
|
||||||
},
|
},
|
||||||
|
|
||||||
// Customer details
|
// Customer details
|
||||||
customer: {
|
customer: {
|
||||||
name: document.querySelector('[name="customerName"]').value,
|
name: document.querySelector('[name="customerName"]').value,
|
||||||
vat: document.querySelector('[name="customerVAT"]').value,
|
vat: document.querySelector('[name="customerVAT"]').value,
|
||||||
companyId: document.querySelector('[name="customerCompanyId"]').value,
|
companyId: document.querySelector('[name="customerCompanyId"]').value,
|
||||||
address: document.querySelector('[name="customerAddress"]').value,
|
address: document.querySelector('[name="customerAddress"]').value,
|
||||||
city: document.querySelector('[name="customerCity"]').value,
|
city: document.querySelector('[name="customerCity"]').value,
|
||||||
county: document.querySelector('[name="customerCountrySubentity"]').value,
|
county: document.querySelector('[name="customerCountrySubentity"]').value,
|
||||||
country: document.querySelector('[name="customerCountry"]').value,
|
country: document.querySelector('[name="customerCountry"]').value,
|
||||||
phone: document.querySelector('[name="customerPhone"]').value,
|
phone: document.querySelector('[name="customerPhone"]').value,
|
||||||
contactName: document.querySelector('[name="customerContactName"]').value,
|
contactName: document.querySelector('[name="customerContactName"]').value,
|
||||||
email: document.querySelector('[name="customerEmail"]').value
|
email: document.querySelector('[name="customerEmail"]').value
|
||||||
},
|
},
|
||||||
|
|
||||||
// Line items with formatted values
|
// Line items with formatted values
|
||||||
items: Array.from(document.querySelectorAll('.line-item')).map((item, index) => ({
|
items: Array.from(document.querySelectorAll('.line-item')).map((item, index) => ({
|
||||||
number: index + 1,
|
number: index + 1,
|
||||||
description: item.querySelector('[name^="description"]').value,
|
description: item.querySelector('[name^="description"]').value,
|
||||||
quantity: this.formatter.formatQuantity(item.querySelector('[name^="quantity"]').value),
|
quantity: this.formatter.formatQuantity(item.querySelector('[name^="quantity"]').value),
|
||||||
unit: item.querySelector('[name^="unit"]').value,
|
unit: item.querySelector('[name^="unit"]').value,
|
||||||
price: this.formatter.formatCurrency(item.querySelector('[name^="price"]').value),
|
price: this.formatter.formatCurrency(item.querySelector('[name^="price"]').value),
|
||||||
vatRate: this.formatter.formatCurrency(item.querySelector('[name^="vatRate"]').value),
|
vatRate: this.formatter.formatCurrency(item.querySelector('[name^="vatRate"]').value),
|
||||||
totalAmount: this.formatter.formatCurrency(
|
totalAmount: this.formatter.formatCurrency(
|
||||||
this.formatter.parseQuantity(item.querySelector('[name^="quantity"]').value) *
|
this.formatter.parseQuantity(item.querySelector('[name^="quantity"]').value) *
|
||||||
this.formatter.parseCurrency(item.querySelector('[name^="price"]').value)
|
this.formatter.parseCurrency(item.querySelector('[name^="price"]').value)
|
||||||
)
|
)
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// Note
|
// Note
|
||||||
note: document.querySelector('[name="invoiceNote"]')?.value,
|
note: document.querySelector('[name="invoiceNote"]')?.value,
|
||||||
|
|
||||||
// Get totals directly from the display elements
|
// Get totals directly from the display elements
|
||||||
totals: {
|
totals: {
|
||||||
subtotal: document.getElementById('subtotal').textContent,
|
subtotal: document.getElementById('subtotal').textContent,
|
||||||
allowances: document.getElementById('totalAllowances').textContent,
|
allowances: document.getElementById('totalAllowances').textContent,
|
||||||
charges: document.getElementById('totalCharges').textContent,
|
charges: document.getElementById('totalCharges').textContent,
|
||||||
netAmount: document.getElementById('netAmount').textContent,
|
netAmount: document.getElementById('netAmount').textContent,
|
||||||
vat: document.getElementById('vat').textContent,
|
vat: document.getElementById('vat').textContent,
|
||||||
total: document.getElementById('total').textContent
|
total: document.getElementById('total').textContent
|
||||||
},
|
},
|
||||||
|
|
||||||
// VAT Breakdown
|
// VAT Breakdown
|
||||||
vatBreakdown: Array.from(document.querySelectorAll('.vat-row')).map(row => ({
|
vatBreakdown: Array.from(document.querySelectorAll('.vat-row')).map(row => ({
|
||||||
type: row.querySelector('.vat-type').value,
|
type: row.querySelector('.vat-type').value,
|
||||||
rate: row.querySelector('.vat-rate').value,
|
rate: row.querySelector('.vat-rate').value,
|
||||||
base: row.querySelector('.vat-base').value,
|
base: row.querySelector('.vat-base').value,
|
||||||
amount: row.querySelector('.vat-amount').value
|
amount: row.querySelector('.vat-amount').value
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
createPartyHTML(party) {
|
createPartyHTML(party) {
|
||||||
return `
|
return `
|
||||||
<p><strong>${party.name}</strong></p>
|
<p><strong>${party.name}</strong></p>
|
||||||
<p>CUI: ${party.vat}</p>
|
<p>CUI: ${party.vat}</p>
|
||||||
<p>Nr. Reg. Com.: ${party.companyId}</p>
|
<p>Nr. Reg. Com.: ${party.companyId}</p>
|
||||||
<p>${party.address}</p>
|
<p>${party.address}</p>
|
||||||
<p>${party.city}${party.county ? ', ' + party.county : ''}</p>
|
<p>${party.city}${party.county ? ', ' + party.county : ''}</p>
|
||||||
<p>${party.country}</p>
|
<p>${party.country}</p>
|
||||||
${party.phone ? `<p>Tel: ${party.phone}</p>` : ''}
|
${party.phone ? `<p>Tel: ${party.phone}</p>` : ''}
|
||||||
${party.contactName ? `<p>Contact: ${party.contactName}</p>` : ''}
|
${party.contactName ? `<p>Contact: ${party.contactName}</p>` : ''}
|
||||||
${party.email ? `<p>Email: ${party.email}</p>` : ''}
|
${party.email ? `<p>Email: ${party.email}</p>` : ''}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVATTypeLabel(type) {
|
getVATTypeLabel(type) {
|
||||||
const labels = {
|
const labels = {
|
||||||
'S': 'Standard',
|
'S': 'Standard',
|
||||||
'AE': 'Taxare Inversă',
|
'AE': 'Taxare Inversă',
|
||||||
'O': 'Neplătitor TVA',
|
'O': 'Neplătitor TVA',
|
||||||
'Z': 'Cotă 0%',
|
'Z': 'Cotă 0%',
|
||||||
'E': 'Scutit'
|
'E': 'Scutit'
|
||||||
};
|
};
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
}
|
}
|
||||||
|
|
||||||
async print() {
|
async print() {
|
||||||
try {
|
try {
|
||||||
// Collect all the data
|
// Collect all the data
|
||||||
const invoiceData = this.collectInvoiceData();
|
const invoiceData = this.collectInvoiceData();
|
||||||
|
|
||||||
// Open new window and load the selected print template
|
// Open new window and load the selected print template
|
||||||
this.printWindow = window.open(
|
this.printWindow = window.open(
|
||||||
this.templates[this.currentTemplate],
|
this.templates[this.currentTemplate],
|
||||||
'_blank',
|
'_blank',
|
||||||
'width=800,height=600'
|
'width=800,height=600'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for the window to load
|
// Wait for the window to load
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
this.printWindow.onload = resolve;
|
this.printWindow.onload = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate QR code
|
// Generate QR code
|
||||||
const qrData = {
|
const qrData = {
|
||||||
invoiceNumber: invoiceData.invoiceNumber,
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
issueDate: invoiceData.issueDate,
|
issueDate: invoiceData.issueDate,
|
||||||
supplier: invoiceData.supplier.name,
|
supplier: invoiceData.supplier.name,
|
||||||
customer: invoiceData.customer.name,
|
customer: invoiceData.customer.name,
|
||||||
total: this.formatter.parseCurrency(invoiceData.totals.total)
|
total: this.formatter.parseCurrency(invoiceData.totals.total)
|
||||||
};
|
};
|
||||||
|
|
||||||
const qrElement = this.printWindow.document.getElementById('qrcode');
|
const qrElement = this.printWindow.document.getElementById('qrcode');
|
||||||
if (qrElement) {
|
if (qrElement) {
|
||||||
new this.printWindow.QRCode(qrElement, {
|
new this.printWindow.QRCode(qrElement, {
|
||||||
text: JSON.stringify(qrData),
|
text: JSON.stringify(qrData),
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
colorDark: "#2563eb",
|
colorDark: "#2563eb",
|
||||||
colorLight: "#ffffff",
|
colorLight: "#ffffff",
|
||||||
correctLevel: this.printWindow.QRCode.CorrectLevel.L
|
correctLevel: this.printWindow.QRCode.CorrectLevel.L
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate the template with data
|
// Populate the template with data
|
||||||
this.populatePrintWindow(invoiceData);
|
this.populatePrintWindow(invoiceData);
|
||||||
|
|
||||||
// Print the window
|
// Print the window
|
||||||
this.printWindow.print();
|
this.printWindow.print();
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
this.printWindow.onafterprint = () => {
|
this.printWindow.onafterprint = () => {
|
||||||
this.printWindow.close();
|
this.printWindow.close();
|
||||||
this.printWindow = null;
|
this.printWindow = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Print failed:', error);
|
console.error('Print failed:', error);
|
||||||
if (this.printWindow) {
|
if (this.printWindow) {
|
||||||
this.printWindow.close();
|
this.printWindow.close();
|
||||||
this.printWindow = null;
|
this.printWindow = null;
|
||||||
}
|
}
|
||||||
alert('A apărut o eroare la printare. Vă rugăm să încercați din nou.');
|
alert('A apărut o eroare la printare. Vă rugăm să încercați din nou.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
populatePrintWindow(data) {
|
populatePrintWindow(data) {
|
||||||
if (!this.printWindow) return;
|
if (!this.printWindow) return;
|
||||||
|
|
||||||
const doc = this.printWindow.document;
|
const doc = this.printWindow.document;
|
||||||
|
|
||||||
// Basic details
|
// Basic details
|
||||||
doc.getElementById('print-invoice-number').textContent = data.invoiceNumber;
|
doc.getElementById('print-invoice-number').textContent = data.invoiceNumber;
|
||||||
doc.getElementById('print-issue-date').textContent = data.issueDate;
|
doc.getElementById('print-issue-date').textContent = data.issueDate;
|
||||||
doc.getElementById('print-due-date').textContent = data.dueDate;
|
doc.getElementById('print-due-date').textContent = data.dueDate;
|
||||||
doc.getElementById('print-document-currency').textContent = data.documentCurrencyCode;
|
doc.getElementById('print-document-currency').textContent = data.documentCurrencyCode;
|
||||||
|
|
||||||
// Currency information
|
// Currency information
|
||||||
const taxCurrencyContainer = doc.getElementById('print-tax-currency-container');
|
const taxCurrencyContainer = doc.getElementById('print-tax-currency-container');
|
||||||
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
||||||
taxCurrencyContainer.style.display = 'block';
|
taxCurrencyContainer.style.display = 'block';
|
||||||
doc.getElementById('print-tax-currency').textContent = data.taxCurrencyCode;
|
doc.getElementById('print-tax-currency').textContent = data.taxCurrencyCode;
|
||||||
doc.getElementById('print-exchange-rate').textContent = this.formatter.formatNumber(data.exchangeRate);
|
doc.getElementById('print-exchange-rate').textContent = this.formatter.formatNumber(data.exchangeRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Party details
|
// Party details
|
||||||
doc.getElementById('print-supplier-details').innerHTML = this.createPartyHTML(data.supplier);
|
doc.getElementById('print-supplier-details').innerHTML = this.createPartyHTML(data.supplier);
|
||||||
doc.getElementById('print-customer-details').innerHTML = this.createPartyHTML(data.customer);
|
doc.getElementById('print-customer-details').innerHTML = this.createPartyHTML(data.customer);
|
||||||
|
|
||||||
// Note
|
// Note
|
||||||
if (data.note) {
|
if (data.note) {
|
||||||
const noteSection = doc.getElementById('print-note');
|
const noteSection = doc.getElementById('print-note');
|
||||||
noteSection.style.display = 'block';
|
noteSection.style.display = 'block';
|
||||||
noteSection.querySelector('div').textContent = data.note;
|
noteSection.querySelector('div').textContent = data.note;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Line items - use formatted values from data
|
// Line items - use formatted values from data
|
||||||
doc.getElementById('print-items').innerHTML = data.items.map(item => `
|
doc.getElementById('print-items').innerHTML = data.items.map(item => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${item.number}</td>
|
<td>${item.number}</td>
|
||||||
<td>${item.description}</td>
|
<td>${item.description}</td>
|
||||||
<td>${item.unit}</td>
|
<td>${item.unit}</td>
|
||||||
<td class="number-cell">${item.quantity}</td>
|
<td class="number-cell">${item.quantity}</td>
|
||||||
<td class="number-cell">${item.price}</td>
|
<td class="number-cell">${item.price}</td>
|
||||||
<td class="number-cell">${item.vatRate}%</td>
|
<td class="number-cell">${item.vatRate}%</td>
|
||||||
<td class="number-cell">${item.totalAmount}</td>
|
<td class="number-cell">${item.totalAmount}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Totals - use values directly from display
|
// Totals - use values directly from display
|
||||||
doc.getElementById('print-subtotal').textContent = data.totals.subtotal;
|
doc.getElementById('print-subtotal').textContent = data.totals.subtotal;
|
||||||
doc.getElementById('print-allowances').textContent = data.totals.allowances;
|
doc.getElementById('print-allowances').textContent = data.totals.allowances;
|
||||||
doc.getElementById('print-charges').textContent = data.totals.charges;
|
doc.getElementById('print-charges').textContent = data.totals.charges;
|
||||||
doc.getElementById('print-net-amount').textContent = data.totals.netAmount;
|
doc.getElementById('print-net-amount').textContent = data.totals.netAmount;
|
||||||
doc.getElementById('print-total').textContent = data.totals.total;
|
doc.getElementById('print-total').textContent = data.totals.total;
|
||||||
|
|
||||||
// VAT Breakdown - use values directly from display
|
// VAT Breakdown - use values directly from display
|
||||||
doc.getElementById('print-vat-breakdown').innerHTML = data.vatBreakdown.map(vat => `
|
doc.getElementById('print-vat-breakdown').innerHTML = data.vatBreakdown.map(vat => `
|
||||||
<div>${this.getVATTypeLabel(vat.type)}</div>
|
<div>${this.getVATTypeLabel(vat.type)}</div>
|
||||||
<div>${vat.rate}%</div>
|
<div>${vat.rate}%</div>
|
||||||
<div>${vat.base}</div>
|
<div>${vat.base}</div>
|
||||||
<div>${vat.amount}</div>
|
<div>${vat.amount}</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// VAT totals
|
// VAT totals
|
||||||
doc.getElementById('print-vat-currency-main').textContent = data.documentCurrencyCode;
|
doc.getElementById('print-vat-currency-main').textContent = data.documentCurrencyCode;
|
||||||
doc.getElementById('print-vat-main').textContent = data.totals.vat;
|
doc.getElementById('print-vat-main').textContent = data.totals.vat;
|
||||||
|
|
||||||
const secondaryVatRow = doc.getElementById('print-vat-secondary');
|
const secondaryVatRow = doc.getElementById('print-vat-secondary');
|
||||||
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
||||||
secondaryVatRow.style.display = 'flex';
|
secondaryVatRow.style.display = 'flex';
|
||||||
doc.getElementById('print-vat-currency-secondary').textContent = data.taxCurrencyCode;
|
doc.getElementById('print-vat-currency-secondary').textContent = data.taxCurrencyCode;
|
||||||
const vatInTaxCurrency = this.formatter.parseCurrency(data.totals.vat) * data.exchangeRate;
|
const vatInTaxCurrency = this.formatter.parseCurrency(data.totals.vat) * data.exchangeRate;
|
||||||
doc.getElementById('print-vat-secondary-amount').textContent =
|
doc.getElementById('print-vat-secondary-amount').textContent =
|
||||||
this.formatter.formatCurrency(vatInTaxCurrency);
|
this.formatter.formatCurrency(vatInTaxCurrency);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +1,43 @@
|
|||||||
// server.js
|
// server.js
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
const MIME_TYPES = {
|
const MIME_TYPES = {
|
||||||
'.html': 'text/html',
|
'.html': 'text/html',
|
||||||
'.css': 'text/css',
|
'.css': 'text/css',
|
||||||
'.js': 'text/javascript',
|
'.js': 'text/javascript',
|
||||||
'.json': 'application/json',
|
'.mjs': 'text/javascript',
|
||||||
'.xml': 'application/xml'
|
'.json': 'application/json',
|
||||||
};
|
'.xml': 'application/xml'
|
||||||
|
};
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
// Convert URL to file path, using index.html for root
|
const server = http.createServer((req, res) => {
|
||||||
let filePath = req.url === '/' ? './index.html' : '.' + req.url;
|
// Convert URL to file path, using index.html for root
|
||||||
|
let filePath = req.url === '/' ? './index.html' : '.' + req.url;
|
||||||
// Get file extension for MIME type
|
|
||||||
const ext = path.extname(filePath);
|
// Get file extension for MIME type
|
||||||
const contentType = MIME_TYPES[ext] || 'text/plain';
|
const ext = path.extname(filePath);
|
||||||
|
const contentType = MIME_TYPES[ext] || 'text/plain';
|
||||||
// Read and serve the file
|
|
||||||
fs.readFile(filePath, (err, content) => {
|
// Read and serve the file
|
||||||
if (err) {
|
fs.readFile(filePath, (err, content) => {
|
||||||
if (err.code === 'ENOENT') {
|
if (err) {
|
||||||
res.writeHead(404);
|
if (err.code === 'ENOENT') {
|
||||||
res.end('File not found');
|
res.writeHead(404);
|
||||||
} else {
|
res.end('File not found');
|
||||||
res.writeHead(500);
|
} else {
|
||||||
res.end('Server error: ' + err.code);
|
res.writeHead(500);
|
||||||
}
|
res.end('Server error: ' + err.code);
|
||||||
} else {
|
}
|
||||||
res.writeHead(200, { 'Content-Type': contentType });
|
} else {
|
||||||
res.end(content);
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
}
|
res.end(content);
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`Server running at http://localhost:${PORT}/`);
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running at http://localhost:${PORT}/`);
|
||||||
});
|
});
|
||||||
185
efactura-generator/js/storage.js
Normal file
185
efactura-generator/js/storage.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// js/storage.js
|
||||||
|
//
|
||||||
|
// Helpers de stocare pentru efactura-generator (PR-PROFIL / A12+A13).
|
||||||
|
//
|
||||||
|
// Reguli:
|
||||||
|
// 1. Toate cheile localStorage/sessionStorage încep cu "efactura." —
|
||||||
|
// enforced la setter; getJSON acceptă orice cheie pentru compatibilitate
|
||||||
|
// retroactivă, dar setJSON/cacheSet aruncă dacă prefixul lipsește.
|
||||||
|
// 2. Quota errors localStorage → toast vizibil "spațiu local plin".
|
||||||
|
// 3. Cheile convenționale: efactura.{tip}.v1
|
||||||
|
// Ex: efactura.profil.v1, efactura.catalog.v1, efactura.session.v1
|
||||||
|
//
|
||||||
|
// Exports:
|
||||||
|
// getJSON(key, default) → valoare parsată sau default
|
||||||
|
// setJSON(key, value) → salvează; toast error dacă QuotaExceeded
|
||||||
|
// cacheGet(key) → sessionStorage (ephemer, null dacă absent)
|
||||||
|
// cacheSet(key, value) → sessionStorage (silențios dacă eșuează)
|
||||||
|
// openCatalog() → Promise<IDBDatabase> pentru catalog produse (A13)
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'efactura.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validează că cheia respectă prefixul obligatoriu.
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
function _enforcePrefix(key) {
|
||||||
|
if (typeof key !== 'string' || !key.startsWith(KEY_PREFIX)) {
|
||||||
|
throw new Error(
|
||||||
|
`storage.js: cheia "${key}" trebuie să înceapă cu "${KEY_PREFIX}". ` +
|
||||||
|
`Convenție: efactura.{tip}.v1`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Afișează un toast (dacă window.showToast e disponibil) sau loghează.
|
||||||
|
* @param {string} msg
|
||||||
|
* @param {string} variant 'error'|'warning'|'info'|'success'
|
||||||
|
*/
|
||||||
|
function _toast(msg, variant = 'error') {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.showToast === 'function') {
|
||||||
|
window.showToast(msg, variant);
|
||||||
|
} else {
|
||||||
|
console.warn('[storage]', msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Citește o valoare JSON din localStorage.
|
||||||
|
* Returnează `defaultValue` dacă cheia lipsește sau JSON e invalid.
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
* @param {*} defaultValue
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
export function getJSON(key, defaultValue = null) {
|
||||||
|
_enforcePrefix(key);
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw === null) return defaultValue;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrie o valoare JSON în localStorage.
|
||||||
|
* La QuotaExceededError → toast "spațiu local plin".
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
* @param {*} value
|
||||||
|
*/
|
||||||
|
export function setJSON(key, value) {
|
||||||
|
_enforcePrefix(key);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (err) {
|
||||||
|
// QuotaExceededError: code 22 (Firefox/Chrome), 1014 (Firefox NS), sau name check.
|
||||||
|
const isQuota = err && (
|
||||||
|
err.name === 'QuotaExceededError' ||
|
||||||
|
err.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
|
||||||
|
err.code === 22 ||
|
||||||
|
err.code === 1014
|
||||||
|
);
|
||||||
|
if (isQuota) {
|
||||||
|
_toast(
|
||||||
|
'Spațiu local plin — datele nu au putut fi salvate.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_toast(`Eroare la salvare locală: ${err && err.message ? err.message : err}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Citește din sessionStorage (cache ephemer, valabil doar pe durata sesiunii).
|
||||||
|
* Returnează null dacă absent sau invalid.
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {*|null}
|
||||||
|
*/
|
||||||
|
export function cacheGet(key) {
|
||||||
|
_enforcePrefix(key);
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(key);
|
||||||
|
if (raw === null) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrie în sessionStorage. Erorile sunt ignorate silențios (storage e
|
||||||
|
* ephemer și poate fi blocat de browser în incognito / iframe sandboxed).
|
||||||
|
*
|
||||||
|
* @param {string} key
|
||||||
|
* @param {*} value
|
||||||
|
*/
|
||||||
|
export function cacheSet(key, value) {
|
||||||
|
_enforcePrefix(key);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (_) {
|
||||||
|
// Ignorat: sessionStorage e ephemer, erorile nu sunt critice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexedDB pentru catalog produse (A13 lazy init).
|
||||||
|
let _catalogDb = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deschide (sau returnează instanța cached a) bazei de date IndexedDB
|
||||||
|
* `efactura` v1. Crează object store `products` la prima rulare.
|
||||||
|
*
|
||||||
|
* Schema v1 (lock per eng review 14A):
|
||||||
|
* - DB name: `efactura`
|
||||||
|
* - store: `products`, keyPath: `id` (uuid v4 generat de caller)
|
||||||
|
* - indexes: `name`, `sellerItemID`, `cpvCode`
|
||||||
|
*
|
||||||
|
* Dacă IndexedDB lipsește (private browsing), Promise rejectează cu Error
|
||||||
|
* `indexeddb-unavailable` — caller-ul trebuie să degradeze la "feature
|
||||||
|
* disabled" cu toast (NU să crash-eze).
|
||||||
|
*
|
||||||
|
* @returns {Promise<IDBDatabase>}
|
||||||
|
*/
|
||||||
|
export function openCatalog() {
|
||||||
|
if (_catalogDb) return Promise.resolve(_catalogDb);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
reject(new Error('indexeddb-unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const req = indexedDB.open('efactura', 1);
|
||||||
|
|
||||||
|
req.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains('products')) {
|
||||||
|
const store = db.createObjectStore('products', { keyPath: 'id' });
|
||||||
|
store.createIndex('name', 'name', { unique: false });
|
||||||
|
store.createIndex('sellerItemID', 'sellerItemID', { unique: false });
|
||||||
|
store.createIndex('cpvCode', 'cpvCode', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
req.onsuccess = (event) => {
|
||||||
|
_catalogDb = event.target.result;
|
||||||
|
resolve(_catalogDb);
|
||||||
|
};
|
||||||
|
|
||||||
|
req.onerror = (event) => {
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
req.onblocked = () => {
|
||||||
|
reject(new Error('indexeddb-blocked'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export prefix pentru tests / consumeri care vor să verifice convenția.
|
||||||
|
export const STORAGE_PREFIX = KEY_PREFIX;
|
||||||
481
efactura-generator/js/validation/br-ro.js
Normal file
481
efactura-generator/js/validation/br-ro.js
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
/**
|
||||||
|
* js/validation/br-ro.js — PR-BR (A2)
|
||||||
|
* Top 30 reguli BR din CIUS-RO Schematron + EN 16931-1.
|
||||||
|
* Selecție: severity fatal/error din Schematron + reguli care vizează
|
||||||
|
* câmpuri editabile (CIF, date, totale, coduri TVA, articole factură).
|
||||||
|
*
|
||||||
|
* Fiecare regulă:
|
||||||
|
* { code, severity ('fatal'|'error'|'warning'), message, fieldRef, check(invoiceData) }
|
||||||
|
*
|
||||||
|
* invoiceData = obiect snapshot din colectInvoiceDataForBR() în script.js.
|
||||||
|
* Toate funcțiile sunt pure — fără acces DOM, fără efecte secundare.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { validateCIF } from './cif.js';
|
||||||
|
import { validateIBAN } from './iban.js';
|
||||||
|
|
||||||
|
// Coduri TVA valide per CIUS-RO
|
||||||
|
const VALID_VAT_TYPES = ['S', 'AE', 'O', 'Z', 'E'];
|
||||||
|
// Coduri tip factură valide per CIUS-RO
|
||||||
|
const VALID_INVOICE_TYPES = ['380', '381', '384', '389'];
|
||||||
|
// Coduri țară ISO 3166-1 alfa-2 (set parțial — UE + țări comune)
|
||||||
|
const EU_COUNTRY_CODES = new Set([
|
||||||
|
'AT','BE','BG','CY','CZ','DE','DK','EE','ES','FI','FR','GR','HR','HU',
|
||||||
|
'IE','IT','LT','LU','LV','MT','NL','PL','PT','RO','SE','SI','SK',
|
||||||
|
'AD','AL','BA','BY','CH','GB','GE','IS','LI','ME','MK','MD','MN','NO',
|
||||||
|
'RS','TR','UA','US','CA','AU','JP','CN','KR','BR','IN','ZA','SG','AE',
|
||||||
|
'XK','SM','VA','MC','GI','FO','GL','IM','JE','GG'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsează un număr din string ignorând formatare (punct sau virgulă drept separator mii).
|
||||||
|
* Returnează NaN dacă nu e un număr valid.
|
||||||
|
*/
|
||||||
|
function parseNum(val) {
|
||||||
|
if (val === null || val === undefined || val === '') return NaN;
|
||||||
|
const s = String(val).trim().replace(/\s/g, '');
|
||||||
|
// Format ro-RO are virgulă ca separator zecimal ("1.234,56" sau "1,5").
|
||||||
|
// Doar când există virgulă tratăm punctele drept separator de mii.
|
||||||
|
// Altfel: parse canonical decimal-dot (dataset.raw, XML) — "1.000" = 1, NU 1000.
|
||||||
|
if (s.includes(',')) {
|
||||||
|
return parseFloat(s.replace(/\./g, '').replace(',', '.'));
|
||||||
|
}
|
||||||
|
return parseFloat(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parsează o dată din format dd.mm.yyyy → Date object (sau null) */
|
||||||
|
function parseRoDate(str) {
|
||||||
|
if (!str || !/^\d{2}\.\d{2}\.\d{4}$/.test(str.trim())) return null;
|
||||||
|
const [d, m, y] = str.trim().split('.').map(Number);
|
||||||
|
const dt = new Date(y, m - 1, d);
|
||||||
|
if (isNaN(dt.getTime())) return null;
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compară două valori numerice cu toleranță ε */
|
||||||
|
function approxEqual(a, b, eps = 0.02) {
|
||||||
|
if (isNaN(a) || isNaN(b)) return false;
|
||||||
|
return Math.abs(a - b) <= eps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// REGULILE BR — 30 reguli în ordinea: ID, date, furnizor, client,
|
||||||
|
// articole, TVA, totaluri, CIUS-RO specifice.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const BR_RULES = [
|
||||||
|
|
||||||
|
// ── Identificare factură ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-01',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Factura trebuie să aibă un număr de identificare (ID).',
|
||||||
|
fieldRef: '[name="invoiceNumber"]',
|
||||||
|
check: (d) => d.invoiceNumber !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-02',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Factura trebuie să aibă o dată de emitere.',
|
||||||
|
fieldRef: '[name="issueDate"]',
|
||||||
|
check: (d) => d.issueDate !== '' && parseRoDate(d.issueDate) !== null,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-03',
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Codul tipului de factură trebuie să fie 380, 381, 384 sau 389.',
|
||||||
|
fieldRef: '[name="invoiceTypeCode"]',
|
||||||
|
check: (d) => VALID_INVOICE_TYPES.includes(d.invoiceTypeCode),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-04',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Factura trebuie să specifice moneda (codul ISO 4217).',
|
||||||
|
fieldRef: '[name="documentCurrencyCode"]',
|
||||||
|
check: (d) => d.currencyCode !== '' && d.currencyCode.length === 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Date scadență ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-DT-01',
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Data emiterii nu poate fi în viitor cu mai mult de 30 zile.',
|
||||||
|
fieldRef: '[name="issueDate"]',
|
||||||
|
check: (d) => {
|
||||||
|
const issued = parseRoDate(d.issueDate);
|
||||||
|
if (!issued) return true; // BR-02 handles missing date
|
||||||
|
const limit = new Date();
|
||||||
|
limit.setDate(limit.getDate() + 30);
|
||||||
|
return issued <= limit;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-DT-02',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Data scadenței (dueDate) nu trebuie să fie anterioară datei de emitere.',
|
||||||
|
fieldRef: '[name="dueDate"]',
|
||||||
|
check: (d) => {
|
||||||
|
if (!d.dueDate) return true;
|
||||||
|
const issued = parseRoDate(d.issueDate);
|
||||||
|
const due = parseRoDate(d.dueDate);
|
||||||
|
if (!issued || !due) return true;
|
||||||
|
return due >= issued;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Furnizor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-06',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Furnizorul trebuie să aibă un nume (RegistrationName).',
|
||||||
|
fieldRef: '[name="supplierName"]',
|
||||||
|
check: (d) => d.supplierName !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-07',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Adresa furnizorului trebuie să includă orașul.',
|
||||||
|
fieldRef: '[name="supplierCity"]',
|
||||||
|
check: (d) => d.supplierCity !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-08',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Țara furnizorului trebuie specificată (cod ISO 3166-1).',
|
||||||
|
fieldRef: '[name="supplierCountry"]',
|
||||||
|
check: (d) => d.supplierCountry !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-RO-001',
|
||||||
|
severity: 'error',
|
||||||
|
message: 'CIF/CUI furnizor invalid: cifra de control nu se potrivește.',
|
||||||
|
fieldRef: '[name="supplierVAT"]',
|
||||||
|
check: (d) => {
|
||||||
|
if (!d.supplierVAT) return true; // gol = alt BR verifică
|
||||||
|
return validateCIF(d.supplierVAT).valid;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-RO-010',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Furnizorul trebuie să aibă un cod de identificare fiscală (CIF/VAT).',
|
||||||
|
fieldRef: '[name="supplierVAT"]',
|
||||||
|
check: (d) => d.supplierVAT !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Client ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-07-C',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Clientul trebuie să aibă un nume (RegistrationName).',
|
||||||
|
fieldRef: '[name="customerName"]',
|
||||||
|
check: (d) => d.customerName !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-08-C',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Țara clientului trebuie specificată (cod ISO 3166-1).',
|
||||||
|
fieldRef: '[name="customerCountry"]',
|
||||||
|
check: (d) => d.customerCountry !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-RO-002',
|
||||||
|
severity: 'error',
|
||||||
|
message: 'CIF/CUI client invalid: cifra de control nu se potrivește.',
|
||||||
|
fieldRef: '[name="customerVAT"]',
|
||||||
|
check: (d) => {
|
||||||
|
if (!d.customerVAT) return true;
|
||||||
|
return validateCIF(d.customerVAT).valid;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Articole factură ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-21',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: (d) => {
|
||||||
|
const bad = d.lineItems.filter(li => !li.description);
|
||||||
|
return bad.length === 1
|
||||||
|
? `Linia ${bad[0].index + 1} trebuie să aibă o denumire (descriere).`
|
||||||
|
: `${bad.length} linii fără denumire (liniile ${bad.map(l => l.index + 1).join(', ')}).`;
|
||||||
|
},
|
||||||
|
fieldRef: null, // dinamic — scroll la prima linie cu eroare
|
||||||
|
fieldRefDynamic: (d) => {
|
||||||
|
const bad = d.lineItems.find(li => !li.description);
|
||||||
|
return bad ? `[name="description${bad.index}"]` : null;
|
||||||
|
},
|
||||||
|
check: (d) => d.lineItems.every(li => li.description !== ''),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-22',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: (d) => {
|
||||||
|
const bad = d.lineItems.filter(li => isNaN(parseNum(li.quantity)) || parseNum(li.quantity) === 0);
|
||||||
|
return bad.length === 1
|
||||||
|
? `Linia ${bad[0].index + 1} trebuie să aibă o cantitate validă (≠ 0).`
|
||||||
|
: `${bad.length} linii cu cantitate lipsă sau zero.`;
|
||||||
|
},
|
||||||
|
fieldRefDynamic: (d) => {
|
||||||
|
const bad = d.lineItems.find(li => isNaN(parseNum(li.quantity)) || parseNum(li.quantity) === 0);
|
||||||
|
return bad ? `[name="quantity${bad.index}"]` : null;
|
||||||
|
},
|
||||||
|
check: (d) => d.lineItems.every(li => !isNaN(parseNum(li.quantity)) && parseNum(li.quantity) !== 0),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-23',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: (d) => {
|
||||||
|
const bad = d.lineItems.filter(li => isNaN(parseNum(li.unitPrice)));
|
||||||
|
return `Linia ${bad[0]?.index + 1 || '?'}: prețul unitar trebuie specificat.`;
|
||||||
|
},
|
||||||
|
fieldRefDynamic: (d) => {
|
||||||
|
const bad = d.lineItems.find(li => isNaN(parseNum(li.unitPrice)));
|
||||||
|
return bad ? `[name="price${bad.index}"]` : null;
|
||||||
|
},
|
||||||
|
check: (d) => d.lineItems.every(li => !isNaN(parseNum(li.unitPrice))),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-24',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: (d) => {
|
||||||
|
const bad = d.lineItems.filter(li => !VALID_VAT_TYPES.includes(li.vatType));
|
||||||
|
return `Linia ${bad[0]?.index + 1 || '?'}: codul categoriei TVA trebuie să fie S/AE/O/Z/E.`;
|
||||||
|
},
|
||||||
|
fieldRefDynamic: (d) => {
|
||||||
|
const bad = d.lineItems.find(li => !VALID_VAT_TYPES.includes(li.vatType));
|
||||||
|
return bad ? `[name="vatType${bad.index}"]` : null;
|
||||||
|
},
|
||||||
|
check: (d) => d.lineItems.every(li => VALID_VAT_TYPES.includes(li.vatType)),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-16',
|
||||||
|
severity: 'error',
|
||||||
|
message: (d) => {
|
||||||
|
const bad = d.lineItems.find(li => {
|
||||||
|
const qty = parseNum(li.quantity);
|
||||||
|
const price = parseNum(li.unitPrice);
|
||||||
|
const disc = parseNum(li.discount) || 0;
|
||||||
|
const net = parseNum(li.lineTotal);
|
||||||
|
if (isNaN(qty) || isNaN(price) || isNaN(net)) return false;
|
||||||
|
return !approxEqual(qty * price - disc, net);
|
||||||
|
});
|
||||||
|
return bad
|
||||||
|
? `Linia ${bad.index + 1}: total net ≠ cantitate × preț − discount.`
|
||||||
|
: 'Total net linie inconsistent.';
|
||||||
|
},
|
||||||
|
fieldRefDynamic: (d) => {
|
||||||
|
const bad = d.lineItems.find(li => {
|
||||||
|
const qty = parseNum(li.quantity);
|
||||||
|
const price = parseNum(li.unitPrice);
|
||||||
|
const disc = parseNum(li.discount) || 0;
|
||||||
|
const net = parseNum(li.lineTotal);
|
||||||
|
if (isNaN(qty) || isNaN(price) || isNaN(net)) return false;
|
||||||
|
return !approxEqual(qty * price - disc, net);
|
||||||
|
});
|
||||||
|
return bad ? `[data-line-total-index="${bad.index}"]` : null;
|
||||||
|
},
|
||||||
|
check: (d) => d.lineItems.every(li => {
|
||||||
|
const qty = parseNum(li.quantity);
|
||||||
|
const price = parseNum(li.unitPrice);
|
||||||
|
const disc = parseNum(li.discount) || 0;
|
||||||
|
const net = parseNum(li.lineTotal);
|
||||||
|
if (isNaN(qty) || isNaN(price) || isNaN(net)) return true; // BR-22/23 handles
|
||||||
|
return approxEqual(qty * price - disc, net);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Factură cu cel puțin un articol ────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-16-L',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Factura trebuie să conțină cel puțin un articol (linie factură).',
|
||||||
|
fieldRef: null,
|
||||||
|
check: (d) => d.lineItems.length > 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TVA breakdown ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-31',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: 'Defalcarea TVA (TaxTotal/TaxSubtotal) nu poate fi goală.',
|
||||||
|
fieldRef: '#vatBreakdownRows',
|
||||||
|
check: (d) => d.vatRows.length > 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-32',
|
||||||
|
severity: 'error',
|
||||||
|
message: (d) => {
|
||||||
|
const bad = d.vatRows.find(r => {
|
||||||
|
const rt = parseNum(r.rate);
|
||||||
|
return isNaN(rt) || rt < 0 || rt > 100;
|
||||||
|
});
|
||||||
|
return `Cota TVA ${bad?.rate ?? ''} este invalidă (trebuie 0–100%).`;
|
||||||
|
},
|
||||||
|
fieldRef: '.vat-rate',
|
||||||
|
check: (d) => d.vatRows.every(r => {
|
||||||
|
const rt = parseNum(r.rate);
|
||||||
|
return !isNaN(rt) && rt >= 0 && rt <= 100;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-45',
|
||||||
|
severity: 'error',
|
||||||
|
message: (d) => {
|
||||||
|
const bad = d.vatRows.find(r => !VALID_VAT_TYPES.includes(r.type));
|
||||||
|
return `Codul categoriei TVA "${bad?.type ?? ''}" este invalid. Valori acceptate: S, AE, O, Z, E.`;
|
||||||
|
},
|
||||||
|
fieldRef: '.vat-type',
|
||||||
|
check: (d) => d.vatRows.every(r => VALID_VAT_TYPES.includes(r.type)),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-AE-01',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Categoria AE (Taxare Inversă) trebuie să aibă cota TVA 0%.',
|
||||||
|
fieldRef: '.vat-rate',
|
||||||
|
check: (d) => d.vatRows
|
||||||
|
.filter(r => r.type === 'AE')
|
||||||
|
.every(r => parseNum(r.rate) === 0),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-O-01',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Categoria O (Neplătitor TVA) trebuie să aibă cota TVA 0%.',
|
||||||
|
fieldRef: '.vat-rate',
|
||||||
|
check: (d) => d.vatRows
|
||||||
|
.filter(r => r.type === 'O')
|
||||||
|
.every(r => parseNum(r.rate) === 0),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-E-01',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Categoria E (Neimpozabil) trebuie să aibă cota TVA 0%.',
|
||||||
|
fieldRef: '.vat-rate',
|
||||||
|
check: (d) => d.vatRows
|
||||||
|
.filter(r => r.type === 'E')
|
||||||
|
.every(r => parseNum(r.rate) === 0),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Consistență totaluri ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-CO-15',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: (d) => {
|
||||||
|
const sumRows = d.vatRows.reduce((s, r) => s + (parseNum(r.amount) || 0), 0);
|
||||||
|
const disp = parseNum(d.totalVat);
|
||||||
|
const diff = Math.abs(sumRows - disp).toFixed(2);
|
||||||
|
return `Total TVA afișat (${disp.toFixed(2)}) ≠ suma rândurilor TVA (${sumRows.toFixed(2)}). Diferență: ${diff} RON.`;
|
||||||
|
},
|
||||||
|
fieldRef: '#vat',
|
||||||
|
check: (d) => {
|
||||||
|
if (d.vatRows.length === 0) return true;
|
||||||
|
const sumRows = d.vatRows.reduce((s, r) => s + (parseNum(r.amount) || 0), 0);
|
||||||
|
const disp = parseNum(d.totalVat);
|
||||||
|
return approxEqual(sumRows, disp);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-CO-16',
|
||||||
|
severity: 'fatal',
|
||||||
|
message: (d) => {
|
||||||
|
const expected = parseNum(d.subtotal) - parseNum(d.allowances) + parseNum(d.charges) + parseNum(d.totalVat);
|
||||||
|
const actual = parseNum(d.grandTotal);
|
||||||
|
const diff = Math.abs(expected - actual).toFixed(2);
|
||||||
|
return `Total factură (${actual.toFixed(2)}) ≠ subtotal − reduceri + adaosuri + TVA (${expected.toFixed(2)}). Diferență: ${diff} RON.`;
|
||||||
|
},
|
||||||
|
fieldRef: '#total',
|
||||||
|
check: (d) => {
|
||||||
|
const expected = parseNum(d.subtotal) - parseNum(d.allowances) + parseNum(d.charges) + parseNum(d.totalVat);
|
||||||
|
const actual = parseNum(d.grandTotal);
|
||||||
|
if (isNaN(expected) || isNaN(actual)) return true;
|
||||||
|
return approxEqual(expected, actual);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── CIUS-RO specifice ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-RO-180',
|
||||||
|
severity: 'error',
|
||||||
|
message: 'CIUS-RO: codul tipului de factură trebuie să fie 380 (factură), 381 (credit note), 384 (corectată) sau 389 (autofactură).',
|
||||||
|
fieldRef: '[name="invoiceTypeCode"]',
|
||||||
|
check: (d) => d.invoiceTypeCode === '' || VALID_INVOICE_TYPES.includes(d.invoiceTypeCode),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-RO-003',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'CIUS-RO: numărul facturii (ID) nu trebuie să fie gol sau să conțină doar spații.',
|
||||||
|
fieldRef: '[name="invoiceNumber"]',
|
||||||
|
check: (d) => d.invoiceNumber.trim() !== '',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
code: 'BR-IBAN-01',
|
||||||
|
severity: 'warning',
|
||||||
|
message: (d) => {
|
||||||
|
const badIdx = d.ibans.findIndex(ib => ib && !validateIBAN(ib).valid);
|
||||||
|
return `IBAN #${badIdx + 1} invalid: verificați lungimea și cifrele de control.`;
|
||||||
|
},
|
||||||
|
fieldRef: null,
|
||||||
|
fieldRefDynamic: (d) => {
|
||||||
|
const badIdx = d.ibans.findIndex(ib => ib && !validateIBAN(ib).valid);
|
||||||
|
return badIdx >= 0 ? `[name="paymentMeansIBAN${badIdx}"]` : null;
|
||||||
|
},
|
||||||
|
check: (d) => d.ibans.every(ib => !ib || validateIBAN(ib).valid),
|
||||||
|
},
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rulează toate regulile pe invoiceData și returnează lista de violări.
|
||||||
|
* @param {object} invoiceData — snapshot din collectInvoiceDataForBR()
|
||||||
|
* @returns {{ code, severity, message, fieldRef }[]}
|
||||||
|
*/
|
||||||
|
export function runBRRules(invoiceData) {
|
||||||
|
const violations = [];
|
||||||
|
for (const rule of BR_RULES) {
|
||||||
|
if (!rule.check(invoiceData)) {
|
||||||
|
const msg = typeof rule.message === 'function'
|
||||||
|
? rule.message(invoiceData)
|
||||||
|
: rule.message;
|
||||||
|
const fRef = rule.fieldRefDynamic
|
||||||
|
? rule.fieldRefDynamic(invoiceData)
|
||||||
|
: rule.fieldRef;
|
||||||
|
violations.push({
|
||||||
|
code: rule.code,
|
||||||
|
severity: rule.severity,
|
||||||
|
message: msg,
|
||||||
|
fieldRef: fRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
71
efactura-generator/js/validation/cif.js
Normal file
71
efactura-generator/js/validation/cif.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* js/validation/cif.js — PR-VALID-IDS (A9)
|
||||||
|
* Validare CIF/CUI românesc prin sumă ponderată (mod 11, mod 10).
|
||||||
|
* Funcție pură, fără efecte secundare, fără dependențe externe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Greutățile pentru cifrele 1-9 (se aplică pe primele 9 cifre ale CIF-ului).
|
||||||
|
const WEIGHTS = [7, 5, 3, 2, 1, 7, 5, 3, 2];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validează un CIF/CUI românesc.
|
||||||
|
*
|
||||||
|
* Algoritm:
|
||||||
|
* 1. Elimină prefixul "RO" dacă există (case-insensitive).
|
||||||
|
* 2. Elimină spații.
|
||||||
|
* 3. Verifică că are între 2 și 10 cifre.
|
||||||
|
* 4. Completează cu zerouri la stânga până la 10 cifre.
|
||||||
|
* 5. Calculează suma ponderată pe primele 9 cifre cu WEIGHTS.
|
||||||
|
* 6. (sumă * 10) % 11 % 10 trebuie să fie egal cu cifra de control (ultima).
|
||||||
|
*
|
||||||
|
* @param {string} value — valoarea brută din câmp (poate fi goală, poate conține "RO")
|
||||||
|
* @returns {{ valid: boolean, message: string }}
|
||||||
|
*/
|
||||||
|
export function validateCIF(value) {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return { valid: true, message: '' }; // câmp gol — valid (nu e required check)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = value.trim().toUpperCase();
|
||||||
|
|
||||||
|
// Elimină prefixul RO
|
||||||
|
if (normalized.startsWith('RO')) {
|
||||||
|
normalized = normalized.slice(2).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elimină spații și cratime rămase
|
||||||
|
normalized = normalized.replace(/[\s\-]/g, '');
|
||||||
|
|
||||||
|
// Trebuie să conțină doar cifre
|
||||||
|
if (!/^\d+$/.test(normalized)) {
|
||||||
|
return { valid: false, message: 'CIF invalid: conține caractere nepermise' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lungime: minim 2, maxim 10 cifre
|
||||||
|
if (normalized.length < 2 || normalized.length > 10) {
|
||||||
|
return { valid: false, message: 'CIF invalid: lungimea trebuie să fie între 2 și 10 cifre' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completează cu zerouri la stânga până la 10 cifre
|
||||||
|
const padded = normalized.padStart(10, '0');
|
||||||
|
|
||||||
|
// Extrage primele 9 cifre (pentru ponderare) și cifra de control (ultima)
|
||||||
|
const digits = padded.split('').map(Number);
|
||||||
|
const checkDigit = digits[9];
|
||||||
|
const controlDigits = digits.slice(0, 9);
|
||||||
|
|
||||||
|
// Calculează suma ponderată
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
sum += controlDigits[i] * WEIGHTS[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cifra de control calculată
|
||||||
|
const computed = (sum * 10) % 11 % 10;
|
||||||
|
|
||||||
|
if (computed !== checkDigit) {
|
||||||
|
return { valid: false, message: 'CIF invalid: cifra de control nu se potrivește' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
63
efactura-generator/js/validation/iban.js
Normal file
63
efactura-generator/js/validation/iban.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* js/validation/iban.js — PR-VALID-IDS (A10)
|
||||||
|
* Validare IBAN internațional prin algoritmul ISO 13616 (mod 97).
|
||||||
|
* Funcție pură, fără efecte secundare, fără dependențe externe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validează un IBAN (orice țară, inclusiv RO).
|
||||||
|
*
|
||||||
|
* Algoritm ISO 13616:
|
||||||
|
* 1. Elimină spații și convertește la uppercase.
|
||||||
|
* 2. Verifică lungimea minimă (4 caractere).
|
||||||
|
* 3. Mută primele 4 caractere la sfârșitul șirului.
|
||||||
|
* 4. Înlocuiește fiecare literă cu echivalentul numeric: A=10, B=11, ..., Z=35.
|
||||||
|
* 5. Calculează numărul rezultat modulo 97 — trebuie să fie 1.
|
||||||
|
*
|
||||||
|
* Lungimi specifice per țară nu sunt forțate (validare structurală generică);
|
||||||
|
* IBAN-ul RO are 24 caractere, verificat separat cu mesaj specific.
|
||||||
|
*
|
||||||
|
* @param {string} value — valoarea brută din câmp
|
||||||
|
* @returns {{ valid: boolean, message: string }}
|
||||||
|
*/
|
||||||
|
export function validateIBAN(value) {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return { valid: true, message: '' }; // câmp gol — valid (nu e required check)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizare: elimină spații, uppercase
|
||||||
|
const normalized = value.trim().toUpperCase().replace(/\s/g, '');
|
||||||
|
|
||||||
|
// Lungime minimă
|
||||||
|
if (normalized.length < 4) {
|
||||||
|
return { valid: false, message: 'IBAN invalid: lungime sau check digits' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifică că IBAN-ul conține doar litere și cifre
|
||||||
|
if (!/^[A-Z0-9]+$/.test(normalized)) {
|
||||||
|
return { valid: false, message: 'IBAN invalid: caractere nepermise' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// IBAN RO trebuie să aibă exact 24 caractere
|
||||||
|
if (normalized.startsWith('RO') && normalized.length !== 24) {
|
||||||
|
return { valid: false, message: 'IBAN invalid: lungime sau check digits' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rearanjare: primele 4 caractere la final
|
||||||
|
const rearranged = normalized.slice(4) + normalized.slice(0, 4);
|
||||||
|
|
||||||
|
// Înlocuiește literele cu cifre: A=10 ... Z=35
|
||||||
|
const numericString = rearranged.replace(/[A-Z]/g, ch => String(ch.charCodeAt(0) - 55));
|
||||||
|
|
||||||
|
// Calculează mod 97 pe un număr mare (string chunking pentru a evita overflow)
|
||||||
|
let remainder = 0;
|
||||||
|
for (let i = 0; i < numericString.length; i++) {
|
||||||
|
remainder = (remainder * 10 + parseInt(numericString[i], 10)) % 97;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainder !== 1) {
|
||||||
|
return { valid: false, message: 'IBAN invalid: lungime sau check digits' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
1027
efactura-generator/js/vendor/big.mjs
vendored
Normal file
1027
efactura-generator/js/vendor/big.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3
efactura-generator/js/vendor/html2pdf.bundle.min.js
vendored
Normal file
3
efactura-generator/js/vendor/html2pdf.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
44
efactura-generator/js/vendor/html2pdf.mjs
vendored
Normal file
44
efactura-generator/js/vendor/html2pdf.mjs
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* js/vendor/html2pdf.mjs — ESM wrapper pentru html2pdf.js 0.10.2
|
||||||
|
*
|
||||||
|
* Încarcă bundle-ul UMD via <script> injection la primul apel.
|
||||||
|
* Bundlul include html2canvas + jsPDF — ~900 KB, deci lazy loading.
|
||||||
|
*
|
||||||
|
* Utilizare:
|
||||||
|
* import getHtml2pdf from './vendor/html2pdf.mjs';
|
||||||
|
* const html2pdf = await getHtml2pdf();
|
||||||
|
* await html2pdf().set({ filename: 'factura.pdf' }).from(element).save();
|
||||||
|
*
|
||||||
|
* @see https://ekoopmans.github.io/html2pdf.js/
|
||||||
|
* @version 0.10.2
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
let _promise = null;
|
||||||
|
|
||||||
|
export default function getHtml2pdf() {
|
||||||
|
if (globalThis.html2pdf) {
|
||||||
|
return Promise.resolve(globalThis.html2pdf);
|
||||||
|
}
|
||||||
|
if (_promise) return _promise;
|
||||||
|
|
||||||
|
_promise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
// Rezolvă calea relativ la locația fișierului curent (ESM import.meta.url)
|
||||||
|
script.src = new URL('./html2pdf.bundle.min.js', import.meta.url).href;
|
||||||
|
script.onload = () => {
|
||||||
|
if (typeof globalThis.html2pdf === 'function') {
|
||||||
|
resolve(globalThis.html2pdf);
|
||||||
|
} else {
|
||||||
|
reject(new Error('html2pdf.js bundle încărcat dar globalThis.html2pdf este undefined'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
_promise = null; // permite retry
|
||||||
|
reject(new Error('Nu s-a putut încărca html2pdf.bundle.min.js'));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return _promise;
|
||||||
|
}
|
||||||
7
efactura-generator/js/vendor/jszip.mjs
vendored
Normal file
7
efactura-generator/js/vendor/jszip.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,156 +1,321 @@
|
|||||||
<?php
|
<?php
|
||||||
// Încărcare configurație
|
// ============================================================================
|
||||||
$config = json_decode(file_get_contents(dirname(__FILE__) . '/config.json'), true);
|
// receiver.php — Endpoint server-side pentru:
|
||||||
if (!$config) {
|
// 1. Primire XML eFactura (POST fără action) → salvare în temp/
|
||||||
header('HTTP/1.1 500 Internal Server Error');
|
// 2. Proxy ANAF APIs:
|
||||||
die('Eroare la încărcarea configurației');
|
// ?action=ping — health check (no auth)
|
||||||
|
// ?action=validate — proxy validare ANAF (necesită anaf_token în config.json)
|
||||||
|
// ?action=pdf — proxy transformare ANAF (ZIP+HTML, necesită anaf_token)
|
||||||
|
// ?action=cif — lookup contribuabil după CIF (nu necesită token OAuth)
|
||||||
|
// 3. Curățare fișiere temporare (?cleanup=xml_XXXX.xml)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// === 1. Configurație ========================================================
|
||||||
|
// Citește config.json (fallback) și suprascrie cu variabile de mediu dacă sunt prezente.
|
||||||
|
// Util pentru deploy în container (Docker/Dokploy): nu trebuie rebuild la schimbare config.
|
||||||
|
// ANAF_API_KEY — suprascrie api_key
|
||||||
|
// ANAF_ALLOWED_IPS — listă IP-uri separate prin virgulă; "*" sau gol = check dezactivat
|
||||||
|
// ANAF_TOKEN — Bearer token OAuth ANAF (opțional, doar pentru viitoare extensii)
|
||||||
|
// ANAF_TEMP_LIFETIME — ore păstrare fișiere temp (default 1)
|
||||||
|
$configPath = dirname(__FILE__) . '/config.json';
|
||||||
|
$config = file_exists($configPath) ? json_decode(file_get_contents($configPath), true) : [];
|
||||||
|
if (!is_array($config)) $config = [];
|
||||||
|
|
||||||
|
if (($v = getenv('ANAF_API_KEY')) !== false && $v !== '') $config['api_key'] = $v;
|
||||||
|
if (($v = getenv('ANAF_TOKEN')) !== false && $v !== '') $config['anaf_token'] = $v;
|
||||||
|
if (($v = getenv('ANAF_TEMP_LIFETIME')) !== false && $v !== '') $config['temp_file_lifetime'] = intval($v);
|
||||||
|
if (($v = getenv('ANAF_ALLOWED_IPS')) !== false) {
|
||||||
|
$config['allowed_ips'] = array_values(array_filter(array_map('trim', explode(',', $v))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Funcție de validare XML
|
if (empty($config['api_key'])) {
|
||||||
|
header('HTTP/1.1 500 Internal Server Error');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
die(json_encode(['success' => false, 'error' => 'Eroare la încărcarea configurației']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 2. Funcții helper ======================================================
|
||||||
|
|
||||||
|
/** Validează structura XML și namespace-urile UBL. */
|
||||||
function validateXML($xmlContent) {
|
function validateXML($xmlContent) {
|
||||||
// Dezactivează raportarea erorilor standard și folosește erori interne libxml
|
|
||||||
libxml_use_internal_errors(true);
|
libxml_use_internal_errors(true);
|
||||||
|
|
||||||
// Elimină BOM (Byte Order Mark) dacă există
|
|
||||||
$xmlContent = preg_replace('/^\xEF\xBB\xBF/', '', $xmlContent);
|
$xmlContent = preg_replace('/^\xEF\xBB\xBF/', '', $xmlContent);
|
||||||
|
|
||||||
// Curăță spațiile de la început și final
|
|
||||||
$xmlContent = trim($xmlContent);
|
$xmlContent = trim($xmlContent);
|
||||||
|
|
||||||
// Încearcă să încarce XML-ul
|
|
||||||
$xml = simplexml_load_string($xmlContent);
|
$xml = simplexml_load_string($xmlContent);
|
||||||
|
|
||||||
if ($xml === false) {
|
if ($xml === false) {
|
||||||
$errors = libxml_get_errors();
|
$errors = libxml_get_errors();
|
||||||
$errorMessages = [];
|
$msgs = array_map(function($e) {
|
||||||
|
return [
|
||||||
foreach ($errors as $error) {
|
'level' => $e->level, 'code' => $e->code,
|
||||||
$errorMessages[] = [
|
'column' => $e->column, 'message' => $e->message, 'line' => $e->line
|
||||||
'level' => $error->level,
|
|
||||||
'code' => $error->code,
|
|
||||||
'column' => $error->column,
|
|
||||||
'message' => $error->message,
|
|
||||||
'line' => $error->line
|
|
||||||
];
|
];
|
||||||
}
|
}, $errors);
|
||||||
|
|
||||||
libxml_clear_errors();
|
libxml_clear_errors();
|
||||||
|
return ['valid' => false, 'errors' => $msgs];
|
||||||
return [
|
|
||||||
'valid' => false,
|
|
||||||
'errors' => $errorMessages
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verifică namespace-urile necesare
|
|
||||||
$namespaces = $xml->getNamespaces(true);
|
$namespaces = $xml->getNamespaces(true);
|
||||||
$requiredNamespaces = [
|
$required = [
|
||||||
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
|
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
|
||||||
'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
|
'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
|
||||||
'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'
|
'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'
|
||||||
];
|
];
|
||||||
|
foreach ($required as $ns) {
|
||||||
foreach ($requiredNamespaces as $ns) {
|
if (!in_array($ns, array_values($namespaces))) {
|
||||||
$found = false;
|
return ['valid' => false, 'errors' => [['message' => "Namespace lipsă: $ns"]]];
|
||||||
foreach ($namespaces as $namespace) {
|
|
||||||
if ($namespace === $ns) {
|
|
||||||
$found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$found) {
|
|
||||||
return [
|
|
||||||
'valid' => false,
|
|
||||||
'errors' => [
|
|
||||||
['message' => "Namespace lipsă: $ns"]
|
|
||||||
]
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ['valid' => true, 'errors' => []];
|
||||||
return [
|
|
||||||
'valid' => true,
|
|
||||||
'errors' => []
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificare IP
|
/**
|
||||||
|
* Verifică IP-ul clientului față de allowed_ips.
|
||||||
|
* Returnează true dacă lista e goală sau conține "*" (check dezactivat — util în
|
||||||
|
* container behind reverse proxy, unde REMOTE_ADDR e IP-ul intern Traefik/nginx).
|
||||||
|
*/
|
||||||
function checkIP() {
|
function checkIP() {
|
||||||
global $config;
|
global $config;
|
||||||
$clientIP = $_SERVER['REMOTE_ADDR'];
|
$allowed = $config['allowed_ips'] ?? [];
|
||||||
return in_array($clientIP, $config['allowed_ips']);
|
if (empty($allowed) || in_array('*', $allowed, true)) return true;
|
||||||
|
return in_array($_SERVER['REMOTE_ADDR'], $allowed, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificare token
|
/** Verifică header-ul X-Api-Key față de api_key din config.json. */
|
||||||
function validateToken() {
|
function validateToken() {
|
||||||
global $config;
|
global $config;
|
||||||
$headers = getallheaders();
|
$headers = getallheaders();
|
||||||
$token = isset($headers['X-Api-Key']) ? $headers['X-Api-Key'] : '';
|
$token = $headers['X-Api-Key'] ?? '';
|
||||||
return hash_equals($config['api_key'], $token);
|
return hash_equals($config['api_key'], $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificare origine request
|
/**
|
||||||
if (false and !checkIP()) {
|
* Execută un request cURL POST. Returnează ['body' => string, 'error' => string].
|
||||||
|
* @param string $url URL destinație
|
||||||
|
* @param string $body Corp request
|
||||||
|
* @param array $headers Headere suplimentare
|
||||||
|
*/
|
||||||
|
function curlPost($url, $body, $headers = []) {
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
return ['body' => '', 'error' => 'cURL nu este disponibil pe acest server'];
|
||||||
|
}
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $body,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return ['body' => $resp ?: '', 'error' => $error];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 3. Action routing (înainte de auth pentru ?action=ping) ================
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// Health check — nu necesită auth
|
||||||
|
if ($action === 'ping') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['pong' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 4. Auth ================================================================
|
||||||
|
// IP check: dacă allowed_ips e gol sau conține "*", se sare peste (vezi checkIP()).
|
||||||
|
if (!checkIP()) {
|
||||||
header('HTTP/1.1 403 Forbidden');
|
header('HTTP/1.1 403 Forbidden');
|
||||||
|
header('Content-Type: application/json');
|
||||||
error_log("Acces interzis pentru IP: " . $_SERVER['REMOTE_ADDR']);
|
error_log("Acces interzis pentru IP: " . $_SERVER['REMOTE_ADDR']);
|
||||||
die(json_encode([
|
die(json_encode(['success' => false, 'error' => 'Acces interzis', 'details' => 'IP-ul nu este autorizat']));
|
||||||
'success' => false,
|
|
||||||
'error' => 'Acces interzis',
|
|
||||||
'details' => 'IP-ul nu este autorizat'
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificare token
|
// ANAF proxy actions: nu necesită X-Api-Key (same-origin implicat prin IP check)
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !validateToken()) {
|
// Upload XML: necesită X-Api-Key
|
||||||
header('HTTP/1.1 401 Unauthorized');
|
if (!in_array($action, ['validate', 'pdf', 'cif'])) {
|
||||||
error_log("Token invalid de la IP: " . $_SERVER['REMOTE_ADDR']);
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !validateToken()) {
|
||||||
die(json_encode([
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
'success' => false,
|
header('Content-Type: application/json');
|
||||||
'error' => 'Token invalid',
|
error_log("Token invalid de la IP: " . $_SERVER['REMOTE_ADDR']);
|
||||||
'details' => 'Autentificare eșuată'
|
die(json_encode(['success' => false, 'error' => 'Token invalid', 'details' => 'Autentificare eșuată']));
|
||||||
]));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configurare director pentru fișiere temporare
|
// === 5. ANAF proxy handlers =================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy validare ANAF eFactura.
|
||||||
|
* Necesită: "anaf_token": "Bearer XXX" în config.json
|
||||||
|
* POST https://api.anaf.ro/prod/FCTEL/rest/validare/FACT1
|
||||||
|
*/
|
||||||
|
function handleAnafValidate() {
|
||||||
|
global $config;
|
||||||
|
$xmlContent = file_get_contents('php://input');
|
||||||
|
$token = $config['anaf_token'] ?? '';
|
||||||
|
|
||||||
|
$headers = ['Content-Type: text/plain; charset=utf-8'];
|
||||||
|
if ($token) {
|
||||||
|
$headers[] = "Authorization: Bearer $token";
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = curlPost('https://api.anaf.ro/prod/FCTEL/rest/validare/FACT1', $xmlContent, $headers);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($result['error']) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'cURL error: ' . $result['error']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// Transmite răspunsul ANAF direct — structura: {"Messages": [...]}
|
||||||
|
echo $result['body'];
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy transformare ANAF (vizualizare ZIP+HTML).
|
||||||
|
* Notă: ANAF returnează ZIP cu fișiere HTML, nu PDF direct.
|
||||||
|
* Necesită: "anaf_token": "Bearer XXX" în config.json
|
||||||
|
* POST https://api.anaf.ro/prod/FCTEL/rest/transformare/FACT1/DA
|
||||||
|
*/
|
||||||
|
function handleAnafPdf() {
|
||||||
|
global $config;
|
||||||
|
$xmlContent = file_get_contents('php://input');
|
||||||
|
$token = $config['anaf_token'] ?? '';
|
||||||
|
|
||||||
|
$headers = ['Content-Type: text/plain; charset=utf-8'];
|
||||||
|
if ($token) {
|
||||||
|
$headers[] = "Authorization: Bearer $token";
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = curlPost('https://api.anaf.ro/prod/FCTEL/rest/transformare/FACT1/DA', $xmlContent, $headers);
|
||||||
|
|
||||||
|
if ($result['error']) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'cURL error: ' . $result['error']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header('Content-Type: application/zip');
|
||||||
|
header('Content-Disposition: attachment; filename="vizualizare_anaf.zip"');
|
||||||
|
echo $result['body'];
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy lookup contribuabil după CIF prin ANAF.
|
||||||
|
* Nu necesită token OAuth — API public ANAF.
|
||||||
|
*
|
||||||
|
* API utilizat: PlatitorTvaRest v9 (sincron)
|
||||||
|
* POST https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva
|
||||||
|
* Body: [{"cui": <int>, "data": "YYYY-MM-DD"}]
|
||||||
|
* Răspuns direct: {found: [...], notFound: [...]}
|
||||||
|
* Doc: https://static.anaf.ro/static/10/Anaf/Informatii_R/Servicii_web/doc_WS_V9.txt
|
||||||
|
*
|
||||||
|
* Alternativă disponibilă: AsynchWebService v8 (async, batch până la 100 CUI-uri)
|
||||||
|
* Submit : POST https://webservicesp.anaf.ro/AsynchWebService/api/v8/ws/tva → correlationId
|
||||||
|
* Result : GET https://webservicesp.anaf.ro/AsynchWebService/api/v7/ws/tva?id={correlationId}
|
||||||
|
* (după min. 2s; rezultat disponibil max. 3 zile)
|
||||||
|
* Doc: https://static.anaf.ro/static/10/Anaf/Informatii_R/Servicii_web/doc_WS_Async_V8.txt
|
||||||
|
* Potrivit pentru bulk lookup (ex. import multiplu CIF-uri); pentru single CIF la click
|
||||||
|
* user, v9 sincron e preferabil (un singur request, fără polling).
|
||||||
|
*/
|
||||||
|
function handleAnafCif() {
|
||||||
|
$cif = intval($_GET['cif'] ?? '0');
|
||||||
|
if ($cif <= 0) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'CIF invalid sau lipsă']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$payload = json_encode([['cui' => $cif, 'data' => $today]]);
|
||||||
|
$headers = ['Content-Type: application/json', 'Accept: application/json'];
|
||||||
|
|
||||||
|
$result = curlPost(
|
||||||
|
'https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva',
|
||||||
|
$payload, $headers
|
||||||
|
);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($result['error']) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'ANAF indisponibil: ' . $result['error']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($result['body'], true);
|
||||||
|
if (!$data || !array_key_exists('found', $data)) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'Răspuns neașteptat ANAF', 'raw' => $result['body']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(_normalizeCifResponse($data, $cif));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizează răspunsul ANAF TVA v9 în formatul js/anaf.js. */
|
||||||
|
function _normalizeCifResponse($data, $cif) {
|
||||||
|
$found = $data['found'] ?? [];
|
||||||
|
if (empty($found)) {
|
||||||
|
return ['found' => false];
|
||||||
|
}
|
||||||
|
$c = $found[0];
|
||||||
|
$dg = $c['date_generale'] ?? [];
|
||||||
|
$as = $c['adresa_sediu_social'] ?? [];
|
||||||
|
$tv = $c['inregistrare_scop_Tva'] ?? [];
|
||||||
|
return [
|
||||||
|
'found' => true,
|
||||||
|
'denumire' => $dg['denumire'] ?? '',
|
||||||
|
'adresa' => $dg['adresa'] ?? '',
|
||||||
|
'nrRegCom' => $dg['nrRegCom'] ?? '',
|
||||||
|
'cui' => $dg['cui'] ?? $cif,
|
||||||
|
'tvaActiv' => !empty($tv['scpTVA']),
|
||||||
|
'strada' => trim(($as['sdenumire_Strada'] ?? '') . ' ' . ($as['snumar_Strada'] ?? '')),
|
||||||
|
'oras' => trim(preg_replace('/^(MUN\.|ORS\.|COM\.)\s+/iu', '', $as['sdenumire_Localitate'] ?? '')),
|
||||||
|
'judetCod' => 'RO-' . strtoupper($as['scod_JudetAuto'] ?? ''),
|
||||||
|
'codPostal' => $as['scod_Postal'] ?? '',
|
||||||
|
'telefon' => $dg['telefon'] ?? '',
|
||||||
|
'statusEFactura'=> !empty($dg['statusRO_e_Factura']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 6. Rutare acțiuni ANAF =================================================
|
||||||
|
switch ($action) {
|
||||||
|
case 'validate': handleAnafValidate();
|
||||||
|
case 'pdf': handleAnafPdf();
|
||||||
|
case 'cif': handleAnafCif();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 7. Upload XML (comportament original) ==================================
|
||||||
$uploadDir = dirname(__FILE__) . '/temp/';
|
$uploadDir = dirname(__FILE__) . '/temp/';
|
||||||
if (!file_exists($uploadDir)) {
|
if (!file_exists($uploadDir)) {
|
||||||
mkdir($uploadDir, 0777, true);
|
mkdir($uploadDir, 0777, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Procesare request POST (primire XML)
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
try {
|
try {
|
||||||
// Citește conținutul XML din request
|
|
||||||
$xmlContent = file_get_contents('php://input');
|
$xmlContent = file_get_contents('php://input');
|
||||||
|
|
||||||
// Validare XML
|
|
||||||
$validationResult = validateXML($xmlContent);
|
$validationResult = validateXML($xmlContent);
|
||||||
|
|
||||||
if (!$validationResult['valid']) {
|
if (!$validationResult['valid']) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode([
|
echo json_encode(['success' => false, 'error' => 'XML invalid', 'details' => $validationResult['errors']]);
|
||||||
'success' => false,
|
|
||||||
'error' => 'XML invalid',
|
|
||||||
'details' => $validationResult['errors']
|
|
||||||
]);
|
|
||||||
error_log("Validare XML eșuată: " . json_encode($validationResult['errors']));
|
error_log("Validare XML eșuată: " . json_encode($validationResult['errors']));
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generează nume unic pentru fișier
|
|
||||||
$fileName = uniqid('xml_') . '.xml';
|
$fileName = uniqid('xml_') . '.xml';
|
||||||
$filePath = $uploadDir . $fileName;
|
$filePath = $uploadDir . $fileName;
|
||||||
|
|
||||||
// Salvează fișierul
|
|
||||||
if (file_put_contents($filePath, $xmlContent)) {
|
if (file_put_contents($filePath, $xmlContent)) {
|
||||||
// Răspuns succes
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode(['success' => true, 'fileName' => $fileName]);
|
||||||
'success' => true,
|
|
||||||
'fileName' => $fileName
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Exception('Eroare la salvarea fișierului');
|
throw new Exception('Eroare la salvarea fișierului');
|
||||||
}
|
}
|
||||||
@@ -158,19 +323,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
error_log("Eroare procesare XML: " . $e->getMessage());
|
error_log("Eroare procesare XML: " . $e->getMessage());
|
||||||
echo json_encode([
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
'success' => false,
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Procesare request GET (curățare fișiere temporare)
|
// === 8. Curățare manuală fișiere temporare (?cleanup=xml_XXXX.xml) =========
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['cleanup'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['cleanup'])) {
|
||||||
$fileName = basename($_GET['cleanup']); // Sanitizare nume fișier
|
$fileName = basename($_GET['cleanup']);
|
||||||
if (preg_match('/^xml_[a-f0-9]+\.xml$/', $fileName)) { // Verifică formatul numelui
|
if (preg_match('/^xml_[a-f0-9]+\.xml$/', $fileName)) {
|
||||||
$filePath = $uploadDir . $fileName;
|
$filePath = $uploadDir . $fileName;
|
||||||
|
|
||||||
if (file_exists($filePath)) {
|
if (file_exists($filePath)) {
|
||||||
if (unlink($filePath)) {
|
if (unlink($filePath)) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -178,35 +340,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['cleanup'])) {
|
|||||||
} else {
|
} else {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
error_log("Nu s-a putut șterge fișierul: " . $filePath);
|
error_log("Nu s-a putut șterge: " . $filePath);
|
||||||
echo json_encode([
|
echo json_encode(['success' => false, 'error' => 'Nu s-a putut șterge fișierul']);
|
||||||
'success' => false,
|
|
||||||
'error' => 'Nu s-a putut șterge fișierul'
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode([
|
echo json_encode(['success' => false, 'error' => 'Fișierul nu există']);
|
||||||
'success' => false,
|
|
||||||
'error' => 'Fișierul nu există'
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
error_log("Nume fișier invalid solicitat: " . $fileName);
|
error_log("Nume fișier invalid solicitat: " . $fileName);
|
||||||
echo json_encode([
|
echo json_encode(['success' => false, 'error' => 'Nume fișier invalid']);
|
||||||
'success' => false,
|
|
||||||
'error' => 'Nume fișier invalid'
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Curățare automată a fișierelor vechi
|
// === 9. Curățare automată fișiere vechi =====================================
|
||||||
$files = glob($uploadDir . 'xml_*.xml');
|
$files = glob($uploadDir . 'xml_*.xml');
|
||||||
$now = time();
|
$now = time();
|
||||||
$maxAge = $config['temp_file_lifetime'] * 3600; // Conversie ore în secunde
|
$maxAge = ($config['temp_file_lifetime'] ?? 1) * 3600;
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
if ($now - filemtime($file) > $maxAge) {
|
if ($now - filemtime($file) > $maxAge) {
|
||||||
@@ -214,4 +368,4 @@ foreach ($files as $file) {
|
|||||||
error_log("Fișier vechi șters: " . basename($file));
|
error_log("Fișier vechi șters: " . basename($file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,331 +1,377 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Factură</title>
|
<title>Factură</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #2563eb;
|
--primary-color: #2563eb;
|
||||||
--text-color: #1e293b;
|
--text-color: #1e293b;
|
||||||
--text-light: #64748b;
|
--text-light: #64748b;
|
||||||
--border-color: #e2e8f0;
|
--border-color: #e2e8f0;
|
||||||
--background-light: #f8fafc;
|
--background-light: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1cm;
|
padding: 1cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-container {
|
.invoice-container {
|
||||||
max-width: 210mm;
|
max-width: 210mm;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-header {
|
.invoice-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 2px solid var(--primary-color);
|
border-bottom: 2px solid var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-title {
|
.invoice-title {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-number, .invoice-dates {
|
.invoice-number, .invoice-dates {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-section {
|
.qr-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: -3rem;
|
margin-left: -3rem;
|
||||||
margin-top: -0.5rem;
|
margin-top: -0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#qrcode {
|
#qrcode {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-invoice-info {
|
.e-invoice-info {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.party-details {
|
.party-details {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.party-box {
|
.party-box {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.party-title {
|
.party-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-table {
|
.items-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-table th,
|
.items-table th,
|
||||||
.items-table td {
|
.items-table td {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-table th:nth-child(1),
|
.items-table th:nth-child(1),
|
||||||
.items-table td:nth-child(1) {
|
.items-table td:nth-child(1) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-table th:nth-child(4),
|
.items-table th:nth-child(4),
|
||||||
.items-table td:nth-child(4),
|
.items-table td:nth-child(4),
|
||||||
.items-table th:nth-child(5),
|
.items-table th:nth-child(5),
|
||||||
.items-table td:nth-child(5),
|
.items-table td:nth-child(5),
|
||||||
.items-table th:nth-child(6),
|
.items-table th:nth-child(6),
|
||||||
.items-table td:nth-child(6),
|
.items-table td:nth-child(6),
|
||||||
.items-table th:nth-child(7),
|
.items-table th:nth-child(7),
|
||||||
.items-table td:nth-child(7) {
|
.items-table td:nth-child(7) {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-section {
|
.note-section {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.totals-container {
|
.totals-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currency-info {
|
.currency-info {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currency-code {
|
.currency-code {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.totals-section {
|
.totals-section {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-row {
|
.total-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-row.final {
|
.total-row.final {
|
||||||
border-top: 1px solid var(--primary-color);
|
border-top: 1px solid var(--primary-color);
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vat-breakdown {
|
.vat-breakdown {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vat-grid {
|
.vat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 1cm;
|
margin: 1cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
</head>
|
<style>
|
||||||
<body>
|
.pdf-toolbar { display: flex; gap: 8px; justify-content: flex-end; margin-bottom: 10px; }
|
||||||
<div class="invoice-container">
|
.pdf-toolbar button {
|
||||||
<div class="invoice-header">
|
padding: 5px 12px; font-size: 12px; border-radius: 4px; cursor: pointer; border: 1px solid;
|
||||||
<div>
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
<h1 class="invoice-title">FACTURĂ</h1>
|
}
|
||||||
<div class="invoice-number">Seria & Nr: <span id="print-invoice-number"></span></div>
|
.pdf-toolbar .btn-pdf { background: #1e40af; color: #fff; border-color: #1e40af; }
|
||||||
<div class="invoice-dates">
|
.pdf-toolbar .btn-pdf:hover { background: #1e3a8a; }
|
||||||
Data emiterii: <span id="print-issue-date"></span> |
|
.pdf-toolbar .btn-pdf:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
Data scadentă: <span id="print-due-date"></span>
|
.pdf-toolbar .btn-print { background: transparent; color: #57534e; border-color: #d6d3d1; }
|
||||||
</div>
|
.pdf-toolbar .btn-print:hover { background: #f5f5f4; }
|
||||||
</div>
|
@media print { .pdf-toolbar { display: none !important; } }
|
||||||
<div class="qr-section">
|
</style>
|
||||||
<div id="qrcode"></div>
|
</head>
|
||||||
<div class="e-invoice-info"></div>
|
<body>
|
||||||
</div>
|
<div class="pdf-toolbar">
|
||||||
</div>
|
<button class="btn-print" onclick="window.print()">Printează</button>
|
||||||
|
<button id="btnSavePdf" class="btn-pdf" onclick="savePdf()">Descarcă PDF</button>
|
||||||
<div class="party-details">
|
</div>
|
||||||
<div class="party-box">
|
<div class="invoice-container" id="invoice-content">
|
||||||
<div class="party-title">Furnizor</div>
|
<div class="invoice-header">
|
||||||
<div id="print-supplier-details"></div>
|
<div>
|
||||||
</div>
|
<h1 class="invoice-title">FACTURĂ</h1>
|
||||||
<div class="party-box">
|
<div class="invoice-number">Seria & Nr: <span id="print-invoice-number"></span></div>
|
||||||
<div class="party-title">Client</div>
|
<div class="invoice-dates">
|
||||||
<div id="print-customer-details"></div>
|
Data emiterii: <span id="print-issue-date"></span> |
|
||||||
</div>
|
Data scadentă: <span id="print-due-date"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<table class="items-table">
|
<div class="qr-section">
|
||||||
<thead>
|
<div id="qrcode"></div>
|
||||||
<tr>
|
<div class="e-invoice-info"></div>
|
||||||
<th>Nr.</th>
|
</div>
|
||||||
<th>Denumire</th>
|
</div>
|
||||||
<th>UM</th>
|
|
||||||
<th>Cant.</th>
|
<div class="party-details">
|
||||||
<th>Preț</th>
|
<div class="party-box">
|
||||||
<th>TVA</th>
|
<div class="party-title">Furnizor</div>
|
||||||
<th>Total</th>
|
<div id="print-supplier-details"></div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="party-box">
|
||||||
<tbody id="print-items"></tbody>
|
<div class="party-title">Client</div>
|
||||||
</table>
|
<div id="print-customer-details"></div>
|
||||||
|
</div>
|
||||||
<div class="note-section" id="print-note" style="display: none;">
|
</div>
|
||||||
<h3>Text Adițional:</h3>
|
|
||||||
<div></div>
|
<table class="items-table">
|
||||||
</div>
|
<thead>
|
||||||
|
<tr>
|
||||||
<div class="totals-container">
|
<th>Nr.</th>
|
||||||
<div class="currency-info">
|
<th>Denumire</th>
|
||||||
<p>Monedă Factură: <span class="currency-code" id="print-document-currency"></span></p>
|
<th>UM</th>
|
||||||
<p id="print-tax-currency-container" style="display: none;">
|
<th>Cant.</th>
|
||||||
Monedă TVA: <span class="currency-code" id="print-tax-currency"></span>
|
<th>Preț</th>
|
||||||
<br>
|
<th>TVA</th>
|
||||||
Curs valutar: <span id="print-exchange-rate"></span>
|
<th>Total</th>
|
||||||
</p>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
|
<tbody id="print-items"></tbody>
|
||||||
<div class="totals-section">
|
</table>
|
||||||
<div class="total-row">
|
|
||||||
<span>Subtotal:</span>
|
<div class="note-section" id="print-note" style="display: none;">
|
||||||
<span id="print-subtotal"></span>
|
<h3>Text Adițional:</h3>
|
||||||
</div>
|
<div></div>
|
||||||
<div id="print-allowances-row" style="display: none;">
|
</div>
|
||||||
<div class="total-row">
|
|
||||||
<span>Reduceri:</span>
|
<div class="totals-container">
|
||||||
<span id="print-allowances"></span>
|
<div class="currency-info">
|
||||||
</div>
|
<p>Monedă Factură: <span class="currency-code" id="print-document-currency"></span></p>
|
||||||
</div>
|
<p id="print-tax-currency-container" style="display: none;">
|
||||||
<div id="print-charges-row" style="display: none;">
|
Monedă TVA: <span class="currency-code" id="print-tax-currency"></span>
|
||||||
<div class="total-row">
|
<br>
|
||||||
<span>Taxe:</span>
|
Curs valutar: <span id="print-exchange-rate"></span>
|
||||||
<span id="print-charges"></span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="total-row">
|
<div class="totals-section">
|
||||||
<span>Valoare Netă:</span>
|
<div class="total-row">
|
||||||
<span id="print-net-amount"></span>
|
<span>Subtotal:</span>
|
||||||
</div>
|
<span id="print-subtotal"></span>
|
||||||
|
</div>
|
||||||
<div class="vat-breakdown">
|
<div id="print-allowances-row" style="display: none;">
|
||||||
<div class="vat-grid">
|
<div class="total-row">
|
||||||
<div>Tip TVA</div>
|
<span>Reduceri:</span>
|
||||||
<div>Cotă</div>
|
<span id="print-allowances"></span>
|
||||||
<div>Bază</div>
|
</div>
|
||||||
<div>TVA</div>
|
</div>
|
||||||
</div>
|
<div id="print-charges-row" style="display: none;">
|
||||||
<div class="vat-grid" id="print-vat-breakdown"></div>
|
<div class="total-row">
|
||||||
|
<span>Taxe:</span>
|
||||||
<div class="total-row">
|
<span id="print-charges"></span>
|
||||||
<span>Total TVA (<span id="print-vat-currency-main"></span>):</span>
|
</div>
|
||||||
<span id="print-vat-main"></span>
|
</div>
|
||||||
</div>
|
<div class="total-row">
|
||||||
<div id="print-vat-secondary" class="total-row" style="display: none;">
|
<span>Valoare Netă:</span>
|
||||||
<span>TVA (<span id="print-vat-currency-secondary"></span>):</span>
|
<span id="print-net-amount"></span>
|
||||||
<span id="print-vat-secondary-amount"></span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div class="vat-breakdown">
|
||||||
|
<div class="vat-grid">
|
||||||
<div class="total-row final">
|
<div>Tip TVA</div>
|
||||||
<span>Total cu TVA:</span>
|
<div>Cotă</div>
|
||||||
<span id="print-total"></span>
|
<div>Bază</div>
|
||||||
</div>
|
<div>TVA</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="vat-grid" id="print-vat-breakdown"></div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="total-row">
|
||||||
|
<span>Total TVA (<span id="print-vat-currency-main"></span>):</span>
|
||||||
</div>
|
<span id="print-vat-main"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="print-vat-secondary" class="total-row" style="display: none;">
|
||||||
<script>
|
<span>TVA (<span id="print-vat-currency-secondary"></span>):</span>
|
||||||
function updateAllowancesChargesVisibility() {
|
<span id="print-vat-secondary-amount"></span>
|
||||||
const allowancesRow = document.getElementById('print-allowances-row');
|
</div>
|
||||||
const chargesRow = document.getElementById('print-charges-row');
|
</div>
|
||||||
const allowancesAmount = parseFloat(document.getElementById('print-allowances').textContent) || 0;
|
|
||||||
const chargesAmount = parseFloat(document.getElementById('print-charges').textContent) || 0;
|
<div class="total-row final">
|
||||||
|
<span>Total cu TVA:</span>
|
||||||
allowancesRow.style.display = allowancesAmount > 0 ? 'block' : 'none';
|
<span id="print-total"></span>
|
||||||
chargesRow.style.display = chargesAmount > 0 ? 'block' : 'none';
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
window.addEventListener('load', updateAllowancesChargesVisibility);
|
|
||||||
</script>
|
<div class="footer">
|
||||||
</body>
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateAllowancesChargesVisibility() {
|
||||||
|
const allowancesRow = document.getElementById('print-allowances-row');
|
||||||
|
const chargesRow = document.getElementById('print-charges-row');
|
||||||
|
const allowancesAmount = parseFloat(document.getElementById('print-allowances').textContent) || 0;
|
||||||
|
const chargesAmount = parseFloat(document.getElementById('print-charges').textContent) || 0;
|
||||||
|
|
||||||
|
allowancesRow.style.display = allowancesAmount > 0 ? 'block' : 'none';
|
||||||
|
chargesRow.style.display = chargesAmount > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', updateAllowancesChargesVisibility);
|
||||||
|
|
||||||
|
async function savePdf() {
|
||||||
|
const btn = document.getElementById('btnSavePdf');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Generare…'; }
|
||||||
|
try {
|
||||||
|
if (!window.html2pdf) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = '../js/vendor/html2pdf.bundle.min.js';
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error('Bundle html2pdf indisponibil'));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const element = document.getElementById('invoice-content');
|
||||||
|
const invoiceNumber = document.getElementById('print-invoice-number')?.textContent || 'factura';
|
||||||
|
await window.html2pdf().set({
|
||||||
|
margin: [8, 8, 8, 8],
|
||||||
|
filename: 'factura_' + invoiceNumber.replace(/[^a-zA-Z0-9_-]/g, '_') + '.pdf',
|
||||||
|
image: { type: 'jpeg', quality: 0.95 },
|
||||||
|
html2canvas: { scale: 2, useCORS: true },
|
||||||
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||||
|
}).from(element).save();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Eroare la generarea PDF: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Descarcă PDF'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,378 +1,446 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Factură</title>
|
<title>Factură</title>
|
||||||
<style>
|
<style>
|
||||||
/* Variables - Keep original colors */
|
/* Variables - Keep original colors */
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #2563eb;
|
--primary-color: #2563eb;
|
||||||
--text-color: #1e293b;
|
--text-color: #1e293b;
|
||||||
--text-light: #64748b;
|
--text-light: #64748b;
|
||||||
--border-color: #e2e8f0;
|
--border-color: #e2e8f0;
|
||||||
--background-light: #f8fafc;
|
--background-light: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base Styles */
|
/* Base Styles */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background: white;
|
background: white;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 1cm;
|
padding: 1cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
.invoice-container {
|
.invoice-container {
|
||||||
max-width: 210mm;
|
max-width: 210mm;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.invoice-header {
|
.invoice-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
border-bottom: 2px solid var(--primary-color);
|
border-bottom: 2px solid var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-title-section {
|
.invoice-title-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-title {
|
.invoice-title {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-number {
|
.invoice-number {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-dates {
|
.invoice-dates {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* QR Code */
|
/* QR Code */
|
||||||
.qr-section {
|
.qr-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 2rem;
|
margin-left: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#qrcode {
|
#qrcode {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e-invoice-info {
|
.e-invoice-info {
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Party Details */
|
/* Party Details */
|
||||||
.party-details {
|
.party-details {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.party-box {
|
.party-box {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.party-title {
|
.party-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.party-info p {
|
.party-info p {
|
||||||
margin: 0.15rem 0;
|
margin: 0.15rem 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Items Table */
|
/* Items Table */
|
||||||
.items-table {
|
.items-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-table th,
|
.items-table th,
|
||||||
.items-table td {
|
.items-table td {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-table th:nth-child(1),
|
.items-table th:nth-child(1),
|
||||||
.items-table td:nth-child(1) {
|
.items-table td:nth-child(1) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.items-table th:nth-child(4),
|
.items-table th:nth-child(4),
|
||||||
.items-table td:nth-child(4),
|
.items-table td:nth-child(4),
|
||||||
.items-table th:nth-child(5),
|
.items-table th:nth-child(5),
|
||||||
.items-table td:nth-child(5),
|
.items-table td:nth-child(5),
|
||||||
.items-table th:nth-child(6),
|
.items-table th:nth-child(6),
|
||||||
.items-table td:nth-child(6),
|
.items-table td:nth-child(6),
|
||||||
.items-table th:nth-child(7),
|
.items-table th:nth-child(7),
|
||||||
.items-table td:nth-child(7) {
|
.items-table td:nth-child(7) {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Note Section */
|
/* Note Section */
|
||||||
.note-section {
|
.note-section {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-section h3 {
|
.note-section h3 {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Totals Container */
|
/* Totals Container */
|
||||||
.totals-container {
|
.totals-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currency-info {
|
.currency-info {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currency-code {
|
.currency-code {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.totals-section {
|
.totals-section {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-row {
|
.total-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-row.final {
|
.total-row.final {
|
||||||
border-bottom: 2px solid var(--primary-color);
|
border-bottom: 2px solid var(--primary-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* VAT Breakdown */
|
/* VAT Breakdown */
|
||||||
.vat-breakdown {
|
.vat-breakdown {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--background-light);
|
background: var(--background-light);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vat-title {
|
.vat-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vat-grid {
|
.vat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vat-grid-header {
|
.vat-grid-header {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vat-amount-row {
|
.vat-amount-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Print Styles */
|
/* Print Styles */
|
||||||
@media print {
|
@media print {
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 1cm;
|
margin: 1cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-print {
|
.no-print {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-in-print {
|
.show-in-print {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
</head>
|
<style>
|
||||||
<body>
|
/* Toolbar PDF — ascuns la print */
|
||||||
<div class="invoice-container">
|
.pdf-toolbar {
|
||||||
<div class="invoice-header">
|
display: flex;
|
||||||
<div class="invoice-title-section">
|
gap: 8px;
|
||||||
<h1 class="invoice-title">FACTURĂ</h1>
|
justify-content: flex-end;
|
||||||
<div class="invoice-number">Seria & Nr: <span id="print-invoice-number"></span></div>
|
margin-bottom: 12px;
|
||||||
<div class="invoice-dates">
|
}
|
||||||
Data emiterii: <span id="print-issue-date"></span> |
|
.pdf-toolbar button {
|
||||||
Data scadentă: <span id="print-due-date"></span>
|
padding: 6px 14px;
|
||||||
</div>
|
font-size: 12px;
|
||||||
</div>
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
<div class="qr-section">
|
border-radius: 4px;
|
||||||
<div id="qrcode"></div>
|
cursor: pointer;
|
||||||
<div class="e-invoice-info"></div>
|
border: 1px solid;
|
||||||
</div>
|
}
|
||||||
</div>
|
.pdf-toolbar .btn-pdf {
|
||||||
|
background: #1e40af;
|
||||||
<div class="party-details">
|
color: #fff;
|
||||||
<div class="party-box">
|
border-color: #1e40af;
|
||||||
<div class="party-title">Furnizor</div>
|
}
|
||||||
<div class="party-info" id="print-supplier-details"></div>
|
.pdf-toolbar .btn-pdf:hover { background: #1e3a8a; }
|
||||||
</div>
|
.pdf-toolbar .btn-pdf:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
<div class="party-box">
|
.pdf-toolbar .btn-print {
|
||||||
<div class="party-title">Client</div>
|
background: transparent;
|
||||||
<div class="party-info" id="print-customer-details"></div>
|
color: #57534e;
|
||||||
</div>
|
border-color: #d6d3d1;
|
||||||
</div>
|
}
|
||||||
|
.pdf-toolbar .btn-print:hover { background: #f5f5f4; }
|
||||||
<table class="items-table">
|
@media print { .pdf-toolbar { display: none !important; } }
|
||||||
<thead>
|
</style>
|
||||||
<tr>
|
</head>
|
||||||
<th>Nr.</th>
|
<body>
|
||||||
<th>Denumire</th>
|
<div class="pdf-toolbar no-print">
|
||||||
<th>UM</th>
|
<button class="btn-print" onclick="window.print()">Printează</button>
|
||||||
<th>Cant.</th>
|
<button id="btnSavePdf" class="btn-pdf" onclick="savePdf()">Descarcă PDF</button>
|
||||||
<th>Preț</th>
|
</div>
|
||||||
<th>TVA</th>
|
<div class="invoice-container" id="invoice-content">
|
||||||
<th>Total</th>
|
<div class="invoice-header">
|
||||||
</tr>
|
<div class="invoice-title-section">
|
||||||
</thead>
|
<h1 class="invoice-title">FACTURĂ</h1>
|
||||||
<tbody id="print-items"></tbody>
|
<div class="invoice-number">Seria & Nr: <span id="print-invoice-number"></span></div>
|
||||||
</table>
|
<div class="invoice-dates">
|
||||||
|
Data emiterii: <span id="print-issue-date"></span> |
|
||||||
<div class="note-section" id="print-note" style="display: none;">
|
Data scadentă: <span id="print-due-date"></span>
|
||||||
<h3>Text Adițional:</h3>
|
</div>
|
||||||
<div></div>
|
</div>
|
||||||
</div>
|
<div class="qr-section">
|
||||||
|
<div id="qrcode"></div>
|
||||||
<div class="totals-container">
|
<div class="e-invoice-info"></div>
|
||||||
<div class="currency-info">
|
</div>
|
||||||
<p>Monedă Factură: <span class="currency-code" id="print-document-currency"></span></p>
|
</div>
|
||||||
<p id="print-tax-currency-container" style="display: none;">
|
|
||||||
Monedă TVA: <span class="currency-code" id="print-tax-currency"></span>
|
<div class="party-details">
|
||||||
<br>
|
<div class="party-box">
|
||||||
Curs valutar: <span id="print-exchange-rate"></span>
|
<div class="party-title">Furnizor</div>
|
||||||
</p>
|
<div class="party-info" id="print-supplier-details"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="party-box">
|
||||||
<div class="totals-section">
|
<div class="party-title">Client</div>
|
||||||
<div class="total-row">
|
<div class="party-info" id="print-customer-details"></div>
|
||||||
<span>Subtotal:</span>
|
</div>
|
||||||
<span id="print-subtotal"></span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="total-row">
|
<table class="items-table">
|
||||||
<span>Total Reduceri:</span>
|
<thead>
|
||||||
<span id="print-allowances"></span>
|
<tr>
|
||||||
</div>
|
<th>Nr.</th>
|
||||||
<div class="total-row">
|
<th>Denumire</th>
|
||||||
<span>Total Taxe:</span>
|
<th>UM</th>
|
||||||
<span id="print-charges"></span>
|
<th>Cant.</th>
|
||||||
</div>
|
<th>Preț</th>
|
||||||
<div class="total-row">
|
<th>TVA</th>
|
||||||
<span>Valoare Netă:</span>
|
<th>Total</th>
|
||||||
<span id="print-net-amount"></span>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
|
<tbody id="print-items"></tbody>
|
||||||
<div class="vat-breakdown">
|
</table>
|
||||||
<div class="vat-title">Defalcare TVA</div>
|
|
||||||
<div class="vat-grid">
|
<div class="note-section" id="print-note" style="display: none;">
|
||||||
<div class="vat-grid-header">Tip TVA</div>
|
<h3>Text Adițional:</h3>
|
||||||
<div class="vat-grid-header">Cotă</div>
|
<div></div>
|
||||||
<div class="vat-grid-header">Bază</div>
|
</div>
|
||||||
<div class="vat-grid-header">TVA</div>
|
|
||||||
</div>
|
<div class="totals-container">
|
||||||
<div class="vat-grid" id="print-vat-breakdown"></div>
|
<div class="currency-info">
|
||||||
|
<p>Monedă Factură: <span class="currency-code" id="print-document-currency"></span></p>
|
||||||
<div id="print-vat-currencies" class="vat-amount-row">
|
<p id="print-tax-currency-container" style="display: none;">
|
||||||
<span>Total TVA (<span id="print-vat-currency-main"></span>):</span>
|
Monedă TVA: <span class="currency-code" id="print-tax-currency"></span>
|
||||||
<span id="print-vat-main"></span>
|
<br>
|
||||||
</div>
|
Curs valutar: <span id="print-exchange-rate"></span>
|
||||||
<div id="print-vat-secondary" class="vat-amount-row" style="display: none;">
|
</p>
|
||||||
<span>Total TVA (<span id="print-vat-currency-secondary"></span>):</span>
|
</div>
|
||||||
<span id="print-vat-secondary-amount"></span>
|
|
||||||
</div>
|
<div class="totals-section">
|
||||||
</div>
|
<div class="total-row">
|
||||||
|
<span>Subtotal:</span>
|
||||||
<div class="total-row final">
|
<span id="print-subtotal"></span>
|
||||||
<span>Total cu TVA:</span>
|
</div>
|
||||||
<span id="print-total"></span>
|
<div class="total-row">
|
||||||
</div>
|
<span>Total Reduceri:</span>
|
||||||
</div>
|
<span id="print-allowances"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
<div class="footer">
|
<span>Total Taxe:</span>
|
||||||
|
<span id="print-charges"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="total-row">
|
||||||
</body>
|
<span>Valoare Netă:</span>
|
||||||
|
<span id="print-net-amount"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vat-breakdown">
|
||||||
|
<div class="vat-title">Defalcare TVA</div>
|
||||||
|
<div class="vat-grid">
|
||||||
|
<div class="vat-grid-header">Tip TVA</div>
|
||||||
|
<div class="vat-grid-header">Cotă</div>
|
||||||
|
<div class="vat-grid-header">Bază</div>
|
||||||
|
<div class="vat-grid-header">TVA</div>
|
||||||
|
</div>
|
||||||
|
<div class="vat-grid" id="print-vat-breakdown"></div>
|
||||||
|
|
||||||
|
<div id="print-vat-currencies" class="vat-amount-row">
|
||||||
|
<span>Total TVA (<span id="print-vat-currency-main"></span>):</span>
|
||||||
|
<span id="print-vat-main"></span>
|
||||||
|
</div>
|
||||||
|
<div id="print-vat-secondary" class="vat-amount-row" style="display: none;">
|
||||||
|
<span>Total TVA (<span id="print-vat-currency-secondary"></span>):</span>
|
||||||
|
<span id="print-vat-secondary-amount"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-row final">
|
||||||
|
<span>Total cu TVA:</span>
|
||||||
|
<span id="print-total"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Descarcă PDF via html2pdf.js (bundle local)
|
||||||
|
async function savePdf() {
|
||||||
|
const btn = document.getElementById('btnSavePdf');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Generare…'; }
|
||||||
|
try {
|
||||||
|
// Încarcă bundle-ul UMD html2pdf.js (dacă nu e deja)
|
||||||
|
if (!window.html2pdf) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = '../js/vendor/html2pdf.bundle.min.js';
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error('Bundle html2pdf indisponibil'));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const element = document.getElementById('invoice-content');
|
||||||
|
const invoiceNumber = document.getElementById('print-invoice-number')?.textContent || 'factura';
|
||||||
|
await window.html2pdf().set({
|
||||||
|
margin: [10, 10, 10, 10],
|
||||||
|
filename: 'factura_' + invoiceNumber.replace(/[^a-zA-Z0-9_-]/g, '_') + '.pdf',
|
||||||
|
image: { type: 'jpeg', quality: 0.95 },
|
||||||
|
html2canvas: { scale: 2, useCORS: true },
|
||||||
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||||
|
}).from(element).save();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Eroare la generarea PDF: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Descarcă PDF'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user