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:
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 {
|
||||
constructor() {
|
||||
this.locale = navigator.language;
|
||||
|
||||
this.currencyFormatter = new Intl.NumberFormat(this.locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: true
|
||||
});
|
||||
|
||||
this.quantityFormatter = new Intl.NumberFormat(this.locale, {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: true
|
||||
});
|
||||
|
||||
this.numberFormatter = new Intl.NumberFormat(this.locale, {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
useGrouping: true
|
||||
});
|
||||
}
|
||||
|
||||
formatCurrency(value) {
|
||||
const numValue = parseFloat(value);
|
||||
return isNaN(numValue) ? '0,00' : this.currencyFormatter.format(numValue);
|
||||
}
|
||||
|
||||
formatQuantity(value) {
|
||||
const numValue = parseFloat(value);
|
||||
return isNaN(numValue) ? '0,000' : this.quantityFormatter.format(numValue);
|
||||
}
|
||||
|
||||
formatNumber(value) {
|
||||
const numValue = parseFloat(value);
|
||||
return isNaN(numValue) ? '0,0000' : this.numberFormatter.format(numValue);
|
||||
}
|
||||
|
||||
parseCurrency(value) {
|
||||
if (typeof value !== 'string') {
|
||||
value = value.toString();
|
||||
}
|
||||
// Remove all non-digit characters except decimal and minus
|
||||
const normalized = value.replace(/[^\d\-.,]/g, '')
|
||||
// Replace thousands separator
|
||||
.replace(/[.,](?=.*[.,])/g, '')
|
||||
// Last dot/comma is decimal separator
|
||||
.replace(/[.,]/, '.');
|
||||
return parseFloat(normalized) || 0;
|
||||
}
|
||||
|
||||
parseQuantity(value) {
|
||||
if (typeof value !== 'string') {
|
||||
value = value.toString();
|
||||
}
|
||||
const normalized = value.replace(/[^\d\-.,]/g, '')
|
||||
.replace(/[.,](?=.*[.,])/g, '')
|
||||
.replace(/[.,]/, '.');
|
||||
return parseFloat(normalized) || 0;
|
||||
}
|
||||
|
||||
parseNumber(value) {
|
||||
if (typeof value !== 'string') {
|
||||
value = value.toString();
|
||||
}
|
||||
const normalized = value.replace(/[^\d\-.,]/g, '')
|
||||
.replace(/[.,](?=.*[.,])/g, '')
|
||||
.replace(/[.,]/, '.');
|
||||
return parseFloat(normalized) || 0;
|
||||
}
|
||||
}
|
||||
// js/formatter.js
|
||||
//
|
||||
// Compatibility-layer formatter folosit de print template + script.js
|
||||
// pentru afișare. Internal delegate la js/numeric.js (PR-E E1+E3+E4).
|
||||
//
|
||||
// E2: locale hardcoded "ro-RO" (înlocuit `navigator.language`). Audiența
|
||||
// țintă e RO; print PDF / display formular trebuie să fie consistent
|
||||
// între browsere și OS-uri.
|
||||
|
||||
import { RO_LOCALE, parseStrict, parseStrictOr, format2, format3, format4 } from './numeric.js';
|
||||
|
||||
export class InvoiceFormatter {
|
||||
constructor() {
|
||||
this.locale = RO_LOCALE;
|
||||
|
||||
this.currencyFormatter = new Intl.NumberFormat(this.locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: true
|
||||
});
|
||||
|
||||
this.quantityFormatter = new Intl.NumberFormat(this.locale, {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: true
|
||||
});
|
||||
|
||||
this.numberFormatter = new Intl.NumberFormat(this.locale, {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
useGrouping: true
|
||||
});
|
||||
}
|
||||
|
||||
formatCurrency(value) {
|
||||
const big = (value === '' || value === null || value === undefined)
|
||||
? null
|
||||
: parseStrict(value);
|
||||
return big === null ? '0,00' : format2(big);
|
||||
}
|
||||
|
||||
formatQuantity(value) {
|
||||
const big = (value === '' || value === null || value === undefined)
|
||||
? null
|
||||
: parseStrict(value);
|
||||
return big === null ? '0,000' : format3(big);
|
||||
}
|
||||
|
||||
formatNumber(value) {
|
||||
const big = (value === '' || value === null || value === undefined)
|
||||
? null
|
||||
: parseStrict(value);
|
||||
return big === null ? '0,0000' : format4(big);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict-but-pragmatic parsing → number (pentru consumatorii vechi).
|
||||
* Pentru cod nou, preferă `parseStrict` din numeric.js (returnează Big).
|
||||
*/
|
||||
parseCurrency(value) {
|
||||
return Number(parseStrictOr(value, '0').toString());
|
||||
}
|
||||
|
||||
parseQuantity(value) {
|
||||
return Number(parseStrictOr(value, '0').toString());
|
||||
}
|
||||
|
||||
parseNumber(value) {
|
||||
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';
|
||||
|
||||
export class InvoicePrintHandler {
|
||||
constructor() {
|
||||
this.printWindow = null;
|
||||
this.formatter = new InvoiceFormatter();
|
||||
this.templates = {
|
||||
standard: './templates/print.html',
|
||||
compact: './templates/print-compact.html'
|
||||
};
|
||||
this.currentTemplate = 'standard';
|
||||
}
|
||||
|
||||
setTemplate(templateName) {
|
||||
if (this.templates[templateName]) {
|
||||
this.currentTemplate = templateName;
|
||||
}
|
||||
}
|
||||
|
||||
collectInvoiceData() {
|
||||
return {
|
||||
// Basic details
|
||||
invoiceNumber: document.querySelector('[name="invoiceNumber"]').value,
|
||||
issueDate: document.querySelector('[name="issueDate"]').value,
|
||||
dueDate: document.querySelector('[name="dueDate"]').value,
|
||||
documentCurrencyCode: document.querySelector('[name="documentCurrencyCode"]').value.toUpperCase() || 'RON',
|
||||
taxCurrencyCode: document.querySelector('[name="taxCurrencyCode"]').value.toUpperCase(),
|
||||
exchangeRate: parseFloat(document.querySelector('[name="exchangeRate"]')?.value || 1),
|
||||
|
||||
// Supplier details
|
||||
supplier: {
|
||||
name: document.querySelector('[name="supplierName"]').value,
|
||||
vat: document.querySelector('[name="supplierVAT"]').value,
|
||||
companyId: document.querySelector('[name="supplierCompanyId"]').value,
|
||||
address: document.querySelector('[name="supplierAddress"]').value,
|
||||
city: document.querySelector('[name="supplierCity"]').value,
|
||||
county: document.querySelector('[name="supplierCountrySubentity"]').value,
|
||||
country: document.querySelector('[name="supplierCountry"]').value,
|
||||
phone: document.querySelector('[name="supplierPhone"]').value,
|
||||
contactName: document.querySelector('[name="supplierContactName"]').value,
|
||||
email: document.querySelector('[name="supplierEmail"]').value
|
||||
},
|
||||
|
||||
// Customer details
|
||||
customer: {
|
||||
name: document.querySelector('[name="customerName"]').value,
|
||||
vat: document.querySelector('[name="customerVAT"]').value,
|
||||
companyId: document.querySelector('[name="customerCompanyId"]').value,
|
||||
address: document.querySelector('[name="customerAddress"]').value,
|
||||
city: document.querySelector('[name="customerCity"]').value,
|
||||
county: document.querySelector('[name="customerCountrySubentity"]').value,
|
||||
country: document.querySelector('[name="customerCountry"]').value,
|
||||
phone: document.querySelector('[name="customerPhone"]').value,
|
||||
contactName: document.querySelector('[name="customerContactName"]').value,
|
||||
email: document.querySelector('[name="customerEmail"]').value
|
||||
},
|
||||
|
||||
// Line items with formatted values
|
||||
items: Array.from(document.querySelectorAll('.line-item')).map((item, index) => ({
|
||||
number: index + 1,
|
||||
description: item.querySelector('[name^="description"]').value,
|
||||
quantity: this.formatter.formatQuantity(item.querySelector('[name^="quantity"]').value),
|
||||
unit: item.querySelector('[name^="unit"]').value,
|
||||
price: this.formatter.formatCurrency(item.querySelector('[name^="price"]').value),
|
||||
vatRate: this.formatter.formatCurrency(item.querySelector('[name^="vatRate"]').value),
|
||||
totalAmount: this.formatter.formatCurrency(
|
||||
this.formatter.parseQuantity(item.querySelector('[name^="quantity"]').value) *
|
||||
this.formatter.parseCurrency(item.querySelector('[name^="price"]').value)
|
||||
)
|
||||
})),
|
||||
|
||||
// Note
|
||||
note: document.querySelector('[name="invoiceNote"]')?.value,
|
||||
|
||||
// Get totals directly from the display elements
|
||||
totals: {
|
||||
subtotal: document.getElementById('subtotal').textContent,
|
||||
allowances: document.getElementById('totalAllowances').textContent,
|
||||
charges: document.getElementById('totalCharges').textContent,
|
||||
netAmount: document.getElementById('netAmount').textContent,
|
||||
vat: document.getElementById('vat').textContent,
|
||||
total: document.getElementById('total').textContent
|
||||
},
|
||||
|
||||
// VAT Breakdown
|
||||
vatBreakdown: Array.from(document.querySelectorAll('.vat-row')).map(row => ({
|
||||
type: row.querySelector('.vat-type').value,
|
||||
rate: row.querySelector('.vat-rate').value,
|
||||
base: row.querySelector('.vat-base').value,
|
||||
amount: row.querySelector('.vat-amount').value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
createPartyHTML(party) {
|
||||
return `
|
||||
<p><strong>${party.name}</strong></p>
|
||||
<p>CUI: ${party.vat}</p>
|
||||
<p>Nr. Reg. Com.: ${party.companyId}</p>
|
||||
<p>${party.address}</p>
|
||||
<p>${party.city}${party.county ? ', ' + party.county : ''}</p>
|
||||
<p>${party.country}</p>
|
||||
${party.phone ? `<p>Tel: ${party.phone}</p>` : ''}
|
||||
${party.contactName ? `<p>Contact: ${party.contactName}</p>` : ''}
|
||||
${party.email ? `<p>Email: ${party.email}</p>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
getVATTypeLabel(type) {
|
||||
const labels = {
|
||||
'S': 'Standard',
|
||||
'AE': 'Taxare Inversă',
|
||||
'O': 'Neplătitor TVA',
|
||||
'Z': 'Cotă 0%',
|
||||
'E': 'Scutit'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
async print() {
|
||||
try {
|
||||
// Collect all the data
|
||||
const invoiceData = this.collectInvoiceData();
|
||||
|
||||
// Open new window and load the selected print template
|
||||
this.printWindow = window.open(
|
||||
this.templates[this.currentTemplate],
|
||||
'_blank',
|
||||
'width=800,height=600'
|
||||
);
|
||||
|
||||
// Wait for the window to load
|
||||
await new Promise(resolve => {
|
||||
this.printWindow.onload = resolve;
|
||||
});
|
||||
|
||||
// Generate QR code
|
||||
const qrData = {
|
||||
invoiceNumber: invoiceData.invoiceNumber,
|
||||
issueDate: invoiceData.issueDate,
|
||||
supplier: invoiceData.supplier.name,
|
||||
customer: invoiceData.customer.name,
|
||||
total: this.formatter.parseCurrency(invoiceData.totals.total)
|
||||
};
|
||||
|
||||
const qrElement = this.printWindow.document.getElementById('qrcode');
|
||||
if (qrElement) {
|
||||
new this.printWindow.QRCode(qrElement, {
|
||||
text: JSON.stringify(qrData),
|
||||
width: 100,
|
||||
height: 100,
|
||||
colorDark: "#2563eb",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: this.printWindow.QRCode.CorrectLevel.L
|
||||
});
|
||||
}
|
||||
|
||||
// Populate the template with data
|
||||
this.populatePrintWindow(invoiceData);
|
||||
|
||||
// Print the window
|
||||
this.printWindow.print();
|
||||
|
||||
// Clean up
|
||||
this.printWindow.onafterprint = () => {
|
||||
this.printWindow.close();
|
||||
this.printWindow = null;
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Print failed:', error);
|
||||
if (this.printWindow) {
|
||||
this.printWindow.close();
|
||||
this.printWindow = null;
|
||||
}
|
||||
alert('A apărut o eroare la printare. Vă rugăm să încercați din nou.');
|
||||
}
|
||||
}
|
||||
|
||||
populatePrintWindow(data) {
|
||||
if (!this.printWindow) return;
|
||||
|
||||
const doc = this.printWindow.document;
|
||||
|
||||
// Basic details
|
||||
doc.getElementById('print-invoice-number').textContent = data.invoiceNumber;
|
||||
doc.getElementById('print-issue-date').textContent = data.issueDate;
|
||||
doc.getElementById('print-due-date').textContent = data.dueDate;
|
||||
doc.getElementById('print-document-currency').textContent = data.documentCurrencyCode;
|
||||
|
||||
// Currency information
|
||||
const taxCurrencyContainer = doc.getElementById('print-tax-currency-container');
|
||||
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
||||
taxCurrencyContainer.style.display = 'block';
|
||||
doc.getElementById('print-tax-currency').textContent = data.taxCurrencyCode;
|
||||
doc.getElementById('print-exchange-rate').textContent = this.formatter.formatNumber(data.exchangeRate);
|
||||
}
|
||||
|
||||
// Party details
|
||||
doc.getElementById('print-supplier-details').innerHTML = this.createPartyHTML(data.supplier);
|
||||
doc.getElementById('print-customer-details').innerHTML = this.createPartyHTML(data.customer);
|
||||
|
||||
// Note
|
||||
if (data.note) {
|
||||
const noteSection = doc.getElementById('print-note');
|
||||
noteSection.style.display = 'block';
|
||||
noteSection.querySelector('div').textContent = data.note;
|
||||
}
|
||||
|
||||
// Line items - use formatted values from data
|
||||
doc.getElementById('print-items').innerHTML = data.items.map(item => `
|
||||
<tr>
|
||||
<td>${item.number}</td>
|
||||
<td>${item.description}</td>
|
||||
<td>${item.unit}</td>
|
||||
<td class="number-cell">${item.quantity}</td>
|
||||
<td class="number-cell">${item.price}</td>
|
||||
<td class="number-cell">${item.vatRate}%</td>
|
||||
<td class="number-cell">${item.totalAmount}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Totals - use values directly from display
|
||||
doc.getElementById('print-subtotal').textContent = data.totals.subtotal;
|
||||
doc.getElementById('print-allowances').textContent = data.totals.allowances;
|
||||
doc.getElementById('print-charges').textContent = data.totals.charges;
|
||||
doc.getElementById('print-net-amount').textContent = data.totals.netAmount;
|
||||
doc.getElementById('print-total').textContent = data.totals.total;
|
||||
|
||||
// VAT Breakdown - use values directly from display
|
||||
doc.getElementById('print-vat-breakdown').innerHTML = data.vatBreakdown.map(vat => `
|
||||
<div>${this.getVATTypeLabel(vat.type)}</div>
|
||||
<div>${vat.rate}%</div>
|
||||
<div>${vat.base}</div>
|
||||
<div>${vat.amount}</div>
|
||||
`).join('');
|
||||
|
||||
// VAT totals
|
||||
doc.getElementById('print-vat-currency-main').textContent = data.documentCurrencyCode;
|
||||
doc.getElementById('print-vat-main').textContent = data.totals.vat;
|
||||
|
||||
const secondaryVatRow = doc.getElementById('print-vat-secondary');
|
||||
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
||||
secondaryVatRow.style.display = 'flex';
|
||||
doc.getElementById('print-vat-currency-secondary').textContent = data.taxCurrencyCode;
|
||||
const vatInTaxCurrency = this.formatter.parseCurrency(data.totals.vat) * data.exchangeRate;
|
||||
doc.getElementById('print-vat-secondary-amount').textContent =
|
||||
this.formatter.formatCurrency(vatInTaxCurrency);
|
||||
}
|
||||
}
|
||||
import { InvoiceFormatter } from './formatter.js';
|
||||
|
||||
export class InvoicePrintHandler {
|
||||
constructor() {
|
||||
this.printWindow = null;
|
||||
this.formatter = new InvoiceFormatter();
|
||||
this.templates = {
|
||||
standard: './templates/print.html',
|
||||
compact: './templates/print-compact.html'
|
||||
};
|
||||
this.currentTemplate = 'standard';
|
||||
}
|
||||
|
||||
setTemplate(templateName) {
|
||||
if (this.templates[templateName]) {
|
||||
this.currentTemplate = templateName;
|
||||
}
|
||||
}
|
||||
|
||||
collectInvoiceData() {
|
||||
return {
|
||||
// Basic details
|
||||
invoiceNumber: document.querySelector('[name="invoiceNumber"]').value,
|
||||
issueDate: document.querySelector('[name="issueDate"]').value,
|
||||
dueDate: document.querySelector('[name="dueDate"]').value,
|
||||
documentCurrencyCode: document.querySelector('[name="documentCurrencyCode"]').value.toUpperCase() || 'RON',
|
||||
taxCurrencyCode: document.querySelector('[name="taxCurrencyCode"]').value.toUpperCase(),
|
||||
exchangeRate: parseFloat(document.querySelector('[name="exchangeRate"]')?.value || 1),
|
||||
|
||||
// Supplier details
|
||||
supplier: {
|
||||
name: document.querySelector('[name="supplierName"]').value,
|
||||
vat: document.querySelector('[name="supplierVAT"]').value,
|
||||
companyId: document.querySelector('[name="supplierCompanyId"]').value,
|
||||
address: document.querySelector('[name="supplierAddress"]').value,
|
||||
city: document.querySelector('[name="supplierCity"]').value,
|
||||
county: document.querySelector('[name="supplierCountrySubentity"]').value,
|
||||
country: document.querySelector('[name="supplierCountry"]').value,
|
||||
phone: document.querySelector('[name="supplierPhone"]').value,
|
||||
contactName: document.querySelector('[name="supplierContactName"]').value,
|
||||
email: document.querySelector('[name="supplierEmail"]').value
|
||||
},
|
||||
|
||||
// Customer details
|
||||
customer: {
|
||||
name: document.querySelector('[name="customerName"]').value,
|
||||
vat: document.querySelector('[name="customerVAT"]').value,
|
||||
companyId: document.querySelector('[name="customerCompanyId"]').value,
|
||||
address: document.querySelector('[name="customerAddress"]').value,
|
||||
city: document.querySelector('[name="customerCity"]').value,
|
||||
county: document.querySelector('[name="customerCountrySubentity"]').value,
|
||||
country: document.querySelector('[name="customerCountry"]').value,
|
||||
phone: document.querySelector('[name="customerPhone"]').value,
|
||||
contactName: document.querySelector('[name="customerContactName"]').value,
|
||||
email: document.querySelector('[name="customerEmail"]').value
|
||||
},
|
||||
|
||||
// Line items with formatted values
|
||||
items: Array.from(document.querySelectorAll('.line-item')).map((item, index) => ({
|
||||
number: index + 1,
|
||||
description: item.querySelector('[name^="description"]').value,
|
||||
quantity: this.formatter.formatQuantity(item.querySelector('[name^="quantity"]').value),
|
||||
unit: item.querySelector('[name^="unit"]').value,
|
||||
price: this.formatter.formatCurrency(item.querySelector('[name^="price"]').value),
|
||||
vatRate: this.formatter.formatCurrency(item.querySelector('[name^="vatRate"]').value),
|
||||
totalAmount: this.formatter.formatCurrency(
|
||||
this.formatter.parseQuantity(item.querySelector('[name^="quantity"]').value) *
|
||||
this.formatter.parseCurrency(item.querySelector('[name^="price"]').value)
|
||||
)
|
||||
})),
|
||||
|
||||
// Note
|
||||
note: document.querySelector('[name="invoiceNote"]')?.value,
|
||||
|
||||
// Get totals directly from the display elements
|
||||
totals: {
|
||||
subtotal: document.getElementById('subtotal').textContent,
|
||||
allowances: document.getElementById('totalAllowances').textContent,
|
||||
charges: document.getElementById('totalCharges').textContent,
|
||||
netAmount: document.getElementById('netAmount').textContent,
|
||||
vat: document.getElementById('vat').textContent,
|
||||
total: document.getElementById('total').textContent
|
||||
},
|
||||
|
||||
// VAT Breakdown
|
||||
vatBreakdown: Array.from(document.querySelectorAll('.vat-row')).map(row => ({
|
||||
type: row.querySelector('.vat-type').value,
|
||||
rate: row.querySelector('.vat-rate').value,
|
||||
base: row.querySelector('.vat-base').value,
|
||||
amount: row.querySelector('.vat-amount').value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
createPartyHTML(party) {
|
||||
return `
|
||||
<p><strong>${party.name}</strong></p>
|
||||
<p>CUI: ${party.vat}</p>
|
||||
<p>Nr. Reg. Com.: ${party.companyId}</p>
|
||||
<p>${party.address}</p>
|
||||
<p>${party.city}${party.county ? ', ' + party.county : ''}</p>
|
||||
<p>${party.country}</p>
|
||||
${party.phone ? `<p>Tel: ${party.phone}</p>` : ''}
|
||||
${party.contactName ? `<p>Contact: ${party.contactName}</p>` : ''}
|
||||
${party.email ? `<p>Email: ${party.email}</p>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
getVATTypeLabel(type) {
|
||||
const labels = {
|
||||
'S': 'Standard',
|
||||
'AE': 'Taxare Inversă',
|
||||
'O': 'Neplătitor TVA',
|
||||
'Z': 'Cotă 0%',
|
||||
'E': 'Scutit'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
async print() {
|
||||
try {
|
||||
// Collect all the data
|
||||
const invoiceData = this.collectInvoiceData();
|
||||
|
||||
// Open new window and load the selected print template
|
||||
this.printWindow = window.open(
|
||||
this.templates[this.currentTemplate],
|
||||
'_blank',
|
||||
'width=800,height=600'
|
||||
);
|
||||
|
||||
// Wait for the window to load
|
||||
await new Promise(resolve => {
|
||||
this.printWindow.onload = resolve;
|
||||
});
|
||||
|
||||
// Generate QR code
|
||||
const qrData = {
|
||||
invoiceNumber: invoiceData.invoiceNumber,
|
||||
issueDate: invoiceData.issueDate,
|
||||
supplier: invoiceData.supplier.name,
|
||||
customer: invoiceData.customer.name,
|
||||
total: this.formatter.parseCurrency(invoiceData.totals.total)
|
||||
};
|
||||
|
||||
const qrElement = this.printWindow.document.getElementById('qrcode');
|
||||
if (qrElement) {
|
||||
new this.printWindow.QRCode(qrElement, {
|
||||
text: JSON.stringify(qrData),
|
||||
width: 100,
|
||||
height: 100,
|
||||
colorDark: "#2563eb",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: this.printWindow.QRCode.CorrectLevel.L
|
||||
});
|
||||
}
|
||||
|
||||
// Populate the template with data
|
||||
this.populatePrintWindow(invoiceData);
|
||||
|
||||
// Print the window
|
||||
this.printWindow.print();
|
||||
|
||||
// Clean up
|
||||
this.printWindow.onafterprint = () => {
|
||||
this.printWindow.close();
|
||||
this.printWindow = null;
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Print failed:', error);
|
||||
if (this.printWindow) {
|
||||
this.printWindow.close();
|
||||
this.printWindow = null;
|
||||
}
|
||||
alert('A apărut o eroare la printare. Vă rugăm să încercați din nou.');
|
||||
}
|
||||
}
|
||||
|
||||
populatePrintWindow(data) {
|
||||
if (!this.printWindow) return;
|
||||
|
||||
const doc = this.printWindow.document;
|
||||
|
||||
// Basic details
|
||||
doc.getElementById('print-invoice-number').textContent = data.invoiceNumber;
|
||||
doc.getElementById('print-issue-date').textContent = data.issueDate;
|
||||
doc.getElementById('print-due-date').textContent = data.dueDate;
|
||||
doc.getElementById('print-document-currency').textContent = data.documentCurrencyCode;
|
||||
|
||||
// Currency information
|
||||
const taxCurrencyContainer = doc.getElementById('print-tax-currency-container');
|
||||
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
||||
taxCurrencyContainer.style.display = 'block';
|
||||
doc.getElementById('print-tax-currency').textContent = data.taxCurrencyCode;
|
||||
doc.getElementById('print-exchange-rate').textContent = this.formatter.formatNumber(data.exchangeRate);
|
||||
}
|
||||
|
||||
// Party details
|
||||
doc.getElementById('print-supplier-details').innerHTML = this.createPartyHTML(data.supplier);
|
||||
doc.getElementById('print-customer-details').innerHTML = this.createPartyHTML(data.customer);
|
||||
|
||||
// Note
|
||||
if (data.note) {
|
||||
const noteSection = doc.getElementById('print-note');
|
||||
noteSection.style.display = 'block';
|
||||
noteSection.querySelector('div').textContent = data.note;
|
||||
}
|
||||
|
||||
// Line items - use formatted values from data
|
||||
doc.getElementById('print-items').innerHTML = data.items.map(item => `
|
||||
<tr>
|
||||
<td>${item.number}</td>
|
||||
<td>${item.description}</td>
|
||||
<td>${item.unit}</td>
|
||||
<td class="number-cell">${item.quantity}</td>
|
||||
<td class="number-cell">${item.price}</td>
|
||||
<td class="number-cell">${item.vatRate}%</td>
|
||||
<td class="number-cell">${item.totalAmount}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Totals - use values directly from display
|
||||
doc.getElementById('print-subtotal').textContent = data.totals.subtotal;
|
||||
doc.getElementById('print-allowances').textContent = data.totals.allowances;
|
||||
doc.getElementById('print-charges').textContent = data.totals.charges;
|
||||
doc.getElementById('print-net-amount').textContent = data.totals.netAmount;
|
||||
doc.getElementById('print-total').textContent = data.totals.total;
|
||||
|
||||
// VAT Breakdown - use values directly from display
|
||||
doc.getElementById('print-vat-breakdown').innerHTML = data.vatBreakdown.map(vat => `
|
||||
<div>${this.getVATTypeLabel(vat.type)}</div>
|
||||
<div>${vat.rate}%</div>
|
||||
<div>${vat.base}</div>
|
||||
<div>${vat.amount}</div>
|
||||
`).join('');
|
||||
|
||||
// VAT totals
|
||||
doc.getElementById('print-vat-currency-main').textContent = data.documentCurrencyCode;
|
||||
doc.getElementById('print-vat-main').textContent = data.totals.vat;
|
||||
|
||||
const secondaryVatRow = doc.getElementById('print-vat-secondary');
|
||||
if (data.taxCurrencyCode && data.taxCurrencyCode !== data.documentCurrencyCode) {
|
||||
secondaryVatRow.style.display = 'flex';
|
||||
doc.getElementById('print-vat-currency-secondary').textContent = data.taxCurrencyCode;
|
||||
const vatInTaxCurrency = this.formatter.parseCurrency(data.totals.vat) * data.exchangeRate;
|
||||
doc.getElementById('print-vat-secondary-amount').textContent =
|
||||
this.formatter.formatCurrency(vatInTaxCurrency);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +1,43 @@
|
||||
// server.js
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 3000;
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml'
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// 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);
|
||||
const contentType = MIME_TYPES[ext] || 'text/plain';
|
||||
|
||||
// Read and serve the file
|
||||
fs.readFile(filePath, (err, content) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end('Server error: ' + err.code);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running at http://localhost:${PORT}/`);
|
||||
// server.js
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 3000;
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'text/javascript',
|
||||
'.mjs': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml'
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// 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);
|
||||
const contentType = MIME_TYPES[ext] || 'text/plain';
|
||||
|
||||
// Read and serve the file
|
||||
fs.readFile(filePath, (err, content) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end('Server error: ' + err.code);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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
Reference in New Issue
Block a user