diff --git a/Training/PV_Training.docx b/Training/PV_Training.docx new file mode 100644 index 0000000..0028ac5 Binary files /dev/null and b/Training/PV_Training.docx differ diff --git a/Training/Sesiunea 1/01_Prezentare aplicatii si login.mp4 b/Training/Sesiunea 1/01_Prezentare aplicatii si login.mp4 new file mode 100644 index 0000000..f441ddd Binary files /dev/null and b/Training/Sesiunea 1/01_Prezentare aplicatii si login.mp4 differ diff --git a/Training/Sesiunea 1/02_Navigare site public.mp4 b/Training/Sesiunea 1/02_Navigare site public.mp4 new file mode 100644 index 0000000..86e8e01 Binary files /dev/null and b/Training/Sesiunea 1/02_Navigare site public.mp4 differ diff --git a/Training/Sesiunea 1/03_Navigare prin dashboard .mp4 b/Training/Sesiunea 1/03_Navigare prin dashboard .mp4 new file mode 100644 index 0000000..090fc76 Binary files /dev/null and b/Training/Sesiunea 1/03_Navigare prin dashboard .mp4 differ diff --git a/Training/Sesiunea 1/04_Achizitie analiza prin stripe.mp4 b/Training/Sesiunea 1/04_Achizitie analiza prin stripe.mp4 new file mode 100644 index 0000000..fb54607 Binary files /dev/null and b/Training/Sesiunea 1/04_Achizitie analiza prin stripe.mp4 differ diff --git a/Training/Sesiunea 1/05_Vizualizare facturi generate in dashboard pdf si serviciu de mail.mp4 b/Training/Sesiunea 1/05_Vizualizare facturi generate in dashboard pdf si serviciu de mail.mp4 new file mode 100644 index 0000000..fa9f567 Binary files /dev/null and b/Training/Sesiunea 1/05_Vizualizare facturi generate in dashboard pdf si serviciu de mail.mp4 differ diff --git a/Training/Sesiunea 1/06_Rulare analiza Achizitionata.mp4 b/Training/Sesiunea 1/06_Rulare analiza Achizitionata.mp4 new file mode 100644 index 0000000..f02723c Binary files /dev/null and b/Training/Sesiunea 1/06_Rulare analiza Achizitionata.mp4 differ diff --git a/Training/Sesiunea 1/07_Formular de contact to ERP.mp4 b/Training/Sesiunea 1/07_Formular de contact to ERP.mp4 new file mode 100644 index 0000000..9d27843 Binary files /dev/null and b/Training/Sesiunea 1/07_Formular de contact to ERP.mp4 differ diff --git a/Training/Sesiunea 2/01_Login si prezentare .mp4 b/Training/Sesiunea 2/01_Login si prezentare .mp4 new file mode 100644 index 0000000..b015b0c Binary files /dev/null and b/Training/Sesiunea 2/01_Login si prezentare .mp4 differ diff --git a/Training/Sesiunea 2/02_Facturile de vanzare.mp4 b/Training/Sesiunea 2/02_Facturile de vanzare.mp4 new file mode 100644 index 0000000..9c43566 Binary files /dev/null and b/Training/Sesiunea 2/02_Facturile de vanzare.mp4 differ diff --git a/Training/Sesiunea 2/03_Plati si registru contabil.mp4 b/Training/Sesiunea 2/03_Plati si registru contabil.mp4 new file mode 100644 index 0000000..41fb471 Binary files /dev/null and b/Training/Sesiunea 2/03_Plati si registru contabil.mp4 differ diff --git a/Training/Sesiunea 2/04_Rapoarte financiare.mp4 b/Training/Sesiunea 2/04_Rapoarte financiare.mp4 new file mode 100644 index 0000000..8162143 Binary files /dev/null and b/Training/Sesiunea 2/04_Rapoarte financiare.mp4 differ diff --git a/Training/Sesiunea 2/05_Clientii si datele platformei didi.mp4 b/Training/Sesiunea 2/05_Clientii si datele platformei didi.mp4 new file mode 100644 index 0000000..b9f4e9a Binary files /dev/null and b/Training/Sesiunea 2/05_Clientii si datele platformei didi.mp4 differ diff --git a/Training/Sesiunea 2/06_Leaduri si pipeline.mp4 b/Training/Sesiunea 2/06_Leaduri si pipeline.mp4 new file mode 100644 index 0000000..321b8a2 Binary files /dev/null and b/Training/Sesiunea 2/06_Leaduri si pipeline.mp4 differ diff --git a/Training/Sesiunea 2/07_Acorduri de servicii si audit plati.mp4 b/Training/Sesiunea 2/07_Acorduri de servicii si audit plati.mp4 new file mode 100644 index 0000000..298bcbd Binary files /dev/null and b/Training/Sesiunea 2/07_Acorduri de servicii si audit plati.mp4 differ diff --git a/Training/Sesiunea 2/08_Continut website CMS.mp4 b/Training/Sesiunea 2/08_Continut website CMS.mp4 new file mode 100644 index 0000000..9a321ad Binary files /dev/null and b/Training/Sesiunea 2/08_Continut website CMS.mp4 differ diff --git a/Training/Sesiunea 2/09_Email Tranzactional.mp4 b/Training/Sesiunea 2/09_Email Tranzactional.mp4 new file mode 100644 index 0000000..b3d0cd3 Binary files /dev/null and b/Training/Sesiunea 2/09_Email Tranzactional.mp4 differ diff --git a/Training/Training_Sesiunea_1_Utilizare_Website_Ecommerce_CRM.docx b/Training/Training_Sesiunea_1_Utilizare_Website_Ecommerce_CRM.docx new file mode 100644 index 0000000..15e3bcd Binary files /dev/null and b/Training/Training_Sesiunea_1_Utilizare_Website_Ecommerce_CRM.docx differ diff --git a/Training/Training_Sesiunea_2_Administrare_ERPNext.docx b/Training/Training_Sesiunea_2_Administrare_ERPNext.docx new file mode 100644 index 0000000..2841e3c Binary files /dev/null and b/Training/Training_Sesiunea_2_Administrare_ERPNext.docx differ diff --git a/documentatii/Arhitectura_Detaliata.docx b/documentatii/Arhitectura_Detaliata.docx new file mode 100644 index 0000000..c5cee62 Binary files /dev/null and b/documentatii/Arhitectura_Detaliata.docx differ diff --git a/documentatii/Documentatie_API.docx b/documentatii/Documentatie_API.docx new file mode 100644 index 0000000..3e075f0 Binary files /dev/null and b/documentatii/Documentatie_API.docx differ diff --git a/documentatii/Ghid_Administrare_ERPNext.docx b/documentatii/Ghid_Administrare_ERPNext.docx new file mode 100644 index 0000000..da7cb72 Binary files /dev/null and b/documentatii/Ghid_Administrare_ERPNext.docx differ diff --git a/documentatii/Manual_Utilizare_Website.docx b/documentatii/Manual_Utilizare_Website.docx new file mode 100644 index 0000000..38b5f6a Binary files /dev/null and b/documentatii/Manual_Utilizare_Website.docx differ diff --git a/documentatii/RAPORT-TESTARE-DiDi-Website-ERP-CRM.docx b/documentatii/RAPORT-TESTARE-DiDi-Website-ERP-CRM.docx new file mode 100644 index 0000000..f624100 Binary files /dev/null and b/documentatii/RAPORT-TESTARE-DiDi-Website-ERP-CRM.docx differ diff --git a/erp_crm/.env.example b/erp_crm/.env.example new file mode 100644 index 0000000..552c23f --- /dev/null +++ b/erp_crm/.env.example @@ -0,0 +1,27 @@ +# ERPNext Docker Configuration — TEMPLATE +# ======================================= +# Copy to .env and fill in real values. +# NEVER commit .env — it is gitignored. + +# MariaDB — generate strong random passwords (e.g. openssl rand -base64 24) +MYSQL_ROOT_PASSWORD= +DB_PASSWORD= + +# ERPNext +ERPNEXT_VERSION=v15 +FRAPPE_VERSION=v15 +SITE_NAME=didi-erp +ADMIN_PASSWORD= + +# Redis (internal Docker DNS — don't change unless changing compose) +REDIS_CACHE=redis-cache:6379 +REDIS_QUEUE=redis-queue:6379 +REDIS_SOCKETIO=redis-socketio:6379 + +# Network +ERPNEXT_PORT=8080 + +# Setup scripts (08-email.py etc) — required when running setup +SENDGRID_API_KEY= +ERPNEXT_ADMIN_PASSWORD= +ERPNEXT_URL=http://localhost:8080 diff --git a/erp_crm/.gitignore b/erp_crm/.gitignore new file mode 100644 index 0000000..97f8ede --- /dev/null +++ b/erp_crm/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.log +.DS_Store +.env diff --git a/erp_crm/.gitlab-ci.yml b/erp_crm/.gitlab-ci.yml new file mode 100644 index 0000000..7941412 --- /dev/null +++ b/erp_crm/.gitlab-ci.yml @@ -0,0 +1,102 @@ +# DiDi ERP/CRM (ERPNext v15 + custom app didi_custom) — GitLab CI/CD +# =================================================================== + +stages: + - security + - lint + - test + - build + - deploy + - release + +security:gitleaks: + stage: security + image: + name: zricethezav/gitleaks:latest + entrypoint: [""] + script: + - gitleaks detect --source . --no-banner --report-format json --report-path gitleaks-report.json + artifacts: + when: on_failure + paths: [gitleaks-report.json] + expire_in: 1 month + allow_failure: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == "main" + +lint:python: + stage: lint + image: python:3.11-slim + before_script: + - pip install --quiet ruff black + script: + - ruff check didi_custom/ scripts/ || true + - black --check didi_custom/ scripts/ || true + allow_failure: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == "main" + +lint:shell: + stage: lint + image: koalaman/shellcheck-alpine:latest + script: + - shellcheck build-from-zero.sh seed-data.sh bin/*.sh || true + allow_failure: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +test:doctype-validation: + stage: test + image: python:3.11-slim + before_script: + - pip install --quiet jsonschema + script: + - python3 -c " + import json, glob; + for f in glob.glob('didi_custom/**/doctype/*/*.json', recursive=True): + d = json.load(open(f)); + assert 'doctype' in d.get('doctype', d), f'Bad doctype in {f}'; + print(f'OK: {f}') + " + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: [didi_custom/**/*] + - if: $CI_COMMIT_BRANCH == "main" + +build:image: + stage: build + script: + - docker compose build + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: manual + tags: [shell, docker] + +deploy:staging: + stage: deploy + script: + - ./build-from-zero.sh + - python3 scripts/setup/01-config.py + - python3 scripts/setup/02-doctypes.py + - python3 scripts/setup/08-email.py + environment: + name: staging + url: http://erp.example.com + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: manual + tags: [shell, docker] + +release: + stage: release + image: registry.gitlab.com/gitlab-org/release-cli:latest + script: + - echo "Release ${CI_COMMIT_TAG}" + release: + tag_name: '$CI_COMMIT_TAG' + name: 'Release $CI_COMMIT_TAG' + description: 'ERP/CRM release $CI_COMMIT_TAG' + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ diff --git a/erp_crm/CONFIGURATION.md b/erp_crm/CONFIGURATION.md new file mode 100644 index 0000000..4b08abd --- /dev/null +++ b/erp_crm/CONFIGURATION.md @@ -0,0 +1,253 @@ +# Configurare ERP/CRM DiDi + +Variabile de mediu, credentiale, integrari externe. + +## Fisier `.env` + +Localizat la `~/erp_crm/.env`. NU se commiteaza in git. + +```env +# MariaDB +MYSQL_ROOT_PASSWORD= +DB_PASSWORD= + +# ERPNext +ERPNEXT_VERSION=v15 +FRAPPE_VERSION=v15 +SITE_NAME=didi-erp +ADMIN_PASSWORD= + +# Redis (intern, container DNS) +REDIS_CACHE=redis-cache:6379 +REDIS_QUEUE=redis-queue:6379 +REDIS_SOCKETIO=redis-queue:6379 + +# Network +ERPNEXT_PORT=8080 +``` + +### Variabile critice de schimbat la productie + +| Variabila | De ce | Recomandare | +|-----------|-------|-------------| +| `MYSQL_ROOT_PASSWORD` | Acces total la DB | 32+ caractere random | +| `ADMIN_PASSWORD` | Login ERPNext Administrator | Min 12 caractere, schimbare la prima logare | +| `DB_PASSWORD` | Conexiune Frappe la DB | Generat random la `bench new-site` | + +Genereaza parole: + +```bash +openssl rand -base64 24 +# sau +pwgen -s 32 1 +``` + +## Configurare in DB (post-install) + +Anumite valori sunt setate doar in DB, nu in `.env`. + +### `Global Defaults` + +| Camp | Valoare implicita | Unde se schimba | +|------|-------------------|-----------------| +| `default_company` | TOP CLOSSERS SRL | UI: Setup > Global Defaults | +| `default_currency` | RON | UI: Setup > Global Defaults | +| `country` | Romania | UI: Setup > Global Defaults | + +### `System Settings` + +| Camp | Valoare implicita | +|------|-------------------| +| `time_zone` | Europe/Bucharest | +| `language` | ro | +| `setup_complete` | 1 | +| `host_name` | http://didi-erpnext:8080 | +| `country` | Romania | + +### `Website Settings` + +| Camp | Valoare | +|------|---------| +| `home_page` | login (sau pagina ce vrei) | +| `disable_signup` | 1 (sign-up doar prin Stripe checkout) | + +## Company config + +Company-ul `TOP CLOSSERS SRL` are urmatoarele conturi default (din planul de conturi RO): + +| Setting | Cont | +|---------|------| +| `default_receivable_account` | Clienti - facturi de intocmit - TC | +| `default_income_account` | 704 - Venituri din servicii prestate - TC | +| `default_cash_account` | Casa in lei - TC | +| `default_bank_account` | Conturi la banci in lei - TC | +| `round_off_account` | Round Off - TC | +| `cost_center` | Main - TC | + +## Tax templates + +| Template | Cota | Cont colectata | +|----------|------|----------------| +| TVA 21% | 21% | 4427 - 4427 - TVA colectata - TC | + +TVA 21% e cota standard RO de la 01.08.2025. Pentru cote reduse (5%, 9%), se creeaza template-uri suplimentare. + +## Custom Fields + +Adaugate de `scripts/setup/02-doctypes.py`: + +### `Customer` +- `didi_user_id` (Data) - UUID Keycloak, link la userul DiDi +- `iam_role` (Data) - `free_tier` / `paid_tier` / `enterprise_tier` +- `active_plan` (Data) - cheia planului curent (paid-monthly, etc.) +- `plan_activation_date` (Date) - data activarii + +### `Lead` +- `source_form` (Data) - formularul de origine (contact, demo, etc.) +- `page_origin` (Data) - pagina de origine +- `utm_source`, `utm_medium`, `utm_campaign` (Data) + +### `Sales Invoice` +- `analysis_consumed` (Check) - daca analiza din factura a fost folosita +- `analysis_session_id` (Data) - ID-ul sesiunii de analiza in DiDi + +### `Payment Log` +- `stripe_session_id` (Data, indexed) - ID Stripe Checkout Session + +## Custom DocTypes (didi_custom) + +| DocType | Scop | +|---------|------| +| `Analysis Report` | Raport rezultate analiza cu PDF atasat | +| `Payment Log` | Log evenimente webhook Stripe | +| `Service Agreement` | Acord servicii la activarea unui plan | +| `Website Content` | CMS pentru paginile publice (homepage, services, etc.) | + +## Integrari externe + +### SendGrid (email tranzactional) + +| Setting | Valoare | +|---------|---------| +| Email Account | `DiDi Outgoing` | +| SMTP Server | smtp.sendgrid.net:587 | +| TLS | DA | +| Username (`login_id`) | `apikey` (literal) | +| Password | API key SendGrid (`SG.xxxxxxxx...`) | +| Default outgoing | DA | +| Send notifications | DA | +| From | office@clossers.com (Sender Identity verificat) | + +API key se genereaza din: SendGrid > Settings > API Keys > Create + +Sender Identity se verifica din: SendGrid > Settings > Sender Authentication > Verify Single Sender (sau Domain Authentication pentru deliverability mai buna). + +### Stripe (plati) + +ERPNext nu se conecteaza direct la Stripe. Toate platile pleaca via website-ul Next.js care: +1. Creeaza Stripe Checkout Session +2. Stripe trimite webhook la `/api/webhooks/stripe` +3. Website apeleaza ERPNext API pentru a crea Sales Invoice + Payment Entry + Payment Log + +Variabilele Stripe sunt in `~/website/.env.production`, nu aici. + +### Keycloak (autentificare) + +ERPNext NU foloseste Keycloak direct - se logheaza doar admin/Administrator local. Userii finali (clientii) se logheaza prin website via Keycloak, iar website-ul ii referenctiaza in ERPNext prin `Customer.didi_user_id`. + +### API tokens pentru integrare externa (website -> ERPNext) + +User dedicat: `Administrator` (sau cream `website_api@didi.local` cu rol Website Integration). + +Token-ul de API key + secret se genereaza din: +ERPNext > Settings > User > [user] > API Access > Generate Keys + +Format header pentru request: +``` +Authorization: token : +``` + +## SECURITATE — token-urile + +**Token-urile NU se mai pun in acest fisier.** Sunt stocate doar in `.env` (gitignored): + +```bash +# In ~/erp_crm/.env si ~/website/.env.production +ERPNEXT_API_KEY= +ERPNEXT_API_SECRET= +``` + +**Recomandare puternica**: NU folosi userul `Administrator` ca service account pentru website. Creeaza un user dedicat: + +```bash +# 1. Creeaza userul website_api@didi.local cu rol "Website Integration" +docker exec didi-erpnext bench --site didi-erp add-user \ + website_api@didi.local --first-name "Website" --last-name "API" --send-welcome-email 0 + +# 2. Asigneaza rol minim necesar (in UI: Settings > User > website_api@didi.local > Roles) +# Roles recomandate: "Website Manager", "Sales User", "Accounts User" +# NU asigna "System Manager" (echivalentul de admin) + +# 3. Genereaza key+secret pentru noul user +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && \ +bench --site didi-erp execute frappe.core.doctype.user.user.generate_keys \ + --args "[\"website_api@didi.local\"]" +' + +# 4. Output: copiaza api_key:api_secret in .env (NU comita in repo) +``` + +**Pentru rotatie** (Administrator sau service account): +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && \ +bench --site didi-erp execute frappe.core.doctype.user.user.generate_keys \ + --args "[\"\"]" +' +``` + +**ATENTIE**: Generarea unei chei noi INVALIDEAZA cheile vechi. Actualizeaza imediat `.env` in toate locatiile (website, erp_crm, agent-v3 daca aplicabil). + +## Porturi expuse + +| Port host | Container | Scop | +|-----------|-----------|------| +| 8080 | didi-erpnext:8080 | Web UI + API | +| 3307 | didi-mariadb:3306 | DB (pentru debug, nu expune la prod) | + +Pentru productie, expune doar 8080 via reverse proxy (nginx/Caddy) cu HTTPS. + +## Workspace-uri custom + +Configurate de `scripts/setup/06-sidebar.py`. Active (vizibile): + +| Workspace | Continut | +|-----------|----------| +| Home | Welcome page Frappe | +| Facturi | Sales Invoice list + shortcut Create | +| Clienti | Customer list | +| CRM | Lead, Opportunity, pipeline Kanban | +| Servicii | Item list (DIDI-*) | +| Plati | Payment Entry + Payment Log | +| Contabilitate | Account, Journal Entry, GL Entry | +| Financial Reports | Trial Balance, P&L, Balance Sheet, etc. | +| Website CMS | Website Content list | + +Ascunse (standard ERPNext nefolosit): Selling, Buying, Stock, Assets, Quality, Projects, Support, Manufacturing. + +## Notificari email + +Configurate de `scripts/setup/08-email.py`: + +| Notification | Trigger | Receiver | +|--------------|---------|----------| +| DiDi - Factura Emisa | Sales Invoice Submit | customer.email_id (cu PDF atasat) | +| DiDi - Plata Confirmata | Payment Log New | customer.email_id | +| DiDi - Expirare Abonament | Subscription, 7 zile inainte | customer.email_id | + +## Limba + +Frappe e configurat in `ro` (Romana). Traduceri suplimentare in `scripts/setup/05-translations.py` (~146 termeni custom DiDi). + +Schimbare limba per user: User > Language. diff --git a/erp_crm/INSTALL.md b/erp_crm/INSTALL.md new file mode 100644 index 0000000..8a412d6 --- /dev/null +++ b/erp_crm/INSTALL.md @@ -0,0 +1,300 @@ +# Instalare ERP/CRM DiDi de la 0 + +Ghid complet pentru a porni stack-ul ERPNext + DiDi custom de la o masina goala. + +## Prerequisite + +### Software pe host + +```bash +docker --version # >= 24.0 +docker compose version # >= v2 +``` + +Toata logica de setup ruleaza IN containerul ERPNext prin `bench execute` pe +functiile din app-ul `didi_custom` — nu e nevoie de Python pe host. + +### Resurse + +- 4 GB RAM disponibili +- 10 GB spatiu disc +- Port 8080 liber (sau schimba ERPNEXT_PORT) + +### Retea Docker + +ERPNext-ul ruleaza pe reteaua `didi-network` (impartita cu website-ul). Creeaz-o: + +```bash +docker network create didi-network 2>/dev/null || true +``` + +## Build automat (recomandat) + +Ruleaza: + +```bash +cd ~/erp_crm +bash build-from-zero.sh +``` + +Scriptul face urmatoarele (~5 min): +1. Verifica `docker network` si `.env` +2. Porneste containerele (`docker compose up -d`) +3. Asteapta MariaDB sa fie healthy +4. Configureaza Redis in `common_site_config.json` +5. Creeaza site-ul `didi-erp` cu ERPNext instalat +6. Instaleaza app-ul `didi_custom` +7. Creeaza Company `TOP CLOSSERS SRL` + plan de conturi RO + An Fiscal curent +8. Ruleaza setup-ul complet din `didi_custom` prin `bench execute` + (doctypes, campuri custom, CMS, items DIDI + user API, print format, + email, workspace + scurtaturi) +9. Build assets (`bench build`) +10. Verifica accesul HTTP + +## Build manual (pas cu pas) + +Daca preferi sa rulezi fiecare pas individual: + +### Pas 1. Containere + +```bash +cd ~/erp_crm +docker compose up -d +``` + +Asteapta ~30 secunde pana MariaDB e healthy: + +```bash +docker compose ps +# Toate trebuie sa fie Up; mariadb cu (healthy) +``` + +### Pas 2. Configurare Redis + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench set-config -g redis_cache "redis://redis-cache:6379" && +bench set-config -g redis_queue "redis://redis-queue:6379" && +bench set-config -g redis_socketio "redis://redis-queue:6379" +' +``` + +### Pas 3. Creare site + instalare ERPNext + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench new-site didi-erp \ + --mariadb-root-password \ + --admin-password \ + --db-host mariadb \ + --install-app erpnext +' +``` + +Dureaza ~3 minute. + +### Pas 4. Fix MariaDB user pentru orice host + +Userul DB creat de Frappe e restrictionat la host-ul curent. Pentru ca containerele cu workers/scheduler sa se conecteze, schimba la wildcard: + +```bash +DB_NAME=$(docker exec didi-erpnext python3 -c " +import json +with open('/home/frappe/frappe-bench/sites/didi-erp/site_config.json') as f: + print(json.load(f)['db_name']) +") +DB_PWD=$(docker exec didi-erpnext python3 -c " +import json +with open('/home/frappe/frappe-bench/sites/didi-erp/site_config.json') as f: + print(json.load(f)['db_password']) +") + +docker exec didi-mariadb mariadb -uroot -p -e " +DROP USER IF EXISTS '$DB_NAME'@'%'; +CREATE USER '$DB_NAME'@'%' IDENTIFIED BY '$DB_PWD'; +GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_NAME'@'%'; +FLUSH PRIVILEGES; +" +``` + +### Pas 5. Instalare didi_custom + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +printf "frappe\nerpnext\ndidi_custom\n" > sites/apps.txt && +env/bin/pip install -e apps/didi_custom && +bench --site didi-erp install-app didi_custom +' +``` + +### Pas 6. Patch ERPNext (portal_users compatibility) + +Frappe v15 are un bug minor cu campul `portal_users` pe Customer (referinta la modul lipsa). Patch: + +```bash +docker exec didi-erpnext sed -i \ + 's|for portal_user in self.portal_users:|for portal_user in (getattr(self, "portal_users", None) or []):|' \ + /home/frappe/frappe-bench/apps/erpnext/erpnext/selling/doctype/customer/customer.py +``` + +### Pas 7. Companie + plan de conturi Romania + An Fiscal + +⚠️ **NU rula logica frappe cu `python3` pe host sau cu python-ul de sistem din +container** — esueaza tacut (frappe e instalat doar in venv-ul bench). Foloseste +`bench --site didi-erp execute `. + +```bash +BE='docker exec didi-erpnext bash -c' + +# Warehouse Type "Transit" — cerut la crearea companiei, lipseste in instalarea curata +$BE 'cd /home/frappe/frappe-bench && bench --site didi-erp execute frappe.client.insert \ + --kwargs "{\"doc\":{\"doctype\":\"Warehouse Type\",\"name\":\"Transit\"}}"' || true + +# Company TOP CLOSSERS SRL (cu patch-uri country fixtures incluse in modul) +$BE 'cd /home/frappe/frappe-bench && bench --site didi-erp execute didi_custom.make_company_ro3.execute' + +# Plan de conturi RO complet (447 conturi: TVA 4426/4427/4423/4424, venituri 704/707, +# default-uri companie, template TVA 21%) +$BE 'cd /home/frappe/frappe-bench && bench --site didi-erp execute didi_custom.setup_ro_coa.execute' + +# An Fiscal curent — lipseste complet dupa instalarea curata; facturile pica fara el +YEAR=$(date +%Y) +$BE "cd /home/frappe/frappe-bench && bench --site didi-erp execute frappe.client.insert \ + --kwargs '{\"doc\":{\"doctype\":\"Fiscal Year\",\"year\":\"$YEAR\",\"year_start_date\":\"$YEAR-01-01\",\"year_end_date\":\"$YEAR-12-31\"}}'" + +# Price list implicit in RON +$BE 'cd /home/frappe/frappe-bench && bench --site didi-erp execute frappe.client.set_value \ + --kwargs "{\"doctype\":\"Price List\",\"name\":\"Standard Selling\",\"fieldname\":\"currency\",\"value\":\"RON\"}"' +``` + +### Pas 8. Doctypes custom, campuri si continut CMS + +```bash +for fn in didi_custom.setup_doctypes.execute \ + didi_custom.integrations.migrations.setup_custom_fields.execute \ + didi_custom.fix_sa.execute \ + didi_custom.populate_content.execute; do + docker exec didi-erpnext bash -c "cd /home/frappe/frappe-bench && bench --site didi-erp execute $fn" +done +``` + +Creeaza: doctype-urile Service Agreement / Payment Log / Website Content, campurile +custom pe Customer / Sales Invoice / Lead (didi_user_id, iam_role, active_plan, +sales_invoice „Factura asociata" pe Service Agreement etc.) si continutul CMS. + +### Pas 9. Integrare website, print format, email, workspace + +```bash +for fn in didi_custom.setup_website_integration.execute \ + didi_custom.setup_phase2_final.setup_print_format \ + didi_custom.setup_website_integration.fix_print_format \ + didi_custom.setup_email.execute \ + didi_custom.setup_workspaces.execute \ + didi_custom.setup_shortcuts.execute; do + docker exec didi-erpnext bash -c "cd /home/frappe/frappe-bench && bench --site didi-erp execute $fn" +done +``` + +- `setup_website_integration.execute` creeaza items DIDI, rolul „Website Integration" + (cu toate permisiunile read necesare validarii facturilor) si userul API + `website_api@didi-erp.local` — **noteaza api_key/api_secret afisate**, intra in + `.env.production`-ul website-ului. +- `fix_print_format` repara print formatul „DiDi Invoice" si seteaza `host_name` + (altfel wkhtmltopdf da `HostNotFoundError` la logo). +- `setup_email.execute` configureaza contul SMTP „DiDi Outgoing" (Mailpit in test; + in productie se schimba doar host/port/credentiale in Email Account). + +### Pas 10. Scripturile legacy (scripts/setup/) — NU se mai folosesc + +Directorul `scripts/setup/` contine prima generatie de scripturi (rulate pe host). +Sunt pastrate doar ca referinta istorica — totul a fost mutat in module +`didi_custom` rulabile cu `bench execute` (pasii 7-9 de mai sus). + +### Pas 11. Build assets + restart + +```bash +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench build --force' +docker restart didi-erpnext +sleep 8 +curl -s -o /dev/null -w "ERPNext: %{http_code}\n" http://localhost:8080 +``` + +## Verificare instalare + +Daca totul a mers, deschide http://localhost:8080: +- Login: `Administrator` / `admin` +- Sidebar trebuie sa contina: Home, Facturi, Clienti, CRM, Servicii, Plati, Contabilitate, Financial Reports, Website CMS +- Pagina `/app/customer` arata Customer-ul `Test User` (daca ai rulat seed) + +Curl rapid de verificare: + +```bash +# Login + verifica company +curl -s -c /tmp/c -X POST http://localhost:8080/api/method/login \ + -d 'usr=Administrator&pwd=admin' && echo + +curl -s -b /tmp/c http://localhost:8080/api/resource/Company | python3 -m json.tool +``` + +## Date de test (seed) + +Pentru smoke testing dupa instalare: + +```bash +bash seed-data.sh +``` + +Creeaza: +- 1 Customer (`Test User`) +- 3 Sales Invoices submitted + paid +- 1 Payment Log (Stripe simulat) +- 1 Service Agreement + +## Probleme frecvente + +### "Module Selling not found" + +`installed_apps` global value nu contine `erpnext`. Fix: + +```bash +DB=$(docker exec didi-erpnext cat /home/frappe/frappe-bench/sites/didi-erp/site_config.json | python3 -c "import sys,json; print(json.load(sys.stdin)['db_name'])") +docker exec didi-mariadb mariadb -uroot -p $DB -e " +UPDATE tabDefaultValue SET defvalue='[\"frappe\",\"erpnext\",\"didi_custom\"]' WHERE defkey='installed_apps'; +" +docker exec didi-redis-cache redis-cli FLUSHALL +docker restart didi-erpnext +``` + +### "wkhtmltopdf reported an error: HostNotFoundError" + +`host_name` lipseste in Website Settings. Fix: + +```bash +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp set-config -g host_name "http://didi-erpnext:8080"' +docker restart didi-erpnext +``` + +### "Access denied for user '_xxxx'@'host'" + +User DB are host limitat. Vezi Pas 4 mai sus pentru fix. + +### Site ramane la 500 pe `/app/home` + +`setup_complete` e 0 si lipsesc accounts. Treci prin Setup Wizard manual din browser, SAU re-ruleaza Pasul 7 + 8. + +## Reset complet + +Pentru a reseta totul si a porni de la 0: + +```bash +cd ~/erp_crm +docker compose down -v # -v sterge volumele! +docker network rm didi-network 2>/dev/null +docker network create didi-network +bash build-from-zero.sh +``` + +ATENTIE: `down -v` sterge IREVERSIBIL toate datele din MariaDB. Faci backup intai daca ai date importante. diff --git a/erp_crm/OPERATIONS.md b/erp_crm/OPERATIONS.md new file mode 100644 index 0000000..009a595 --- /dev/null +++ b/erp_crm/OPERATIONS.md @@ -0,0 +1,309 @@ +# Operare ERP/CRM DiDi + +Ghid pentru administrare zilnica: backup, restart, log-uri, troubleshooting. + +## Comenzi uzuale + +### Status containere + +```bash +docker compose ps +docker stats --no-stream # CPU/RAM realtime +``` + +### Restart serviciu + +```bash +# Doar ERPNext (cel mai des) +docker compose restart erpnext + +# Toate +docker compose restart + +# Restart cu rebuild image +docker compose up -d --build erpnext +``` + +### Log-uri + +```bash +# Container ERPNext (gunicorn + nginx) +docker compose logs -f erpnext + +# Worker job-uri background +docker compose logs -f queue-short +docker compose logs -f queue-long +docker compose logs -f scheduler + +# Fisier de log Frappe (in container) +docker exec didi-erpnext tail -f /home/frappe/frappe-bench/sites/didi-erp/logs/web.log +docker exec didi-erpnext tail -f /home/frappe/logs/database.log +``` + +### Bench (CLI Frappe) + +```bash +# Cache + cache static +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp clear-cache' +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp clear-website-cache' + +# Migrate (dupa update Frappe sau didi_custom) +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp migrate' + +# Rebuild assets (CSS/JS) +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench build --force' + +# Reset Administrator password +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp set-admin-password admin' + +# Console Python interactiv +docker exec -it didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp console' +``` + +## Backup + +### Backup automat zilnic (recomandare cron) + +Adauga in crontab (`crontab -e`): + +```cron +# Backup zilnic la 02:00, retentie 14 zile +0 2 * * * ~/erp_crm/bin/backup.sh +``` + +Continut `bin/backup.sh` (creeaza-l): + +```bash +#!/bin/bash +set -e +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR=/var/backups/didi-erp +mkdir -p $BACKUP_DIR + +# DB dump +docker exec didi-mariadb mariadb-dump -uroot -p --all-databases \ + | gzip > $BACKUP_DIR/db-$DATE.sql.gz + +# Files (uploaded attachments) +docker run --rm \ + -v didi-data-layer_erpnext-sites:/data \ + -v $BACKUP_DIR:/backup \ + alpine tar czf /backup/sites-$DATE.tar.gz /data + +# Retention: keep last 14 days +find $BACKUP_DIR -mtime +14 -delete +echo "Backup OK: $DATE" +``` + +### Backup manual (Frappe-managed) + +ERPNext are propriu mecanism de backup: + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp backup --with-files +' +# Fisierele apar in: sites/didi-erp/private/backups/ +``` + +Copy local: + +```bash +docker cp didi-erpnext:/home/frappe/frappe-bench/sites/didi-erp/private/backups/ ./backups-$(date +%Y%m%d) +``` + +## Restore din backup + +```bash +# 1. Copiaza fisierele in container +docker cp ./backups-20260525 didi-erpnext:/tmp/restore + +# 2. Restore +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp restore /tmp/restore/db-XXXXXX.sql.gz \ + --with-public-files /tmp/restore/files-XXXXXX.tar \ + --with-private-files /tmp/restore/private-files-XXXXXX.tar \ + --mariadb-root-password +' +``` + +## Update / upgrade + +### Update didi_custom (app-ul nostru) + +Dupa ce modifici cod in `didi_custom/`: + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp migrate +' +``` + +Daca ai modificat doctype: + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp execute "frappe.model.sync.sync_for" --kwargs "{\"app\":\"didi_custom\"}" +' +``` + +### Upgrade ERPNext (major version) + +NU recomandat fara o copie test. Procedura: + +1. Backup complet +2. Edit `docker-compose.yml`: `ERPNEXT_VERSION=v16` (cand apare) +3. `docker compose pull` +4. `docker compose up -d` +5. `bench --site didi-erp migrate` +6. Test extensiv + +## Monitorizare + +### Health checks + +```bash +# HTTP +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080 + +# DB +docker exec didi-mariadb mariadb -uroot -p -e "SELECT 1" + +# Redis +docker exec didi-redis-cache redis-cli ping +docker exec didi-redis-queue redis-cli ping + +# Background workers (verifica heartbeat) +docker exec didi-redis-queue redis-cli KEYS 'rq:worker:*' +``` + +### Email queue + +```bash +docker exec didi-mariadb mariadb -u_xxx -pxxx didi_db -e " +SELECT status, COUNT(*) FROM \`tabEmail Queue\` GROUP BY status; +" + +# Flush manual +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp execute frappe.email.queue.flush' + +# Trimitere imediata (bypass queue) +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench --site didi-erp send-email --send-now' +``` + +## Troubleshooting + +### Site nu raspunde / 500 + +```bash +# 1. Log-uri +docker compose logs --tail 100 erpnext + +# 2. Restart curat +docker exec didi-redis-cache redis-cli FLUSHALL +docker exec didi-redis-queue redis-cli FLUSHALL +docker compose restart erpnext + +# 3. Daca tot nu merge, verifica DB conexiune +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && bench --site didi-erp doctor +' +``` + +### Workers nu proceseaza job-uri + +```bash +# Status workers +docker exec didi-redis-queue redis-cli LRANGE "rq:queue:short" 0 -1 | head +docker exec didi-redis-queue redis-cli LRANGE "rq:queue:long" 0 -1 | head + +# Restart workers +docker compose restart queue-short queue-long queue-default +``` + +### "Module Selling not found" sau alte module standard + +`installed_apps` din DB e dezsincronizat. Fix: + +```bash +DB=$(docker exec didi-erpnext cat /home/frappe/frappe-bench/sites/didi-erp/site_config.json | python3 -c "import sys,json; print(json.load(sys.stdin)['db_name'])") + +docker exec didi-mariadb mariadb -uroot -p $DB -e " +UPDATE tabDefaultValue SET defvalue='[\"frappe\",\"erpnext\",\"didi_custom\"]' WHERE defkey='installed_apps'; +" +docker exec didi-redis-cache redis-cli FLUSHALL +docker compose restart erpnext +``` + +### PDF nu se genereaza ("HostNotFoundError") + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp set-config -g host_name "http://didi-erpnext:8080" +' +docker compose restart erpnext +``` + +### Workspace cu titlu romanesc nu se gaseste + +URL-urile Frappe sunt slug-ificate. Diacriticele se pierd. Pune titlul fara diacritice in DB: + +```sql +UPDATE tabWorkspace SET title='Plati' WHERE name='Plati'; +``` + +## Scenarii de operare + +### Adaugare user admin nou + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp add-user nume.user@clossers.com \ + --first-name Nume \ + --last-name User \ + --password parolaTemp123 \ + --add-system-manager +' +``` + +### Schimbare email From-name (SendGrid) + +```bash +# Update Email Account +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp execute "frappe.db.set_value" --kwargs "{\"doctype\":\"Email Account\", \"docname\":\"DiDi Outgoing\", \"fieldname\":\"email_id\", \"value\":\"office@clossers.com\"}" +' +docker compose restart erpnext +``` + +### Sterge factura emisa (anulare) + +ERPNext nu permite delete pe `docstatus=1`. Trebuie cancel + delete: + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp execute "frappe.client.cancel" --kwargs "{\"doctype\":\"Sales Invoice\", \"name\":\"DIDI-INV-2026-00001\"}" +' +``` + +Apoi din UI sterge documentul anulat. + +## SLA si severitati (conform contract) + +| Severitate | Definitie | Timp raspuns | Timp rezolvare | +|------------|-----------|--------------|----------------| +| Critica | Sistemul nu functioneaza / plati nefunctionale | 4 ore | 24 ore | +| Majora | Functionalitate importanta afectata | 8 ore | 3 zile lucratoare | +| Minora | Probleme cosmetice / minore | 24 ore | 10 zile lucratoare | + +Canal tichete: office@clossers.com (sau sistem helpdesk dedicat la productie). + +Program suport: zile lucratoare 09:00 - 18:00. diff --git a/erp_crm/README.md b/erp_crm/README.md new file mode 100644 index 0000000..a16b913 --- /dev/null +++ b/erp_crm/README.md @@ -0,0 +1,153 @@ +# DiDi ERP/CRM + +Componenta ERP si CRM a platformei DiDi, construita pe ERPNext v15 (Frappe Framework). Gestioneaza facturarea, contabilitatea, clientii, lead-urile si continutul CMS al website-ului public. + +## Cuprins + +- [Arhitectura](#arhitectura) +- [Stack tehnic](#stack-tehnic) +- [Structura folder](#structura-folder) +- [Quickstart](#quickstart) +- [Configurare](#configurare) +- [Documentatie detaliata](#documentatie-detaliata) + +--- + +## Arhitectura + +``` ++--------------------+ +-----------+ +----------+ +| Website Next.js | <--> | ERPNext | ---> | MariaDB | +| (clienti, plati) | API | (gunicorn)| | (date) | ++--------------------+ +-----+-----+ +----------+ + | + +-------+--------+ + | Redis cache | + | Redis queue | + +----------------+ + | + +-------+--------+ + | 3 workeri | + | (short/long/ | + | default) | + | + scheduler | + +----------------+ +``` + +7 containere: +- `didi-erpnext` (web API + nginx) +- `didi-mariadb` (baza de date) +- `didi-redis-cache`, `didi-redis-queue` +- `didi-queue-short`, `didi-queue-long`, `didi-queue-default` (background jobs) +- `didi-scheduler` (cron jobs) + +## Stack tehnic + +| Componenta | Versiune | +|------------|----------| +| ERPNext | v15 | +| Frappe Framework | v15 | +| MariaDB | 10.11 | +| Redis | 7-alpine | +| Python | 3.11 (in container) | +| Domeniu | Services | +| Limba | Romana | +| Plan conturi | Romania - Chart of Accounts (OMFP 1802/2014) | +| TVA | 21% (cota standard RO de la 01.08.2025) | +| Moneda | RON | + +## Structura folder + +``` +erp_crm/ +├── README.md # Acest fisier +├── INSTALL.md # Ghid de instalare de la 0 +├── OPERATIONS.md # Ghid operare (backup, restart, troubleshooting) +├── CONFIGURATION.md # Variabile mediu, credentiale, integrari +├── docker-compose.yml # Stack Docker complet +├── .env # Configurare credentiale (NU se commiteaza) +├── nginx.conf # Reverse proxy intern container +├── bin/ +│ ├── entrypoint.sh # ERPNext startup +│ ├── start-bench-service.sh # Worker startup +│ └── setup-site.sh # Initial site setup (one-time) +├── didi_custom/ # App custom DiDi (Frappe) +│ ├── didi_custom/ +│ │ ├── hooks.py # Frappe hooks +│ │ ├── doctype/ # 4 DocType-uri custom: +│ │ │ ├── analysis_report/ # - rezultate analize cu PDF +│ │ │ ├── payment_log/ # - log evenimente Stripe +│ │ │ ├── service_agreement/# - acorduri servicii +│ │ │ └── website_content/ # - CMS pagini website +│ │ └── ... +│ └── pyproject.toml +├── mariadb-init/ # Init scripts MariaDB +│ └── init.sql +├── scripts/ +│ ├── setup/ # Scripturi setup (numerotate, idempotente) +│ │ ├── 01-config.py # Company, taxe, naming series +│ │ ├── 02-doctypes.py # Custom fields pe Customer/Lead/Sales Invoice +│ │ ├── 03-cleanup.py # Ascunde module standard ERPNext +│ │ ├── 04-populate-content.py# Continut CMS implicit (RO + EN) +│ │ ├── 05-translations.py # Traduceri suplimentare +│ │ ├── 06-sidebar.py # Workspaces custom (Facturi, Clienti, etc.) +│ │ ├── 07-reports.py # Rapoarte DiDi (Dashboard Financiar, CRM) +│ │ ├── 08-email.py # Email Account SendGrid + notificari +│ │ └── 09-phase2-final.py # Finalizari (suppliers, hidden fields) +│ └── archive/ # Scripturi vechi/one-off (referinta) +└── docs/ + └── oferta/ # Caietul de sarcini + propunerea tehnica +``` + +## Quickstart + +Cere docker si docker compose v2 instalate. Si o retea Docker numita `didi-network`: + +```bash +docker network create didi-network 2>/dev/null || true +``` + +Build complet de la 0 (~5 minute): + +```bash +cd ~/erp_crm +bash build-from-zero.sh +``` + +Dupa ce termina, deschide http://localhost:8080 si te loghezi cu `Administrator` / `admin`. + +Pentru detalii, vezi [INSTALL.md](./INSTALL.md). + +## Configurare + +Editezi `.env` pentru a schimba credentiale, parole, porturi: + +```env +MYSQL_ROOT_PASSWORD= +ADMIN_PASSWORD=admin +ERPNEXT_PORT=8080 +SITE_NAME=didi-erp +``` + +Detalii complete in [CONFIGURATION.md](./CONFIGURATION.md). + +## Documentatie detaliata + +| Document | Scop | +|----------|------| +| [INSTALL.md](./INSTALL.md) | Build de la 0, dependinte, prima rulare | +| [OPERATIONS.md](./OPERATIONS.md) | Backup, restart, log-uri, troubleshooting | +| [CONFIGURATION.md](./CONFIGURATION.md) | Env vars, credentiale, integrari externe | +| [docs/oferta/](./docs/oferta/) | Caietul de sarcini si propunerea tehnica | + +## Credentiale implicite (DE SCHIMBAT la productie) + +| Cui | Valoare | +|-----|---------| +| ERPNext Administrator | `Administrator` / `admin` | +| MariaDB root | `` | +| Site name (intern) | `didi-erp` | + +## Suport + +Pentru intrebari tehnice: office@clossers.com diff --git a/erp_crm/bin/entrypoint.sh b/erp_crm/bin/entrypoint.sh new file mode 100755 index 0000000..bec11b7 --- /dev/null +++ b/erp_crm/bin/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +APP_DIR="/home/frappe/frappe-bench/apps/didi_custom" +VENV_PYTHON="/home/frappe/frappe-bench/env/bin/python" + +if [ -d "$APP_DIR" ]; then + if ! "$VENV_PYTHON" -c "import didi_custom" >/dev/null 2>&1; then + echo "[bootstrap] Installing didi_custom..." + /home/frappe/frappe-bench/env/bin/pip install -e "$APP_DIR" + fi +fi + +# Start nginx in background for reverse proxy +nginx + +# Change to frappe-bench directory (required for site resolution) +cd /home/frappe/frappe-bench + +# Start gunicorn (default frappe entrypoint) +exec /home/frappe/frappe-bench/env/bin/gunicorn \ + --bind 0.0.0.0:8000 \ + --workers 2 \ + --threads 4 \ + --worker-class gthread \ + --worker-tmp-dir /dev/shm \ + --timeout 120 \ + --preload \ + --chdir /home/frappe/frappe-bench/sites \ + frappe.app:application diff --git a/erp_crm/bin/setup-site.sh b/erp_crm/bin/setup-site.sh new file mode 100755 index 0000000..f51f9f3 --- /dev/null +++ b/erp_crm/bin/setup-site.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# ============================================================= +# ERPNext Site Setup Script +# Run this ONCE after first docker compose up +# Usage: docker exec -it didi-erpnext bash /home/frappe/setup-site.sh +# ============================================================= + +set -e + +SITE_NAME="${SITE_NAME:-didi.localhost}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin}" +DB_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:?seteaza MYSQL_ROOT_PASSWORD in .env}" +DB_HOST="mariadb" + +echo "============================================" +echo " ERPNext Site Setup - DiDi" +echo "============================================" +echo "" +echo "Site: $SITE_NAME" +echo "DB Host: $DB_HOST" +echo "" + +# Wait for MariaDB to be ready +echo "[1/4] Waiting for MariaDB..." +for i in $(seq 1 30); do + if mysql -h "$DB_HOST" -u root -p"$DB_ROOT_PASSWORD" -e "SELECT 1" &>/dev/null; then + echo " MariaDB is ready!" + break + fi + echo " Waiting... ($i/30)" + sleep 2 +done + +# Create new site +echo "[2/4] Creating site: $SITE_NAME" +bench new-site "$SITE_NAME" \ + --mariadb-root-password "$DB_ROOT_PASSWORD" \ + --admin-password "$ADMIN_PASSWORD" \ + --db-host "$DB_HOST" \ + --no-mariadb-socket + +# Install ERPNext +echo "[3/4] Installing ERPNext app on site..." +bench --site "$SITE_NAME" install-app erpnext + +# Set as default site +echo "[4/4] Setting as default site..." +bench use "$SITE_NAME" + +echo "" +echo "============================================" +echo " Setup Complete!" +echo "============================================" +echo "" +echo " URL: http://localhost:8080" +echo " User: Administrator" +echo " Password: $ADMIN_PASSWORD" +echo "" +echo "============================================" diff --git a/erp_crm/bin/start-bench-service.sh b/erp_crm/bin/start-bench-service.sh new file mode 100755 index 0000000..fa33dc1 --- /dev/null +++ b/erp_crm/bin/start-bench-service.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +APP_DIR="/home/frappe/frappe-bench/apps/didi_custom" +VENV_PYTHON="/home/frappe/frappe-bench/env/bin/python" + +if [ -d "$APP_DIR" ]; then + if ! "$VENV_PYTHON" -c "import didi_custom" >/dev/null 2>&1; then + echo "[bootstrap] Installing didi_custom..." + /home/frappe/frappe-bench/env/bin/pip install -e "$APP_DIR" + fi +fi + +exec "$@" diff --git a/erp_crm/build-from-zero.sh b/erp_crm/build-from-zero.sh new file mode 100755 index 0000000..09941c2 --- /dev/null +++ b/erp_crm/build-from-zero.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Build de la 0 ERP/CRM DiDi - one-shot installer +# Versiune 2.0, iulie 2026 +# +# Cerinte: docker, docker compose v2. Reteaua Docker `didi-network` trebuie sa existe +# (o creeaza scriptul daca lipseste). +# +# NOTA: toata logica de setup ruleaza IN container, prin `bench execute` pe functiile +# din app-ul didi_custom. Scripturile din scripts/setup/ sunt LEGACY (prima versiune, +# rulate pe host) si NU mai sunt folosite de acest installer. + +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +log() { printf '\033[1;36m[%(%H:%M:%S)T]\033[0m %s\n' -1 "$*"; } +err() { printf '\033[1;31m[%(%H:%M:%S)T] ERROR:\033[0m %s\n' -1 "$*" >&2; exit 1; } + +# ── Verificari ────────────────────────────────────────────── +command -v docker >/dev/null || err "docker nu e instalat" +docker compose version >/dev/null || err "docker compose v2 nu e instalat" + +[ -f .env ] || err ".env nu exista. Copiaza din .env.example si configureaza." + +source .env +SITE_NAME="${SITE_NAME:-didi-erp}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin}" +MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:?seteaza MYSQL_ROOT_PASSWORD in .env}" +ERPNEXT_PORT="${ERPNEXT_PORT:-8080}" + +if ! docker network inspect didi-network >/dev/null 2>&1; then + log "Creez reteaua didi-network..." + docker network create didi-network +fi + +# Helper: ruleaza o functie Python din bench, IN container (cu venv-ul frappe). +# NU folosi `python3` pe host sau in container pentru logica frappe — esueaza tacut. +bexec() { + log " bench execute $1" + docker exec didi-erpnext bash -c \ + "cd /home/frappe/frappe-bench && bench --site $SITE_NAME execute $1" \ + || log " ⚠️ $1 a esuat — verifica manual" +} +# Helper: insert/set-value tolerant la 'exista deja' +bexec_kw() { + docker exec didi-erpnext bash -c \ + "cd /home/frappe/frappe-bench && bench --site $SITE_NAME execute $1 --kwargs '$2'" \ + >/dev/null 2>&1 || true +} + +# ── Pas 1. Pornire containere ─────────────────────────────── +log "Pas 1/10. Pornire containere..." +docker compose up -d +log " Astept MariaDB sa fie healthy..." +for i in $(seq 1 30); do + status=$(docker inspect --format='{{.State.Health.Status}}' didi-mariadb 2>/dev/null || echo "starting") + [ "$status" = "healthy" ] && break + sleep 2 +done +[ "$status" = "healthy" ] || err "MariaDB nu a devenit healthy dupa 60s" +log " OK" + +# ── Pas 2. Astepta ERPNext gunicorn ──────────────────────── +log "Pas 2/10. Astept ERPNext gunicorn..." +for i in $(seq 1 30); do + if docker exec didi-erpnext curl -sf http://127.0.0.1:8000 >/dev/null 2>&1; then + log " OK" + break + fi + sleep 3 +done + +# ── Pas 3. Config Redis ───────────────────────────────────── +log "Pas 3/10. Configurare Redis in common_site_config..." +docker exec didi-erpnext bash -c " +cd /home/frappe/frappe-bench && +bench set-config -g redis_cache 'redis://redis-cache:6379' && +bench set-config -g redis_queue 'redis://redis-queue:6379' && +bench set-config -g redis_socketio 'redis://redis-queue:6379' +" >/dev/null + +# ── Pas 4. Creare site daca nu exista ────────────────────── +log "Pas 4/10. Verificare/creare site $SITE_NAME..." +if docker exec didi-erpnext test -f "/home/frappe/frappe-bench/sites/${SITE_NAME}/site_config.json"; then + log " Site $SITE_NAME exista deja" +else + log " Creez site $SITE_NAME (dureaza ~3 min)..." + docker exec didi-erpnext bash -c " + cd /home/frappe/frappe-bench && + bench new-site $SITE_NAME \ + --mariadb-root-password '$MYSQL_ROOT_PASSWORD' \ + --admin-password '$ADMIN_PASSWORD' \ + --db-host mariadb \ + --install-app erpnext + " +fi + +# ── Pas 5. Fix MariaDB user @ wildcard ────────────────────── +log "Pas 5/10. Fix DB user pentru orice host..." +DB_NAME=$(docker exec didi-erpnext python3 -c " +import json +with open('/home/frappe/frappe-bench/sites/${SITE_NAME}/site_config.json') as f: + print(json.load(f)['db_name']) +") +DB_PWD=$(docker exec didi-erpnext python3 -c " +import json +with open('/home/frappe/frappe-bench/sites/${SITE_NAME}/site_config.json') as f: + print(json.load(f)['db_password']) +") +docker exec didi-mariadb mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" -e " +CREATE USER IF NOT EXISTS '$DB_NAME'@'%' IDENTIFIED BY '$DB_PWD'; +GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_NAME'@'%'; +FLUSH PRIVILEGES; +" 2>/dev/null + +# ── Pas 6. Patch portal_user + install didi_custom ───────── +log "Pas 6/10. Patch portal_users + install didi_custom..." +docker exec didi-erpnext sed -i \ + 's|for portal_user in self.portal_users:|for portal_user in (getattr(self, "portal_users", None) or []):|' \ + /home/frappe/frappe-bench/apps/erpnext/erpnext/selling/doctype/customer/customer.py 2>/dev/null || true + +docker exec didi-erpnext bash -c " +cd /home/frappe/frappe-bench && +printf 'frappe\nerpnext\ndidi_custom\n' > sites/apps.txt && +env/bin/pip install -e apps/didi_custom 2>&1 | tail -2 && +bench --site $SITE_NAME install-app didi_custom 2>&1 | tail -2 || true +" >/dev/null + +# Fix installed_apps registry +docker exec didi-mariadb mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$DB_NAME" -e " +UPDATE tabDefaultValue SET defvalue='[\"frappe\",\"erpnext\",\"didi_custom\"]' WHERE defkey='installed_apps'; +" 2>/dev/null + +# ── Pas 7. Companie + plan de conturi Romania ────────────── +log "Pas 7/10. Companie TOP CLOSSERS SRL + plan de conturi RO..." +# Warehouse Type "Transit" e cerut la crearea companiei si lipseste in instalarea curata +bexec_kw frappe.client.insert '{"doc":{"doctype":"Warehouse Type","name":"Transit"}}' +bexec didi_custom.make_company_ro3.execute +bexec didi_custom.setup_ro_coa.execute + +# An fiscal curent — lipseste complet dupa instalarea curata; facturile pica fara el +YEAR=$(date +%Y) +log " An fiscal $YEAR..." +bexec_kw frappe.client.insert "{\"doc\":{\"doctype\":\"Fiscal Year\",\"year\":\"$YEAR\",\"year_start_date\":\"$YEAR-01-01\",\"year_end_date\":\"$YEAR-12-31\"}}" + +# Price list implicit in RON +bexec_kw frappe.client.set_value '{"doctype":"Price List","name":"Standard Selling","fieldname":"currency","value":"RON"}' + +# ── Pas 8. Doctypes, campuri custom, continut CMS ────────── +log "Pas 8/10. Doctypes + campuri custom + CMS..." +bexec didi_custom.setup_doctypes.execute +bexec didi_custom.integrations.migrations.setup_custom_fields.execute +bexec didi_custom.fix_sa.execute +bexec didi_custom.populate_content.execute + +# ── Pas 9. Integrare website, print format, email, workspace ─ +log "Pas 9/10. Items DIDI + user API + print format + email + workspace..." +bexec didi_custom.setup_website_integration.execute +bexec didi_custom.setup_phase2_final.setup_print_format +bexec didi_custom.setup_website_integration.fix_print_format +bexec didi_custom.setup_email.execute +bexec didi_custom.setup_workspaces.execute +bexec didi_custom.setup_shortcuts.execute + +# ── Pas 10. Build + restart ──────────────────────────────── +log "Pas 10/10. Build assets + restart..." +docker exec didi-erpnext bash -c 'cd /home/frappe/frappe-bench && bench build --force' 2>&1 | tail -2 +docker exec didi-redis-cache redis-cli FLUSHALL >/dev/null +docker exec didi-redis-queue redis-cli FLUSHALL >/dev/null +docker restart didi-erpnext >/dev/null +sleep 8 + +# ── Verificare finala ─────────────────────────────────────── +HTTP=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${ERPNEXT_PORT}") +log "=============================================" +if [ "$HTTP" = "200" ] || [ "$HTTP" = "301" ] || [ "$HTTP" = "302" ]; then + log " ERPNext UP: http://localhost:${ERPNEXT_PORT}" + log " Login: Administrator / ${ADMIN_PASSWORD}" + log " Site: ${SITE_NAME}" +else + log " HTTP ${HTTP} - verifica log-urile cu: docker compose logs erpnext" +fi +log "=============================================" +log "" +log "Credentialele API pentru website (user website_api@didi-erp.local) se genereaza" +log "la Pas 9 — vezi output-ul lui setup_website_integration.execute." +log "Pentru date de test (smoke testing): bash seed-data.sh" diff --git a/erp_crm/didi_custom/.editorconfig b/erp_crm/didi_custom/.editorconfig new file mode 100644 index 0000000..b901702 --- /dev/null +++ b/erp_crm/didi_custom/.editorconfig @@ -0,0 +1,21 @@ +# Root editor config file +root = true + +# Common settings +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# python, js indentation settings +[{*.py,*.js,*.vue,*.css,*.scss,*.html}] +indent_style = tab +indent_size = 4 +max_line_length = 99 + +# JSON files - mostly doctype schema files +[{*.json}] +insert_final_newline = false +indent_style = space +indent_size = 2 diff --git a/erp_crm/didi_custom/.eslintrc b/erp_crm/didi_custom/.eslintrc new file mode 100644 index 0000000..c5e7d68 --- /dev/null +++ b/erp_crm/didi_custom/.eslintrc @@ -0,0 +1,124 @@ +{ + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "parserOptions": { + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "indent": "off", + "brace-style": "off", + "no-mixed-spaces-and-tabs": "off", + "no-useless-escape": "off", + "space-unary-ops": ["error", { "words": true }], + "linebreak-style": "off", + "quotes": ["off"], + "semi": "off", + "camelcase": "off", + "no-unused-vars": "off", + "no-console": ["warn"], + "no-extra-boolean-cast": ["off"], + "no-control-regex": ["off"], + }, + "root": true, + "globals": { + "frappe": true, + "Vue": true, + "SetVueGlobals": true, + "__": true, + "repl": true, + "Class": true, + "locals": true, + "cint": true, + "cstr": true, + "cur_frm": true, + "cur_dialog": true, + "cur_page": true, + "cur_list": true, + "cur_tree": true, + "msg_dialog": true, + "is_null": true, + "in_list": true, + "has_common": true, + "posthog": true, + "has_words": true, + "validate_email": true, + "open_web_template_values_editor": true, + "validate_name": true, + "validate_phone": true, + "validate_url": true, + "get_number_format": true, + "format_number": true, + "format_currency": true, + "comment_when": true, + "open_url_post": true, + "toTitle": true, + "lstrip": true, + "rstrip": true, + "strip": true, + "strip_html": true, + "replace_all": true, + "flt": true, + "precision": true, + "CREATE": true, + "AMEND": true, + "CANCEL": true, + "copy_dict": true, + "get_number_format_info": true, + "strip_number_groups": true, + "print_table": true, + "Layout": true, + "web_form_settings": true, + "$c": true, + "$a": true, + "$i": true, + "$bg": true, + "$y": true, + "$c_obj": true, + "refresh_many": true, + "refresh_field": true, + "toggle_field": true, + "get_field_obj": true, + "get_query_params": true, + "unhide_field": true, + "hide_field": true, + "set_field_options": true, + "getCookie": true, + "getCookies": true, + "get_url_arg": true, + "md5": true, + "$": true, + "jQuery": true, + "moment": true, + "hljs": true, + "Awesomplete": true, + "Sortable": true, + "Showdown": true, + "Taggle": true, + "Gantt": true, + "Slick": true, + "Webcam": true, + "PhotoSwipe": true, + "PhotoSwipeUI_Default": true, + "io": true, + "JsBarcode": true, + "L": true, + "Chart": true, + "DataTable": true, + "Cypress": true, + "cy": true, + "it": true, + "describe": true, + "expect": true, + "context": true, + "before": true, + "beforeEach": true, + "after": true, + "qz": true, + "localforage": true, + "extend_cscript": true + } +} diff --git a/erp_crm/didi_custom/.pre-commit-config.yaml b/erp_crm/didi_custom/.pre-commit-config.yaml new file mode 100644 index 0000000..f9ba440 --- /dev/null +++ b/erp_crm/didi_custom/.pre-commit-config.yaml @@ -0,0 +1,69 @@ +exclude: 'node_modules|.git' +default_stages: [pre-commit] +fail_fast: false + + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + files: "didi_custom.*" + exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" + - id: check-merge-conflict + - id: check-ast + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.1 + hooks: + - id: ruff + name: "Run ruff import sorter" + args: ["--select=I", "--fix"] + + - id: ruff + name: "Run ruff linter" + + - id: ruff-format + name: "Run ruff formatter" + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 + hooks: + - id: prettier + types_or: [javascript, vue, scss] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + didi_custom/public/dist/.*| + .*node_modules.*| + .*boilerplate.*| + didi_custom/templates/includes/.*| + didi_custom/public/js/lib/.* + )$ + + + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.44.0 + hooks: + - id: eslint + types_or: [javascript] + args: ['--quiet'] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + didi_custom/public/dist/.*| + cypress/.*| + .*node_modules.*| + .*boilerplate.*| + didi_custom/templates/includes/.*| + didi_custom/public/js/lib/.* + )$ + +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/erp_crm/didi_custom/README.md b/erp_crm/didi_custom/README.md new file mode 100644 index 0000000..9bf6ef7 --- /dev/null +++ b/erp_crm/didi_custom/README.md @@ -0,0 +1,33 @@ +### Didi Custom + +Custom DocTypes for DiDi Platform + +### Installation + +You can install this app using the [bench](https://github.com/frappe/bench) CLI: + +```bash +cd $PATH_TO_YOUR_BENCH +bench get-app $URL_OF_THIS_REPO --branch develop +bench install-app didi_custom +``` + +### Contributing + +This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: + +```bash +cd apps/didi_custom +pre-commit install +``` + +Pre-commit is configured to use the following tools for checking and formatting your code: + +- ruff +- eslint +- prettier +- pyupgrade + +### License + +mit diff --git a/erp_crm/didi_custom/didi_custom/__init__.py b/erp_crm/didi_custom/didi_custom/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/README.md b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/README.md new file mode 100644 index 0000000..095af46 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/README.md @@ -0,0 +1,4 @@ +# Arhivă scripturi one-off (build & fixuri aplicate deja) +Scripturi folosite O SINGURĂ DATĂ la construcția/repararea instanței, păstrate +pentru trasabilitate. NU se rulează din nou — efectele lor sunt deja în baza de +date. Modulele active ale aplicației sunt în rădăcina didi_custom/ și integrations/. diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/__init__.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_custom_fields.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_custom_fields.py new file mode 100644 index 0000000..17a2834 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_custom_fields.py @@ -0,0 +1,34 @@ +import frappe + +def execute(): + custom_fields = [ + {"dt": "Customer", "fieldname": "didi_user_id", "label": "DiDi Keycloak User ID", "fieldtype": "Data", "insert_after": "customer_name", "in_standard_filter": 1, "search_index": 1}, + {"dt": "Customer", "fieldname": "iam_role", "label": "IAM Role", "fieldtype": "Data", "insert_after": "didi_user_id"}, + {"dt": "Customer", "fieldname": "active_plan", "label": "Active Plan", "fieldtype": "Data", "insert_after": "iam_role"}, + {"dt": "Customer", "fieldname": "plan_activation_date", "label": "Plan Activation Date", "fieldtype": "Date", "insert_after": "active_plan"}, + {"dt": "Lead", "fieldname": "source_form", "label": "Source Form", "fieldtype": "Data", "insert_after": "source"}, + {"dt": "Lead", "fieldname": "page_origin", "label": "Page Origin", "fieldtype": "Data", "insert_after": "source_form"}, + {"dt": "Sales Invoice", "fieldname": "analysis_consumed", "label": "Analysis Consumed", "fieldtype": "Check", "insert_after": "naming_series"}, + {"dt": "Sales Invoice", "fieldname": "analysis_session_id", "label": "Analysis Session ID", "fieldtype": "Data", "insert_after": "analysis_consumed"}, + {"dt": "Payment Log", "fieldname": "stripe_session_id", "label": "Stripe Session ID", "fieldtype": "Data", "insert_after": "stripe_payment_intent_id", "in_standard_filter": 1, "search_index": 1}, + ] + for cf in custom_fields: + name = cf["dt"] + "-" + cf["fieldname"] + try: + if frappe.db.exists("Custom Field", name): + doc = frappe.get_doc("Custom Field", name) + for k, v in cf.items(): + if k != "dt": + setattr(doc, k, v) + doc.save(ignore_permissions=True) + print("Updated: " + name) + else: + doc = frappe.get_doc({"doctype": "Custom Field", **cf}) + doc.flags.ignore_permissions = True + doc.insert() + print("Created: " + name) + except Exception as e: + print("Failed " + name + ": " + str(e)[:200]) + frappe.db.commit() + frappe.clear_cache() + print("DONE") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_ro.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_ro.py new file mode 100644 index 0000000..b5b38b2 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_ro.py @@ -0,0 +1,51 @@ +import frappe + +def execute(): + company = "TOP CLOSSERS SRL" + def find_parent(like): + r = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND name LIKE %s AND is_group=1 LIMIT 1", (company, like)) + return r[0][0] if r else None + + bugetul = find_parent("%Bugetul statului%TC") + venituri_root = find_parent("VENITURI - TC") + print("TVA parent:", bugetul) + print("Income root:", venituri_root) + + accounts = [ + ("4426 - TVA deductibila", bugetul, "Asset", "Tax", "4426"), + ("4427 - TVA colectata", bugetul, "Liability", "Tax", "4427"), + ("4423 - TVA de plata", bugetul, "Liability", "Tax", "4423"), + ("4424 - TVA de recuperat", bugetul, "Asset", "Tax", "4424"), + ("704 - Venituri din servicii prestate", venituri_root, "Income", "Income Account", "704"), + ("707 - Venituri din vanzarea marfurilor", venituri_root, "Income", "Income Account", "707"), + ] + for name, parent, rt, at, num in accounts: + if not parent: + print(" SKIP", name) + continue + doc = frappe.get_doc({ + "doctype": "Account", + "company": company, + "is_group": 0, + "account_name": name, + "parent_account": parent, + "root_type": rt, + "account_type": at, + "account_number": num, + }) + doc.flags.ignore_permissions = True + try: + doc.insert() + print(" OK", doc.name) + except Exception as e: + print(" FAIL", name, str(e)[:100]) + frappe.db.commit() + + income = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND account_number=%s", (company, "704")) + if income: + co = frappe.get_doc("Company", company) + co.default_income_account = income[0][0] + co.flags.ignore_permissions = True + co.save() + frappe.db.commit() + print("Default income:", income[0][0]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_ro_accounts.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_ro_accounts.py new file mode 100644 index 0000000..cfbf3c2 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/add_ro_accounts.py @@ -0,0 +1,54 @@ +import frappe + +def execute(): + company = "TOP CLOSSERS SRL" + + # Find parent groups + def find_parent(like): + r = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND name LIKE %s AND is_group=1 LIMIT 1", (company, like)) + return r[0][0] if r else None + + bugetul = find_parent("%Bugetul statului%TC") + venituri_root = find_parent("VENITURI - TC") + + print(f"TVA parent: {bugetul}") + print(f"Income root: {venituri_root}") + + # Add specific accounts + accounts_to_create = [ + {"account_name": "4426 - TVA deductibila", "parent_account": bugetul, "root_type": "Asset", "account_type": "Tax", "account_number": "4426"}, + {"account_name": "4427 - TVA colectata", "parent_account": bugetul, "root_type": "Liability", "account_type": "Tax", "account_number": "4427"}, + {"account_name": "4423 - TVA de plata", "parent_account": bugetul, "root_type": "Liability", "account_type": "Tax", "account_number": "4423"}, + {"account_name": "4424 - TVA de recuperat", "parent_account": bugetul, "root_type": "Asset", "account_type": "Tax", "account_number": "4424"}, + {"account_name": "704 - Venituri din servicii prestate", "parent_account": venituri_root, "root_type": "Income", "account_type": "Income Account", "account_number": "704"}, + {"account_name": "707 - Venituri din vanzarea marfurilor", "parent_account": venituri_root, "root_type": "Income", "account_type": "Income Account", "account_number": "707"}, + ] + + for acc in accounts_to_create: + if not acc["parent_account"]: + print(f" SKIP {acc[\"account_name\"]} - no parent") + continue + doc = frappe.get_doc({ + "doctype": "Account", + "company": company, + "is_group": 0, + **acc, + }) + doc.flags.ignore_permissions = True + try: + doc.insert() + print(f" CREATED: {doc.name}") + except Exception as e: + print(f" FAIL {acc[\"account_name\"]}: {str(e)[:100]}") + + frappe.db.commit() + + # Update Company default income account + income = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND account_number=%s", (company, "704")) + if income: + co = frappe.get_doc("Company", company) + co.default_income_account = income[0][0] + co.flags.ignore_permissions = True + co.save() + frappe.db.commit() + print(f" Default income → {income[0][0]}") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/check_module.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/check_module.py new file mode 100644 index 0000000..8a698f6 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/check_module.py @@ -0,0 +1,28 @@ +import frappe + +def execute(): + # Check what get_doctype_module returns for Customer + from frappe.model.utils import get_doctype_module + try: + mod = get_doctype_module('Customer') + print('Module for Customer:', mod) + except Exception as e: + print('get_doctype_module ERROR:', e) + + # Try to actually insert a Customer + try: + c = frappe.get_doc({ + 'doctype': 'Customer', + 'customer_name': 'Test User Direct', + 'customer_type': 'Individual', + 'customer_group': 'Commercial', + 'territory': 'Romania', + 'didi_user_id': '14142351-ad1e-466b-ac5d-4a7a0ff562bf', + 'email_id': 'test@didi.local', + }) + c.flags.ignore_permissions = True + c.insert() + frappe.db.commit() + print('Customer created:', c.name) + except Exception as e: + print('Insert ERROR:', str(e)[:300]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/configure_company.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/configure_company.py new file mode 100644 index 0000000..8389d4c --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/configure_company.py @@ -0,0 +1,64 @@ +import frappe + +def execute(): + company = "TOP CLOSSERS SRL" + + # Find key accounts + def find_account(like): + r = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND name LIKE %s AND is_group=0 LIMIT 1", (company, like)) + return r[0][0] if r else None + + # Receivable (Clienti) + receivable = find_account("%Clienti%TC") or find_account("%Clienti 1 - TC") + # Cash account (Casa) - or create one + cash = find_account("%Casa%lei%TC") or find_account("%Casa 1%TC") or find_account("%Casa - TC") + # Bank account + bank = find_account("%Conturi la banci in lei%TC") or find_account("%5121%TC") + # Income for services + income = find_account("%Venituri din prestari de servicii%TC") or find_account("%704%TC") or find_account("%Venituri din vanzarea%TC") + # TVA colectata (output VAT) + tva_colectata = find_account("%TVA colectata%TC") or find_account("%4427%TC") + # TVA deductibila (input VAT) + tva_deductibila = find_account("%TVA deductibila%TC") or find_account("%4426%TC") + + print(f"Receivable: {receivable}") + print(f"Cash: {cash}") + print(f"Bank: {bank}") + print(f"Income: {income}") + print(f"TVA colectata: {tva_colectata}") + print(f"TVA deductibila: {tva_deductibila}") + + # Update Company defaults + co = frappe.get_doc("Company", company) + if receivable: co.default_receivable_account = receivable + if cash: co.default_cash_account = cash + if bank: co.default_bank_account = bank + if income: co.default_income_account = income + co.flags.ignore_permissions = True + co.save() + frappe.db.commit() + print("Company defaults updated") + + # Delete old tax templates + frappe.db.sql("DELETE FROM \`tabSales Taxes and Charges Template\` WHERE company=%s", (company,)) + frappe.db.sql("DELETE FROM \`tabSales Taxes and Charges\` WHERE parent NOT IN (SELECT name FROM \`tabSales Taxes and Charges Template\`)") + frappe.db.commit() + + # Create TVA 19% template + if tva_colectata: + tax = frappe.get_doc({ + "doctype": "Sales Taxes and Charges Template", + "title": "TVA 19%", + "company": company, + "is_default": 1, + "taxes": [{ + "charge_type": "On Net Total", + "account_head": tva_colectata, + "description": "TVA 19%", + "rate": 19, + }], + }) + tax.flags.ignore_permissions = True + tax.insert() + frappe.db.commit() + print(f"Created tax template: {tax.name} → {tva_colectata}") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/create_company.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/create_company.py new file mode 100644 index 0000000..2b401aa --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/create_company.py @@ -0,0 +1,18 @@ +import frappe + +def execute(): + if frappe.db.exists("Company", "TOP CLOSSERS SRL"): + print("Company already exists") + return + + doc = frappe.get_doc({ + "doctype": "Company", + "company_name": "TOP CLOSSERS SRL", + "abbr": "TC", + "default_currency": "RON", + "country": "Romania", + "chart_of_accounts": "Standard", + }) + doc.insert(ignore_permissions=True, ignore_mandatory=True) + frappe.db.commit() + print("Company created:", doc.name) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/create_tva_report.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/create_tva_report.py new file mode 100644 index 0000000..ffd76f9 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/create_tva_report.py @@ -0,0 +1,55 @@ +import frappe + +def execute(): + # Create custom Report "DiDi - Raport TVA D300" (Script Report) + report_name = "DiDi - Raport TVA D300" + if frappe.db.exists("Report", report_name): + print("Already exists, deleting first") + frappe.delete_doc("Report", report_name, ignore_permissions=True, force=True) + frappe.db.commit() + + report_js = """ +frappe.query_reports["DiDi - Raport TVA D300"] = { + filters: [ + { fieldname: "company", label: "Companie", fieldtype: "Link", options: "Company", default: frappe.defaults.get_user_default("Company"), reqd: 1 }, + { fieldname: "from_date", label: "De la data", fieldtype: "Date", default: frappe.datetime.month_start(), reqd: 1 }, + { fieldname: "to_date", label: "Pana la data", fieldtype: "Date", default: frappe.datetime.month_end(), reqd: 1 }, + ] +}; +""" + + # Server-side query method (we'll create as a custom script) + report_query = """ +SELECT + DATE_FORMAT(posting_date, '%Y-%m') as luna, + CASE + WHEN account LIKE '%4427%' THEN 'TVA colectata (vanzari)' + WHEN account LIKE '%4426%' THEN 'TVA deductibila (cumparari)' + WHEN account LIKE '%4423%' THEN 'TVA de plata' + WHEN account LIKE '%4424%' THEN 'TVA de recuperat' + ELSE account + END as categorie, + ROUND(SUM(debit), 2) as total_debit, + ROUND(SUM(credit), 2) as total_credit, + ROUND(SUM(credit) - SUM(debit), 2) as sold_net +FROM `tabGL Entry` +WHERE company = %(company)s + AND posting_date BETWEEN %(from_date)s AND %(to_date)s + AND (account LIKE '%%4426%%' OR account LIKE '%%4427%%' OR account LIKE '%%4423%%' OR account LIKE '%%4424%%') +GROUP BY luna, categorie +ORDER BY luna, categorie +""" + + doc = frappe.get_doc({ + "doctype": "Report", + "report_name": report_name, + "ref_doctype": "Sales Invoice", + "report_type": "Query Report", + "is_standard": "No", + "query": report_query, + "module": "Accounts", + }) + doc.flags.ignore_permissions = True + doc.insert() + frappe.db.commit() + print("Created:", doc.name) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/debug_company.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/debug_company.py new file mode 100644 index 0000000..e5b2f93 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/debug_company.py @@ -0,0 +1,12 @@ +import frappe +import traceback + +def execute(): + from erpnext.setup.setup_wizard.operations.install_fixtures import install_company + args = frappe._dict({"country": "Romania", "company_name": "TOP CLOSSERS SRL", "company_abbr": "TC"}) + try: + install_company(args) + print("OK") + except Exception as e: + print("ERROR:", str(e)[:500]) + print(traceback.format_exc()[:2000]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/drop_company.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/drop_company.py new file mode 100644 index 0000000..52a380a --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/drop_company.py @@ -0,0 +1,26 @@ +import frappe + +def execute(): + company = "TOP CLOSSERS SRL" + if not frappe.db.exists("Company", company): + print("Company not found") + return + # Patch out the country_fixtures call to avoid Romania ImportError + from erpnext.setup.doctype.company import company as cm + cm.install_country_fixtures = lambda *a, **kw: None + + # Delete company (cascades through accounts/cost_centers/fiscal_year/series) + try: + frappe.delete_doc("Company", company, ignore_permissions=True, force=True, ignore_missing=True) + frappe.db.commit() + print("Deleted:", company) + except Exception as e: + print("Soft-delete via SQL:", str(e)[:200]) + # Fallback: direct SQL + for tbl in ["tabAccount","tabCost Center","tabFiscal Year","tabSeries","tabMode of Payment Account","tabParty Account"]: + try: + frappe.db.sql(f"DELETE FROM \`{tbl}\` WHERE company=%s OR name LIKE %s", (company, "%- TC")) + except: pass + frappe.db.sql("DELETE FROM tabCompany WHERE name=%s", (company,)) + frappe.db.commit() + print("Force deleted:", company) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/final_setup.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/final_setup.py new file mode 100644 index 0000000..a2d5bfc --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/final_setup.py @@ -0,0 +1,59 @@ +import frappe + +def execute(): + company = "TOP CLOSSERS SRL" + + # Create TVA 19% tax template using 4427 - TVA colectata + tva_colectata = "4427 - 4427 - TVA colectata - TC" + + # Delete old templates + for t in frappe.get_all("Sales Taxes and Charges Template", filters={"company": company}): + frappe.delete_doc("Sales Taxes and Charges Template", t.name, ignore_permissions=True, force=True) + + tax = frappe.get_doc({ + "doctype": "Sales Taxes and Charges Template", + "title": "TVA 19%", + "company": company, + "is_default": 1, + "taxes": [{ + "charge_type": "On Net Total", + "account_head": tva_colectata, + "description": "TVA 19%", + "rate": 19, + }], + }) + tax.flags.ignore_permissions = True + tax.insert() + print("Tax template:", tax.name) + + # Recreate Customer + if frappe.db.exists("Customer", "Test User"): + frappe.delete_doc("Customer", "Test User", ignore_permissions=True, force=True) + + cust = frappe.get_doc({ + "doctype": "Customer", + "customer_name": "Test User", + "customer_type": "Individual", + "customer_group": "Commercial", + "territory": "Romania", + "didi_user_id": "14142351-ad1e-466b-ac5d-4a7a0ff562bf", + "email_id": "test@didi.local", + "iam_role": "free_tier", + }) + cust.flags.ignore_permissions = True + cust.insert() + frappe.db.commit() + print("Customer:", cust.name) + + # Verify + print("\n=== Accounts summary ===") + for rt in ["Asset","Liability","Equity","Income","Expense"]: + c = frappe.db.sql("SELECT COUNT(*) FROM tabAccount WHERE company=%s AND root_type=%s", (company, rt))[0][0] + print(f" {rt}: {c}") + + print("\n=== Company defaults ===") + co = frappe.get_doc("Company", company) + print(f" default_receivable: {co.default_receivable_account}") + print(f" default_income: {co.default_income_account}") + print(f" default_cash: {co.default_cash_account}") + print(f" default_bank: {co.default_bank_account}") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fire_notif.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fire_notif.py new file mode 100644 index 0000000..603b40c --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fire_notif.py @@ -0,0 +1,8 @@ +import frappe + +def execute(): + notif = frappe.get_doc("Notification", "DiDi - Factura Emisa") + doc = frappe.get_doc("Sales Invoice", "DIDI-INV-2026-00010") + notif.send(doc) + frappe.db.commit() + print("Notification fired for", doc.name) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_consume.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_consume.py new file mode 100644 index 0000000..4cabfa2 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_consume.py @@ -0,0 +1,20 @@ +import frappe + +def execute(): + # Allow analysis_consumed and analysis_session_id to be set on submitted invoices + fields_to_fix = [ + ("Sales Invoice", "analysis_consumed"), + ("Sales Invoice", "analysis_session_id"), + ] + for dt, fn in fields_to_fix: + name = f"{dt}-{fn}" + if frappe.db.exists("Custom Field", name): + doc = frappe.get_doc("Custom Field", name) + doc.allow_on_submit = 1 + doc.save(ignore_permissions=True) + print(f" Allow on Submit set: {name}") + else: + print(f" NOT FOUND: {name}") + frappe.db.commit() + frappe.clear_cache() + print("DONE") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_customer.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_customer.py new file mode 100644 index 0000000..5a79ae2 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_customer.py @@ -0,0 +1,13 @@ +import frappe + +def execute(): + # Find the broken row pointing to Customer Portal Users + rows = frappe.db.sql("SELECT name, fieldname, options FROM tabDocField WHERE parent=%s AND options=%s", ("Customer", "Customer Portal Users"), as_dict=True) + print("Found broken rows:", rows) + for r in rows: + # Delete the broken field + frappe.db.delete("DocField", {"name": r["name"]}) + print("Deleted:", r["fieldname"]) + frappe.db.commit() + frappe.clear_cache() + print("DONE") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_customer2.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_customer2.py new file mode 100644 index 0000000..793d711 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_customer2.py @@ -0,0 +1,10 @@ +import frappe + +def execute(): + # Get row 79 of Customer doctype + rows = frappe.db.sql("SELECT name, fieldname, fieldtype, options, idx FROM tabDocField WHERE parent=%s ORDER BY idx", ("Customer",), as_dict=True) + for r in rows[75:85]: + print(f" idx={r[\"idx\"]} fn={r[\"fieldname\"]} ft={r[\"fieldtype\"]} opt={r[\"options\"]}") + # Find any row referencing Customer Portal Users + rows2 = frappe.db.sql("SELECT name, parent, fieldname, options FROM tabDocField WHERE options LIKE %s", ("%Customer Portal%",), as_dict=True) + print("Any with Customer Portal opt:", rows2) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_round.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_round.py new file mode 100644 index 0000000..e4e97e5 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_round.py @@ -0,0 +1,55 @@ +import frappe + +def execute(): + company = "TOP CLOSSERS SRL" + + # Find/create Round Off accounts + # Round Off Account - for rounding (Expense or Income) + # Round Off Cost Center - cost center + + venituri = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND name LIKE %s AND is_group=1 LIMIT 1", (company, "VENITURI%TC")) + chelt = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND name LIKE %s AND is_group=1 LIMIT 1", (company, "CHELTUIELI%TC")) + + venituri_parent = venituri[0][0] if venituri else None + chelt_parent = chelt[0][0] if chelt else None + + # Create Round Off accounts + round_off_name = None + if chelt_parent: + if not frappe.db.exists("Account", {"company": company, "account_name": "Round Off"}): + doc = frappe.get_doc({ + "doctype": "Account", + "company": company, + "account_name": "Round Off", + "parent_account": chelt_parent, + "root_type": "Expense", + "account_type": "Round Off", + "is_group": 0, + }) + doc.flags.ignore_permissions = True + doc.insert() + round_off_name = doc.name + print("Created Round Off:", round_off_name) + else: + round_off_name = frappe.db.get_value("Account", {"company": company, "account_name": "Round Off"}, "name") + print("Round Off exists:", round_off_name) + + # Find a Cost Center + cc = frappe.db.sql("SELECT name FROM `tabCost Center` WHERE company=%s LIMIT 1", (company,)) + cost_center = cc[0][0] if cc else None + print("Cost Center:", cost_center) + + # Update Company + co = frappe.get_doc("Company", company) + if round_off_name: + co.round_off_account = round_off_name + if cost_center: + co.round_off_cost_center = cost_center + co.cost_center = cost_center # default cost center + co.flags.ignore_permissions = True + co.save() + frappe.db.commit() + print("Updated Company defaults") + + print(f" round_off_account: {co.round_off_account}") + print(f" cost_center: {co.cost_center}") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva.py new file mode 100644 index 0000000..c37f737 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva.py @@ -0,0 +1,26 @@ +import frappe + +def execute(): + company = "TOP CLOSSERS SRL" + tva_colectata = "4427 - 4427 - TVA colectata - TC" + + # Update existing template OR recreate + for t in frappe.get_all("Sales Taxes and Charges Template", filters={"company": company}): + frappe.delete_doc("Sales Taxes and Charges Template", t.name, ignore_permissions=True, force=True) + + tax = frappe.get_doc({ + "doctype": "Sales Taxes and Charges Template", + "title": "TVA 21%", + "company": company, + "is_default": 1, + "taxes": [{ + "charge_type": "On Net Total", + "account_head": tva_colectata, + "description": "TVA 21% (cota standard RO din 01.08.2025)", + "rate": 21, + }], + }) + tax.flags.ignore_permissions = True + tax.insert() + frappe.db.commit() + print("Tax template updated:", tax.name, "rate=21%") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva_report.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva_report.py new file mode 100644 index 0000000..8ffbf65 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva_report.py @@ -0,0 +1,25 @@ +import frappe + +def execute(): + query = """ +SELECT + CASE + WHEN account LIKE "%4427%" THEN "TVA colectata (vanzari)" + WHEN account LIKE "%4426%" THEN "TVA deductibila (cumparari)" + WHEN account LIKE "%4423%" THEN "TVA de plata" + WHEN account LIKE "%4424%" THEN "TVA de recuperat" + ELSE account + END as categorie, + ROUND(SUM(debit), 2) as total_debit, + ROUND(SUM(credit), 2) as total_credit, + ROUND(SUM(credit) - SUM(debit), 2) as sold_net +FROM `tabGL Entry` +WHERE company = %(company)s + AND posting_date BETWEEN %(from_date)s AND %(to_date)s + AND (account LIKE "%4426%" OR account LIKE "%4427%" OR account LIKE "%4423%" OR account LIKE "%4424%") +GROUP BY categorie +ORDER BY categorie +""" + frappe.db.sql("UPDATE tabReport SET query=%s WHERE name=%s", (query, "DiDi - Raport TVA D300")) + frappe.db.commit() + print("Updated query") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva_v2.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva_v2.py new file mode 100644 index 0000000..a7dd994 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/fix_tva_v2.py @@ -0,0 +1,26 @@ +import frappe + +def execute(): + # Escape % as %% for psycopg/pymysql named-param queries + query = """ +SELECT + CASE + WHEN account LIKE "%%4427%%" THEN "TVA colectata (vanzari)" + WHEN account LIKE "%%4426%%" THEN "TVA deductibila (cumparari)" + WHEN account LIKE "%%4423%%" THEN "TVA de plata" + WHEN account LIKE "%%4424%%" THEN "TVA de recuperat" + ELSE account + END as categorie, + ROUND(SUM(debit), 2) as total_debit, + ROUND(SUM(credit), 2) as total_credit, + ROUND(SUM(credit) - SUM(debit), 2) as sold_net +FROM `tabGL Entry` +WHERE company = %(company)s + AND posting_date BETWEEN %(from_date)s AND %(to_date)s + AND (account LIKE "%%4426%%" OR account LIKE "%%4427%%" OR account LIKE "%%4423%%" OR account LIKE "%%4424%%") +GROUP BY categorie +ORDER BY categorie +""" + frappe.db.sql("UPDATE tabReport SET query=%s WHERE name=%s", (query, "DiDi - Raport TVA D300")) + frappe.db.commit() + print("Updated") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/insert_customer.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/insert_customer.py new file mode 100644 index 0000000..886bf25 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/insert_customer.py @@ -0,0 +1,21 @@ +import frappe + +def execute(): + try: + c = frappe.get_doc({ + 'doctype': 'Customer', + 'customer_name': 'Test User Direct', + 'customer_type': 'Individual', + 'customer_group': 'Commercial', + 'territory': 'Romania', + 'didi_user_id': '14142351-ad1e-466b-ac5d-4a7a0ff562bf', + 'email_id': 'test@didi.local', + }) + c.flags.ignore_permissions = True + c.insert() + frappe.db.commit() + print('Customer created:', c.name) + except Exception as e: + import traceback + print('Insert ERROR:', str(e)[:300]) + print(traceback.format_exc()[:1500]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/inspect_cf.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/inspect_cf.py new file mode 100644 index 0000000..e882817 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/inspect_cf.py @@ -0,0 +1,16 @@ +import frappe + +def execute(): + # Check Custom Field table + rows = frappe.db.sql("SELECT name, dt, fieldname, fieldtype, options, idx FROM `tabCustom Field` WHERE dt='Customer' ORDER BY idx", as_dict=True) + print("Custom Fields on Customer:", len(rows)) + for r in rows: + print(' ', r) + + # Check Property Setter + rows3 = frappe.db.sql("SELECT name, doc_type, field_name, property, value FROM `tabProperty Setter` WHERE doc_type='Customer'", as_dict=True) + print("Property Setters on Customer:", len(rows3)) + + # Search ALL for "Customer Portal Users" + rows4 = frappe.db.sql("SELECT name, dt, fieldname, options FROM `tabCustom Field` WHERE options LIKE '%Customer Portal%'", as_dict=True) + print("Custom Fields with Customer Portal:", rows4) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/inspect_customer.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/inspect_customer.py new file mode 100644 index 0000000..818dd4c --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/inspect_customer.py @@ -0,0 +1,13 @@ +import frappe + +def execute(): + rows = frappe.db.sql("SELECT name, fieldname, fieldtype, options, idx FROM tabDocField WHERE parent='Customer' ORDER BY idx", as_dict=True) + print("Total Customer fields:", len(rows)) + for r in rows[75:85]: + idx = r['idx'] + fn = r['fieldname'] + ft = r['fieldtype'] + opt = r['options'] + print(' idx=', idx, 'fn=', fn, 'ft=', ft, 'opt=', opt) + rows2 = frappe.db.sql("SELECT name, parent, fieldname, options FROM tabDocField WHERE options LIKE '%Customer Portal%'", as_dict=True) + print("Rows with Customer Portal in options:", rows2) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa.py new file mode 100644 index 0000000..b6f0605 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa.py @@ -0,0 +1,21 @@ +import frappe +from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import create_charts + +def execute(): + company = "TOP CLOSSERS SRL" + print("Installing Romania COA for", company) + try: + create_charts(company, "Romania - Chart of Accounts", None, "Standard Template") + frappe.db.commit() + count = frappe.db.sql("SELECT COUNT(*) FROM tabAccount WHERE company=%s", (company,))[0][0] + print(f"OK - {count} accounts created") + + # Sample + samples = frappe.db.sql("SELECT account_number, account_name FROM tabAccount WHERE company=%s AND (account_number LIKE %s OR account_number LIKE %s OR account_number LIKE %s) LIMIT 10", + (company, "4426%", "707%", "411%")) + for s in samples: + print(f" {s[0]} {s[1]}") + except Exception as e: + import traceback + print("ERROR:", str(e)[:200]) + print(traceback.format_exc()[:1500]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa2.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa2.py new file mode 100644 index 0000000..306767f --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa2.py @@ -0,0 +1,33 @@ +import json +import frappe +from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import create_charts + +def execute(): + company = "TOP CLOSSERS SRL" + + # Load and fix JSON + with open("/home/frappe/frappe-bench/apps/erpnext/erpnext/accounts/doctype/account/chart_of_accounts/unverified/ro_ro_chart_template.json") as f: + chart_data = json.load(f) + + tree = chart_data.get("tree", {}) + + # Assign root_type to root accounts based on Romanian COA conventions + # Class 1=Equity, 2=Asset, 3=Asset, 4=Liab+Asset, 5=Asset, 6=Expense, 7=Income, 8=Off-balance + # The two roots are "CONTURI FINANCIARE" and "CONTURI IN AFARA BILANTULUI" + if "CONTURI FINANCIARE" in tree: + tree["CONTURI FINANCIARE"]["root_type"] = "Asset" # default; children have their own + tree["CONTURI FINANCIARE"]["is_group"] = 1 + if "CONTURI IN AFARA BILANTULUI" in tree: + tree["CONTURI IN AFARA BILANTULUI"]["root_type"] = "Asset" + tree["CONTURI IN AFARA BILANTULUI"]["is_group"] = 1 + + print("Fixed tree, installing...") + try: + create_charts(company, custom_chart=tree) + frappe.db.commit() + count = frappe.db.sql("SELECT COUNT(*) FROM tabAccount WHERE company=%s", (company,))[0][0] + print(f"OK - {count} accounts created") + except Exception as e: + import traceback + print("ERROR:", str(e)[:200]) + print(traceback.format_exc()[-800:]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa3.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa3.py new file mode 100644 index 0000000..fdc9b35 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/install_coa3.py @@ -0,0 +1,84 @@ +import json +import frappe +from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import create_charts + +def execute(): + company = "TOP CLOSSERS SRL" + + with open("/home/frappe/frappe-bench/apps/erpnext/erpnext/accounts/doctype/account/chart_of_accounts/unverified/ro_ro_chart_template.json") as f: + chart_data = json.load(f) + tree = chart_data["tree"] + + # Reorganize roots properly for ERPNext + new_tree = {} + + fin = tree.get("CONTURI FINANCIARE", {}) + bilant = fin.get("CONTURI DE BILANT", {}) + venituri = fin.get("CONTURI DE VENITURI SI CHELTUIELI", {}) + + # Balance Sheet -> need to be split into Asset/Liability/Equity + # In Romanian COA: Class 1=Capital(Equity+long Liab), 2=Imobilizari(Asset), 3=Stocuri(Asset), 4=Terti(mixed), 5=Trezorerie(Asset) + # Simplest: keep "Bilant" as Asset wrapper but mark Liab/Equity ones properly + + # Class-based split + capitaluri = bilant.get("CONTURI DE CAPITALURI", {}) + imobilizari = bilant.get("CONTURI DE IMOBILIZARI", {}) + stocuri = bilant.get("CONTURI DE STOCURI SI PRODUCTIE IN CURS DE EXECUTIE", {}) + terti = bilant.get("CONTURI DE TERTI", {}) + trezorerie = bilant.get("CONTURI DE TREZORERIE", {}) + + new_tree["ACTIVE"] = { + "root_type": "Asset", "is_group": 1, + "Imobilizari (Clasa 2)": {**imobilizari, "root_type": "Asset", "is_group": 1}, + "Stocuri (Clasa 3)": {**stocuri, "root_type": "Asset", "is_group": 1}, + "Trezorerie (Clasa 5)": {**trezorerie, "root_type": "Asset", "is_group": 1}, + "Clienti si conturi asimilate": { + "root_type": "Asset", "is_group": 1, + **terti.get("CLIENTI SI CONTURI ASIMILATE", {}), + }, + "Debitori diversi": { + "root_type": "Asset", "is_group": 1, + **terti.get("DEBITORI SI CREDITORI DIVERSI", {}), + }, + } + new_tree["PASIVE (DATORII)"] = { + "root_type": "Liability", "is_group": 1, + "Furnizori si conturi asimilate": { + "root_type": "Liability", "is_group": 1, + **terti.get("FURNIZORI SI CONTURI ASIMILATE", {}), + }, + "Personal si conturi asimilate": { + "root_type": "Liability", "is_group": 1, + **terti.get("PERSONAL SI CONTURI ASIMILATE", {}), + }, + "Asigurari sociale, protectia sociala": { + "root_type": "Liability", "is_group": 1, + **terti.get("ASIGURARI SOCIALE, PROTECTIA SOCIALA SI CONTURI ASIMILATE", {}), + }, + "Bugetul statului (TVA, impozite)": { + "root_type": "Liability", "is_group": 1, + **terti.get("BUGETUL STATULUI, FONDURI SPECIALE SI CONTURI ASIMILATE", {}), + }, + } + new_tree["CAPITALURI PROPRII"] = { + "root_type": "Equity", "is_group": 1, + **capitaluri, + } + new_tree["VENITURI"] = { + "root_type": "Income", "is_group": 1, + **venituri.get("CONTURI DE VENITURI", {}), + } + new_tree["CHELTUIELI"] = { + "root_type": "Expense", "is_group": 1, + **venituri.get("CONTURI DE CHELTUIELI", {}), + } + + create_charts(company, custom_chart=new_tree) + frappe.db.commit() + + count = frappe.db.sql("SELECT COUNT(*) FROM tabAccount WHERE company=%s", (company,))[0][0] + print(f"Total accounts: {count}") + + for rt in ["Asset","Liability","Equity","Income","Expense"]: + c = frappe.db.sql("SELECT COUNT(*) FROM tabAccount WHERE company=%s AND root_type=%s", (company, rt))[0][0] + print(f" {rt}: {c}") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/list_cf.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/list_cf.py new file mode 100644 index 0000000..b242c2f --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/list_cf.py @@ -0,0 +1,13 @@ +import frappe + +def execute(): + rows = frappe.db.sql("SELECT name, fieldname, fieldtype FROM `tabCustom Field` WHERE dt='Customer'", as_dict=True) + for r in rows: + print(' CF:', r['fieldname'], '-', r['fieldtype']) + + # Check if API can now query Customer by didi_user_id + try: + result = frappe.db.sql("SELECT name FROM tabCustomer WHERE didi_user_id='test'", as_dict=True) + print("Query worked! Result:", result) + except Exception as e: + print("Query failed:", str(e)[:200]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company.py new file mode 100644 index 0000000..98dbee1 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company.py @@ -0,0 +1,33 @@ +import frappe + +def execute(): + # First clear any partial state + frappe.db.sql("DELETE FROM tabCompany WHERE name LIKE %s", ("TOP CLOSSERS%",)) + frappe.db.commit() + + # Create directly via SQL bypassing all validation + company = frappe.get_doc({ + "doctype": "Company", + "company_name": "TOP CLOSSERS SRL", + "abbr": "TC", + "default_currency": "RON", + "country": "Romania", + "chart_of_accounts": "Standard", + "domain": "Services", + "create_chart_of_accounts_based_on": "Standard Template", + }) + company.flags.ignore_permissions = True + company.flags.ignore_mandatory = True + company.flags.ignore_links = True + try: + company.insert() + frappe.db.commit() + print("Company inserted:", company.name) + except Exception as e: + print("Insert failed:", str(e)[:300]) + frappe.db.rollback() + + # Re-mark setup as complete + frappe.db.set_single_value("System Settings", "setup_complete", 1) + frappe.db.commit() + print("Final state - Companies:", frappe.db.sql("SELECT name FROM tabCompany")) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company2.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company2.py new file mode 100644 index 0000000..7f91434 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company2.py @@ -0,0 +1,34 @@ +import frappe + +def execute(): + # Create with India (which has the most complete fixtures) then change to Romania after + if frappe.db.exists("Company", "TOP CLOSSERS SRL"): + print("Already exists") + return + + # Patch the country fixtures function to skip + from erpnext.setup.doctype.company import company as company_module + original = company_module.install_country_fixtures + company_module.install_country_fixtures = lambda *a, **kw: None + + try: + doc = frappe.get_doc({ + "doctype": "Company", + "company_name": "TOP CLOSSERS SRL", + "abbr": "TC", + "default_currency": "RON", + "country": "Romania", + "chart_of_accounts": "Standard", + }) + doc.flags.ignore_permissions = True + doc.insert() + frappe.db.commit() + print("Created:", doc.name) + except Exception as e: + print("Error:", str(e)[:300]) + import traceback + print(traceback.format_exc()[:1500]) + finally: + company_module.install_country_fixtures = original + + print("Final:", frappe.db.sql("SELECT name, country, default_currency FROM tabCompany")) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company_ro.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company_ro.py new file mode 100644 index 0000000..ccf678d --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company_ro.py @@ -0,0 +1,39 @@ +import frappe + +def execute(): + if frappe.db.exists("Company", "TOP CLOSSERS SRL"): + print("Already exists") + return + + # Patch country fixtures to skip the broken portal_user import in Romania-specific setup + from erpnext.setup.doctype.company import company as cm + cm.install_country_fixtures = lambda *a, **kw: None + + doc = frappe.get_doc({ + "doctype": "Company", + "company_name": "TOP CLOSSERS SRL", + "abbr": "TC", + "default_currency": "RON", + "country": "Romania", + "chart_of_accounts": "Romania - Chart of Accounts", + "domain": "Services", + "create_chart_of_accounts_based_on": "Standard Template", + }) + doc.flags.ignore_permissions = True + doc.insert() + frappe.db.commit() + print("Created:", doc.name) + + # Set as default + frappe.db.set_single_value("Global Defaults", "default_company", "TOP CLOSSERS SRL") + frappe.db.set_single_value("Global Defaults", "default_currency", "RON") + frappe.db.commit() + + # Check accounts created + count = frappe.db.sql("SELECT COUNT(*) FROM tabAccount WHERE company=%s", ("TOP CLOSSERS SRL",))[0][0] + print(f"Accounts created: {count}") + + # Show some Romania-specific accounts + samples = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s) LIMIT 10", + ("TOP CLOSSERS SRL", "4426%", "4427%", "707%", "411%"), as_dict=False) + print("Sample RO accounts:", samples[:10]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company_ro2.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company_ro2.py new file mode 100644 index 0000000..e5782fa --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/make_company_ro2.py @@ -0,0 +1,26 @@ +import frappe +import traceback + +def execute(): + if frappe.db.exists("Company", "TOP CLOSSERS SRL"): + print("Already exists") + return + from erpnext.setup.doctype.company import company as cm + cm.install_country_fixtures = lambda *a, **kw: None + try: + doc = frappe.get_doc({ + "doctype": "Company", + "company_name": "TOP CLOSSERS SRL", + "abbr": "TC", + "default_currency": "RON", + "country": "Romania", + "chart_of_accounts": "Romania - Chart of Accounts", + "create_chart_of_accounts_based_on": "Standard Template", + }) + doc.flags.ignore_permissions = True + doc.insert() + frappe.db.commit() + print("Created:", doc.name) + except Exception as e: + print("ERROR:", str(e)[:400]) + print(traceback.format_exc()[:2000]) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/run_wizard.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/run_wizard.py new file mode 100644 index 0000000..b936a52 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/run_wizard.py @@ -0,0 +1,25 @@ +import frappe +from erpnext.setup.setup_wizard.setup_wizard import setup_complete + +def execute(): + args = frappe._dict({ + "language": "Romanian", + "country": "Romania", + "timezone": "Europe/Bucharest", + "currency": "RON", + "full_name": "Administrator", + "email": "admin@didi.local", + "company_name": "TOP CLOSSERS SRL", + "company_abbr": "TC", + "company_tagline": "DiDi Platform", + "bank_account": "BCR", + "chart_of_accounts": "Standard", + "fy_start_date": "2026-01-01", + "fy_end_date": "2026-12-31", + "domains": ["Services"], + }) + frappe.db.set_single_value("System Settings", "setup_complete", 0) + frappe.db.commit() + setup_complete(args) + frappe.db.commit() + print("DONE - Companies:", frappe.db.sql("SELECT name FROM tabCompany")) diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/setup_cleanup.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/setup_cleanup.py new file mode 100644 index 0000000..8640e78 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/setup_cleanup.py @@ -0,0 +1,126 @@ +""" +Cleanup ERPNext: hide unused modules, configure sidebar, check users/permissions. +""" +import frappe + + +def execute(): + cleanup_modules() + cleanup_users() + setup_workspace() + frappe.db.commit() + print("\n=== Cleanup Complete ===\n") + + +def cleanup_modules(): + """Hide modules that are not needed from the sidebar.""" + print("[1/3] Hiding unused modules...") + + # Modules to KEEP visible + keep_modules = { + "Accounts", "Selling", "CRM", "Buying", + "Didi Custom", "Setup", "Core", + } + + # Modules to HIDE + hide_modules = { + "Stock", "Manufacturing", "Assets", "Projects", + "Quality Management", "Subcontracting", "Support", + "Maintenance", "EDI", "Telephony", "Portal", + "Bulk Transaction", "ERPNext Integrations", + "Social", "Website", + } + + # Use Module Profile to restrict what Administrator sees + # Actually in ERPNext v15, we use "Allowed Modules" on User + # But for Administrator we can't restrict. Instead, we hide workspaces. + + # Hide workspaces for unused modules + workspaces_to_hide = [ + "Stock", "Manufacturing", "Assets", "Projects", + "Quality", "Subcontracting", "Support", + "HR", "Payroll", "Website", + ] + + for ws_name in workspaces_to_hide: + ws_list = frappe.get_all("Workspace", filters={"name": ["like", f"%{ws_name}%"]}) + for ws in ws_list: + doc = frappe.get_doc("Workspace", ws.name) + if doc.public: + doc.public = 0 + doc.save(ignore_permissions=True) + print(f" Hidden workspace: {ws.name}") + + print(" Module cleanup done.") + + +def cleanup_users(): + """Verify users and their roles.""" + print("[2/3] Checking users...") + + users = frappe.get_all("User", + filters={"user_type": "System User", "enabled": 1}, + fields=["name", "full_name", "user_type"] + ) + + for u in users: + roles = frappe.get_all("Has Role", + filters={"parent": u.name}, + fields=["role"] + ) + role_names = [r.role for r in roles] + print(f" User: {u.name} ({u.full_name})") + print(f" Roles: {', '.join(role_names)}") + + # Ensure website_api user has minimal roles + api_user = "website_api@didi-erp" + if frappe.db.exists("User", api_user): + user_doc = frappe.get_doc("User", api_user) + current_roles = [r.role for r in user_doc.roles] + print(f"\n API User roles: {current_roles}") + + # Remove unnecessary roles if any + wanted_roles = {"Website Integration"} + unwanted = set(current_roles) - wanted_roles - {"All", "Guest"} + if unwanted: + for role_name in unwanted: + user_doc.roles = [r for r in user_doc.roles if r.role != role_name] + user_doc.save(ignore_permissions=True) + print(f" Removed unwanted roles: {unwanted}") + + print(" Users OK.") + + +def setup_workspace(): + """Create a clean DiDi workspace with only relevant shortcuts.""" + print("[3/3] Setting up DiDi workspace...") + + ws_name = "DiDi" + + if frappe.db.exists("Workspace", ws_name): + frappe.delete_doc("Workspace", ws_name, force=True) + + ws = frappe.get_doc({ + "doctype": "Workspace", + "name": ws_name, + "label": "DiDi", + "title": "DiDi", + "module": "Didi Custom", + "icon": "home", + "public": 1, + "sequence_id": 1, + "shortcuts": [ + {"type": "DocType", "link_to": "Customer", "label": "Clienti", "color": "#2563eb"}, + {"type": "DocType", "link_to": "Lead", "label": "Lead-uri", "color": "#7c3aed"}, + {"type": "DocType", "link_to": "Sales Invoice", "label": "Facturi", "color": "#059669"}, + {"type": "DocType", "link_to": "Subscription Plan", "label": "Planuri Abonament", "color": "#d97706"}, + {"type": "DocType", "link_to": "Subscription", "label": "Subscriptii", "color": "#dc2626"}, + {"type": "DocType", "link_to": "Website Content", "label": "Continut Website (CMS)", "color": "#0891b2"}, + {"type": "DocType", "link_to": "Service Agreement", "label": "Acorduri Servicii", "color": "#4f46e5"}, + {"type": "DocType", "link_to": "Payment Log", "label": "Log Plati", "color": "#be185d"}, + {"type": "DocType", "link_to": "Supplier", "label": "Furnizori", "color": "#64748b"}, + {"type": "DocType", "link_to": "Email Template", "label": "Template-uri Email", "color": "#475569"}, + ], + }) + ws.insert(ignore_permissions=True) + print(f" Created workspace: DiDi (with shortcuts + number cards)") diff --git a/erp_crm/didi_custom/didi_custom/arhiva_oneoff/setup_config.py b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/setup_config.py new file mode 100644 index 0000000..c9924d8 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/arhiva_oneoff/setup_config.py @@ -0,0 +1,287 @@ +""" +ERPNext Configuration Script for DiDi Project +Run with: docker exec didi-erpnext bench --site didi-erp execute didi_setup +Or copy to container and run via bench. +""" +import frappe + +def execute(): + """Main setup function - run all configurations.""" + setup_company_details() + setup_tax_template() + setup_invoice_naming() + setup_crm_pipeline() + setup_subscription_items() + setup_email_templates() + frappe.db.commit() + print("\n=== DiDi ERPNext Configuration Complete ===\n") + + +def setup_company_details(): + """Configure company details - CUI, address, etc.""" + print("[1/6] Configuring company details...") + + company = frappe.get_doc("Company", "TOP CLOSSERS SRL") + company.tax_id = "36193026" + company.domain = "Services" + company.default_currency = "RON" + company.country = "Romania" + company.save(ignore_permissions=True) + + # Create company address + if not frappe.db.exists("Address", {"address_title": "TOP CLOSSERS SRL - Sediu"}): + addr = frappe.get_doc({ + "doctype": "Address", + "address_title": "TOP CLOSSERS SRL - Sediu", + "address_type": "Office", + "address_line1": "Str. Targovistei 15", + "address_line2": "Bl. 2 Et. 3 Ap. 22", + "city": "Ploiesti", + "state": "Prahova", + "pincode": "100299", + "country": "Romania", + "phone": "+40721063078", + "email_id": "office@clossers.com", + "is_primary_address": 1, + "links": [{"link_doctype": "Company", "link_name": "TOP CLOSSERS SRL"}] + }) + addr.insert(ignore_permissions=True) + + print(" Company details configured.") + + +def setup_tax_template(): + """Configure VAT 19% tax template for Romania.""" + print("[2/6] Configuring tax templates...") + + # Get or create default income account + company_abbr = "TC" + tax_account = f"TVA Colectata - {company_abbr}" + + # Check if tax account exists, create if not + if not frappe.db.exists("Account", tax_account): + parent_account = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "account_type": "Tax", "is_group": 1}, + "name" + ) + if not parent_account: + parent_account = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "root_type": "Liability", "is_group": 1}, + "name" + ) + + if parent_account: + tax_acc = frappe.get_doc({ + "doctype": "Account", + "account_name": "TVA Colectata", + "parent_account": parent_account, + "account_type": "Tax", + "company": "TOP CLOSSERS SRL", + "tax_rate": 19.0 + }) + tax_acc.insert(ignore_permissions=True) + + # Create Sales Tax Template + if not frappe.db.exists("Sales Taxes and Charges Template", {"title": "TVA 19% Romania"}): + template = frappe.get_doc({ + "doctype": "Sales Taxes and Charges Template", + "title": "TVA 19% Romania", + "company": "TOP CLOSSERS SRL", + "is_default": 1, + "taxes": [{ + "charge_type": "On Net Total", + "account_head": tax_account if frappe.db.exists("Account", tax_account) else "", + "description": "TVA 19%", + "rate": 19.0 + }] + }) + try: + template.insert(ignore_permissions=True) + print(" Tax template TVA 19% created.") + except Exception as e: + print(f" Tax template: {e}") + else: + print(" Tax template already exists.") + + +def setup_invoice_naming(): + """Configure Romanian invoice naming series.""" + print("[3/6] Configuring invoice naming series...") + + # Set naming series for Sales Invoice + if frappe.db.exists("DocType", "Sales Invoice"): + prop_setter_name = "Sales Invoice-naming_series-options" + if not frappe.db.exists("Property Setter", prop_setter_name): + try: + ps = frappe.get_doc({ + "doctype": "Property Setter", + "doctype_or_field": "DocType", + "doc_type": "Sales Invoice", + "field_name": "naming_series", + "property": "options", + "value": "DIDI-INV-.YYYY.-.#####\nACC-SINV-.YYYY.-", + "property_type": "Text" + }) + ps.insert(ignore_permissions=True) + print(" Invoice naming series configured: DIDI-INV-YYYY-#####") + except Exception as e: + print(f" Naming series: {e}") + else: + print(" Naming series already configured.") + + +def setup_crm_pipeline(): + """Configure CRM sales pipeline stages.""" + print("[4/6] Configuring CRM pipeline...") + + # CRM Sales Stages + stages = [ + {"stage_name": "Lead", "department": ""}, + {"stage_name": "Calificat", "department": ""}, + {"stage_name": "Demo", "department": ""}, + {"stage_name": "Client", "department": ""}, + ] + + for stage_data in stages: + if not frappe.db.exists("Sales Stage", stage_data["stage_name"]): + stage = frappe.get_doc({ + "doctype": "Sales Stage", + "stage_name": stage_data["stage_name"] + }) + stage.insert(ignore_permissions=True) + print(f" Created stage: {stage_data['stage_name']}") + + # Lead Source for website forms + sources = ["Website - Contact Form", "Website - Pricing Page", "Website - Demo Request"] + for source_name in sources: + if not frappe.db.exists("Lead Source", source_name): + source = frappe.get_doc({ + "doctype": "Lead Source", + "source_name": source_name + }) + source.insert(ignore_permissions=True) + + print(" CRM pipeline configured: Lead > Calificat > Demo > Client") + + +def setup_subscription_items(): + """Create subscription plan items.""" + print("[5/6] Creating subscription items and plans...") + + # Item Group for services + if not frappe.db.exists("Item Group", "DiDi Services"): + ig = frappe.get_doc({ + "doctype": "Item Group", + "item_group_name": "DiDi Services", + "parent_item_group": "All Item Groups" + }) + ig.insert(ignore_permissions=True) + + # Subscription Items + items = [ + { + "item_code": "DIDI-FREE", + "item_name": "Abonament DiDi Free", + "description": "Plan gratuit - acces de baza la platforma DiDi pentru analiza dezinformarii", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 0 + }, + { + "item_code": "DIDI-PAID", + "item_name": "Abonament DiDi Paid", + "description": "Plan platit - acces complet la platforma DiDi cu credite lunare pentru analize avansate", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 99 + }, + { + "item_code": "DIDI-ENTERPRISE", + "item_name": "Abonament DiDi Enterprise", + "description": "Plan enterprise - acces nelimitat la platforma DiDi, suport dedicat, API avansat", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 499 + }, + ] + + for item_data in items: + if not frappe.db.exists("Item", item_data["item_code"]): + item = frappe.get_doc({"doctype": "Item", **item_data}) + item.insert(ignore_permissions=True) + print(f" Created item: {item_data['item_code']} ({item_data['standard_rate']} RON)") + + # Subscription Plans + plans = [ + {"plan_name": "DiDi Free - Lunar", "item": "DIDI-FREE", "cost": 0, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Paid - Lunar", "item": "DIDI-PAID", "cost": 99, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Paid - Anual", "item": "DIDI-PAID", "cost": 999, "billing_interval": "Year", "billing_interval_count": 1}, + {"plan_name": "DiDi Enterprise - Lunar", "item": "DIDI-ENTERPRISE", "cost": 499, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Enterprise - Anual", "item": "DIDI-ENTERPRISE", "cost": 4990, "billing_interval": "Year", "billing_interval_count": 1}, + ] + + for plan_data in plans: + if not frappe.db.exists("Subscription Plan", plan_data["plan_name"]): + plan = frappe.get_doc({ + "doctype": "Subscription Plan", + "plan_name": plan_data["plan_name"], + "item": plan_data["item"], + "price_determination": "Fixed Rate", + "cost": plan_data["cost"], + "currency": "RON", + "billing_interval": plan_data["billing_interval"], + "billing_interval_count": plan_data["billing_interval_count"] + }) + plan.insert(ignore_permissions=True) + print(f" Created plan: {plan_data['plan_name']} ({plan_data['cost']} RON/{plan_data['billing_interval']})") + + print(" Subscription items and plans configured.") + + +def setup_email_templates(): + """Create email notification templates.""" + print("[6/6] Creating email templates...") + + templates = [ + { + "name": "DiDi - Confirmare Plata", + "subject": "Plata confirmata - {{ doc.name }}", + "response": """

Stimate {{ doc.customer_name }},

+

Va confirmam ca plata pentru factura {{ doc.name }} in valoare de {{ doc.grand_total }} {{ doc.currency }} a fost procesata cu succes.

+

Puteti descarca factura din dashboard-ul contului dumneavoastra.

+

Va multumim,
Echipa DiDi

""" + }, + { + "name": "DiDi - Expirare Abonament", + "subject": "Abonamentul dumneavoastra expira in curand", + "response": """

Stimate client,

+

Va informam ca abonamentul dumneavoastra DiDi expira in 7 zile.

+

Pentru a continua sa beneficiati de serviciile noastre, va rugam sa verificati metoda de plata in dashboard-ul contului.

+

Va multumim,
Echipa DiDi

""" + }, + { + "name": "DiDi - Esec Plata", + "subject": "Problema la procesarea platii", + "response": """

Stimate client,

+

Din pacate, nu am reusit sa procesam plata pentru abonamentul dumneavoastra DiDi.

+

Va rugam sa actualizati metoda de plata in dashboard-ul contului pentru a evita intreruperea serviciului.

+

Va multumim,
Echipa DiDi

""" + }, + ] + + for tmpl in templates: + if not frappe.db.exists("Email Template", tmpl["name"]): + doc = frappe.get_doc({ + "doctype": "Email Template", + "name": tmpl["name"], + "subject": tmpl["subject"], + "response": tmpl["response"], + "use_html": 1 + }) + doc.insert(ignore_permissions=True) + print(f" Created template: {tmpl['name']}") + + print(" Email templates configured.") diff --git a/erp_crm/didi_custom/didi_custom/config/__init__.py b/erp_crm/didi_custom/didi_custom/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/__init__.py b/erp_crm/didi_custom/didi_custom/didi_custom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/__init__.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/__init__.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/__init__.py @@ -0,0 +1 @@ + diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.js b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.js new file mode 100644 index 0000000..81857d9 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.js @@ -0,0 +1,6 @@ +// Copyright (c) 2026, TOP CLOSSERS SRL and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Analysis Report", { +// }); + diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.json b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.json new file mode 100644 index 0000000..8a773c2 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.json @@ -0,0 +1,173 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "DIDI-AR-.YYYY.-.#####", + "creation": "2026-04-02 14:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "customer", + "customer_name", + "column_break_1", + "sales_invoice", + "status", + "section_context", + "analysis_session_id", + "component", + "column_break_2", + "media_type", + "report_title", + "section_output", + "pdf_file", + "generated_at", + "result_json" + ], + "fields": [ + { + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Customer", + "options": "Customer", + "reqd": 1 + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "read_only": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "sales_invoice", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Sales Invoice", + "options": "Sales Invoice", + "reqd": 0, + "description": "Gol pentru analizele rulate din abonament (pe credite)." + }, + { + "default": "Completed", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Draft\nCompleted\nFailed", + "reqd": 1 + }, + { + "fieldname": "section_context", + "fieldtype": "Section Break", + "label": "Analysis Context" + }, + { + "fieldname": "analysis_session_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Analysis Session ID", + "read_only": 1, + "unique": 1 + }, + { + "fieldname": "component", + "fieldtype": "Data", + "label": "Component", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "media_type", + "fieldtype": "Data", + "label": "Media Type", + "read_only": 1 + }, + { + "fieldname": "report_title", + "fieldtype": "Data", + "label": "Report Title", + "read_only": 1 + }, + { + "fieldname": "section_output", + "fieldtype": "Section Break", + "label": "Report Output" + }, + { + "fieldname": "pdf_file", + "fieldtype": "Attach", + "label": "PDF File", + "read_only": 1 + }, + { + "fieldname": "generated_at", + "fieldtype": "Datetime", + "label": "Generated At", + "read_only": 1 + }, + { + "fieldname": "result_json", + "fieldtype": "Code", + "label": "Result JSON", + "options": "JSON", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-02 14:00:00.000000", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Analysis Report", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "search_fields": "customer,sales_invoice,analysis_session_id,report_title", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "report_title" +} diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.py new file mode 100644 index 0000000..4c8f422 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/analysis_report.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class AnalysisReport(Document): + pass + diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/test_analysis_report.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/test_analysis_report.py new file mode 100644 index 0000000..113293d --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/analysis_report/test_analysis_report.py @@ -0,0 +1,7 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and Contributors +# See license.txt + + +def test_placeholder(): + assert True + diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/__init__.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.js b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.js new file mode 100644 index 0000000..d4b4fa1 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, TOP CLOSSERS SRL and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Payment Log", { +// refresh(frm) { + +// }, +// }); diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.json b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.json new file mode 100644 index 0000000..5b0f8de --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.json @@ -0,0 +1,160 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "DIDI-PL-.YYYY.-.#####", + "creation": "2026-03-30 12:05:21.168080", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "customer", + "customer_name", + "column_break_1", + "event_type", + "status", + "section_stripe", + "stripe_session_id", + "stripe_payment_intent_id", + "stripe_subscription_id", + "column_break_2", + "amount", + "currency", + "section_raw", + "raw_webhook_data" + ], + "fields": [ + { + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Customer", + "options": "Customer" + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "read_only": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "event_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Event Type", + "options": "checkout.session.completed\npayment_intent.succeeded\ninvoice.payment_failed\ncustomer.subscription.updated\ncustomer.subscription.deleted\nrefund.created\nother" + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Succeeded\nFailed\nRefunded\nPending", + "reqd": 1 + }, + { + "fieldname": "section_stripe", + "fieldtype": "Section Break", + "label": "Stripe Details" + }, + { + "fieldname": "stripe_session_id", + "fieldtype": "Data", + "label": "Stripe Session ID" + }, + { + "fieldname": "stripe_payment_intent_id", + "fieldtype": "Data", + "label": "Stripe Payment Intent ID" + }, + { + "fieldname": "stripe_subscription_id", + "fieldtype": "Data", + "label": "Stripe Subscription ID" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount" + }, + { + "default": "RON", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "collapsible": 1, + "fieldname": "section_raw", + "fieldtype": "Section Break", + "label": "Raw Data" + }, + { + "fieldname": "raw_webhook_data", + "fieldtype": "Code", + "label": "Raw Webhook Data", + "options": "JSON" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-30 12:05:21.168080", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Payment Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "search_fields": "customer,stripe_session_id,stripe_payment_intent_id,status", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "customer_name" +} diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.py new file mode 100644 index 0000000..3db22a1 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/payment_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PaymentLog(Document): + pass diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/test_payment_log.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/test_payment_log.py new file mode 100644 index 0000000..40d82ca --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/payment_log/test_payment_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPaymentLog(FrappeTestCase): + pass diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/__init__.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.js b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.js new file mode 100644 index 0000000..d1f7069 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, TOP CLOSSERS SRL and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Service Agreement", { +// refresh(frm) { + +// }, +// }); diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.json b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.json new file mode 100644 index 0000000..77db942 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.json @@ -0,0 +1,144 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "DIDI-SA-.YYYY.-.#####", + "creation": "2026-03-30 12:05:20.761021", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "customer", + "customer_name", + "column_break_1", + "plan", + "status", + "section_acceptance", + "acceptance_date", + "client_ip", + "column_break_2", + "terms_version", + "subscription", + "section_document", + "agreement_html" + ], + "fields": [ + { + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Customer", + "options": "Customer", + "reqd": 1 + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "read_only": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "plan", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Subscription Plan", + "options": "Subscription Plan", + "reqd": 1 + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Draft\nAccepted\nExpired\nCancelled", + "reqd": 1 + }, + { + "fieldname": "section_acceptance", + "fieldtype": "Section Break", + "label": "Acceptance Details" + }, + { + "fieldname": "acceptance_date", + "fieldtype": "Datetime", + "label": "Acceptance Date" + }, + { + "fieldname": "client_ip", + "fieldtype": "Data", + "label": "Client IP", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "terms_version", + "fieldtype": "Data", + "label": "Terms Version" + }, + { + "fieldname": "subscription", + "fieldtype": "Link", + "label": "Subscription", + "options": "Subscription" + }, + { + "fieldname": "section_document", + "fieldtype": "Section Break", + "label": "Agreement Document" + }, + { + "fieldname": "agreement_html", + "fieldtype": "Text Editor", + "label": "Agreement Content" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-30 12:05:20.761021", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Service Agreement", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "search_fields": "customer,plan,status", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "customer_name" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.py new file mode 100644 index 0000000..676b2d9 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/service_agreement.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ServiceAgreement(Document): + pass diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/test_service_agreement.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/test_service_agreement.py new file mode 100644 index 0000000..928bc40 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/service_agreement/test_service_agreement.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestServiceAgreement(FrappeTestCase): + pass diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/__init__.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/test_website_content.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/test_website_content.py new file mode 100644 index 0000000..6169b59 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/test_website_content.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestWebsiteContent(FrappeTestCase): + pass diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.js b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.js new file mode 100644 index 0000000..43e0a6f --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, TOP CLOSSERS SRL and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Website Content", { +// refresh(frm) { + +// }, +// }); diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.json b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.json new file mode 100644 index 0000000..ef5c4db --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.json @@ -0,0 +1,138 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{page_slug}-{section_key}", + "creation": "2026-03-30 12:05:21.259657", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "page_slug", + "section_key", + "display_order", + "is_active", + "section_content_ro", + "content_ro", + "section_content_en", + "content_en", + "section_media", + "image", + "column_break_media", + "extra_data" + ], + "fields": [ + { + "fieldname": "page_slug", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Page", + "options": "homepage\npricing\ncontact\nabout\nprivacy\nterms\nservices", + "reqd": 1 + }, + { + "description": "Unique key for this section (e.g. hero_title, feature_1, cta_text)", + "fieldname": "section_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Section Key", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "display_order", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Display Order" + }, + { + "default": "1", + "fieldname": "is_active", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Active" + }, + { + "fieldname": "section_content_ro", + "fieldtype": "Section Break", + "label": "Content (Romanian)" + }, + { + "fieldname": "content_ro", + "fieldtype": "Text Editor", + "label": "Content RO" + }, + { + "fieldname": "section_content_en", + "fieldtype": "Section Break", + "label": "Content (English)" + }, + { + "fieldname": "content_en", + "fieldtype": "Text Editor", + "label": "Content EN" + }, + { + "fieldname": "section_media", + "fieldtype": "Section Break", + "label": "Media" + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "label": "Image" + }, + { + "fieldname": "column_break_media", + "fieldtype": "Column Break" + }, + { + "description": "Additional structured data (e.g. button links, feature lists)", + "fieldname": "extra_data", + "fieldtype": "Code", + "label": "Extra Data (JSON)", + "options": "JSON" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-31 16:40:22.911045", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Website Content", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "search_fields": "page_slug,section_key", + "sort_field": "display_order", + "sort_order": "ASC", + "states": [], + "title_field": "section_key" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.py b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.py new file mode 100644 index 0000000..48d9782 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/doctype/website_content/website_content.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, TOP CLOSSERS SRL and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class WebsiteContent(Document): + pass diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/clienti/clienti.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/clienti/clienti.json new file mode 100644 index 0000000..e711fa5 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/clienti/clienti.json @@ -0,0 +1,82 @@ +{ + "charts": [], + "content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"Clienti\", \"col\": 12}}, {\"id\": \"s0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Toti Clientii\", \"col\": 4}}, {\"id\": \"s1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Acorduri Servicii\", \"col\": 4}}]", + "creation": "2026-04-03 13:27:54.927572", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "users", + "idx": 0, + "indicator_color": "blue", + "is_hidden": 0, + "label": "Clienti", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Clienti", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Customer", + "link_count": 0, + "link_to": "Customer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Service Agreement", + "link_count": 0, + "link_to": "Service Agreement", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Subscription Plan", + "link_count": 0, + "link_to": "Subscription Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-04-03 13:27:54.927572", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Clienti", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 20.0, + "shortcuts": [ + { + "color": "Blue", + "doc_view": "List", + "label": "Toti Clientii", + "link_to": "Customer", + "type": "DocType" + }, + { + "color": "Purple", + "doc_view": "List", + "label": "Acorduri Servicii", + "link_to": "Service Agreement", + "type": "DocType" + } + ], + "title": "Clienti" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/contabilitate/contabilitate.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/contabilitate/contabilitate.json new file mode 100644 index 0000000..3db504c --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/contabilitate/contabilitate.json @@ -0,0 +1,99 @@ +{ + "charts": [], + "content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"Contabilitate\", \"col\": 12}}, {\"id\": \"s0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Plan Conturi\", \"col\": 4}}, {\"id\": \"s1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Furnizori\", \"col\": 4}}, {\"id\": \"s2\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Companie\", \"col\": 4}}]", + "creation": "2026-04-03 13:27:55.254523", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "calculator", + "idx": 0, + "indicator_color": "", + "is_hidden": 0, + "label": "Contabilitate", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Contabilitate", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Account", + "link_count": 0, + "link_to": "Account", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Company", + "link_count": 0, + "link_to": "Company", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Supplier", + "link_count": 0, + "link_to": "Supplier", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Invoice", + "link_count": 0, + "link_to": "Purchase Invoice", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-04-03 13:27:55.254523", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Contabilitate", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 60.0, + "shortcuts": [ + { + "color": "Grey", + "doc_view": "Tree", + "label": "Plan Conturi", + "link_to": "Account", + "type": "DocType" + }, + { + "color": "Blue", + "doc_view": "", + "label": "Furnizori", + "link_to": "Supplier", + "type": "DocType" + }, + { + "color": "Green", + "doc_view": "", + "label": "Companie", + "link_to": "Company", + "type": "DocType" + } + ], + "title": "Contabilitate" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/crm/crm.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/crm/crm.json new file mode 100644 index 0000000..14176c6 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/crm/crm.json @@ -0,0 +1,89 @@ +{ + "charts": [], + "content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"CRM\", \"col\": 12}}, {\"id\": \"s0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Lead-uri\", \"col\": 4}}, {\"id\": \"s1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Sales Stage\", \"col\": 4}}, {\"id\": \"s2\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Lead Source\", \"col\": 4}}]", + "creation": "2026-04-03 13:27:55.010265", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "share", + "idx": 0, + "indicator_color": "orange", + "is_hidden": 0, + "label": "CRM", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "CRM", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Lead", + "link_count": 0, + "link_to": "Lead", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Sales Stage", + "link_count": 0, + "link_to": "Sales Stage", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Lead Source", + "link_count": 0, + "link_to": "Lead Source", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-04-03 13:27:55.010265", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "CRM", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 30.0, + "shortcuts": [ + { + "color": "Orange", + "doc_view": "List", + "label": "Lead-uri", + "link_to": "Lead", + "type": "DocType" + }, + { + "color": "Yellow", + "doc_view": "", + "label": "Sales Stage", + "link_to": "Sales Stage", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "", + "label": "Lead Source", + "link_to": "Lead Source", + "type": "DocType" + } + ], + "title": "CRM" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/didi/didi.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/didi/didi.json new file mode 100644 index 0000000..366209d --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/didi/didi.json @@ -0,0 +1,94 @@ +{ + "charts": [], + "content": "[]", + "creation": "2026-03-31 20:55:24.921276", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "home", + "idx": 0, + "indicator_color": "green", + "is_hidden": 1, + "label": "DiDi", + "links": [], + "modified": "2026-04-03 13:22:23.399406", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "DiDi", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 0.0, + "shortcuts": [ + { + "doc_view": "", + "label": "FACTURI", + "link_to": "Sales Invoice", + "type": "DocType" + }, + { + "doc_view": "", + "label": "CLIENTI", + "link_to": "Customer", + "type": "DocType" + }, + { + "doc_view": "", + "label": "LEAD-URI CRM", + "link_to": "Lead", + "type": "DocType" + }, + { + "doc_view": "", + "label": "LOG PLATI STRIPE", + "link_to": "Payment Log", + "type": "DocType" + }, + { + "doc_view": "", + "label": "ACORDURI SERVICII", + "link_to": "Service Agreement", + "type": "DocType" + }, + { + "doc_view": "", + "label": "SERVICII / PRODUSE", + "link_to": "Item", + "type": "DocType" + }, + { + "doc_view": "", + "label": "PLANURI ABONAMENT", + "link_to": "Subscription Plan", + "type": "DocType" + }, + { + "doc_view": "", + "label": "CONTINUT WEBSITE (CMS)", + "link_to": "Website Content", + "type": "DocType" + }, + { + "doc_view": "", + "label": "FURNIZORI", + "link_to": "Supplier", + "type": "DocType" + }, + { + "doc_view": "", + "label": "TEMPLATE-URI EMAIL", + "link_to": "Email Template", + "type": "DocType" + }, + { + "doc_view": "", + "label": "PLAN DE CONTURI", + "link_to": "Account", + "type": "DocType" + } + ], + "title": "DiDi" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/didi_platform/didi_platform.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/didi_platform/didi_platform.json new file mode 100644 index 0000000..22e3dbb --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/didi_platform/didi_platform.json @@ -0,0 +1,214 @@ +{ + "charts": [], + "content": "[ {\"id\":\"h1\",\"type\":\"header\",\"data\":{\"text\":\"Acces rapid\",\"col\":12}}, {\"id\":\"s1\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Facturi\",\"col\":3}}, {\"id\":\"s2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Clienti\",\"col\":3}}, {\"id\":\"s3\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead-uri CRM\",\"col\":3}}, {\"id\":\"s4\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Plati Stripe\",\"col\":3}}, {\"id\":\"s5\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"CMS Content\",\"col\":3}}, {\"id\":\"s6\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Servicii\",\"col\":3}}, {\"id\":\"s7\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Service Agreements\",\"col\":3}}, {\"id\":\"sp1\",\"type\":\"spacer\",\"data\":{\"col\":12}}, {\"id\":\"h2\",\"type\":\"header\",\"data\":{\"text\":\"Module\",\"col\":12}} ]\n", + "creation": "2026-03-31 22:31:39.036298", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "tool", + "idx": 0, + "indicator_color": "green", + "is_hidden": 1, + "label": "DiDi Platform", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Vanzari & Facturare", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Facturi", + "link_count": 0, + "link_to": "Sales Invoice", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Clienti", + "link_count": 0, + "link_to": "Customer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Servicii (Items)", + "link_count": 0, + "link_to": "Item", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Subscription Plan", + "link_count": 0, + "link_to": "Subscription Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "CRM", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Lead-uri", + "link_count": 0, + "link_to": "Lead", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Service Agreement", + "link_count": 0, + "link_to": "Service Agreement", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Plati & Financiar", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Payment Log", + "link_count": 0, + "link_to": "Payment Log", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Companie", + "link_count": 0, + "link_to": "Company", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Plan de Conturi", + "link_count": 0, + "link_to": "Account", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Website CMS", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Website Content", + "link_count": 0, + "link_to": "Website Content", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + } + ], + "modified": "2026-04-03 13:22:23.287762", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "DiDi Platform", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 1.0, + "shortcuts": [ + { + "color": "Green", + "doc_view": "", + "label": "Facturi", + "link_to": "Sales Invoice", + "type": "DocType" + }, + { + "color": "Blue", + "doc_view": "", + "label": "Clienti", + "link_to": "Customer", + "type": "DocType" + }, + { + "color": "Orange", + "doc_view": "", + "label": "Lead-uri CRM", + "link_to": "Lead", + "type": "DocType" + }, + { + "color": "Yellow", + "doc_view": "", + "label": "Plati Stripe", + "link_to": "Payment Log", + "type": "DocType" + }, + { + "color": "Cyan", + "doc_view": "", + "label": "CMS Content", + "link_to": "Website Content", + "type": "DocType" + }, + { + "color": "Purple", + "doc_view": "", + "label": "Servicii", + "link_to": "Item", + "type": "DocType" + }, + { + "color": "Pink", + "doc_view": "", + "label": "Service Agreements", + "link_to": "Service Agreement", + "type": "DocType" + } + ], + "title": "DiDi Platform" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/facturi/facturi.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/facturi/facturi.json new file mode 100644 index 0000000..e65a71d --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/facturi/facturi.json @@ -0,0 +1,82 @@ +{ + "charts": [], + "content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"Facturi\", \"col\": 12}}, {\"id\": \"s0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Toate Facturile\", \"col\": 4}}, {\"id\": \"s1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Payment Entry\", \"col\": 4}}]", + "creation": "2026-04-03 13:22:24.538065", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "file-text", + "idx": 0, + "indicator_color": "green", + "is_hidden": 0, + "label": "Facturi", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Facturi", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Sales Invoice", + "link_count": 0, + "link_to": "Sales Invoice", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Payment Entry", + "link_count": 0, + "link_to": "Payment Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Taxe (TVA)", + "link_count": 0, + "link_to": "Sales Taxes and Charges Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-04-03 13:27:54.838261", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Facturi", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 10.0, + "shortcuts": [ + { + "color": "Green", + "doc_view": "List", + "label": "Toate Facturile", + "link_to": "Sales Invoice", + "type": "DocType" + }, + { + "color": "Yellow", + "doc_view": "List", + "label": "Payment Entry", + "link_to": "Payment Entry", + "type": "DocType" + } + ], + "title": "Facturi" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/plati/plati.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/plati/plati.json new file mode 100644 index 0000000..dcb078a --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/plati/plati.json @@ -0,0 +1,72 @@ +{ + "charts": [], + "content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"Plati\", \"col\": 12}}, {\"id\": \"s0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Payment Log\", \"col\": 4}}, {\"id\": \"s1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Rapoarte Analiza\", \"col\": 4}}]", + "creation": "2026-04-03 13:27:55.169179", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "credit-card", + "idx": 0, + "indicator_color": "yellow", + "is_hidden": 0, + "label": "Plati", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Plati", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Payment Log", + "link_count": 0, + "link_to": "Payment Log", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Analysis Report", + "link_count": 0, + "link_to": "Analysis Report", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-04-03 13:27:55.169179", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Plati", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 50.0, + "shortcuts": [ + { + "color": "Yellow", + "doc_view": "List", + "label": "Payment Log", + "link_to": "Payment Log", + "type": "DocType" + }, + { + "color": "Pink", + "doc_view": "List", + "label": "Rapoarte Analiza", + "link_to": "Analysis Report", + "type": "DocType" + } + ], + "title": "Plati" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/servicii/servicii.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/servicii/servicii.json new file mode 100644 index 0000000..20d97dd --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/servicii/servicii.json @@ -0,0 +1,82 @@ +{ + "charts": [], + "content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"Servicii\", \"col\": 12}}, {\"id\": \"s0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Articole\", \"col\": 4}}, {\"id\": \"s1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Planuri Abonament\", \"col\": 4}}]", + "creation": "2026-04-03 13:27:55.090677", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "box", + "idx": 0, + "indicator_color": "purple", + "is_hidden": 0, + "label": "Servicii", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Catalog", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_count": 0, + "link_to": "Item", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Item Group", + "link_count": 0, + "link_to": "Item Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Subscription Plan", + "link_count": 0, + "link_to": "Subscription Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-04-03 13:27:55.090677", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Servicii", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 40.0, + "shortcuts": [ + { + "color": "Purple", + "doc_view": "List", + "label": "Articole", + "link_to": "Item", + "type": "DocType" + }, + { + "color": "Cyan", + "doc_view": "", + "label": "Planuri Abonament", + "link_to": "Subscription Plan", + "type": "DocType" + } + ], + "title": "Servicii" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/didi_custom/workspace/website_cms/website_cms.json b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/website_cms/website_cms.json new file mode 100644 index 0000000..0169087 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/didi_custom/workspace/website_cms/website_cms.json @@ -0,0 +1,72 @@ +{ + "charts": [], + "content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"Website CMS\", \"col\": 12}}, {\"id\": \"s0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Continut Website\", \"col\": 4}}, {\"id\": \"s1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Template Email\", \"col\": 4}}]", + "creation": "2026-04-03 13:27:55.336131", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "icon": "globe", + "idx": 0, + "indicator_color": "cyan", + "is_hidden": 0, + "label": "Website CMS", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "CMS", + "link_count": 0, + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Website Content", + "link_count": 0, + "link_to": "Website Content", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Email Template", + "link_count": 0, + "link_to": "Email Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-04-03 13:27:55.336131", + "modified_by": "Administrator", + "module": "Didi Custom", + "name": "Website CMS", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 70.0, + "shortcuts": [ + { + "color": "Cyan", + "doc_view": "List", + "label": "Continut Website", + "link_to": "Website Content", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "", + "label": "Template Email", + "link_to": "Email Template", + "type": "DocType" + } + ], + "title": "Website CMS" +} \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/fix_sa.py b/erp_crm/didi_custom/didi_custom/fix_sa.py new file mode 100644 index 0000000..d103b4e --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/fix_sa.py @@ -0,0 +1,33 @@ +import frappe + +def execute(): + # Use Property Setter to change plan from Link → Data without modifying core doctype + if not frappe.db.exists("Property Setter", {"doc_type": "Service Agreement", "field_name": "plan", "property": "fieldtype"}): + ps = frappe.get_doc({ + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "doc_type": "Service Agreement", + "field_name": "plan", + "property": "fieldtype", + "value": "Data", + "property_type": "Select", + }) + ps.flags.ignore_permissions = True + ps.insert() + print("Created Property Setter: fieldtype=Data") + if not frappe.db.exists("Property Setter", {"doc_type": "Service Agreement", "field_name": "plan", "property": "options"}): + ps2 = frappe.get_doc({ + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "doc_type": "Service Agreement", + "field_name": "plan", + "property": "options", + "value": "", + "property_type": "Text", + }) + ps2.flags.ignore_permissions = True + ps2.insert() + print("Created Property Setter: options=") + frappe.db.commit() + frappe.clear_cache() + print("DONE") diff --git a/erp_crm/didi_custom/didi_custom/hooks.py b/erp_crm/didi_custom/didi_custom/hooks.py new file mode 100644 index 0000000..b8d064d --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/hooks.py @@ -0,0 +1,292 @@ +app_name = "didi_custom" +app_title = "Didi Custom" +app_publisher = "TOP CLOSSERS SRL" +app_description = "Custom DocTypes for DiDi Platform" +app_email = "office@clossers.com" +app_license = "mit" + +# ============================================ +# DiDi Business Logic Hooks +# ============================================ + +# DocType lifecycle events +# ------------------------ +# When Payment Log is created (by website /api/webhooks/stripe), trigger IAM +# provisioning to Keycloak (update user attributes + roles). +# When Sales Invoice is saved, tag sales_channel for DESI 11 tracking. +doc_events = { + "Payment Log": { + "after_insert": "didi_custom.integrations.stripe_provisioning.on_payment_log_insert", + }, + "Sales Invoice": { + "before_save": "didi_custom.integrations.sales_channel.tag_sales_channel", + }, + # Subscription cancellation triggers downgrade to free tier + "Subscription": { + "on_cancel": "didi_custom.integrations.stripe_provisioning.on_subscription_canceled", + }, +} + +# Scheduler events +# ---------------- +# Daily: expire subscriptions past their billing cycle (downgrade to free). +# Hourly: sync Stripe state (lightweight reconciliation between webhooks). +scheduler_events = { + "daily": [ + "didi_custom.integrations.subscription_lifecycle.expire_subscriptions", + "didi_custom.integrations.subscription_lifecycle.send_expiry_reminders", + ], + "hourly": [ + "didi_custom.integrations.subscription_lifecycle.hourly_sync_stripe", + ], +} + +# Whitelisted REST methods (callable via /api/method/) +# --------------------------------------------------------- +# DESI 11 audit report — read-only metric for compliance dashboard. +override_whitelisted_methods = { + "didi_custom.api.desi11_report": "didi_custom.integrations.sales_channel.desi11_report", +} + +# Apps +# ------------------ + +# required_apps = [] + +# Each item in the list will be shown as an app in the apps page +# add_to_apps_screen = [ +# { +# "name": "didi_custom", +# "logo": "/assets/didi_custom/logo.png", +# "title": "Didi Custom", +# "route": "/didi_custom", +# "has_permission": "didi_custom.api.permission.has_app_permission" +# } +# ] + +# Includes in +# ------------------ + +# include js, css files in header of desk.html +# app_include_css = "/assets/didi_custom/css/didi_custom.css" +# app_include_js = "/assets/didi_custom/js/didi_custom.js" + +# include js, css files in header of web template +# web_include_css = "/assets/didi_custom/css/didi_custom.css" +# web_include_js = "/assets/didi_custom/js/didi_custom.js" + +# include custom scss in every website theme (without file extension ".scss") +# website_theme_scss = "didi_custom/public/scss/website" + +# include js, css files in header of web form +# webform_include_js = {"doctype": "public/js/doctype.js"} +# webform_include_css = {"doctype": "public/css/doctype.css"} + +# include js in page +# page_js = {"page" : "public/js/file.js"} + +# include js in doctype views +# doctype_js = {"doctype" : "public/js/doctype.js"} +# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} +# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} +# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} + +# Svg Icons +# ------------------ +# include app icons in desk +# app_include_icons = "didi_custom/public/icons.svg" + +# Home Pages +# ---------- + +# application home page (will override Website Settings) +# home_page = "login" + +# website user home page (by Role) +# role_home_page = { +# "Role": "home_page" +# } + +# Generators +# ---------- + +# automatically create page for each record of this doctype +# website_generators = ["Web Page"] + +# Jinja +# ---------- + +# add methods and filters to jinja environment +# jinja = { +# "methods": "didi_custom.utils.jinja_methods", +# "filters": "didi_custom.utils.jinja_filters" +# } + +# Installation +# ------------ + +# before_install = "didi_custom.install.before_install" +# after_install = "didi_custom.install.after_install" + +# Uninstallation +# ------------ + +# before_uninstall = "didi_custom.uninstall.before_uninstall" +# after_uninstall = "didi_custom.uninstall.after_uninstall" + +# Integration Setup +# ------------------ +# To set up dependencies/integrations with other apps +# Name of the app being installed is passed as an argument + +# before_app_install = "didi_custom.utils.before_app_install" +# after_app_install = "didi_custom.utils.after_app_install" + +# Integration Cleanup +# ------------------- +# To clean up dependencies/integrations with other apps +# Name of the app being uninstalled is passed as an argument + +# before_app_uninstall = "didi_custom.utils.before_app_uninstall" +# after_app_uninstall = "didi_custom.utils.after_app_uninstall" + +# Desk Notifications +# ------------------ +# See frappe.core.notifications.get_notification_config + +# notification_config = "didi_custom.notifications.get_notification_config" + +# Permissions +# ----------- +# Permissions evaluated in scripted ways + +# permission_query_conditions = { +# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", +# } +# +# has_permission = { +# "Event": "frappe.desk.doctype.event.event.has_permission", +# } + +# DocType Class +# --------------- +# Override standard doctype classes + +# override_doctype_class = { +# "ToDo": "custom_app.overrides.CustomToDo" +# } + +# Document Events +# --------------- +# Hook on document methods and events + +# doc_events = { +# "*": { +# "on_update": "method", +# "on_cancel": "method", +# "on_trash": "method" +# } +# } + +# Scheduled Tasks +# --------------- + +# scheduler_events = { +# "all": [ +# "didi_custom.tasks.all" +# ], +# "daily": [ +# "didi_custom.tasks.daily" +# ], +# "hourly": [ +# "didi_custom.tasks.hourly" +# ], +# "weekly": [ +# "didi_custom.tasks.weekly" +# ], +# "monthly": [ +# "didi_custom.tasks.monthly" +# ], +# } + +# Testing +# ------- + +# before_tests = "didi_custom.install.before_tests" + +# Overriding Methods +# ------------------------------ +# +# override_whitelisted_methods = { +# "frappe.desk.doctype.event.event.get_events": "didi_custom.event.get_events" +# } +# +# each overriding function accepts a `data` argument; +# generated from the base implementation of the doctype dashboard, +# along with any modifications made in other Frappe apps +# override_doctype_dashboards = { +# "Task": "didi_custom.task.get_dashboard_data" +# } + +# exempt linked doctypes from being automatically cancelled +# +# auto_cancel_exempted_doctypes = ["Auto Repeat"] + +# Ignore links to specified DocTypes when deleting documents +# ----------------------------------------------------------- + +# ignore_links_on_delete = ["Communication", "ToDo"] + +# Request Events +# ---------------- +# before_request = ["didi_custom.utils.before_request"] +# after_request = ["didi_custom.utils.after_request"] + +# Job Events +# ---------- +# before_job = ["didi_custom.utils.before_job"] +# after_job = ["didi_custom.utils.after_job"] + +# User Data Protection +# -------------------- + +# user_data_fields = [ +# { +# "doctype": "{doctype_1}", +# "filter_by": "{filter_by}", +# "redact_fields": ["{field_1}", "{field_2}"], +# "partial": 1, +# }, +# { +# "doctype": "{doctype_2}", +# "filter_by": "{filter_by}", +# "partial": 1, +# }, +# { +# "doctype": "{doctype_3}", +# "strict": False, +# }, +# { +# "doctype": "{doctype_4}" +# } +# ] + +# Authentication and authorization +# -------------------------------- + +# auth_hooks = [ +# "didi_custom.auth.validate" +# ] + +# Automatically update python controller files with type annotations for this app. +# export_python_type_annotations = True + +# default_log_clearing_doctypes = { +# "Logging DocType Name": 30 # days to retain logs +# } + +# Translation +# ------------ +# List of apps whose translatable strings should be excluded from this app's translations. +# ignore_translatable_strings_from = [] + diff --git a/erp_crm/didi_custom/didi_custom/inspect_cms_changes.py b/erp_crm/didi_custom/didi_custom/inspect_cms_changes.py new file mode 100644 index 0000000..8a319df --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/inspect_cms_changes.py @@ -0,0 +1,7 @@ +import frappe, re + +def execute(): + for n in ["contact-title", "contact-info", "about-hero", "homepage-hero_title"]: + d = frappe.get_doc("Website Content", n) + txt = re.sub(r"<[^>]+>", " ", d.content_ro or "").strip()[:90] + print("ROW:", n, "=>", repr(txt)) diff --git a/erp_crm/didi_custom/didi_custom/integrations/__init__.py b/erp_crm/didi_custom/didi_custom/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/integrations/keycloak.py b/erp_crm/didi_custom/didi_custom/integrations/keycloak.py new file mode 100644 index 0000000..528d196 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/integrations/keycloak.py @@ -0,0 +1,185 @@ +""" +Keycloak Admin API wrapper for DiDi user provisioning. + +Used by Stripe webhook flow to update user attributes after successful payment: + - active_plan (free | paid | enterprise) + - plan_activation_date + - iam_role (free_tier | paid_tier | enterprise_tier) + +Configuration via Frappe site_config (NOT in code): + bench --site didi-erp set-config keycloak_url 'http://didi-keycloak:8080' + bench --site didi-erp set-config keycloak_realm 'didi-clients' + bench --site didi-erp set-config keycloak_admin_user 'admin' + bench --site didi-erp set-config keycloak_admin_password 'admin123' +""" +import frappe +import requests +from typing import Optional, Dict, Any + + +def _get_config() -> Dict[str, str]: + """Read Keycloak config from Frappe site_config.""" + return { + "url": frappe.conf.get("keycloak_url", "http://didi-keycloak:8080"), + "realm": frappe.conf.get("keycloak_realm", "didi-clients"), + "admin_user": frappe.conf.get("keycloak_admin_user", "admin"), + "admin_pwd": frappe.conf.get("keycloak_admin_password"), + } + + +def _get_admin_token() -> Optional[str]: + """Login as admin against master realm — returns access token.""" + cfg = _get_config() + if not cfg["admin_pwd"]: + frappe.log_error( + "Keycloak admin password not configured in site_config", + "Keycloak provisioning", + ) + return None + + url = f"{cfg['url']}/realms/master/protocol/openid-connect/token" + try: + r = requests.post( + url, + data={ + "grant_type": "password", + "client_id": "admin-cli", + "username": cfg["admin_user"], + "password": cfg["admin_pwd"], + }, + timeout=10, + ) + r.raise_for_status() + return r.json().get("access_token") + except Exception as e: + frappe.log_error(f"Keycloak admin login failed: {e}", "Keycloak provisioning") + return None + + +def _find_user_by_email(token: str, email: str) -> Optional[Dict[str, Any]]: + """Lookup Keycloak user by email. Returns user dict or None.""" + cfg = _get_config() + url = f"{cfg['url']}/admin/realms/{cfg['realm']}/users" + try: + r = requests.get( + url, + params={"email": email, "exact": "true"}, + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + r.raise_for_status() + users = r.json() + return users[0] if users else None + except Exception as e: + frappe.log_error( + f"Keycloak user lookup failed for {email}: {e}", + "Keycloak provisioning", + ) + return None + + +def update_user_attributes( + email: str, + attributes: Dict[str, str], + roles_add: Optional[list] = None, + roles_remove: Optional[list] = None, +) -> bool: + """ + Update Keycloak user with new attributes + optional role changes. + + Used by stripe_provisioning to set: + - active_plan: "free" | "paid" | "enterprise" + - plan_activation_date: ISO timestamp + - subscription_id: Stripe sub_xxx + + Roles add/remove use realm-level role names (free_tier, paid_tier, enterprise_tier). + + Returns True on success, False on failure (logged to Error Log doctype). + """ + token = _get_admin_token() + if not token: + return False + + user = _find_user_by_email(token, email) + if not user: + frappe.log_error( + f"Keycloak user not found for email {email}", + "Keycloak provisioning", + ) + return False + + cfg = _get_config() + user_id = user["id"] + + # Merge attributes (Keycloak stores attributes as list of strings) + existing_attrs = user.get("attributes", {}) or {} + for key, value in attributes.items(): + existing_attrs[key] = [str(value)] + + # PUT user with merged attributes + try: + r = requests.put( + f"{cfg['url']}/admin/realms/{cfg['realm']}/users/{user_id}", + json={**user, "attributes": existing_attrs}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + timeout=10, + ) + r.raise_for_status() + except Exception as e: + frappe.log_error( + f"Keycloak user update failed for {email}: {e}", + "Keycloak provisioning", + ) + return False + + # Handle role changes (separate Keycloak endpoints) + if roles_add or roles_remove: + _update_user_roles(token, cfg, user_id, roles_add, roles_remove) + + return True + + +def _update_user_roles( + token: str, + cfg: Dict[str, str], + user_id: str, + roles_add: Optional[list], + roles_remove: Optional[list], +): + """Add/remove realm-level roles on user.""" + role_url = f"{cfg['url']}/admin/realms/{cfg['realm']}/users/{user_id}/role-mappings/realm" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + # Resolve role names → role objects (Keycloak needs full role representation) + def _resolve_roles(names: list) -> list: + out = [] + for name in names: + try: + r = requests.get( + f"{cfg['url']}/admin/realms/{cfg['realm']}/roles/{name}", + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + if r.ok: + out.append(r.json()) + except Exception as e: + frappe.log_error(f"Role resolve failed {name}: {e}", "Keycloak provisioning") + return out + + try: + if roles_add: + resolved = _resolve_roles(roles_add) + if resolved: + requests.post(role_url, json=resolved, headers=headers, timeout=10).raise_for_status() + if roles_remove: + resolved = _resolve_roles(roles_remove) + if resolved: + requests.delete(role_url, json=resolved, headers=headers, timeout=10).raise_for_status() + except Exception as e: + frappe.log_error(f"Role update failed: {e}", "Keycloak provisioning") diff --git a/erp_crm/didi_custom/didi_custom/integrations/migrations/__init__.py b/erp_crm/didi_custom/didi_custom/integrations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/integrations/migrations/setup_custom_fields.py b/erp_crm/didi_custom/didi_custom/integrations/migrations/setup_custom_fields.py new file mode 100644 index 0000000..b8bc42b --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/integrations/migrations/setup_custom_fields.py @@ -0,0 +1,216 @@ +""" +ERPNext Custom Fields migration for DiDi business logic. + +Adds fields required by hookurile din stripe_provisioning + sales_channel + +subscription_lifecycle. Idempotent — safe to run multiple times. + +Run via: + docker exec didi-erpnext bench --site didi-erp execute \ + didi_custom.integrations.migrations.setup_custom_fields.execute + +Or via bench console: + docker exec -it didi-erpnext bench --site didi-erp console + >>> from didi_custom.integrations.migrations import setup_custom_fields + >>> setup_custom_fields.execute() +""" +import frappe + + +CUSTOM_FIELDS = { + "Customer": [ + { + "fieldname": "didi_section", + "fieldtype": "Section Break", + "label": "DiDi Platform", + "insert_after": "tax_id", + "collapsible": 1, + }, + { + "fieldname": "didi_user_id", + "fieldtype": "Data", + "label": "DiDi User ID (Keycloak sub)", + "insert_after": "didi_section", + "read_only": 1, + "unique": 1, + "description": "Keycloak UUID — linked to Authentication subject", + }, + { + "fieldname": "active_plan", + "fieldtype": "Select", + "label": "Active Plan", + "options": "\nfree\npaid\nenterprise", + "insert_after": "didi_user_id", + "default": "free", + "in_list_view": 1, + }, + { + "fieldname": "iam_role", + "fieldtype": "Select", + "label": "IAM Role", + "options": "\nfree_tier\npaid_tier\nenterprise_tier", + "insert_after": "active_plan", + "default": "free_tier", + "read_only": 1, + "description": "Auto-synced cu Keycloak realm role", + }, + { + "fieldname": "plan_activation_date", + "fieldtype": "Date", + "label": "Plan Activation Date", + "insert_after": "iam_role", + "read_only": 1, + }, + { + "fieldname": "stripe_customer_id", + "fieldtype": "Data", + "label": "Stripe Customer ID", + "insert_after": "plan_activation_date", + "read_only": 1, + "unique": 1, + }, + { + "fieldname": "stripe_subscription_id", + "fieldtype": "Data", + "label": "Stripe Subscription ID", + "insert_after": "stripe_customer_id", + "read_only": 1, + }, + ], + "Sales Invoice": [ + { + "fieldname": "didi_section", + "fieldtype": "Section Break", + "label": "DiDi Sales Tracking", + "insert_after": "total_taxes_and_charges", + "collapsible": 1, + }, + { + "fieldname": "sales_channel", + "fieldtype": "Select", + "label": "Sales Channel", + "options": "\nonline\napi\noffline", + "insert_after": "didi_section", + "default": "offline", + "in_list_view": 1, + "in_standard_filter": 1, + "description": "Auto-tagged: online=Stripe Checkout, api=DiDi platform direct, offline=manual", + }, + { + "fieldname": "stripe_invoice_id", + "fieldtype": "Data", + "label": "Stripe Invoice ID", + "insert_after": "sales_channel", + "read_only": 1, + "unique": 1, + "allow_on_submit": 1, + }, + { + "fieldname": "stripe_payment_intent_id", + "fieldtype": "Data", + "label": "Stripe Payment Intent", + "insert_after": "stripe_invoice_id", + "read_only": 1, + "allow_on_submit": 1, + }, + { + "fieldname": "analysis_session_id", + "fieldtype": "Data", + "label": "DiDi Analysis Session ID", + "insert_after": "stripe_payment_intent_id", + "read_only": 1, + "allow_on_submit": 1, + "description": "Set când factura provine din consum API DiDi (per-analyze billing)", + }, + { + "fieldname": "analysis_consumed", + "fieldtype": "Int", + "label": "Analyses Consumed", + "insert_after": "analysis_session_id", + "default": 0, + "allow_on_submit": 1, + "description": "Numar analize procesate pentru aceasta factura", + }, + ], + "Service Agreement": [ + { + "fieldname": "sales_invoice", + "fieldtype": "Link", + "label": "Factura asociata", + "options": "Sales Invoice", + "insert_after": "plan", + "read_only": 1, + }, + ], + "Lead": [ + { + "fieldname": "didi_section", + "fieldtype": "Section Break", + "label": "DiDi Marketing", + "insert_after": "source", + "collapsible": 1, + }, + { + "fieldname": "source_form", + "fieldtype": "Data", + "label": "Source Form (page)", + "insert_after": "didi_section", + }, + { + "fieldname": "page_origin", + "fieldtype": "Data", + "label": "Origin URL", + "insert_after": "source_form", + }, + { + "fieldname": "utm_source", + "fieldtype": "Data", + "label": "UTM Source", + "insert_after": "page_origin", + }, + { + "fieldname": "utm_medium", + "fieldtype": "Data", + "label": "UTM Medium", + "insert_after": "utm_source", + }, + { + "fieldname": "utm_campaign", + "fieldtype": "Data", + "label": "UTM Campaign", + "insert_after": "utm_medium", + }, + ], +} + + +def execute(): + """Add all custom fields idempotently.""" + from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + print("Setting up DiDi custom fields...") + create_custom_fields(CUSTOM_FIELDS, ignore_validate=True) + frappe.db.commit() + print("✓ Custom fields created/updated") + + # Reload doctype meta so changes are immediately visible + for doctype in CUSTOM_FIELDS.keys(): + frappe.clear_cache(doctype=doctype) + print(f"✓ Cache cleared for {doctype}") + + print("\nDone! Custom fields ready for use by:") + print(" - stripe_provisioning.py (Customer.iam_role, active_plan, ...)") + print(" - sales_channel.py (Sales Invoice.sales_channel)") + print(" - subscription_lifecycle.py (Customer.stripe_subscription_id)") + + +def rollback(): + """Remove all DiDi custom fields. USE WITH CAUTION.""" + print("Removing DiDi custom fields...") + for doctype, fields in CUSTOM_FIELDS.items(): + for field in fields: + name = f"{doctype}-{field['fieldname']}" + if frappe.db.exists("Custom Field", name): + frappe.delete_doc("Custom Field", name, ignore_permissions=True) + print(f" ✗ Removed {name}") + frappe.db.commit() + print("Done.") diff --git a/erp_crm/didi_custom/didi_custom/integrations/sales_channel.py b/erp_crm/didi_custom/didi_custom/integrations/sales_channel.py new file mode 100644 index 0000000..2ea179b --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/integrations/sales_channel.py @@ -0,0 +1,106 @@ +""" +Sales Invoice channel tagging for DESI 11 (e-commerce online vs offline tracking). + +DESI 11 cere "minim 1% vanzari online" — pentru audit PNRR avem nevoie sa distingem +clar facturile vandute prin website Stripe (online) de cele facute manual in ERPNext +(office sales, offline). + +Trigger: Sales Invoice before_save (Frappe hook). +Logic: daca factura are stripe_invoice_id sau analysis_session_id setat → online. +Altfel → offline (default). + +Adauga in Sales Invoice un custom field "sales_channel" cu valori: + - online: facturat prin website Stripe checkout + - offline: facturat manual din ERPNext UI + - api: facturat prin integration directa (extension browser, API key, etc) +""" +import frappe + + +def tag_sales_channel(doc, method=None): + """ + Frappe hook: before_save on Sales Invoice. + Sets sales_channel field based on invoice metadata. + """ + # Skip if already tagged (preserve manual override) + if getattr(doc, "sales_channel", None): + return + + # Heuristic 1: Stripe-originated invoices have stripe_invoice_id + if getattr(doc, "stripe_invoice_id", None): + doc.sales_channel = "online" + return + + # Heuristic 2: API-originated (extension browser, agent-v3, etc.) + if getattr(doc, "analysis_session_id", None): + doc.sales_channel = "api" + return + + # Heuristic 3: Items linked to consumable analyze services (per-analysis pricing) + didi_service_items = ["DIDI-TECHNIQUES", "DIDI-AI_DETECTION", "DIDI-CLAIMS", "DIDI-SOURCE"] + for item_row in (doc.items or []): + item_code = getattr(item_row, "item_code", "") + if any(item_code.startswith(prefix) for prefix in didi_service_items): + doc.sales_channel = "api" + return + + # Default: offline (manual entry) + doc.sales_channel = "offline" + + +def desi11_report(): + """ + Generate DESI 11 metric: % of revenue from online sales (vs total). + Returns dict with: + - total_revenue + - online_revenue + - online_percentage + - threshold_met: True if online_percentage >= 1.0% + + Used by audit script (e.g., audit/desi_report.py) to produce annual report. + """ + from frappe.utils import get_fiscal_year, getdate, today + + fy = get_fiscal_year(today()) + fy_start, fy_end = fy[1], fy[2] + + # Sum all submitted invoices in current fiscal year + invoices = frappe.get_all( + "Sales Invoice", + filters={ + "docstatus": 1, + "posting_date": ["between", [fy_start, fy_end]], + }, + fields=["name", "grand_total", "sales_channel"], + ) + + totals = {"total_revenue": 0, "online_revenue": 0, "api_revenue": 0, "offline_revenue": 0} + for inv in invoices: + amt = inv.grand_total or 0 + totals["total_revenue"] += amt + channel = inv.sales_channel or "offline" + if channel == "online": + totals["online_revenue"] += amt + elif channel == "api": + totals["api_revenue"] += amt + else: + totals["offline_revenue"] += amt + + total = totals["total_revenue"] + online_pct = (totals["online_revenue"] / total * 100) if total > 0 else 0 + api_pct = (totals["api_revenue"] / total * 100) if total > 0 else 0 + # DESI 11 = online OR api (both are "non-traditional" channels) + desi11_pct = ((totals["online_revenue"] + totals["api_revenue"]) / total * 100) if total > 0 else 0 + + return { + "fiscal_year": fy[0], + "period": {"start": str(fy_start), "end": str(fy_end)}, + "total_revenue": totals["total_revenue"], + "online_revenue": totals["online_revenue"], + "api_revenue": totals["api_revenue"], + "offline_revenue": totals["offline_revenue"], + "online_percentage": round(online_pct, 2), + "api_percentage": round(api_pct, 2), + "desi11_percentage": round(desi11_pct, 2), + "threshold_met": desi11_pct >= 1.0, + } diff --git a/erp_crm/didi_custom/didi_custom/integrations/stripe_provisioning.py b/erp_crm/didi_custom/didi_custom/integrations/stripe_provisioning.py new file mode 100644 index 0000000..0d60bc1 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/integrations/stripe_provisioning.py @@ -0,0 +1,194 @@ +""" +Stripe Payment Log → IAM provisioning hook. + +Triggered on Payment Log insert (via hooks.py doc_events): + - When Stripe webhook arrives and creates Payment Log → this hook runs + - Maps Stripe event_type to active_plan + iam_role + - Calls Keycloak Admin API to update user attributes + roles + - Also updates Customer doctype iam_role + active_plan fields + +The Payment Log is created by the website's /api/webhooks/stripe handler. +""" +import frappe +from datetime import datetime +from typing import Optional +from .keycloak import update_user_attributes + + +# Map Stripe event type → desired IAM state +PLAN_FROM_PRICE_ID = { + # Paid monthly/yearly + frappe.conf.get("stripe_price_paid_monthly", "price_1TazV2COBVRKKFDgXAZy0PCa"): ("paid", "paid_tier"), + frappe.conf.get("stripe_price_paid_yearly", "price_1TazV2COBVRKKFDgvar35wzy"): ("paid", "paid_tier"), + # Enterprise monthly/yearly + frappe.conf.get("stripe_price_enterprise_monthly", "price_1TazV3COBVRKKFDgNbaz7PTO"): ("enterprise", "enterprise_tier"), + frappe.conf.get("stripe_price_enterprise_yearly", "price_1TazV4COBVRKKFDgHRaklc5l"): ("enterprise", "enterprise_tier"), +} + + +def on_payment_log_insert(doc, method=None): + """ + Frappe hook called when a Payment Log document is inserted. + + Configured in hooks.py: + doc_events = { + "Payment Log": { + "after_insert": "didi_custom.integrations.stripe_provisioning.on_payment_log_insert" + } + } + """ + # Only process successful subscription events + event_type = (doc.event_type or "").lower() + if event_type not in ( + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "invoice.payment_succeeded", + ): + return + + if doc.status != "succeeded" and event_type != "customer.subscription.updated": + return + + # Find related customer + customer_email = _extract_customer_email(doc) + if not customer_email: + frappe.log_error( + f"Payment Log {doc.name}: cannot determine customer email — skipping IAM", + "Stripe provisioning", + ) + return + + # Determine target plan + role from Stripe data + plan, role = _resolve_plan_role(doc) + if not plan: + frappe.log_error( + f"Payment Log {doc.name}: cannot determine plan from event — defaulting to free", + "Stripe provisioning", + ) + plan, role = ("free", "free_tier") + + # Update Customer in ERPNext + _update_customer(customer_email, plan, role, doc.stripe_subscription_id) + + # Update Keycloak (best-effort, fail-open) + activation_iso = datetime.utcnow().isoformat() + "Z" + update_user_attributes( + email=customer_email, + attributes={ + "active_plan": plan, + "plan_activation_date": activation_iso, + "stripe_subscription_id": doc.stripe_subscription_id or "", + }, + roles_add=[role], + roles_remove=[r for r in ("free_tier", "paid_tier", "enterprise_tier") if r != role], + ) + + +def on_subscription_canceled(doc, method=None): + """ + Triggered on subscription cancellation / expiration. + Downgrades user to free_tier. + """ + customer_email = _extract_customer_email(doc) + if not customer_email: + return + + _update_customer(customer_email, "free", "free_tier", None) + update_user_attributes( + email=customer_email, + attributes={ + "active_plan": "free", + "plan_activation_date": datetime.utcnow().isoformat() + "Z", + "stripe_subscription_id": "", + }, + roles_add=["free_tier"], + roles_remove=["paid_tier", "enterprise_tier"], + ) + + +def _extract_customer_email(doc) -> Optional[str]: + """Try multiple sources to find customer email.""" + # 1. From raw_webhook_data JSON + if doc.raw_webhook_data: + try: + import json + data = json.loads(doc.raw_webhook_data) if isinstance(doc.raw_webhook_data, str) else doc.raw_webhook_data + obj = data.get("data", {}).get("object", {}) + for key in ("customer_email", "receipt_email"): + if obj.get(key): + return obj[key] + # Look up via customer ID + if obj.get("customer"): + # Stripe customer ID — would need to query Stripe API to get email + # For now, look up linked Customer in ERP by stripe_customer_id + pass + except Exception: + pass + + # 2. From linked Customer (via stripe_customer_id field) + if doc.stripe_payment_intent_id: + customers = frappe.get_all( + "Customer", + filters={"stripe_customer_id": doc.stripe_payment_intent_id}, + fields=["email_id"], + limit=1, + ) + if customers and customers[0].email_id: + return customers[0].email_id + + return None + + +def _resolve_plan_role(doc) -> tuple: + """Map Stripe price_id from webhook → (plan, role) tuple.""" + if doc.raw_webhook_data: + try: + import json + data = json.loads(doc.raw_webhook_data) if isinstance(doc.raw_webhook_data, str) else doc.raw_webhook_data + # Try to extract price_id from various Stripe object shapes + obj = data.get("data", {}).get("object", {}) + # Checkout session shape + for line in obj.get("line_items", {}).get("data", []) or []: + price_id = line.get("price", {}).get("id") + if price_id in PLAN_FROM_PRICE_ID: + return PLAN_FROM_PRICE_ID[price_id] + # Subscription shape + for item in obj.get("items", {}).get("data", []) or []: + price_id = item.get("price", {}).get("id") + if price_id in PLAN_FROM_PRICE_ID: + return PLAN_FROM_PRICE_ID[price_id] + # Invoice line items + for line in obj.get("lines", {}).get("data", []) or []: + price_id = line.get("price", {}).get("id") + if price_id in PLAN_FROM_PRICE_ID: + return PLAN_FROM_PRICE_ID[price_id] + except Exception as e: + frappe.log_error(f"Plan resolution failed: {e}", "Stripe provisioning") + return (None, None) + + +def _update_customer(email: str, plan: str, role: str, subscription_id: Optional[str]): + """Update Customer doctype with plan + role.""" + customers = frappe.get_all( + "Customer", + filters={"email_id": email}, + fields=["name"], + limit=1, + ) + if not customers: + return + try: + cust = frappe.get_doc("Customer", customers[0].name) + if hasattr(cust, "active_plan"): + cust.active_plan = plan + if hasattr(cust, "iam_role"): + cust.iam_role = role + if hasattr(cust, "plan_activation_date"): + cust.plan_activation_date = datetime.utcnow().date() + if subscription_id and hasattr(cust, "stripe_subscription_id"): + cust.stripe_subscription_id = subscription_id + cust.save(ignore_permissions=True) + frappe.db.commit() + except Exception as e: + frappe.log_error(f"Customer update failed for {email}: {e}", "Stripe provisioning") diff --git a/erp_crm/didi_custom/didi_custom/integrations/subscription_lifecycle.py b/erp_crm/didi_custom/didi_custom/integrations/subscription_lifecycle.py new file mode 100644 index 0000000..0ace1cb --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/integrations/subscription_lifecycle.py @@ -0,0 +1,190 @@ +""" +Subscription lifecycle scheduler — daily check for expired subscriptions. + +Triggered by Frappe scheduler (hooks.py): + scheduler_events = { + "daily": [ + "didi_custom.integrations.subscription_lifecycle.expire_subscriptions", + ] + } + +Workflow: + 1. Find all Customer records with `active_plan IN ('paid', 'enterprise')` + 2. For each, check Stripe Subscription `current_period_end < now` + 3. If expired AND no successful renewal: + - downgrade Customer.active_plan to 'free' + - update Keycloak attributes + roles + - send notification email (via existing "DiDi - Expirare Abonament" Notification) +""" +import frappe +from datetime import datetime +from .stripe_provisioning import on_subscription_canceled + + +def expire_subscriptions(): + """ + Daily scheduler: detect expired subscriptions and downgrade users. + Idempotent — safe to run multiple times. + """ + # Find candidates: customers with active paid plans + candidates = frappe.get_all( + "Customer", + filters={"active_plan": ["in", ["paid", "enterprise"]]}, + fields=["name", "email_id", "stripe_subscription_id", "active_plan", "plan_activation_date"], + ) + + if not candidates: + return + + now = datetime.utcnow().date() + downgraded_count = 0 + + for cust in candidates: + if not cust.email_id: + continue + + # Check expiry — for now, use a simple lifetime check: + # If activation_date older than 30 days for monthly OR 365 days for yearly + no recent successful payment, downgrade. + # In production, query Stripe API directly with stripe_subscription_id for authoritative status. + if _should_downgrade(cust, now): + try: + _downgrade_customer(cust) + downgraded_count += 1 + except Exception as e: + frappe.log_error( + f"Downgrade failed for {cust.email_id}: {e}", + "Subscription lifecycle", + ) + + if downgraded_count: + frappe.log(f"Subscription lifecycle: downgraded {downgraded_count} expired subscriptions") + + +def _should_downgrade(cust: dict, now) -> bool: + """ + Check if customer's subscription should be downgraded. + + Implementation: check for recent successful Payment Log entries (last 35 days for monthly). + If none found AND plan_activation_date older than 35 days, downgrade. + + Production-grade would call Stripe API: GET /v1/subscriptions/{id} to get current_period_end + status. + """ + if not cust.stripe_subscription_id: + return False # No Stripe sub linked — manually managed, skip + + if not cust.plan_activation_date: + return False + + days_since_activation = (now - cust.plan_activation_date).days + if days_since_activation < 35: + return False # Within first monthly cycle — never downgrade + + # Check for recent successful payments + recent_success = frappe.get_all( + "Payment Log", + filters={ + "stripe_subscription_id": cust.stripe_subscription_id, + "status": "succeeded", + "creation": [">", frappe.utils.add_days(frappe.utils.now(), -35)], + }, + limit=1, + ) + return not bool(recent_success) + + +def _downgrade_customer(cust: dict): + """Apply downgrade — Customer + Keycloak + send notification.""" + # Use stripe_provisioning's helper for consistency + fake_doc = frappe._dict({ + "name": f"manual-downgrade-{cust.name}", + "raw_webhook_data": "", + "stripe_payment_intent_id": cust.stripe_subscription_id, + }) + # Manual update since we don't have a webhook doc + customer = frappe.get_doc("Customer", cust.name) + customer.active_plan = "free" + if hasattr(customer, "iam_role"): + customer.iam_role = "free_tier" + customer.save(ignore_permissions=True) + frappe.db.commit() + + # Update Keycloak attributes + roles + from .keycloak import update_user_attributes + update_user_attributes( + email=cust.email_id, + attributes={ + "active_plan": "free", + "plan_activation_date": datetime.utcnow().isoformat() + "Z", + }, + roles_add=["free_tier"], + roles_remove=["paid_tier", "enterprise_tier"], + ) + + # Send notification (uses existing "DiDi - Expirare Abonament" Notification) + try: + frappe.sendmail( + recipients=[cust.email_id], + subject="Abonament DiDi expirat", + message=( + f"

Buna ziua,

" + f"

Abonamentul dumneavoastra DiDi ({cust.active_plan}) a expirat. " + f"Contul a fost retrogradat la planul Free.

" + f"

Pentru reactivare, va rugam vizitati didi365.eu/pricing.

" + f"

Echipa DiDi / Clossers

" + ), + now=True, + ) + except Exception as e: + frappe.log_error(f"Downgrade email failed for {cust.email_id}: {e}", "Subscription lifecycle") + + +def send_expiry_reminders(days_before: int = 7, cycle_days: int = 30): + """ + Daily scheduler (ref. oferta B.1.2): remind customers whose subscription + expires in `days_before` days. Uses the same date heuristic as + expire_subscriptions (activation + cycle_days) until Stripe is authoritative. + Idempotent per day: at most one reminder, on the single matching day. + """ + candidates = frappe.get_all( + "Customer", + filters={"active_plan": ["in", ["paid", "enterprise"]]}, + fields=["name", "email_id", "active_plan", "plan_activation_date"], + ) + today = datetime.utcnow().date() + sent = 0 + for cust in candidates: + if not cust.email_id or not cust.plan_activation_date: + continue + days_left = cycle_days - (today - cust.plan_activation_date).days + if days_left != days_before: + continue + try: + frappe.sendmail( + recipients=[cust.email_id], + subject=f"Abonamentul DiDi expiră în {days_left} zile", + message=( + f"

Bună ziua,

" + f"

Abonamentul dumneavoastră DiDi ({cust.active_plan}) expiră în " + f"{days_left} zile.

" + f"

Pentru a evita întreruperea serviciilor, verificați metoda de plată " + f"sau reînnoiți din pagina de planuri.

" + f"

Echipa DiDi / Clossers

" + ), + now=True, + ) + sent += 1 + except Exception as e: + frappe.log_error(f"Expiry reminder failed for {cust.email_id}: {e}", "Subscription lifecycle") + print(f"expiry reminders sent: {sent}") + return sent + + +def hourly_sync_stripe(): + """ + Hourly scheduler: sync Stripe Subscription status to local Customer record. + Lighter version of expire_subscriptions — only flags inconsistencies, doesn't downgrade. + + Useful for keeping ERP in sync with Stripe between webhook events. + """ + # Implementation TODO: query Stripe API for all subscriptions, update Customer state + pass diff --git a/erp_crm/didi_custom/didi_custom/make_company_ro3.py b/erp_crm/didi_custom/didi_custom/make_company_ro3.py new file mode 100644 index 0000000..a955028 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/make_company_ro3.py @@ -0,0 +1,37 @@ +import frappe + +def execute(): + if frappe.db.exists("Company", "TOP CLOSSERS SRL"): + print("Already exists") + return + # Patch ALL the country-specific setup that crashes + from erpnext.setup.doctype.company import company as cm + cm.install_country_fixtures = lambda *a, **kw: None + # Skip default tax template creation too (we will create our own RO TVA 19% template) + original_method = cm.Company.create_default_tax_template + cm.Company.create_default_tax_template = lambda self: None + try: + doc = frappe.get_doc({ + "doctype": "Company", + "company_name": "TOP CLOSSERS SRL", + "abbr": "TC", + "default_currency": "RON", + "country": "Romania", + "chart_of_accounts": "Romania - Chart of Accounts", + "create_chart_of_accounts_based_on": "Standard Template", + }) + doc.flags.ignore_permissions = True + doc.insert() + frappe.db.commit() + print("Created:", doc.name) + finally: + cm.Company.create_default_tax_template = original_method + + count = frappe.db.sql("SELECT COUNT(*) FROM tabAccount WHERE company=%s", ("TOP CLOSSERS SRL",))[0][0] + print(f"Accounts: {count}") + + # Set defaults + frappe.db.set_single_value("Global Defaults", "default_company", "TOP CLOSSERS SRL") + frappe.db.set_single_value("Global Defaults", "default_currency", "RON") + frappe.db.commit() + print("Defaults set") diff --git a/erp_crm/didi_custom/didi_custom/make_didi_items.py b/erp_crm/didi_custom/didi_custom/make_didi_items.py new file mode 100644 index 0000000..d9a2ff1 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/make_didi_items.py @@ -0,0 +1,42 @@ +import frappe + +ITEMS = [ + ("DIDI-TECHNIQUES-TEXT", "DIDI Techniques - Text", 50), + ("DIDI-TECHNIQUES-IMAGE", "DIDI Techniques - Image", 75), + ("DIDI-TECHNIQUES-AUDIO", "DIDI Techniques - Audio", 100), + ("DIDI-TECHNIQUES-VIDEO", "DIDI Techniques - Video", 200), + ("DIDI-AI_DETECTION-TEXT", "DIDI AI Detection - Text", 50), + ("DIDI-AI_DETECTION-IMAGE", "DIDI AI Detection - Image", 75), + ("DIDI-AI_DETECTION-AUDIO", "DIDI AI Detection - Audio", 100), + ("DIDI-AI_DETECTION-VIDEO", "DIDI AI Detection - Video", 200), + ("DIDI-CLAIMS-TEXT", "DIDI Claims - Text", 50), + ("DIDI-CLAIMS-IMAGE", "DIDI Claims - Image", 75), + ("DIDI-CLAIMS-AUDIO", "DIDI Claims - Audio", 75), + ("DIDI-CLAIMS-VIDEO", "DIDI Claims - Video", 150), + ("DIDI-SOURCE-TEXT", "DIDI Source Assessment - Text", 50), + ("DIDI-SOURCE-URL", "DIDI Source Assessment - URL", 50), +] + +def execute(): + created = 0 + for code, name, price in ITEMS: + if frappe.db.exists("Item", code): + print(f" {code}: exists") + continue + doc = frappe.get_doc({ + "doctype": "Item", + "item_code": code, + "item_name": name, + "item_group": "Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "is_service_item": 1, + "description": name, + "standard_rate": price, + }) + doc.flags.ignore_permissions = True + doc.insert() + created += 1 + print(f" {code}: created (RON {price})") + frappe.db.commit() + print(f"Created {created} items") diff --git a/erp_crm/didi_custom/didi_custom/modules.txt b/erp_crm/didi_custom/didi_custom/modules.txt new file mode 100644 index 0000000..a463364 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/modules.txt @@ -0,0 +1 @@ +Didi Custom \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/notifications.py b/erp_crm/didi_custom/didi_custom/notifications.py new file mode 100644 index 0000000..35c7521 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/notifications.py @@ -0,0 +1,70 @@ +""" +Emailuri tranzactionale DiDi (ref. oferta B.1.2/B.1.3): confirmare plata cu +factura PDF atasata, trimisa clientului la finalizarea fulfillment-ului. + +Apelata de website dupa plata confirmata: + POST /api/method/didi_custom.notifications.send_invoice_email + body: {"invoice_name": "DIDI-INV-...."} + +SMTP-ul vine din Email Account "DiDi Outgoing" (Mailpit in test, SendGrid in +productie — vezi setup_email.py). +""" +import frappe + + +@frappe.whitelist() +def send_payment_failed_email(email: str, amount=None, currency: str = "RON", reactivation_url: str = ""): + """Notificare eșec plată cu link de reactivare (ref. oferta B.1.2).""" + if not email: + return {"sent": False, "reason": "missing email"} + link = reactivation_url or "https://didi365.eu/pricing" + amount_line = ( + f"

Suma: {float(amount):.2f} {currency}.

" if amount else "" + ) + frappe.sendmail( + recipients=[email], + subject="DiDi — Plata nu a putut fi procesată", + message=( + f"

Bună ziua,

" + f"

Încercarea de plată pentru abonamentul DiDi a eșuat.

" + f"{amount_line}" + f"

Vă rugăm să verificați metoda de plată și să " + f"reactivați abonamentul. Vom reîncerca automat plata " + f"în următoarele zile.

" + f"

Echipa DiDi

" + ), + delayed=False, + ) + frappe.db.commit() + return {"sent": True, "to": email} + + +@frappe.whitelist() +def send_invoice_email(invoice_name: str): + doc = frappe.get_doc("Sales Invoice", invoice_name) + customer_email = frappe.db.get_value("Customer", doc.customer, "email_id") + if not customer_email: + return {"sent": False, "reason": f"customer {doc.customer} has no email_id"} + + pdf = frappe.attach_print( + "Sales Invoice", invoice_name, print_format="DiDi Invoice", + file_name=f"{invoice_name}.pdf", + ) + frappe.sendmail( + recipients=[customer_email], + subject=f"DiDi — Confirmare plată și factura {invoice_name}", + message=( + f"

Bună ziua, {doc.customer_name},

" + f"

Vă mulțumim pentru plată. Găsiți atașată factura " + f"{invoice_name} în valoare de " + f"{doc.grand_total:.2f} {doc.currency}.

" + f"

Serviciul achiziționat este activ în contul dumneavoastră DiDi.

" + f"

Echipa DiDi

" + ), + attachments=[pdf], + reference_doctype="Sales Invoice", + reference_name=invoice_name, + delayed=False, + ) + frappe.db.commit() + return {"sent": True, "to": customer_email, "invoice": invoice_name} diff --git a/erp_crm/didi_custom/didi_custom/patches.txt b/erp_crm/didi_custom/didi_custom/patches.txt new file mode 100644 index 0000000..f15c3a9 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/patches.txt @@ -0,0 +1,6 @@ +[pre_model_sync] +# Patches added in this section will be executed before doctypes are migrated +# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations + +[post_model_sync] +# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file diff --git a/erp_crm/didi_custom/didi_custom/patches/__init__.py b/erp_crm/didi_custom/didi_custom/patches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/populate_content.py b/erp_crm/didi_custom/didi_custom/populate_content.py new file mode 100644 index 0000000..5d2a346 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/populate_content.py @@ -0,0 +1,397 @@ +""" +Populate ERPNext Website Content with real DiDi platform content. +All text content is managed here - website pulls from ERPNext CMS. +""" +import frappe +import json + + +def execute(): + populate_homepage() + populate_services() + populate_about() + populate_contact() + populate_privacy() + populate_terms() + frappe.db.commit() + print("\n=== Content populated ===\n") + + +def upsert(page_slug, section_key, content_ro, content_en, display_order=0, extra_data=""): + """Create or update Website Content entry.""" + name = f"{page_slug}-{section_key}" + if frappe.db.exists("Website Content", name): + doc = frappe.get_doc("Website Content", name) + doc.content_ro = content_ro + doc.content_en = content_en + doc.display_order = display_order + doc.extra_data = extra_data + doc.is_active = 1 + doc.save(ignore_permissions=True) + else: + doc = frappe.get_doc({ + "doctype": "Website Content", + "page_slug": page_slug, + "section_key": section_key, + "content_ro": content_ro, + "content_en": content_en, + "display_order": display_order, + "extra_data": extra_data, + "is_active": 1, + }) + doc.insert(ignore_permissions=True) + print(f" {name}") + + +def populate_homepage(): + print("[1/6] Homepage...") + + upsert("homepage", "hero_title", + "Combate dezinformarea cu inteligenta artificiala", + "Fight disinformation with artificial intelligence", 1) + + upsert("homepage", "hero_subtitle", + "Platforma DiDi de la Clossers analizeaza automat continut media — text, imagini, audio si video — pentru a detecta dezinformarea si a verifica informatiile in timp real.", + "The DiDi platform by Clossers automatically analyzes media content — text, images, audio and video — to detect disinformation and verify information in real time.", 2) + + upsert("homepage", "hero_cta", + "Incepe gratuit", "Start free", 3, + json.dumps({"link": "/pricing", "secondary_text_ro": "Descopera serviciile", "secondary_text_en": "Discover services", "secondary_link": "/services"})) + + upsert("homepage", "features", + "Servicii de detectie si analiza", "Detection and analysis services", 10, + json.dumps([ + {"icon": "text", "title_ro": "Analiza Text & NLP", "title_en": "Text Analysis & NLP", + "desc_ro": "Detectie automata a propagandei, limbajului manipulativ si a informatiilor false din articole si postari", + "desc_en": "Automatic detection of propaganda, manipulative language and false information from articles and posts"}, + {"icon": "image", "title_ro": "Detectie Deepfake", "title_en": "Deepfake Detection", + "desc_ro": "Identificare imagini si videoclipuri generate sau manipulate prin AI, inclusiv face-swap si lip-sync", + "desc_en": "Identification of AI-generated or manipulated images and videos, including face-swap and lip-sync"}, + {"icon": "check", "title_ro": "Fact-Checking Automat", "title_en": "Automated Fact-Checking", + "desc_ro": "Verificare automata a afirmatiilor cu surse oficiale, baze de date credibile si articole de presa verificate", + "desc_en": "Automatic verification of claims with official sources, credible databases and verified press articles"}, + {"icon": "source", "title_ro": "Evaluare Surse", "title_en": "Source Assessment", + "desc_ro": "Scor de credibilitate pentru domenii web, publicatii si conturi social media cu analiza in profunzime", + "desc_en": "Credibility score for web domains, publications and social media accounts with in-depth analysis"}, + ])) + + upsert("homepage", "how_it_works", + "Cum functioneaza DiDi", "How DiDi works", 20, + json.dumps([ + {"step": 1, "title_ro": "Incarca continutul", "title_en": "Upload content", + "desc_ro": "Trimite un text, URL, imagine sau video pentru analiza. Suportam toate formatele majore.", "desc_en": "Submit a text, URL, image or video for analysis. We support all major formats."}, + {"step": 2, "title_ro": "Analiza AI multi-model", "title_en": "Multi-model AI analysis", + "desc_ro": "6 module AI specializate ruleaza in paralel: analiza text, detectie deepfake, fact-checking, evaluare surse, detectie tehnici de manipulare si monitorizare.", "desc_en": "6 specialized AI modules run in parallel: text analysis, deepfake detection, fact-checking, source assessment, manipulation technique detection and monitoring."}, + {"step": 3, "title_ro": "Validare Human-in-the-Loop", "title_en": "Human-in-the-Loop validation", + "desc_ro": "Expertii nostri verifica si valideaza rezultatele AI pentru acuratete maxima. Combinam viteza AI cu expertiza umana.", "desc_en": "Our experts verify and validate AI results for maximum accuracy. We combine AI speed with human expertise."}, + {"step": 4, "title_ro": "Raport detaliat", "title_en": "Detailed report", + "desc_ro": "Primesti un raport complet: scor de incredere, dovezi, surse, tehnici identificate si recomandari. Export PDF disponibil.", "desc_en": "Receive a complete report: confidence score, evidence, sources, identified techniques and recommendations. PDF export available."}, + ])) + + upsert("homepage", "stats", + "DiDi in cifre", "DiDi in numbers", 30, + json.dumps([ + {"value": "6", "label_ro": "Module AI specializate", "label_en": "Specialized AI modules"}, + {"value": "18+", "label_ro": "Tehnici de manipulare detectate", "label_en": "Manipulation techniques detected"}, + {"value": "<30s", "label_ro": "Timp mediu analiza", "label_en": "Average analysis time"}, + {"value": "99.5%", "label_ro": "Disponibilitate platforma", "label_en": "Platform availability"}, + ])) + + upsert("homepage", "use_cases", + "Cine foloseste DiDi?", "Who uses DiDi?", 40, + json.dumps([ + {"title_ro": "Redactii & Jurnalisti", "title_en": "Newsrooms & Journalists", + "desc_ro": "Verificare rapida a informatiilor inainte de publicare. Detectie surse nesigure si continut manipulat.", + "desc_en": "Quick information verification before publishing. Detection of unreliable sources and manipulated content."}, + {"title_ro": "Agentii de comunicare", "title_en": "Communication agencies", + "desc_ro": "Monitorizare spatiu mediatic, analiza narrativa si protectie brand impotriva dezinformarii.", + "desc_en": "Media space monitoring, narrative analysis and brand protection against disinformation."}, + {"title_ro": "Institutii publice", "title_en": "Public institutions", + "desc_ro": "Detectie campanii de dezinformare care vizeaza institutii, alegeri sau politici publice.", + "desc_en": "Detection of disinformation campaigns targeting institutions, elections or public policies."}, + {"title_ro": "Cercetatori", "title_en": "Researchers", + "desc_ro": "API si instrumente de analiza pentru studiul fenomenului dezinformarii in proiecte academice.", + "desc_en": "API and analysis tools for studying the disinformation phenomenon in academic projects."}, + {"title_ro": "ONG-uri & Fact-checkeri", "title_en": "NGOs & Fact-checkers", + "desc_ro": "Automatizare si accelerare a procesului de verificare cu instrumente profesionale.", + "desc_en": "Automation and acceleration of the verification process with professional tools."}, + {"title_ro": "Companii", "title_en": "Companies", + "desc_ro": "Protectia reputatiei impotriva stirilor false. Monitorizare si alerte automate.", + "desc_en": "Reputation protection against fake news. Automated monitoring and alerts."}, + ])) + + +def populate_services(): + print("[2/6] Services...") + + modules_data = [ + {"id": "text-analysis", "order": 1, + "title_ro": "Analiza Text & NLP", "title_en": "Text Analysis & NLP", + "subtitle_ro": "Detectie automata a dezinformarii din articole, postari si documente", + "subtitle_en": "Automatic detection of disinformation from articles, posts and documents", + "desc_ro": "Motorul nostru de procesare a limbajului natural analizeaza textul la nivel semantic, identificand tipare de manipulare, limbaj emotional excesiv, afirmatii neverificabile si inconsistente factuale. Suporta limba romana si engleza cu modele antrenate pe corpus media local.", + "desc_en": "Our natural language processing engine analyzes text at semantic level, identifying manipulation patterns, excessive emotional language, unverifiable claims and factual inconsistencies. Supports Romanian and English with models trained on local media corpus.", + "capabilities_ro": ["Detectie propaganda si limbaj manipulativ", "Analiza sentimentului si a tonului emotional", "Identificare afirmatii factuale vs. opinii", "Detectie clickbait si titluri inselatoare", "Analiza coerenta narativa", "Suport limba romana si engleza"], + "capabilities_en": ["Propaganda and manipulative language detection", "Sentiment and emotional tone analysis", "Factual claims vs opinions identification", "Clickbait and misleading headline detection", "Narrative coherence analysis", "Romanian and English support"]}, + + {"id": "deepfake", "order": 2, + "title_ro": "Detectie Deepfake & Manipulare Media", "title_en": "Deepfake & Media Manipulation Detection", + "subtitle_ro": "Identificare imagini si videoclipuri manipulate prin AI", + "subtitle_en": "Identification of AI-manipulated images and videos", + "desc_ro": "Modulul de analiza vizuala foloseste retele neuronale convolutionale si transformere vizuale pentru a detecta manipulari la nivel de pixel, artefacte de generare AI, inconsistente in iluminare si geometrie faciala.", + "desc_en": "The visual analysis module uses convolutional neural networks and visual transformers to detect pixel-level manipulations, AI generation artifacts, lighting inconsistencies and facial geometry.", + "capabilities_ro": ["Detectie imagini generate de AI (DALL-E, Midjourney, Stable Diffusion)", "Detectie deepfake video (face-swap, lip-sync)", "Analiza artefacte JPEG si metadata EXIF", "Detectie splice, copy-move, inpainting", "Verificare autenticitate fotografii", "Analiza consistenta iluminare si umbre"], + "capabilities_en": ["AI-generated image detection (DALL-E, Midjourney, Stable Diffusion)", "Deepfake video detection (face-swap, lip-sync)", "JPEG artifacts and EXIF metadata analysis", "Splice, copy-move, inpainting detection", "Photo authenticity verification", "Lighting and shadow consistency analysis"]}, + + {"id": "fact-checking", "order": 3, + "title_ro": "Fact-Checking Automat", "title_en": "Automated Fact-Checking", + "subtitle_ro": "Verificare automata a afirmatiilor cu surse credibile", + "subtitle_en": "Automatic claim verification with credible sources", + "desc_ro": "Sistemul de fact-checking extrage automat afirmatiile verificabile din text, le compara cu baze de date de fapte verificate, surse oficiale si articole de presa credibile. Foloseste tehnici de retrieval-augmented generation (RAG) si reranking.", + "desc_en": "The fact-checking system automatically extracts verifiable claims from text, compares them with verified fact databases, official sources and credible press articles. Uses retrieval-augmented generation (RAG) and reranking techniques.", + "capabilities_ro": ["Extractie automata a afirmatiilor verificabile", "Cautare in baze de date de fact-checking", "Verificare cu surse oficiale (INS, Eurostat, OMS)", "Scor de credibilitate cu explicatii", "Linkuri directe catre surse", "Istoric verificari"], + "capabilities_en": ["Automatic verifiable claim extraction", "Fact-checking database search", "Official source verification (INS, Eurostat, WHO)", "Credibility score with explanations", "Direct source links", "Verification history"]}, + + {"id": "source-assessment", "order": 4, + "title_ro": "Evaluare Surse & Domenii", "title_en": "Source & Domain Assessment", + "subtitle_ro": "Analiza credibilitatii surselor de informare", + "subtitle_en": "Information source credibility analysis", + "desc_ro": "Modulul de evaluare a surselor analizeaza domenii web, publicatii si conturi de social media pentru a determina nivelul de credibilitate. Combina indicatori tehnici cu indicatori editoriali.", + "desc_en": "The source assessment module analyzes web domains, publications and social media accounts to determine credibility level. Combines technical and editorial indicators.", + "capabilities_ro": ["Scor credibilitate domeniu (0-100)", "Verificare WHOIS si istoric domeniu", "Analiza retea de link-uri", "Detectie site-uri clone si typosquatting", "Monitorizare reputatie", "Baza de date surse de dezinformare"], + "capabilities_en": ["Domain credibility score (0-100)", "WHOIS and domain history verification", "Link network analysis", "Clone sites and typosquatting detection", "Reputation monitoring", "Disinformation source database"]}, + + {"id": "techniques", "order": 5, + "title_ro": "Detectie Tehnici de Manipulare", "title_en": "Manipulation Technique Detection", + "subtitle_ro": "Identificarea tehnicilor de propaganda si dezinformare", + "subtitle_en": "Identification of propaganda and disinformation techniques", + "desc_ro": "Bazat pe taxonomia tehnicilor de propaganda dezvoltata in cercetare academica, acest modul identifica si clasifica tehnicile specifice de manipulare folosite intr-un text. De la apel la emotii pana la whataboutism si cherry-picking.", + "desc_en": "Based on the propaganda techniques taxonomy developed in academic research, this module identifies and classifies specific manipulation techniques used in text. From appeal to emotions to whataboutism and cherry-picking.", + "capabilities_ro": ["Clasificare pe 18+ tehnici de propaganda", "Apel la emotii (frica, furie, empatie falsa)", "Apel la autoritate falsa sau anonima", "Generalizari pripite si stereotipuri", "Whataboutism si false echivalente", "Cherry-picking si scoatere din context"], + "capabilities_en": ["Classification of 18+ propaganda techniques", "Appeal to emotions (fear, anger, false empathy)", "Appeal to false or anonymous authority", "Hasty generalizations and stereotypes", "Whataboutism and false equivalences", "Cherry-picking and out-of-context"]}, + + {"id": "monitoring", "order": 6, + "title_ro": "Monitorizare Media & Alerte", "title_en": "Media Monitoring & Alerts", + "subtitle_ro": "Supraveghere continua a spatiului informational", + "subtitle_en": "Continuous information space surveillance", + "desc_ro": "Sistemul de monitorizare scaneaza in timp real surse media, retele sociale si site-uri de stiri pentru a identifica narrativa dezinformante emergente. Alertele automate notifica echipa cand se detecteaza campanii coordonate.", + "desc_en": "The monitoring system scans media sources, social networks and news sites in real time to identify emerging disinformation narratives. Automatic alerts notify the team when coordinated campaigns are detected.", + "capabilities_ro": ["Monitorizare 24/7 surse media si social media", "Detectie narrativa emergente si trenduri", "Alerte automate pe email si in dashboard", "Identificare campanii coordonate", "Harta de raspandire a dezinformarii", "Rapoarte periodice automate"], + "capabilities_en": ["24/7 media and social media monitoring", "Emerging narrative and trend detection", "Automatic email and dashboard alerts", "Coordinated campaign identification", "Disinformation spread map", "Automatic periodic reports"]}, + ] + + for mod in modules_data: + upsert("services", f"module_{mod['id']}", + f"

{mod['title_ro']}

{mod['subtitle_ro']}

{mod['desc_ro']}

", + f"

{mod['title_en']}

{mod['subtitle_en']}

{mod['desc_en']}

", + mod["order"], + json.dumps({ + "id": mod["id"], + "title_ro": mod["title_ro"], "title_en": mod["title_en"], + "subtitle_ro": mod["subtitle_ro"], "subtitle_en": mod["subtitle_en"], + "desc_ro": mod["desc_ro"], "desc_en": mod["desc_en"], + "capabilities_ro": mod["capabilities_ro"], + "capabilities_en": mod["capabilities_en"], + })) + + +def populate_about(): + print("[3/6] About...") + + upsert("about", "hero", + "

Despre Clossers si platforma DiDi

TOP CLOSSERS SRL dezvolta solutii digitale inovatoare pentru combaterea dezinformarii si protectia spatiului informational romanesc si european.

", + "

About Clossers and the DiDi platform

TOP CLOSSERS SRL develops innovative digital solutions for combating disinformation and protecting the Romanian and European information space.

", 1) + + upsert("about", "mission", + "

Misiunea noastra

DiDi (Platforma Digitala Inteligenta pentru Prevenirea si Combaterea Dezinformarii) este raspunsul nostru la una dintre cele mai mari provocari ale erei digitale: dezinformarea. Combinam inteligenta artificiala de ultima generatie cu expertiza umana pentru a oferi instrumente profesionale de verificare a informatiilor.

", + "

Our mission

DiDi (Intelligent Digital Platform for Preventing and Combating Disinformation) is our answer to one of the biggest challenges of the digital era: disinformation. We combine cutting-edge artificial intelligence with human expertise to provide professional information verification tools.

", 2) + + upsert("about", "project", + "

Proiect PNRR

Platforma DiDi este dezvoltata in cadrul proiectului finantat prin Planul National de Redresare si Rezilienta (PNRR), Componenta C9 - Suport pentru sectorul privat, cercetare, dezvoltare si inovare, Investitia I3 - Scheme de ajutor pentru sectorul privat. Contract de finantare nr. 11.1.i3.c9/10.05.2024.

", + "

PNRR Project

The DiDi platform is developed within the project funded by the National Recovery and Resilience Plan (NRRP), Component C9 - Support for private sector, research, development and innovation, Investment I3 - Aid schemes for private sector. Funding contract no. 11.1.i3.c9/10.05.2024.

", 3) + + upsert("about", "technology", + "

Tehnologie

Platforma DiDi integreaza multiple modele AI/ML intr-o arhitectura de orchestrare multi-model. Fiecare tip de analiza (text, imagine, video, fact-checking) este procesat de module specializate care ruleaza in paralel, oferind rezultate in timp real. Arhitectura este containerizata, scalabila si gazduita in cloud pe servere din Uniunea Europeana.

", + "

Technology

The DiDi platform integrates multiple AI/ML models in a multi-model orchestration architecture. Each type of analysis (text, image, video, fact-checking) is processed by specialized modules running in parallel, providing real-time results. The architecture is containerized, scalable and hosted in cloud on EU servers.

", 4) + + upsert("about", "company", + "", "", 5, + json.dumps({ + "name": "TOP CLOSSERS SRL", + "cui": "36193026", + "reg_com": "J2022000345035", + "address": "Str. Targovistei 15, Bl. 2, Et. 3, Ap. 22, Ploiesti, Prahova 100299", + "email": "office@clossers.com", + "phone": "+40721063078", + "website": "https://clossers.com", + })) + + +def populate_contact(): + print("[4/6] Contact...") + + upsert("contact", "title", + "

Contacteaza-ne

Ai intrebari despre platforma DiDi sau vrei sa discutam despre nevoile tale? Echipa noastra iti sta la dispozitie.

", + "

Contact us

Have questions about the DiDi platform or want to discuss your needs? Our team is here to help.

", 1) + + upsert("contact", "info", "", "", 2, + json.dumps({ + "email": "office@clossers.com", + "phone": "+40721063078", + "address_ro": "Str. Targovistei 15, Bl. 2, Et. 3, Ap. 22\nPloiesti, Prahova 100299\nRomania", + "address_en": "15 Targovistei St., Bl. 2, Fl. 3, Ap. 22\nPloiesti, Prahova 100299\nRomania", + "schedule_ro": "Luni - Vineri, 09:00 - 18:00", + "schedule_en": "Monday - Friday, 09:00 - 18:00", + })) + + +def populate_privacy(): + print("[5/6] Privacy...") + + upsert("privacy", "content", + """

Politica de Confidentialitate

+

Ultima actualizare: 30 martie 2026

+ +

1. Introducere

+

TOP CLOSSERS SRL (CUI: 36193026), cu sediul in Ploiesti, Str. Targovistei 15, opereaza platforma DiDi si site-ul clossers.com. Respectam confidentialitatea datelor dumneavoastra personale si ne conformam Regulamentului General privind Protectia Datelor (GDPR - Regulamentul UE 2016/679).

+ +

2. Date colectate

+

Colectam urmatoarele categorii de date:

+
    +
  • Date de identificare: nume, prenume, adresa email, numar telefon, denumire companie, CUI
  • +
  • Date de autentificare: gestionate prin Keycloak (Identity and Access Management) - nu stocam parole
  • +
  • Date de utilizare: continut incarcat pentru analiza, istoricul analizelor, credite consumate
  • +
  • Date de plata: gestionate de Stripe - nu stocam date de card
  • +
  • Date tehnice: adresa IP, tip browser, cookie-uri (conform politicii de cookie-uri)
  • +
+ +

3. Scopul prelucrarii

+
    +
  • Furnizarea serviciilor platformei DiDi (analiza continut media)
  • +
  • Gestionarea contului si a abonamentului
  • +
  • Facturare si evidenta contabila
  • +
  • Comunicari tranzactionale (facturi, notificari cont)
  • +
  • Imbunatatirea serviciilor si suport tehnic
  • +
+ +

4. Temeiul legal

+
    +
  • Executarea contractului (furnizarea serviciilor)
  • +
  • Obligatii legale (facturare, contabilitate)
  • +
  • Consimtamant (cookie-uri analitice, comunicari marketing)
  • +
  • Interes legitim (securitate, prevenire frauda)
  • +
+ +

5. Stocarea datelor

+

Toate datele sunt stocate exclusiv pe servere localizate in Uniunea Europeana, conform cerintelor GDPR. Datele sunt criptate in tranzit (TLS 1.2+) si la repaus (AES-256).

+ +

6. Drepturile dumneavoastra

+

Conform GDPR, aveti dreptul la:

+
    +
  • Acces - puteti vizualiza datele din dashboard-ul contului
  • +
  • Rectificare - puteti modifica datele din profilul contului
  • +
  • Stergere - puteti solicita stergerea contului din dashboard (sectiunea Profil)
  • +
  • Portabilitate - puteti exporta datele in format CSV din dashboard
  • +
  • Opozitie - puteti retrage consimtamantul pentru cookie-uri analitice
  • +
+ +

7. Contact DPO

+

Pentru exercitarea drepturilor sau intrebari privind protectia datelor: office@clossers.com

""", + + """

Privacy Policy

+

Last updated: March 30, 2026

+ +

1. Introduction

+

TOP CLOSSERS SRL (Tax ID: 36193026), headquartered in Ploiesti, 15 Targovistei St., operates the DiDi platform and clossers.com website. We respect the privacy of your personal data and comply with the General Data Protection Regulation (GDPR - EU Regulation 2016/679).

+ +

2. Data collected

+
    +
  • Identification data: name, email, phone, company name, tax ID
  • +
  • Authentication data: managed by Keycloak (IAM) - we do not store passwords
  • +
  • Usage data: content uploaded for analysis, analysis history, credits consumed
  • +
  • Payment data: managed by Stripe - we do not store card data
  • +
  • Technical data: IP address, browser type, cookies
  • +
+ +

3. Purpose

+
    +
  • Providing DiDi platform services
  • +
  • Account and subscription management
  • +
  • Invoicing and accounting
  • +
  • Transactional communications
  • +
  • Service improvement and support
  • +
+ +

4. Your rights

+

Under GDPR: access, rectification, erasure, portability, objection. Contact: office@clossers.com

""", 1) + + +def populate_terms(): + print("[6/6] Terms...") + + upsert("terms", "content", + """

Termeni si Conditii

+

Ultima actualizare: 30 martie 2026

+ +

1. Definitii

+
    +
  • Furnizor: TOP CLOSSERS SRL, CUI 36193026, operator al platformei DiDi
  • +
  • Utilizator: persoana fizica sau juridica care acceseaza si utilizeaza serviciile DiDi
  • +
  • Platforma: platforma digitala DiDi accesibila prin intermediul site-ului clossers.com
  • +
  • Servicii: serviciile de analiza si detectie a dezinformarii oferite prin platforma DiDi
  • +
+ +

2. Servicii oferite

+

Platforma DiDi ofera servicii de analiza automata a continutului media (text, imagini, audio, video) pentru detectarea dezinformarii, prin intermediul unor module AI specializate: analiza text, detectie deepfake, fact-checking automat, evaluare surse, detectie tehnici de manipulare si monitorizare media.

+ +

3. Inregistrare si cont

+

Accesul la servicii necesita crearea unui cont. Utilizatorul este responsabil pentru securitatea credentialelor de acces si pentru toate activitatile desfasurate prin contul sau.

+ +

4. Planuri si plata

+

Serviciile sunt disponibile in mai multe planuri (Free, Paid, Enterprise). Platile sunt procesate securizat prin Stripe. Facturile sunt generate automat si disponibile in dashboard.

+ +

5. Utilizare acceptabila

+

Utilizatorul se obliga sa nu foloseasca platforma pentru:

+
    +
  • Activitati ilegale sau care incalca drepturile tertilor
  • +
  • Incarcarea de continut ilegal sau care incalca drepturile de autor
  • +
  • Tentative de compromitere a securitatii platformei
  • +
  • Utilizare automata abuziva (scraping, DDoS)
  • +
+ +

6. Proprietate intelectuala

+

Platforma DiDi, algoritmii, modelele AI si documentatia sunt proprietatea TOP CLOSSERS SRL. Utilizatorul pastreaza drepturile asupra continutului incarcat pentru analiza.

+ +

7. Limitarea raspunderii

+

Rezultatele analizelor DiDi sunt furnizate ca instrumente de suport decizional si nu constituie adevar absolut. TOP CLOSSERS SRL nu raspunde pentru decizii luate exclusiv pe baza rezultatelor platformei.

+ +

8. Legislatie aplicabila

+

Prezentii termeni sunt guvernati de legislatia din Romania. Orice litigiu va fi solutionat de instantele competente din Ploiesti, Romania.

+ +

9. Contact

+

office@clossers.com | +40 721 063 078

""", + + """

Terms and Conditions

+

Last updated: March 30, 2026

+ +

1. Definitions

+
    +
  • Provider: TOP CLOSSERS SRL, Tax ID 36193026, operator of DiDi platform
  • +
  • User: person or entity accessing DiDi services
  • +
  • Platform: DiDi digital platform accessible via clossers.com
  • +
+ +

2. Services

+

DiDi provides automated media content analysis services for disinformation detection through specialized AI modules.

+ +

3. Payment

+

Services available in Free, Paid, Enterprise plans. Payments processed via Stripe. Invoices generated automatically.

+ +

4. Acceptable use

+

Users must not use the platform for illegal activities, copyright infringement, security attacks, or abusive automation.

+ +

5. Governing law

+

Romanian law applies. Disputes resolved by Ploiesti courts.

+ +

6. Contact

+

office@clossers.com

""", 1) diff --git a/erp_crm/didi_custom/didi_custom/public/.gitkeep b/erp_crm/didi_custom/didi_custom/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/setup_doctypes.py b/erp_crm/didi_custom/didi_custom/setup_doctypes.py new file mode 100644 index 0000000..cf7ba01 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/setup_doctypes.py @@ -0,0 +1,262 @@ +""" +Create custom DocTypes for DiDi Platform. +""" +import frappe +import json + + +def execute(): + create_service_agreement() + create_payment_log() + create_website_content() + add_custom_fields() + frappe.db.commit() + print("\n=== Custom DocTypes Created ===\n") + + +def create_service_agreement(): + """DocType: Service Agreement - contract auto-generat la activarea abonamentului.""" + print("[1/4] Creating Service Agreement DocType...") + + if frappe.db.exists("DocType", "Service Agreement"): + print(" Already exists, skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Service Agreement", + "module": "Didi Custom", + "custom": 0, + "is_submittable": 0, + "autoname": "DIDI-SA-.YYYY.-.#####", + "title_field": "customer_name", + "search_fields": "customer,plan,status", + "fields": [ + {"fieldname": "customer", "label": "Customer", "fieldtype": "Link", "options": "Customer", "reqd": 1, "in_list_view": 1}, + {"fieldname": "customer_name", "label": "Customer Name", "fieldtype": "Data", "fetch_from": "customer.customer_name", "read_only": 1}, + {"fieldname": "column_break_1", "fieldtype": "Column Break"}, + {"fieldname": "plan", "label": "Subscription Plan", "fieldtype": "Link", "options": "Subscription Plan", "reqd": 1, "in_list_view": 1}, + {"fieldname": "status", "label": "Status", "fieldtype": "Select", "options": "Draft\nAccepted\nExpired\nCancelled", "default": "Draft", "in_list_view": 1, "reqd": 1}, + {"fieldname": "section_acceptance", "label": "Acceptance Details", "fieldtype": "Section Break"}, + {"fieldname": "acceptance_date", "label": "Acceptance Date", "fieldtype": "Datetime"}, + {"fieldname": "client_ip", "label": "Client IP", "fieldtype": "Data", "read_only": 1}, + {"fieldname": "column_break_2", "fieldtype": "Column Break"}, + {"fieldname": "terms_version", "label": "Terms Version", "fieldtype": "Data"}, + {"fieldname": "subscription", "label": "Subscription", "fieldtype": "Link", "options": "Subscription"}, + {"fieldname": "section_document", "label": "Agreement Document", "fieldtype": "Section Break"}, + {"fieldname": "agreement_html", "label": "Agreement Content", "fieldtype": "Text Editor"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "Sales User", "read": 1, "write": 1, "create": 1}, + ] + }) + doc.insert(ignore_permissions=True) + print(" Service Agreement DocType created.") + + +def create_payment_log(): + """DocType: Payment Log - audit trail for Stripe transactions.""" + print("[2/4] Creating Payment Log DocType...") + + if frappe.db.exists("DocType", "Payment Log"): + print(" Already exists, skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Payment Log", + "module": "Didi Custom", + "custom": 0, + "is_submittable": 0, + "autoname": "DIDI-PL-.YYYY.-.#####", + "title_field": "customer_name", + "search_fields": "customer,stripe_payment_intent_id,status", + "fields": [ + {"fieldname": "customer", "label": "Customer", "fieldtype": "Link", "options": "Customer", "in_list_view": 1}, + {"fieldname": "customer_name", "label": "Customer Name", "fieldtype": "Data", "fetch_from": "customer.customer_name", "read_only": 1}, + {"fieldname": "column_break_1", "fieldtype": "Column Break"}, + {"fieldname": "event_type", "label": "Event Type", "fieldtype": "Select", + "options": "payment_intent.succeeded\ninvoice.payment_failed\ncustomer.subscription.updated\ncustomer.subscription.deleted\nrefund.created\nother", + "in_list_view": 1}, + {"fieldname": "status", "label": "Status", "fieldtype": "Select", + "options": "Succeeded\nFailed\nRefunded\nPending", + "default": "Pending", "in_list_view": 1, "reqd": 1}, + {"fieldname": "section_stripe", "label": "Stripe Details", "fieldtype": "Section Break"}, + {"fieldname": "stripe_payment_intent_id", "label": "Stripe Payment Intent ID", "fieldtype": "Data"}, + {"fieldname": "stripe_subscription_id", "label": "Stripe Subscription ID", "fieldtype": "Data"}, + {"fieldname": "column_break_2", "fieldtype": "Column Break"}, + {"fieldname": "amount", "label": "Amount", "fieldtype": "Currency", "in_list_view": 1}, + {"fieldname": "currency", "label": "Currency", "fieldtype": "Link", "options": "Currency", "default": "RON"}, + {"fieldname": "section_raw", "label": "Raw Data", "fieldtype": "Section Break", "collapsible": 1}, + {"fieldname": "raw_webhook_data", "label": "Raw Webhook Data", "fieldtype": "Code", "options": "JSON"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "Accounts User", "read": 1}, + ] + }) + doc.insert(ignore_permissions=True) + print(" Payment Log DocType created.") + + +def create_website_content(): + """DocType: Website Content - CMS for website pages managed from ERPNext.""" + print("[3/4] Creating Website Content DocType...") + + if frappe.db.exists("DocType", "Website Content"): + print(" Already exists, skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Website Content", + "module": "Didi Custom", + "custom": 0, + "is_submittable": 0, + "autoname": "format:{page_slug}-{section_key}", + "title_field": "section_key", + "search_fields": "page_slug,section_key", + "sort_field": "display_order", + "sort_order": "ASC", + "fields": [ + {"fieldname": "page_slug", "label": "Page", "fieldtype": "Select", + "options": "homepage\npricing\ncontact\nabout\nprivacy\nterms", + "reqd": 1, "in_list_view": 1}, + {"fieldname": "section_key", "label": "Section Key", "fieldtype": "Data", + "reqd": 1, "in_list_view": 1, + "description": "Unique key for this section (e.g. hero_title, feature_1, cta_text)"}, + {"fieldname": "display_order", "label": "Display Order", "fieldtype": "Int", + "default": 0, "in_list_view": 1}, + {"fieldname": "is_active", "label": "Active", "fieldtype": "Check", + "default": 1, "in_list_view": 1}, + {"fieldname": "section_content_ro", "label": "Content (Romanian)", "fieldtype": "Section Break"}, + {"fieldname": "content_ro", "label": "Content RO", "fieldtype": "Text Editor"}, + {"fieldname": "section_content_en", "label": "Content (English)", "fieldtype": "Section Break"}, + {"fieldname": "content_en", "label": "Content EN", "fieldtype": "Text Editor"}, + {"fieldname": "section_media", "label": "Media", "fieldtype": "Section Break"}, + {"fieldname": "image", "label": "Image", "fieldtype": "Attach Image"}, + {"fieldname": "column_break_media", "fieldtype": "Column Break"}, + {"fieldname": "extra_data", "label": "Extra Data (JSON)", "fieldtype": "Code", "options": "JSON", + "description": "Additional structured data (e.g. button links, feature lists)"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "Website Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + ] + }) + doc.insert(ignore_permissions=True) + + # Populate default content + _populate_default_content() + print(" Website Content DocType created with default content.") + + +def _populate_default_content(): + """Insert default website content for all pages.""" + defaults = [ + # Homepage + {"page_slug": "homepage", "section_key": "hero_title", "display_order": 1, + "content_ro": "

Combate dezinformarea cu inteligenta artificiala

", + "content_en": "

Fight disinformation with artificial intelligence

"}, + {"page_slug": "homepage", "section_key": "hero_subtitle", "display_order": 2, + "content_ro": "

DiDi analizeaza automat continut media - text, imagini, audio si video - pentru a detecta dezinformarea si a verifica informatiile in timp real.

", + "content_en": "

DiDi automatically analyzes media content - text, images, audio and video - to detect disinformation and verify information in real time.

"}, + {"page_slug": "homepage", "section_key": "hero_cta", "display_order": 3, + "content_ro": "Incearca gratuit", + "content_en": "Try for free", + "extra_data": '{"link": "/pricing", "secondary_text_ro": "Vezi planuri", "secondary_text_en": "View plans", "secondary_link": "/pricing"}'}, + {"page_slug": "homepage", "section_key": "features", "display_order": 10, + "content_ro": "

Functionalitati

", + "content_en": "

Features

", + "extra_data": json.dumps([ + {"icon": "search", "title_ro": "Analiza Text", "title_en": "Text Analysis", "desc_ro": "Detectie automata a dezinformarii din articole si postari", "desc_en": "Automatic disinformation detection from articles and posts"}, + {"icon": "image", "title_ro": "Analiza Imagine/Video", "title_en": "Image/Video Analysis", "desc_ro": "Detectie deepfake si manipulare media", "desc_en": "Deepfake and media manipulation detection"}, + {"icon": "check", "title_ro": "Fact-Checking", "title_en": "Fact-Checking", "desc_ro": "Verificare automata a afirmatiilor cu surse credibile", "desc_en": "Automatic claim verification with credible sources"}, + {"icon": "users", "title_ro": "Human-in-the-Loop", "title_en": "Human-in-the-Loop", "desc_ro": "Colaborare intre AI si experti validatori", "desc_en": "Collaboration between AI and expert validators"}, + ])}, + {"page_slug": "homepage", "section_key": "how_it_works", "display_order": 20, + "content_ro": "

Cum functioneaza

", + "content_en": "

How it works

", + "extra_data": json.dumps([ + {"step": 1, "title_ro": "Incarca continutul", "title_en": "Upload content", "desc_ro": "Text, imagine, audio sau video", "desc_en": "Text, image, audio or video"}, + {"step": 2, "title_ro": "Analiza AI automata", "title_en": "Automatic AI analysis", "desc_ro": "Multiple modele AI analizeaza continutul", "desc_en": "Multiple AI models analyze the content"}, + {"step": 3, "title_ro": "Rezultate verificate", "title_en": "Verified results", "desc_ro": "Raport detaliat cu scor de incredere", "desc_en": "Detailed report with confidence score"}, + ])}, + # Contact + {"page_slug": "contact", "section_key": "title", "display_order": 1, + "content_ro": "

Contacteaza-ne

Ai intrebari? Echipa noastra iti sta la dispozitie.

", + "content_en": "

Contact us

Have questions? Our team is here to help.

"}, + {"page_slug": "contact", "section_key": "info", "display_order": 2, + "content_ro": "", "content_en": "", + "extra_data": json.dumps({"email": "office@clossers.com", "phone": "+40721063078", "address": "Str. Targovistei 15, Ploiesti, Romania"})}, + # Privacy + {"page_slug": "privacy", "section_key": "content", "display_order": 1, + "content_ro": "

Politica de Confidentialitate

Ultima actualizare: 2026-03-30

TOP CLOSSERS SRL respecta confidentialitatea datelor dumneavoastra personale...

", + "content_en": "

Privacy Policy

Last updated: 2026-03-30

TOP CLOSSERS SRL respects the privacy of your personal data...

"}, + # Terms + {"page_slug": "terms", "section_key": "content", "display_order": 1, + "content_ro": "

Termeni si Conditii

Ultima actualizare: 2026-03-30

Prin utilizarea serviciilor DiDi, acceptati urmatorii termeni...

", + "content_en": "

Terms and Conditions

Last updated: 2026-03-30

By using DiDi services, you accept the following terms...

"}, + ] + + for item in defaults: + name = f"{item['page_slug']}-{item['section_key']}" + if not frappe.db.exists("Website Content", name): + doc = frappe.get_doc({ + "doctype": "Website Content", + "page_slug": item["page_slug"], + "section_key": item["section_key"], + "display_order": item.get("display_order", 0), + "is_active": 1, + "content_ro": item.get("content_ro", ""), + "content_en": item.get("content_en", ""), + "extra_data": item.get("extra_data", ""), + }) + doc.insert(ignore_permissions=True) + + +def add_custom_fields(): + """Add custom fields to existing DocTypes (Lead, Customer).""" + print("[4/4] Adding custom fields to Lead and Customer...") + + custom_fields = { + "Lead": [ + {"fieldname": "source_form", "label": "Source Form", "fieldtype": "Data", + "insert_after": "source", "description": "Which website form generated this lead"}, + {"fieldname": "page_origin", "label": "Page Origin", "fieldtype": "Data", + "insert_after": "source_form", "description": "URL of the page where lead was captured"}, + {"fieldname": "utm_source", "label": "UTM Source", "fieldtype": "Data", + "insert_after": "page_origin"}, + {"fieldname": "utm_medium", "label": "UTM Medium", "fieldtype": "Data", + "insert_after": "utm_source"}, + {"fieldname": "utm_campaign", "label": "UTM Campaign", "fieldtype": "Data", + "insert_after": "utm_medium"}, + ], + "Customer": [ + {"fieldname": "didi_user_id", "label": "DiDi User ID", "fieldtype": "Data", + "insert_after": "customer_name", "unique": 1, + "description": "User ID in DiDi IAM (Keycloak)"}, + {"fieldname": "iam_role", "label": "IAM Role", "fieldtype": "Select", + "options": "\nfree_tier\npaid_tier\nenterprise_tier", + "insert_after": "didi_user_id", "description": "Current role in DiDi IAM"}, + {"fieldname": "active_plan", "label": "Active Plan", "fieldtype": "Link", + "options": "Subscription Plan", "insert_after": "iam_role"}, + {"fieldname": "plan_activation_date", "label": "Plan Activation Date", + "fieldtype": "Date", "insert_after": "active_plan"}, + ], + } + + for dt, fields in custom_fields.items(): + for field_data in fields: + field_name = f"{dt}-{field_data['fieldname']}" + if not frappe.db.exists("Custom Field", field_name): + cf = frappe.get_doc({ + "doctype": "Custom Field", + "dt": dt, + **field_data + }) + cf.insert(ignore_permissions=True) + print(f" Added {dt}.{field_data['fieldname']}") + + print(" Custom fields added.") diff --git a/erp_crm/didi_custom/didi_custom/setup_email.py b/erp_crm/didi_custom/didi_custom/setup_email.py new file mode 100644 index 0000000..aee9843 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/setup_email.py @@ -0,0 +1,46 @@ +""" +Outgoing email pentru DiDi — cont SMTP implicit (Mailpit in test, SendGrid in +productie: schimbi smtp_server/port/credentiale, restul ramane). + +Rulare: + docker exec didi-erpnext bench --site didi-erp execute didi_custom.setup_email.execute +Test trimitere: + docker exec didi-erpnext bench --site didi-erp execute didi_custom.setup_email.send_test +""" +import frappe + +ACCOUNT_NAME = "DiDi Outgoing" + + +def execute(): + if frappe.db.exists("Email Account", ACCOUNT_NAME): + acc = frappe.get_doc("Email Account", ACCOUNT_NAME) + else: + acc = frappe.new_doc("Email Account") + acc.email_account_name = ACCOUNT_NAME + acc.update({ + "email_id": "noreply@didi.clossers.com", + "enable_outgoing": 1, + "default_outgoing": 1, + "smtp_server": frappe.conf.get("didi_smtp_server") or "didi-mailpit", + "smtp_port": int(frappe.conf.get("didi_smtp_port") or 1025), + "use_tls": 0, + "use_ssl": 0, + "no_smtp_authentication": 1, + "enable_incoming": 0, + }) + acc.flags.ignore_permissions = True + acc.save() + frappe.db.commit() + print(f"OK: Email Account '{acc.name}' -> {acc.smtp_server}:{acc.smtp_port} (default outgoing)") + + +def send_test(): + frappe.sendmail( + recipients=["test@didi.local"], + subject="Test SMTP DiDi", + message="

Email de proba prin Mailpit.

", + delayed=False, + ) + frappe.db.commit() + print("OK: email de test trimis") diff --git a/erp_crm/didi_custom/didi_custom/setup_phase2_final.py b/erp_crm/didi_custom/didi_custom/setup_phase2_final.py new file mode 100644 index 0000000..f2014e0 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/setup_phase2_final.py @@ -0,0 +1,288 @@ +""" +ERPNext Phase 2 Final Setup: Buying, API User, Reports +""" +import frappe +import json + + +def execute(): + setup_buying() + setup_api_user() + setup_print_format() + frappe.db.commit() + print("\n=== Phase 2 Final Setup Complete ===\n") + + +def setup_buying(): + """2.6 - Configure suppliers and expense categories.""" + print("[1/3] Configuring suppliers and expenses...") + + company_abbr = "TC" + + # Create expense accounts under existing parent + expenses_parent = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "root_type": "Expense", "is_group": 1, "parent_account": ["like", "%Expenses%"]}, + "name" + ) + if not expenses_parent: + expenses_parent = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "root_type": "Expense", "is_group": 1}, + "name" + ) + + if expenses_parent: + expense_accounts = [ + {"account_name": "Hosting si Infrastructura", "account_type": "Expense Account"}, + {"account_name": "Servicii Software", "account_type": "Expense Account"}, + {"account_name": "Marketing si Publicitate", "account_type": "Expense Account"}, + {"account_name": "Servicii Plati (Stripe)", "account_type": "Expense Account"}, + ] + for acc in expense_accounts: + full_name = f"{acc['account_name']} - {company_abbr}" + if not frappe.db.exists("Account", full_name): + try: + a = frappe.get_doc({ + "doctype": "Account", + "account_name": acc["account_name"], + "parent_account": expenses_parent, + "account_type": acc["account_type"], + "company": "TOP CLOSSERS SRL", + }) + a.insert(ignore_permissions=True) + print(f" Created account: {acc['account_name']}") + except Exception as e: + print(f" Account {acc['account_name']}: {e}") + + # Create suppliers + suppliers = [ + {"supplier_name": "Hetzner Online GmbH", "supplier_group": "Services", "country": "Germany", + "supplier_type": "Company"}, + {"supplier_name": "Stripe Payments Europe", "supplier_group": "Services", "country": "Ireland", + "supplier_type": "Company"}, + {"supplier_name": "Twilio SendGrid", "supplier_group": "Services", "country": "United States", + "supplier_type": "Company"}, + ] + + # Ensure supplier group exists + if not frappe.db.exists("Supplier Group", "Services"): + sg = frappe.get_doc({"doctype": "Supplier Group", "supplier_group_name": "Services"}) + sg.insert(ignore_permissions=True) + + for s in suppliers: + if not frappe.db.exists("Supplier", s["supplier_name"]): + doc = frappe.get_doc({"doctype": "Supplier", **s}) + doc.insert(ignore_permissions=True) + print(f" Created supplier: {s['supplier_name']}") + + print(" Buying configured.") + + +def setup_api_user(): + """2.9 - Create dedicated API user for website integration.""" + print("[2/3] Creating API user and role...") + + # Create custom role + if not frappe.db.exists("Role", "Website Integration"): + role = frappe.get_doc({ + "doctype": "Role", + "role_name": "Website Integration", + "desk_access": 0, + "is_custom": 1 + }) + role.insert(ignore_permissions=True) + print(" Created role: Website Integration") + + # Set permissions for the role + doctypes_read_write = [ + "Customer", "Lead", "Sales Invoice", "Payment Entry", "Subscription", + "Subscription Plan", "Website Content", "Service Agreement", + "Analysis Report", + "Payment Log", "Item", "Address" + ] + doctypes_read_only = [ + "Company", "Account", "Sales Taxes and Charges Template", + "Email Template", "Sales Stage" + ] + + for dt in doctypes_read_write: + try: + # Check if DocType exists + if not frappe.db.exists("DocType", dt): + continue + + existing_name = frappe.db.exists("Custom DocPerm", {"parent": dt, "role": "Website Integration"}) + values = { + "read": 1, + "write": 1, + "create": 1, + "delete": 0, + "email": 0, + "print": 1, + "export": 1, + "submit": 1 if dt in ("Sales Invoice", "Payment Entry") else 0, + } + + if existing_name: + frappe.db.set_value("Custom DocPerm", existing_name, values, update_modified=False) + else: + perm = frappe.get_doc({ + "doctype": "Custom DocPerm", + "parent": dt, + "parenttype": "DocType", + "parentfield": "permissions", + "role": "Website Integration", + **values, + }) + perm.insert(ignore_permissions=True) + except Exception: + pass + + for dt in doctypes_read_only: + if not frappe.db.exists("Custom DocPerm", {"parent": dt, "role": "Website Integration"}): + try: + if frappe.db.exists("DocType", dt): + perm = frappe.get_doc({ + "doctype": "Custom DocPerm", + "parent": dt, + "parenttype": "DocType", + "parentfield": "permissions", + "role": "Website Integration", + "read": 1, + "write": 0, + "create": 0, + }) + perm.insert(ignore_permissions=True) + except Exception: + pass + + # Create API user + api_user_email = "website_api@didi-erp" + if not frappe.db.exists("User", api_user_email): + user = frappe.get_doc({ + "doctype": "User", + "email": api_user_email, + "first_name": "Website", + "last_name": "API", + "enabled": 1, + "user_type": "System User", + "roles": [ + {"role": "Website Integration"}, + ], + "new_password": "didi_api_secure_pwd_2026!", + "send_welcome_email": 0, + }) + user.insert(ignore_permissions=True) + print(f" Created API user: {api_user_email}") + + # Generate API keys + api_secret = frappe.generate_hash(length=15) + user.reload() + user.api_key = frappe.generate_hash(length=15) + user.api_secret = api_secret + user.save(ignore_permissions=True) + + print(f" API Key: {user.api_key}") + print(f" API Secret: {api_secret}") + print(f" >>> Save these credentials in website/.env.local <<<") + else: + user = frappe.get_doc("User", api_user_email) + print(f" API user already exists: {api_user_email}") + print(f" API Key: {user.api_key}") + + print(" API user configured.") + + +def setup_print_format(): + """2.3.2 - Create custom invoice PDF print format.""" + print("[3/3] Creating custom invoice print format...") + + if frappe.db.exists("Print Format", "DiDi Invoice"): + print(" Already exists, skipping.") + return + + html = """ + +
+
+
+
FACTURA
+
{{ doc.name }}
+
Data: {{ doc.posting_date }}
+
Scadenta: {{ doc.due_date }}
+
+
+
{{ doc.company }}
+
CUI: {{ frappe.db.get_value("Company", doc.company, "tax_id") }}
+
{{ frappe.db.get_value("Company", doc.company, "address") or "Str. Targovistei 15, Ploiesti" }}
+
+
+ +
+ Catre:
+ {{ doc.customer_name }}
+ {% if doc.tax_id %}CUI: {{ doc.tax_id }}
{% endif %} + {{ doc.address_display or "" }} +
+ + + + + + + + + + + + + {% for item in doc.items %} + + + + + + + + {% endfor %} + +
Nr.DescriereCant.Pret unitarTotal
{{ loop.index }}{{ item.item_name }}
{{ item.description or "" }}
{{ item.qty }}{{ frappe.format(item.rate, {"fieldtype": "Currency", "currency": doc.currency}) }}{{ frappe.format(item.amount, {"fieldtype": "Currency", "currency": doc.currency}) }}
+ + + + {% for tax in doc.taxes %} + + {% endfor %} + +
Subtotal:{{ frappe.format(doc.net_total, {"fieldtype": "Currency", "currency": doc.currency}) }}
{{ tax.description }}:{{ frappe.format(tax.tax_amount, {"fieldtype": "Currency", "currency": doc.currency}) }}
TOTAL:{{ frappe.format(doc.grand_total, {"fieldtype": "Currency", "currency": doc.currency}) }}
+ + +
+""" + + pf = frappe.get_doc({ + "doctype": "Print Format", + "name": "DiDi Invoice", + "doc_type": "Sales Invoice", + "module": "Didi Custom", + "html": html, + "print_format_type": "Jinja", + "standard": "No", + "custom_format": 1, + "default_print_language": "ro", + }) + pf.insert(ignore_permissions=True) + print(" DiDi Invoice print format created.") diff --git a/erp_crm/didi_custom/didi_custom/setup_ro_coa.py b/erp_crm/didi_custom/didi_custom/setup_ro_coa.py new file mode 100644 index 0000000..28c0c28 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/setup_ro_coa.py @@ -0,0 +1,109 @@ +"""Construieste planul de conturi RO minimal + conturi TVA/venituri + default-uri companie ++ template TVA 21%, pentru ca facturarea din website sa functioneze. +Rulare: bench --site didi-erp console -> import didi_custom.setup_ro_coa as c; c.execute() +""" +import json +import frappe + +COMPANY = "TOP CLOSSERS SRL" + + +def execute(): + company = COMPANY + if frappe.db.sql("SELECT count(*) FROM tabAccount WHERE company=%s", (company,))[0][0] == 0: + from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import create_charts + with open('/home/frappe/frappe-bench/apps/erpnext/erpnext/accounts/doctype/account/chart_of_accounts/unverified/ro_ro_chart_template.json') as f: + chart_data = json.load(f) + tree = chart_data["tree"] + fin = tree.get("CONTURI FINANCIARE", {}) + bilant = fin.get("CONTURI DE BILANT", {}) + venituri = fin.get("CONTURI DE VENITURI SI CHELTUIELI", {}) + terti = bilant.get("CONTURI DE TERTI", {}) + new_tree = { + "ACTIVE": { + "root_type": "Asset", "is_group": 1, + "Imobilizari": {**bilant.get("CONTURI DE IMOBILIZARI", {}), "root_type": "Asset", "is_group": 1}, + "Stocuri": {**bilant.get("CONTURI DE STOCURI SI PRODUCTIE IN CURS DE EXECUTIE", {}), "root_type": "Asset", "is_group": 1}, + "Trezorerie": {**bilant.get("CONTURI DE TREZORERIE", {}), "root_type": "Asset", "is_group": 1}, + "Clienti si conturi asimilate": {"root_type": "Asset", "is_group": 1, **terti.get("CLIENTI SI CONTURI ASIMILATE", {})}, + "Debitori diversi": {"root_type": "Asset", "is_group": 1, **terti.get("DEBITORI SI CREDITORI DIVERSI", {})}, + }, + "PASIVE (DATORII)": { + "root_type": "Liability", "is_group": 1, + "Furnizori si conturi asimilate": {"root_type": "Liability", "is_group": 1, **terti.get("FURNIZORI SI CONTURI ASIMILATE", {})}, + "Personal si conturi asimilate": {"root_type": "Liability", "is_group": 1, **terti.get("PERSONAL SI CONTURI ASIMILATE", {})}, + "Asigurari sociale, protectia sociala": {"root_type": "Liability", "is_group": 1, **terti.get("ASIGURARI SOCIALE, PROTECTIA SOCIALA SI CONTURI ASIMILATE", {})}, + "Bugetul statului (TVA, impozite)": {"root_type": "Liability", "is_group": 1, **terti.get("BUGETUL STATULUI, FONDURI SPECIALE SI CONTURI ASIMILATE", {})}, + }, + "CAPITALURI PROPRII": {"root_type": "Equity", "is_group": 1, **bilant.get("CONTURI DE CAPITALURI", {})}, + "VENITURI": {"root_type": "Income", "is_group": 1, **venituri.get("CONTURI DE VENITURI", {})}, + "CHELTUIELI": {"root_type": "Expense", "is_group": 1, **venituri.get("CONTURI DE CHELTUIELI", {})}, + } + create_charts(company, custom_chart=new_tree) + frappe.db.commit() + + def find_parent(like): + r = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND name LIKE %s AND is_group=1 LIMIT 1", (company, like)) + return r[0][0] if r else None + + bugetul = find_parent("%Bugetul statului%TC") + venituri_root = find_parent("VENITURI - TC") + accounts = [ + ("4426 - TVA deductibila", bugetul, "Asset", "Tax", "4426"), + ("4427 - TVA colectata", bugetul, "Liability", "Tax", "4427"), + ("4423 - TVA de plata", bugetul, "Liability", "Tax", "4423"), + ("4424 - TVA de recuperat", bugetul, "Asset", "Tax", "4424"), + ("704 - Venituri din servicii prestate", venituri_root, "Income", "Income Account", "704"), + ("707 - Venituri din vanzarea marfurilor", venituri_root, "Income", "Income Account", "707"), + ] + for name, parent, rt, at, num in accounts: + if not parent: + continue + if frappe.db.exists("Account", {"company": company, "account_number": num}): + continue + try: + frappe.get_doc({"doctype": "Account", "company": company, "is_group": 0, + "account_name": name, "parent_account": parent, + "root_type": rt, "account_type": at, "account_number": num}).insert(ignore_permissions=True) + except Exception as e: + print(" skip", name, str(e)[:80]) + frappe.db.commit() + + def find_acc(like): + r = frappe.db.sql("SELECT name FROM tabAccount WHERE company=%s AND name LIKE %s AND is_group=0 LIMIT 1", (company, like)) + return r[0][0] if r else None + + co = frappe.get_doc("Company", company) + co.default_receivable_account = find_acc("%Clienti%TC") + co.default_income_account = find_acc("%704%TC") + co.default_cash_account = find_acc("%Casa in lei%TC") or find_acc("%Casa%TC") + co.default_bank_account = find_acc("%Conturi la banci in lei%TC") or find_acc("%banci%TC") + if not frappe.db.exists("Account", {"company": company, "account_name": "Round Off"}): + chelt_parent = find_parent("CHELTUIELI - TC") + if chelt_parent: + frappe.get_doc({"doctype": "Account", "company": company, "is_group": 0, + "account_name": "Round Off", "parent_account": chelt_parent, + "root_type": "Expense", "account_type": "Round Off"}).insert(ignore_permissions=True) + co.round_off_account = find_acc("Round Off%TC") + cc = frappe.db.sql("SELECT name FROM `tabCost Center` WHERE company=%s AND is_group=0 LIMIT 1", (company,)) + if cc: + co.cost_center = cc[0][0] + co.round_off_cost_center = cc[0][0] + co.save(ignore_permissions=True) + frappe.db.commit() + + for t in frappe.get_all("Sales Taxes and Charges Template", filters={"company": company}): + frappe.delete_doc("Sales Taxes and Charges Template", t.name, ignore_permissions=True, force=True) + tva_acc = frappe.db.get_value("Account", {"company": company, "account_number": "4427"}, "name") + if tva_acc: + frappe.get_doc({"doctype": "Sales Taxes and Charges Template", "title": "TVA 21%", + "company": company, "is_default": 1, + "taxes": [{"charge_type": "On Net Total", "account_head": tva_acc, "description": "TVA 21%", "rate": 21}]}).insert(ignore_permissions=True) + frappe.db.set_single_value("Website Settings", "host_name", "http://didi-erpnext:8080") + frappe.db.commit() + + print("RESULT_ACCOUNTS", frappe.db.count("Account", {"company": company})) + print("RESULT_TVA_ACC", tva_acc) + print("RESULT_RECEIVABLE", co.default_receivable_account) + print("RESULT_INCOME", co.default_income_account) + print("RESULT_COSTCENTER", co.cost_center) diff --git a/erp_crm/didi_custom/didi_custom/setup_shortcuts.py b/erp_crm/didi_custom/didi_custom/setup_shortcuts.py new file mode 100644 index 0000000..d69bae8 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/setup_shortcuts.py @@ -0,0 +1,56 @@ +""" +Scurtaturi 1-click in workspace-ul DiDi: rapoartele si listele folosite zilnic, +ca sa nu mai fie nevoie de cautare sau URL-uri manuale. + +Rulare: + docker exec didi-erpnext bench --site didi-erp execute didi_custom.setup_shortcuts.execute +""" +import frappe + +SHORTCUTS = [ + # (label, type, link_to) + ("Facturi de vânzare", "DocType", "Sales Invoice"), + ("Încasări", "DocType", "Payment Entry"), + ("Clienți", "DocType", "Customer"), + ("Lead-uri (CRM)", "DocType", "Lead"), + ("Acorduri de servicii", "DocType", "Service Agreement"), + ("Jurnal plăți Stripe", "DocType", "Payment Log"), + ("Conținut website (CMS)", "DocType", "Website Content"), + ("Email SMTP (DiDi Outgoing)", "DocType", "Email Account"), + ("Sales Register (centralizator vânzări)", "Report", "Sales Register"), + ("General Ledger (jurnal contabil)", "Report", "General Ledger"), + ("Trial Balance (balanța)", "Report", "Trial Balance"), + ("Accounts Receivable (creanțe)", "Report", "Accounts Receivable"), +] + + +def execute(): + name = "DiDi Platform" if frappe.db.exists("Workspace", "DiDi Platform") else "DiDi" + if not frappe.db.exists("Workspace", name): + print("Nu exista workspace DiDi — abandonez") + return + w = frappe.get_doc("Workspace", name) + existing = {s.link_to for s in w.shortcuts} + added = 0 + for label, typ, link in SHORTCUTS: + if link in existing: + continue + row = {"label": label, "type": typ, "link_to": link} + if typ == "Report": + row["report_ref_doctype"] = frappe.db.get_value("Report", link, "ref_doctype") + w.append("shortcuts", row) + added += 1 + # workspace-ul afiseaza doar blocurile din campul content — adaugam si acolo + import json, random, string + content = json.loads(w.content or "[]") + shown = {b.get("data", {}).get("shortcut_name") for b in content if b.get("type") == "shortcut"} + rid = lambda: "".join(random.choices(string.ascii_letters + string.digits, k=10)) + for s_row in w.shortcuts: + if s_row.label not in shown: + content.append({"id": rid(), "type": "shortcut", "data": {"shortcut_name": s_row.label, "col": 4}}) + w.content = json.dumps(content) + w.flags.ignore_permissions = True + w.save() + frappe.db.commit() + frappe.clear_cache() + print(f"OK: workspace '{name}' — {added} scurtaturi adaugate (total {len(w.shortcuts)})") diff --git a/erp_crm/didi_custom/didi_custom/setup_website_integration.py b/erp_crm/didi_custom/didi_custom/setup_website_integration.py new file mode 100644 index 0000000..7e2b82e --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/setup_website_integration.py @@ -0,0 +1,106 @@ +"""One-shot setup pentru integrarea website: item groups/UOM, itemele DIDI, user API. +Rulare: bench --site didi-erp execute didi_custom.setup_website_integration.execute +""" +import frappe +from didi_custom.make_didi_items import ITEMS + + +def _ensure_prereqs(): + # UOM Nos + if not frappe.db.exists("UOM", "Nos"): + frappe.get_doc({"doctype": "UOM", "uom_name": "Nos"}).insert(ignore_permissions=True) + # Item Group "Services" sub root "All Item Groups" + if not frappe.db.exists("Item Group", "Services"): + parent = frappe.db.get_value("Item Group", {"is_group": 1, "parent_item_group": ""}, "name") \ + or frappe.db.get_value("Item Group", {"name": "All Item Groups"}, "name") + frappe.get_doc({ + "doctype": "Item Group", "item_group_name": "Services", + "parent_item_group": parent, "is_group": 0, + }).insert(ignore_permissions=True) + + +def _ensure_items(): + for code, name, price in ITEMS: + if frappe.db.exists("Item", code): + continue + frappe.get_doc({ + "doctype": "Item", "item_code": code, "item_name": name, + "item_group": "Services", "stock_uom": "Nos", + "is_stock_item": 0, "is_sales_item": 1, "include_item_in_manufacturing": 0, + "standard_rate": price, + }).insert(ignore_permissions=True) + + +def _ensure_role(): + if not frappe.db.exists("Role", "Website Integration"): + frappe.get_doc({ + "doctype": "Role", "role_name": "Website Integration", + "desk_access": 0, "is_custom": 1, + }).insert(ignore_permissions=True) + rw = ["Customer", "Lead", "Sales Invoice", "Payment Entry", "Subscription", + "Subscription Plan", "Website Content", "Service Agreement", + "Analysis Report", "Payment Log", "Item", "Address"] + for dt in rw: + if not frappe.db.exists("DocType", dt): + continue + vals = {"read": 1, "write": 1, "create": 1, "delete": 0, "print": 1, + "export": 1, "submit": 1 if dt in ("Sales Invoice", "Payment Entry") else 0} + existing = frappe.db.exists("Custom DocPerm", {"parent": dt, "role": "Website Integration"}) + if existing: + frappe.db.set_value("Custom DocPerm", existing, vals, update_modified=False) + else: + frappe.get_doc({"doctype": "Custom DocPerm", "parent": dt, "parenttype": "DocType", + "parentfield": "permissions", "role": "Website Integration", **vals}).insert(ignore_permissions=True) + frappe.db.commit() + + +def _ensure_api_user(): + email = "website_api@didi-erp.local" + if not frappe.db.exists("User", email): + u = frappe.get_doc({ + "doctype": "User", "email": email, "first_name": "Website", "last_name": "API", + "enabled": 1, "user_type": "System User", + "roles": [{"role": "Website Integration"}], + "new_password": "didi_api_secure_pwd_2026!", "send_welcome_email": 0, + }) + u.insert(ignore_permissions=True) + else: + u = frappe.get_doc("User", email) + if not any(r.role == "Website Integration" for r in u.roles): + u.append("roles", {"role": "Website Integration"}) + u.save(ignore_permissions=True) + secret = u.get_password("api_secret", raise_exception=False) + if not u.api_key or not secret: + u.api_key = frappe.generate_hash(length=15) + secret = frappe.generate_hash(length=15) + u.api_secret = secret + u.save(ignore_permissions=True) + return email, u.api_key, secret + + +def execute(): + _ensure_prereqs() + _ensure_items() + _ensure_role() + email, key, secret = _ensure_api_user() + frappe.db.commit() + print("RESULT_ITEMS", frappe.db.count("Item", {"item_code": ["like", "DIDI-%"]})) + print("RESULT_APIUSER", email) + print("RESULT_APIKEY", key) + print("RESULT_APISECRET", secret) + + +def fix_print_format(): + import frappe + pf = frappe.get_doc("Print Format", "DiDi Invoice") + bad = 'frappe.db.get_value("Company", doc.company, "address")' + if bad in (pf.html or ""): + pf.html = pf.html.replace(bad, '"Str. Targovistei 15, Ploiesti, jud. Prahova"') + pf.save(ignore_permissions=True) + frappe.db.commit() + print("PF_PATCHED") + try: + pdf = frappe.get_print("Sales Invoice", "DIDI-INV-2026-00001", print_format="DiDi Invoice", as_pdf=True) + print("PF_PDF_OK", len(pdf)) + except Exception as e: + print("PF_PDF_ERR", repr(e)[:160]) diff --git a/erp_crm/didi_custom/didi_custom/setup_workspaces.py b/erp_crm/didi_custom/didi_custom/setup_workspaces.py new file mode 100644 index 0000000..0efc136 --- /dev/null +++ b/erp_crm/didi_custom/didi_custom/setup_workspaces.py @@ -0,0 +1,54 @@ +""" +Sidebar ERP curat pentru proiectul DiDi: raman vizibile doar workspace-urile +relevante (contabilitate, facturare, CRM, clienti, CMS, DiDi); restul modulelor +standard ERPNext (Manufacturing, Stock, Quality etc.) se ascund (public=0). +Idempotent — safe de rerulat; workspace-urile noi necunoscute NU sunt atinse. + +Rulare: + docker exec didi-erpnext bench --site didi-erp execute didi_custom.setup_workspaces.execute +Revenire (arata tot): + docker exec didi-erpnext bench --site didi-erp execute didi_custom.setup_workspaces.show_all +""" +import frappe + +KEEP = { + "Home", + # hub-uri DiDi custom + "DiDi", "DiDi Platform", + # contabilitate & facturare + "Accounting", "Receivables", "Payables", "Financial Reports", + "Contabilitate", "Facturi", "Plati", + # vanzari & clienti & CRM + "Selling", "CRM", "CRM-DiDi", "Clienti", "Servicii", + # continut website + "Website CMS", +} + +HIDE = { + "Assets", "Build", "Buying", "ERPNext Integrations", "ERPNext Settings", + "Integrations", "Manufacturing", "Projects", "Quality", "Stock", + "Support", "Tools", "Users", "Welcome Workspace", + "Website", # modulul standard ERPNext (portal) — CMS-ul custom ramane + "Website-CMS", # duplicat vechi al "Website CMS" +} + + +def execute(): + for name in HIDE: + if frappe.db.exists("Workspace", name): + frappe.db.set_value("Workspace", name, "public", 0) + print(f" ascuns: {name}") + for name in KEEP: + if frappe.db.exists("Workspace", name): + frappe.db.set_value("Workspace", name, "public", 1) + frappe.db.commit() + frappe.clear_cache() + print("OK: sidebar curatat (raman doar workspace-urile proiectului)") + + +def show_all(): + for w in frappe.get_all("Workspace"): + frappe.db.set_value("Workspace", w.name, "public", 1) + frappe.db.commit() + frappe.clear_cache() + print("OK: toate workspace-urile vizibile din nou") diff --git a/erp_crm/didi_custom/didi_custom/templates/__init__.py b/erp_crm/didi_custom/didi_custom/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/didi_custom/templates/pages/__init__.py b/erp_crm/didi_custom/didi_custom/templates/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erp_crm/didi_custom/license.txt b/erp_crm/didi_custom/license.txt new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/erp_crm/didi_custom/license.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/erp_crm/didi_custom/pyproject.toml b/erp_crm/didi_custom/pyproject.toml new file mode 100644 index 0000000..be93276 --- /dev/null +++ b/erp_crm/didi_custom/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "didi_custom" +authors = [ + { name = "TOP CLOSSERS SRL", email = "office@clossers.com"} +] +description = "Custom DocTypes for DiDi Platform" +requires-python = ">=3.10" +readme = "README.md" +dynamic = ["version"] +dependencies = [ + # "frappe~=15.0.0" # Installed and managed by bench. +] + +[build-system] +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" + +# These dependencies are only installed when developer mode is enabled +[tool.bench.dev-dependencies] +# package_name = "~=1.1.0" + +[tool.ruff] +line-length = 110 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "F", + "E", + "W", + "I", + "UP", + "B", + "RUF", +] +ignore = [ + "B017", # assertRaises(Exception) - should be more specific + "B018", # useless expression, not assigned to anything + "B023", # function doesn't bind loop variable - will have last iteration's value + "B904", # raise inside except without from + "E101", # indentation contains mixed spaces and tabs + "E402", # module level import not at top of file + "E501", # line too long + "E741", # ambiguous variable name + "F401", # "unused" imports + "F403", # can't detect undefined names from * import + "F405", # can't detect undefined names from * import + "F722", # syntax error in forward type annotation + "W191", # indentation contains tabs +] +typing-modules = ["frappe.types.DF"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "tab" +docstring-code-format = true diff --git a/erp_crm/didi_custom/setup_config.py b/erp_crm/didi_custom/setup_config.py new file mode 100644 index 0000000..7184ba0 --- /dev/null +++ b/erp_crm/didi_custom/setup_config.py @@ -0,0 +1,287 @@ +""" +ERPNext Configuration Script for DiDi Project +Run with: docker exec didi-erpnext bench --site didi.localhost execute didi_setup +Or copy to container and run via bench. +""" +import frappe + +def execute(): + """Main setup function - run all configurations.""" + setup_company_details() + setup_tax_template() + setup_invoice_naming() + setup_crm_pipeline() + setup_subscription_items() + setup_email_templates() + frappe.db.commit() + print("\n=== DiDi ERPNext Configuration Complete ===\n") + + +def setup_company_details(): + """Configure company details - CUI, address, etc.""" + print("[1/6] Configuring company details...") + + company = frappe.get_doc("Company", "TOP CLOSSERS SRL") + company.tax_id = "36193026" + company.domain = "Services" + company.default_currency = "RON" + company.country = "Romania" + company.save(ignore_permissions=True) + + # Create company address + if not frappe.db.exists("Address", {"address_title": "TOP CLOSSERS SRL - Sediu"}): + addr = frappe.get_doc({ + "doctype": "Address", + "address_title": "TOP CLOSSERS SRL - Sediu", + "address_type": "Office", + "address_line1": "Str. Targovistei 15", + "address_line2": "Bl. 2 Et. 3 Ap. 22", + "city": "Ploiesti", + "state": "Prahova", + "pincode": "100299", + "country": "Romania", + "phone": "+40721063078", + "email_id": "office@clossers.com", + "is_primary_address": 1, + "links": [{"link_doctype": "Company", "link_name": "TOP CLOSSERS SRL"}] + }) + addr.insert(ignore_permissions=True) + + print(" Company details configured.") + + +def setup_tax_template(): + """Configure VAT 19% tax template for Romania.""" + print("[2/6] Configuring tax templates...") + + # Get or create default income account + company_abbr = "TC" + tax_account = f"TVA Colectata - {company_abbr}" + + # Check if tax account exists, create if not + if not frappe.db.exists("Account", tax_account): + parent_account = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "account_type": "Tax", "is_group": 1}, + "name" + ) + if not parent_account: + parent_account = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "root_type": "Liability", "is_group": 1}, + "name" + ) + + if parent_account: + tax_acc = frappe.get_doc({ + "doctype": "Account", + "account_name": "TVA Colectata", + "parent_account": parent_account, + "account_type": "Tax", + "company": "TOP CLOSSERS SRL", + "tax_rate": 19.0 + }) + tax_acc.insert(ignore_permissions=True) + + # Create Sales Tax Template + if not frappe.db.exists("Sales Taxes and Charges Template", {"title": "TVA 19% Romania"}): + template = frappe.get_doc({ + "doctype": "Sales Taxes and Charges Template", + "title": "TVA 19% Romania", + "company": "TOP CLOSSERS SRL", + "is_default": 1, + "taxes": [{ + "charge_type": "On Net Total", + "account_head": tax_account if frappe.db.exists("Account", tax_account) else "", + "description": "TVA 19%", + "rate": 19.0 + }] + }) + try: + template.insert(ignore_permissions=True) + print(" Tax template TVA 19% created.") + except Exception as e: + print(f" Tax template: {e}") + else: + print(" Tax template already exists.") + + +def setup_invoice_naming(): + """Configure Romanian invoice naming series.""" + print("[3/6] Configuring invoice naming series...") + + # Set naming series for Sales Invoice + if frappe.db.exists("DocType", "Sales Invoice"): + prop_setter_name = "Sales Invoice-naming_series-options" + if not frappe.db.exists("Property Setter", prop_setter_name): + try: + ps = frappe.get_doc({ + "doctype": "Property Setter", + "doctype_or_field": "DocType", + "doc_type": "Sales Invoice", + "field_name": "naming_series", + "property": "options", + "value": "DIDI-INV-.YYYY.-.#####\nACC-SINV-.YYYY.-", + "property_type": "Text" + }) + ps.insert(ignore_permissions=True) + print(" Invoice naming series configured: DIDI-INV-YYYY-#####") + except Exception as e: + print(f" Naming series: {e}") + else: + print(" Naming series already configured.") + + +def setup_crm_pipeline(): + """Configure CRM sales pipeline stages.""" + print("[4/6] Configuring CRM pipeline...") + + # CRM Sales Stages + stages = [ + {"stage_name": "Lead", "department": ""}, + {"stage_name": "Calificat", "department": ""}, + {"stage_name": "Demo", "department": ""}, + {"stage_name": "Client", "department": ""}, + ] + + for stage_data in stages: + if not frappe.db.exists("Sales Stage", stage_data["stage_name"]): + stage = frappe.get_doc({ + "doctype": "Sales Stage", + "stage_name": stage_data["stage_name"] + }) + stage.insert(ignore_permissions=True) + print(f" Created stage: {stage_data['stage_name']}") + + # Lead Source for website forms + sources = ["Website - Contact Form", "Website - Pricing Page", "Website - Demo Request"] + for source_name in sources: + if not frappe.db.exists("Lead Source", source_name): + source = frappe.get_doc({ + "doctype": "Lead Source", + "source_name": source_name + }) + source.insert(ignore_permissions=True) + + print(" CRM pipeline configured: Lead > Calificat > Demo > Client") + + +def setup_subscription_items(): + """Create subscription plan items.""" + print("[5/6] Creating subscription items and plans...") + + # Item Group for services + if not frappe.db.exists("Item Group", "DiDi Services"): + ig = frappe.get_doc({ + "doctype": "Item Group", + "item_group_name": "DiDi Services", + "parent_item_group": "All Item Groups" + }) + ig.insert(ignore_permissions=True) + + # Subscription Items + items = [ + { + "item_code": "DIDI-FREE", + "item_name": "Abonament DiDi Free", + "description": "Plan gratuit - acces de baza la platforma DiDi pentru analiza dezinformarii", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 0 + }, + { + "item_code": "DIDI-PAID", + "item_name": "Abonament DiDi Paid", + "description": "Plan platit - acces complet la platforma DiDi cu credite lunare pentru analize avansate", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 99 + }, + { + "item_code": "DIDI-ENTERPRISE", + "item_name": "Abonament DiDi Enterprise", + "description": "Plan enterprise - acces nelimitat la platforma DiDi, suport dedicat, API avansat", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 499 + }, + ] + + for item_data in items: + if not frappe.db.exists("Item", item_data["item_code"]): + item = frappe.get_doc({"doctype": "Item", **item_data}) + item.insert(ignore_permissions=True) + print(f" Created item: {item_data['item_code']} ({item_data['standard_rate']} RON)") + + # Subscription Plans + plans = [ + {"plan_name": "DiDi Free - Lunar", "item": "DIDI-FREE", "cost": 0, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Paid - Lunar", "item": "DIDI-PAID", "cost": 99, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Paid - Anual", "item": "DIDI-PAID", "cost": 999, "billing_interval": "Year", "billing_interval_count": 1}, + {"plan_name": "DiDi Enterprise - Lunar", "item": "DIDI-ENTERPRISE", "cost": 499, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Enterprise - Anual", "item": "DIDI-ENTERPRISE", "cost": 4990, "billing_interval": "Year", "billing_interval_count": 1}, + ] + + for plan_data in plans: + if not frappe.db.exists("Subscription Plan", plan_data["plan_name"]): + plan = frappe.get_doc({ + "doctype": "Subscription Plan", + "plan_name": plan_data["plan_name"], + "item": plan_data["item"], + "price_determination": "Fixed Rate", + "cost": plan_data["cost"], + "currency": "RON", + "billing_interval": plan_data["billing_interval"], + "billing_interval_count": plan_data["billing_interval_count"] + }) + plan.insert(ignore_permissions=True) + print(f" Created plan: {plan_data['plan_name']} ({plan_data['cost']} RON/{plan_data['billing_interval']})") + + print(" Subscription items and plans configured.") + + +def setup_email_templates(): + """Create email notification templates.""" + print("[6/6] Creating email templates...") + + templates = [ + { + "name": "DiDi - Confirmare Plata", + "subject": "Plata confirmata - {{ doc.name }}", + "response": """

Stimate {{ doc.customer_name }},

+

Va confirmam ca plata pentru factura {{ doc.name }} in valoare de {{ doc.grand_total }} {{ doc.currency }} a fost procesata cu succes.

+

Puteti descarca factura din dashboard-ul contului dumneavoastra.

+

Va multumim,
Echipa DiDi

""" + }, + { + "name": "DiDi - Expirare Abonament", + "subject": "Abonamentul dumneavoastra expira in curand", + "response": """

Stimate client,

+

Va informam ca abonamentul dumneavoastra DiDi expira in 7 zile.

+

Pentru a continua sa beneficiati de serviciile noastre, va rugam sa verificati metoda de plata in dashboard-ul contului.

+

Va multumim,
Echipa DiDi

""" + }, + { + "name": "DiDi - Esec Plata", + "subject": "Problema la procesarea platii", + "response": """

Stimate client,

+

Din pacate, nu am reusit sa procesam plata pentru abonamentul dumneavoastra DiDi.

+

Va rugam sa actualizati metoda de plata in dashboard-ul contului pentru a evita intreruperea serviciului.

+

Va multumim,
Echipa DiDi

""" + }, + ] + + for tmpl in templates: + if not frappe.db.exists("Email Template", tmpl["name"]): + doc = frappe.get_doc({ + "doctype": "Email Template", + "name": tmpl["name"], + "subject": tmpl["subject"], + "response": tmpl["response"], + "use_html": 1 + }) + doc.insert(ignore_permissions=True) + print(f" Created template: {tmpl['name']}") + + print(" Email templates configured.") diff --git a/erp_crm/docker-compose.yml b/erp_crm/docker-compose.yml new file mode 100644 index 0000000..e3dcd42 --- /dev/null +++ b/erp_crm/docker-compose.yml @@ -0,0 +1,169 @@ +services: + # =================== + # MariaDB Database + # =================== + mariadb: + image: mariadb:10.11 + container_name: didi-mariadb + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?seteaza MYSQL_ROOT_PASSWORD in .env} + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + volumes: + - mariadb-data:/var/lib/mysql + - ./mariadb-init:/docker-entrypoint-initdb.d + ports: + - "127.0.0.1:3307:3306" + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --skip-character-set-client-handshake + - --skip-innodb-read-only-compressed + - --innodb-buffer-pool-size=512M + - --innodb-log-file-size=64M + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - didi-network + + # =================== + # Redis Cache + # =================== + redis-cache: + image: redis:7-alpine + container_name: didi-redis-cache + restart: unless-stopped + volumes: + - redis-cache-data:/data + networks: + - didi-network + + # =================== + # Redis Queue + # =================== + redis-queue: + image: redis:7-alpine + container_name: didi-redis-queue + restart: unless-stopped + volumes: + - redis-queue-data:/data + networks: + - didi-network + + # =================== + # ERPNext Backend + # =================== + erpnext: + image: frappe/erpnext:${ERPNEXT_VERSION:-v15} + container_name: didi-erpnext + restart: unless-stopped + depends_on: + mariadb: + condition: service_healthy + redis-cache: + condition: service_started + redis-queue: + condition: service_started + entrypoint: ["/bin/bash", "/home/frappe/entrypoint.sh"] + environment: + DB_HOST: mariadb + DB_PORT: "3306" + REDIS_CACHE: redis://redis-cache:6379 + REDIS_QUEUE: redis://redis-queue:6379 + SOCKETIO_PORT: "9000" + volumes: + - erpnext-sites:/home/frappe/frappe-bench/sites + - erpnext-logs:/home/frappe/frappe-bench/logs + - ./didi_custom:/home/frappe/frappe-bench/apps/didi_custom + - ./nginx.conf:/etc/nginx/conf.d/erpnext.conf:ro + - ./bin/entrypoint.sh:/home/frappe/entrypoint.sh:ro + - ./bin/start-bench-service.sh:/home/frappe/start-bench-service.sh:ro + ports: + - "127.0.0.1:${ERPNEXT_PORT:-8082}:8080" + networks: + - didi-network + + # =================== + # Frappe Queue Workers + # =================== + queue-short: + image: frappe/erpnext:${ERPNEXT_VERSION:-v15} + container_name: didi-queue-short + restart: unless-stopped + depends_on: + - erpnext + entrypoint: ["/bin/bash", "/home/frappe/start-bench-service.sh"] + command: ["bench", "worker", "--queue", "short"] + volumes: + - erpnext-sites:/home/frappe/frappe-bench/sites + - erpnext-logs:/home/frappe/frappe-bench/logs + - ./didi_custom:/home/frappe/frappe-bench/apps/didi_custom + - ./bin/start-bench-service.sh:/home/frappe/start-bench-service.sh:ro + networks: + - didi-network + + queue-long: + image: frappe/erpnext:${ERPNEXT_VERSION:-v15} + container_name: didi-queue-long + restart: unless-stopped + depends_on: + - erpnext + entrypoint: ["/bin/bash", "/home/frappe/start-bench-service.sh"] + command: ["bench", "worker", "--queue", "long"] + volumes: + - erpnext-sites:/home/frappe/frappe-bench/sites + - erpnext-logs:/home/frappe/frappe-bench/logs + - ./didi_custom:/home/frappe/frappe-bench/apps/didi_custom + - ./bin/start-bench-service.sh:/home/frappe/start-bench-service.sh:ro + networks: + - didi-network + + queue-default: + image: frappe/erpnext:${ERPNEXT_VERSION:-v15} + container_name: didi-queue-default + restart: unless-stopped + depends_on: + - erpnext + entrypoint: ["/bin/bash", "/home/frappe/start-bench-service.sh"] + command: ["bench", "worker", "--queue", "default"] + volumes: + - erpnext-sites:/home/frappe/frappe-bench/sites + - erpnext-logs:/home/frappe/frappe-bench/logs + - ./didi_custom:/home/frappe/frappe-bench/apps/didi_custom + - ./bin/start-bench-service.sh:/home/frappe/start-bench-service.sh:ro + networks: + - didi-network + + # =================== + # Frappe Scheduler + # =================== + scheduler: + image: frappe/erpnext:${ERPNEXT_VERSION:-v15} + container_name: didi-scheduler + restart: unless-stopped + depends_on: + - erpnext + entrypoint: ["/bin/bash", "/home/frappe/start-bench-service.sh"] + command: ["bench", "schedule"] + volumes: + - erpnext-sites:/home/frappe/frappe-bench/sites + - erpnext-logs:/home/frappe/frappe-bench/logs + - ./didi_custom:/home/frappe/frappe-bench/apps/didi_custom + - ./bin/start-bench-service.sh:/home/frappe/start-bench-service.sh:ro + networks: + - didi-network + +volumes: + mariadb-data: + redis-cache-data: + redis-queue-data: + erpnext-sites: + erpnext-logs: + +networks: + didi-network: + external: true diff --git a/erp_crm/mariadb-init/init.sql b/erp_crm/mariadb-init/init.sql new file mode 100644 index 0000000..a1a71f8 --- /dev/null +++ b/erp_crm/mariadb-init/init.sql @@ -0,0 +1,4 @@ +-- Create ERPNext database user +CREATE USER IF NOT EXISTS 'erpnext'@'%' IDENTIFIED BY 'erpnext_db_pwd_123'; +GRANT ALL PRIVILEGES ON *.* TO 'erpnext'@'%' WITH GRANT OPTION; +FLUSH PRIVILEGES; diff --git a/erp_crm/nginx.conf b/erp_crm/nginx.conf new file mode 100644 index 0000000..0e0d584 --- /dev/null +++ b/erp_crm/nginx.conf @@ -0,0 +1,35 @@ +upstream frappe-bench { + server 127.0.0.1:8000 fail_timeout=0; +} + +server { + listen 8080; + server_name _; + + client_max_body_size 50m; + + # Static assets (CSS, JS, images) + location /assets { + alias /home/frappe/frappe-bench/sites/assets; + add_header Cache-Control "max-age=3600"; + } + + # Site-specific public files (uploaded files, etc.) + location /files { + alias /home/frappe/frappe-bench/sites/didi-erp/public/files; + add_header Cache-Control "max-age=3600"; + } + + # Everything else -> gunicorn + location / { + proxy_pass http://frappe-bench; + proxy_set_header Host didi-erp; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/erp_crm/scripts/archive/add-invoice-fields.py b/erp_crm/scripts/archive/add-invoice-fields.py new file mode 100644 index 0000000..e85cf86 --- /dev/null +++ b/erp_crm/scripts/archive/add-invoice-fields.py @@ -0,0 +1,76 @@ +""" +Add custom fields to Sales Invoice for analysis tracking. +Run inside ERPNext container: + docker exec -it didi-erpnext bench --site didi.localhost execute add-invoice-fields.add_fields +Or via API (this script uses API approach). +""" +import requests +import sys + +ERPNEXT_URL = "http://localhost:8080" + +# Login as Administrator (has permission for Custom Field) +session = requests.Session() +login_res = session.post(f"{ERPNEXT_URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +if login_res.status_code != 200: + print(f"Login failed: {login_res.status_code}") + sys.exit(1) +print("Logged in as Administrator") + +HEADERS = {"Content-Type": "application/json"} + +FIELDS = [ + { + "dt": "Sales Invoice", + "fieldname": "analysis_consumed", + "fieldtype": "Check", + "label": "Analysis Consumed", + "insert_after": "amended_from", + "default": "0", + "allow_on_submit": 1, + "description": "Checked when the purchased analysis has been used", + }, + { + "dt": "Sales Invoice", + "fieldname": "analysis_session_id", + "fieldtype": "Data", + "label": "Analysis Session ID", + "insert_after": "analysis_consumed", + "read_only": 1, + "allow_on_submit": 1, + "description": "Session ID from DiDi API after analysis was submitted", + }, +] + + +def add_fields(): + for field in FIELDS: + # Check if field already exists + check = session.get( + f"{ERPNEXT_URL}/api/resource/Custom Field", + params={ + "filters": f'[["dt","=","{field["dt"]}"],["fieldname","=","{field["fieldname"]}"]]', + "fields": '["name"]', + }, + headers=HEADERS, + ) + data = check.json() + if data.get("data") and len(data["data"]) > 0: + print(f" Field '{field['fieldname']}' already exists, skipping.") + continue + + res = session.post( + f"{ERPNEXT_URL}/api/resource/Custom Field", + json=field, + headers=HEADERS, + ) + if res.status_code in (200, 201): + print(f" Created field '{field['fieldname']}' on {field['dt']}") + else: + print(f" ERROR creating '{field['fieldname']}': {res.status_code} {res.text}") + + +if __name__ == "__main__": + print("Adding custom fields to Sales Invoice...") + add_fields() + print("Done.") diff --git a/erp_crm/scripts/archive/fix-sidebar.py b/erp_crm/scripts/archive/fix-sidebar.py new file mode 100644 index 0000000..bfdb54e --- /dev/null +++ b/erp_crm/scripts/archive/fix-sidebar.py @@ -0,0 +1,67 @@ +import frappe + +def execute(): + # Hide ALL public workspaces + for ws in frappe.get_all("Workspace", filters={"public": 1}, fields=["name"]): + doc = frappe.get_doc("Workspace", ws.name) + doc.public = 0 + doc.save(ignore_permissions=True) + + # Delete old DiDi workspaces + for ws in frappe.get_all("Workspace", filters={"name": ["like", "%DiDi%"]}, fields=["name"]): + frappe.delete_doc("Workspace", ws.name, force=True) + + # Create sidebar workspaces - each one is a clean page + create_workspace("Facturi", "invoice", 1, "Sales Invoice", [ + {"type": "DocType", "link_to": "Sales Invoice", "label": "Lista Facturi"}, + ]) + + create_workspace("Clienti", "user", 2, "Customer", [ + {"type": "DocType", "link_to": "Customer", "label": "Lista Clienti"}, + ]) + + create_workspace("CRM", "lead", 3, "Lead", [ + {"type": "DocType", "link_to": "Lead", "label": "Lead-uri"}, + {"type": "DocType", "link_to": "Sales Stage", "label": "Etape Pipeline"}, + {"type": "DocType", "link_to": "Lead Source", "label": "Surse Lead"}, + ]) + + create_workspace("Servicii", "box", 4, "Item", [ + {"type": "DocType", "link_to": "Item", "label": "Produse / Servicii"}, + {"type": "DocType", "link_to": "Subscription Plan", "label": "Planuri Abonament"}, + ]) + + create_workspace("Plati", "credit-card", 5, "Payment Log", [ + {"type": "DocType", "link_to": "Payment Log", "label": "Log Plati Stripe"}, + {"type": "DocType", "link_to": "Service Agreement", "label": "Acorduri Servicii"}, + ]) + + create_workspace("Contabilitate", "calculator", 6, "Account", [ + {"type": "DocType", "link_to": "Account", "label": "Plan de Conturi"}, + {"type": "DocType", "link_to": "Sales Taxes and Charges Template", "label": "Template TVA"}, + {"type": "DocType", "link_to": "Supplier", "label": "Furnizori"}, + {"type": "DocType", "link_to": "Purchase Invoice", "label": "Facturi Furnizori"}, + ]) + + create_workspace("Website CMS", "globe", 7, "Website Content", [ + {"type": "DocType", "link_to": "Website Content", "label": "Continut Pagini"}, + {"type": "DocType", "link_to": "Email Template", "label": "Template-uri Email"}, + ]) + + frappe.db.commit() + print("\n=== Sidebar creat: Facturi, Clienti, CRM, Servicii, Plati, Contabilitate, Website CMS ===") + + +def create_workspace(label, icon, seq, module_doctype, shortcuts): + ws = frappe.get_doc({ + "doctype": "Workspace", + "label": label, + "title": label, + "module": "Didi Custom", + "icon": icon, + "public": 1, + "sequence_id": seq, + "shortcuts": shortcuts, + }) + ws.insert(ignore_permissions=True) + print(f" Created: {label}") diff --git a/erp_crm/scripts/archive/fix-translations.py b/erp_crm/scripts/archive/fix-translations.py new file mode 100644 index 0000000..60a4639 --- /dev/null +++ b/erp_crm/scripts/archive/fix-translations.py @@ -0,0 +1,295 @@ +"""Fix ALL remaining untranslated labels in ERPNext.""" +import sys, io, json, requests +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +URL = "http://localhost:8080" +s = requests.Session() +s.post(f"{URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +print("Logged in") + +T = { + # ── Payment Entry ── + "Party Name": "Nume Client", + "Nume partid": "Nume Client", + "Party": "Client", + "Partener": "Client", + "Party Type": "Tip Client", + "Tip de partid": "Tip Client", + "Party Balance": "Sold Client", + "Balanța Party": "Sold Client", + "Paid From": "Platit Din", + "Contul plătit De la": "Platit Din", + "Paid To": "Platit Catre", + "Contul Plătite": "Platit Catre", + "Account Balance (From)": "Sold Cont (Sursa)", + "Account Balance (To)": "Sold Cont (Destinatie)", + "Account Currency (From)": "Moneda Cont (Sursa)", + "Account Currency (To)": "Moneda Cont (Destinatie)", + "Paid Amount": "Suma Platita", + "Paid Amount After Tax (RON)": "Suma Platita dupa Taxe (RON)", + "Received Amount": "Suma Primita", + "Received Amount After Tax (RON)": "Suma Primita dupa Taxe (RON)", + "Suma primită (RON)": "Suma Primita (RON)", + "Sumă Primită (RON)": "Suma Primita (RON)", + "Reference No": "Nr. Referinta", + "Cecul / de referință nr": "Nr. Referinta Plata", + "Reference Date": "Data Referinta", + "Cec/Dată de Referință": "Data Referinta Plata", + "Payment References": "Referinte Plata", + "Referințe de plată": "Referinte Plata", + "Allocated": "Alocat", + "Unallocated Amount": "Suma Nealocata", + "Suma nealocată (RON)": "Suma Nealocata (RON)", + "Total Allocated Amount": "Total Alocat", + "Suma totală alocată (RON)": "Total Alocat (RON)", + "Difference Amount": "Diferenta", + "Diferența Sumă (RON)": "Diferenta (RON)", + "Outstanding": "Restant", + "remarcabil (RON)": "Restant (RON)", + "outstanding (RON)": "Restant (RON)", + "remarkable (RON)": "Restant (RON)", + "Payment Type": "Tip Plata", + "Tipul de plată": "Tip Plata", + "Plata De la / la": "Plata De la / Catre", + "Contact Person": "Persoana de Contact", + "Persoana de Contact": "Persoana de Contact", + "Transaction ID": "ID Tranzactie", + "ID-ul de tranzacție": "ID Tranzactie", + "Cheque/Reference No": "Nr. Referinta Plata", + "Cheque/Reference Date": "Data Referinta Plata", + "Source Exchange Rate": "Curs Valutar Sursa", + "Target Exchange Rate": "Curs Valutar Destinatie", + "Connections": "Legaturi", + "Account Dimensions": "Dimensiuni Contabile", + "Dimensiuni contabile": "Dimensiuni Contabile", + "More Information": "Mai Multe Informatii", + "Mai multe informatii": "Mai Multe Informatii", + "Remarks": "Observatii", + "Remarci": "Observatii", + "In Words (Company Currency)": "In Litere (Moneda Companie)", + "În cuvinte (Compania valutar)": "In Litere (Moneda Companie)", + "In Words": "In Litere", + "În cuvinte": "In Litere", + "In Words (RON)": "In Litere (RON)", + "În cuvinte (RON)": "In Litere (RON)", + "Taxes and Charges": "Taxe si Impozite", + "Impozite și Taxe": "Taxe si Impozite", + "Total Taxes and Charges": "Total Taxe", + "Total Impozite și Taxe (RON)": "Total Taxe (RON)", + "Tax Withholding": "Retinere Taxe", + "Descărcarea de impozite": "Retinere Taxe", + "Deductions or Losses": "Deduceri sau Pierderi", + "Deduceri sau Pierderi": "Deduceri sau Pierderi", + "Naming Series": "Serie Numerotare", + "Serii": "Serie Numerotare", + "Account Holder": "Titular Cont", + "Titularul Contului": "Titular Cont", + "Section Subscriptions": "Sectiunea Abonamente", + "Secțiunea de abonamente": "Sectiunea Abonamente", + "Is Opening": "Este Deschidere", + "Se deschide": "Este Deschidere", + "Deschiderea este de intrare": "Inregistrare de Deschidere", + "Tax Rate": "Cota Impozit", + "Cota de impozitare": "Cota Impozit", + "Statistics": "Statistici", + "Statistici": "Statistici", + "Graph": "Grafic", + + # ── Sales Invoice ── + "Additional Info": "Informatii Suplimentare", + "Billing Address": "Adresa de Facturare", + "Adresa De Facturare": "Adresa de Facturare", + "Shipping Address": "Adresa de Livrare", + "Adresa de livrare": "Adresa de Livrare", + "Company Address": "Adresa Companie", + "Adresă Companie": "Adresa Companie", + "Rounding Adjustment": "Ajustare Rotunjire", + "Ajustare Rotunjire (RON)": "Ajustare Rotunjire (RON)", + "Rotunjire ajustare (RON)": "Ajustare Rotunjire (RON)", + "Write Off Amount": "Suma Casare", + "Anulați suma (RON)": "Suma Casare (RON)", + "Scrie Off Suma (RON)": "Suma Casare (RON)", + "Apply Additional Discount On": "Aplica Discount Suplimentar Pe", + "Aplicați Discount suplimentare La": "Aplica Discount Suplimentar Pe", + "Items": "Articole", + "Articole": "Articole", + "Total Quantity": "Cantitate Totala", + "Cantitatea totala": "Cantitate Totala", + "Commission": "Comision", + "Comision": "Comision", + "Company Tax ID": "CUI Companie", + "Due Date": "Data Scadenta", + "Data scadentă de plată": "Data Scadenta", + "Base Change Amount (RON)": "Suma Modificare Baza (RON)", + "De schimbare a bazei Suma (RON)": "Suma Modificare Baza (RON)", + "Debit To": "Cont Debit", + "Debit Pentru": "Cont Debit", + "Accounting Details": "Detalii Contabilitate", + "Detalii Contabilitate": "Detalii Contabilitate", + "Customer PO Details": "Detalii Comanda Client", + "Detalii PO pentru clienți": "Detalii Comanda Client", + "Additional Discount": "Discount Suplimentar", + "Discount suplimentar": "Discount Suplimentar", + "Additional Discount Amount": "Suma Discount Suplimentar", + "Discount suplimentar Suma (RON)": "Suma Discount Suplimentar (RON)", + "Additional Discount Percentage": "Procent Discount Suplimentar", + "Procent de reducere suplimentară": "Procent Discount Suplimentar", + "Suma de reducere suplimentară (RON)": "Suma Discount Suplimentar (RON)", + "Sales Team": "Echipa Vanzari", + "Echipa de vânzări": "Echipa Vanzari", + "Taxes and Charges Calculation": "Calcul Taxe", + "Impozite și Taxe Calcul": "Calcul Taxe", + "Print Language": "Limba Tiparire", + "Limba de imprimare": "Limba Tiparire", + "Price List": "Lista de Preturi", + "Lista Prețuri": "Lista de Preturi", + "Packing List": "Lista de Ambalare", + "Lista de ambalare": "Lista de Ambalare", + "Price List Currency": "Moneda Lista Preturi", + "Lista de pret Valuta": "Moneda Lista Preturi", + "Price List Exchange Rate": "Curs Valutar Lista Preturi", + "Lista de schimb valutar": "Curs Valutar Lista Preturi", + "Time Sheet List": "Lista Pontaj", + "Listă de timp Sheet": "Lista Pontaj", + "Net Total": "Total Net", + "Net total (RON)": "Total Net (RON)", + "Customer Name": "Nume Client", + "Company Name": "Nume Companie", + "Nume Companie": "Nume Companie", + "Write Off": "Casare Creante", + "Pierderi din Creante": "Casare Creante", + "Advance Payments": "Plati in Avans", + "Plățile în avans": "Plati in Avans", + "Payments": "Plati", + "Plăți": "Plati", + "Posting Time": "Ora Inregistrare", + "PostingTime": "Ora Inregistrare", + "Postarea de timp": "Ora Inregistrare", + "Pricing Rules": "Reguli de Pret", + "Reguli privind prețurile": "Reguli de Pret", + "Rounded Total": "Total Rotunjit", + "Rotunjite total (RON)": "Total Rotunjit (RON)", + "Redeem Loyalty Points": "Rascumparare Puncte Loialitate", + "Răscumpărarea punctelor de loialitate": "Rascumparare Puncte Loialitate", + "Exchange Rate": "Curs Valutar", + "Rata de schimb": "Curs Valutar", + "Print Settings": "Setari Tiparire", + "Setări de imprimare": "Setari Tiparire", + "Outstanding Amount": "Suma Restanta", + "Suma Restanta (RON)": "Suma Restanta (RON)", + "Payment Terms": "Termeni Plata", + "Termeni de plată": "Termeni Plata", + "Terms and Conditions": "Termeni si Conditii", + "Termeni si conditii": "Termeni si Conditii", + "Total Advance": "Total Avans", + "Total de Advance (RON)": "Total Avans (RON)", + "Totals": "Totaluri", + "Totaluri": "Totaluri", + "Currency and Price List": "Moneda si Lista de Preturi", + "Valută și lista de prețuri": "Moneda si Lista de Preturi", + "Subscription": "Abonament", + "Changes": "Modificari", + + # ── Customer ── + "Primary Customer Address": "Adresa Principala Client", + "Adresa primară a clientului": "Adresa Principala Client", + "Address and Contact": "Adresa si Contact", + "Adresa și Contact": "Adresa si Contact", + "Tax Category": "Categorie Fiscala", + "Categoria fiscală": "Categorie Fiscala", + "Primary Customer Contact": "Contact Principal Client", + "Contact primar client": "Contact Principal Client", + "Default Accounts": "Conturi Implicite", + "Conturi implicite": "Conturi Implicite", + "Defaults": "Valori Implicite", + "Implicite": "Valori Implicite", + "Internal Customer": "Client Intern", + "Credit Limit and Payment Terms": "Limita Credit si Termeni Plata", + "Limita de credit și termenii de plată": "Limita Credit si Termeni Plata", + "Primary Address and Contact": "Adresa si Contact Principal", + "Loyalty Points": "Puncte Loialitate", + "Puncte de loialitate": "Puncte Loialitate", + "Default Payment Terms Template": "Sablon Termeni Plata Standard", + "Șablonul Termenii de plată standard": "Sablon Termeni Plata Standard", + "Tax ID": "Cod Fiscal (CUI)", + "ID impozit": "Cod Fiscal (CUI)", + "Email ID": "Adresa Email", + "ID-ul de e-mail": "Adresa Email", + + # ── Lead ── + "Lead Name": "Nume Lead", + "Email Address": "Adresa Email", + "Lead Source": "Sursa Lead", + "Campaign Name": "Nume Campanie", + + # ── Common / Navigation ── + "Activity": "Activitate", + "Activitate": "Activitate", + "Assigned To": "Atribuit Pentru", + "Atribuit pentru": "Atribuit Pentru", + "Attachments": "Atasamente", + "Ataşamente": "Atasamente", + "Tags": "Etichete", + "Etichete": "Etichete", + "Action": "Actiune", + "Acțiune": "Actiune", + "Follow": "Urmareste", + "Urma": "Urmareste", + "Comment": "Comentariu", + "Comentarii": "Comentarii", + "Comments": "Comentarii", + "New Email": "Email Nou", + "Email nou": "Email Nou", + "Type a reply / comment": "Scrie un raspuns / comentariu", + "No new notifications": "Nicio notificare noua", + "Help Dropdown": "Meniu Ajutor", + "Meniul drop-down Ajutor": "Meniu Ajutor", + "User Menu": "Meniu Utilizator", + "Toggle Sidebar": "Comuta Bara Laterala", + "Comutați bara laterală": "Comuta Bara Laterala", + "You last edited this": "Ultima editare", + "You created this": "Creat de tine", + "You submitted this document": "Document confirmat de tine", + "yesterday": "ieri", + "Bank Transaction": "Tranzactie Bancara", + "Tranzacție bancară": "Tranzactie Bancara", + "Settle": "Achita", + "Achita": "Achita", + "General Ledger": "Registru Contabil", + "Registru Contabil": "Registru Contabil", +} + +created = 0 +skipped = 0 +for source, translated in T.items(): + check = s.get(f"{URL}/api/resource/Translation", params={ + "filters": json.dumps([["language", "=", "ro"], ["source_text", "=", source]]), + "fields": json.dumps(["name", "translated_text"]), + "limit_page_length": 1, + }) + existing = check.json().get("data", []) + + if existing: + # Update if different + if existing[0].get("translated_text") != translated: + s.put(f"{URL}/api/resource/Translation/{existing[0]['name']}", json={"translated_text": translated}) + created += 1 + else: + skipped += 1 + continue + + r = s.post(f"{URL}/api/resource/Translation", json={ + "language": "ro", + "source_text": source, + "translated_text": translated, + }) + if r.status_code in (200, 201): + created += 1 + else: + skipped += 1 + +print(f"Created/Updated: {created}, Skipped: {skipped}") + +# Clear cache +s.post(f"{URL}/api/method/frappe.client.clear_cache") +print("Cache cleared. Ctrl+Shift+R in browser.") diff --git a/erp_crm/scripts/archive/fix-workspace.py b/erp_crm/scripts/archive/fix-workspace.py new file mode 100644 index 0000000..ee6b618 --- /dev/null +++ b/erp_crm/scripts/archive/fix-workspace.py @@ -0,0 +1,42 @@ +import frappe + +def execute(): + # Delete old DiDi workspace + if frappe.db.exists("Workspace", "DiDi"): + frappe.delete_doc("Workspace", "DiDi", force=True) + + # Hide ALL other workspaces + for ws in frappe.get_all("Workspace", filters={"public": 1}, fields=["name"]): + doc = frappe.get_doc("Workspace", ws.name) + doc.public = 0 + doc.save(ignore_permissions=True) + print(f" Hidden: {ws.name}") + + # Create clean DiDi workspace + ws = frappe.get_doc({ + "doctype": "Workspace", + "name": "DiDi", + "label": "DiDi", + "title": "DiDi - Panou Principal", + "module": "Didi Custom", + "icon": "home", + "public": 1, + "sequence_id": 0, + "shortcuts": [ + {"type": "DocType", "link_to": "Sales Invoice", "label": "FACTURI"}, + {"type": "DocType", "link_to": "Customer", "label": "CLIENTI"}, + {"type": "DocType", "link_to": "Lead", "label": "LEAD-URI CRM"}, + {"type": "DocType", "link_to": "Payment Log", "label": "LOG PLATI STRIPE"}, + {"type": "DocType", "link_to": "Service Agreement", "label": "ACORDURI SERVICII"}, + {"type": "DocType", "link_to": "Item", "label": "SERVICII / PRODUSE"}, + {"type": "DocType", "link_to": "Subscription Plan", "label": "PLANURI ABONAMENT"}, + {"type": "DocType", "link_to": "Website Content", "label": "CONTINUT WEBSITE (CMS)"}, + {"type": "DocType", "link_to": "Supplier", "label": "FURNIZORI"}, + {"type": "DocType", "link_to": "Email Template", "label": "TEMPLATE-URI EMAIL"}, + {"type": "DocType", "link_to": "Account", "label": "PLAN DE CONTURI"}, + ], + }) + ws.insert(ignore_permissions=True) + print("DiDi workspace created with all shortcuts") + + frappe.db.commit() diff --git a/erp_crm/scripts/archive/setup-sidebar.py b/erp_crm/scripts/archive/setup-sidebar.py new file mode 100644 index 0000000..5ad66c8 --- /dev/null +++ b/erp_crm/scripts/archive/setup-sidebar.py @@ -0,0 +1,291 @@ +""" +Create clean sidebar workspaces for DiDi ERPNext. +Run: python setup-sidebar.py +""" +import requests +import json + +ERPNEXT_URL = "http://localhost:8080" + +# Login as Administrator +session = requests.Session() +login = session.post(f"{ERPNEXT_URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +if login.status_code != 200: + print(f"Login failed: {login.status_code}") + exit(1) +print("Logged in as Administrator") + + +def api(method, endpoint, data=None): + url = f"{ERPNEXT_URL}{endpoint}" + if method == "GET": + r = session.get(url, params=data) + elif method == "POST": + r = session.post(url, json=data) + elif method == "PUT": + r = session.put(url, json=data) + elif method == "DELETE": + r = session.delete(url) + else: + return None + return r + + +# ─── 1. Give API user Workspace Manager role ─── +print("\n1. Adding Workspace Manager role to API user...") +try: + r = session.post(f"{ERPNEXT_URL}/api/method/frappe.client.get_list", json={ + "doctype": "Has Role", + "filters": {"parent": "website_api@didi-erp", "role": "Workspace Manager"}, + "fields": ["name"], + }) + existing = r.json().get("message", []) + if not existing: + user_doc = session.get(f"{ERPNEXT_URL}/api/resource/User/website_api@didi-erp").json()["data"] + roles = user_doc.get("roles", []) + roles.append({"role": "Workspace Manager"}) + session.put(f"{ERPNEXT_URL}/api/resource/User/website_api@didi-erp", json={"roles": roles}) + print(" Added Workspace Manager role") + else: + print(" Already has Workspace Manager role") +except Exception as e: + print(f" Warning: {e}") + +# ─── 2. Hide old workspaces ─── +print("\n2. Hiding old/unused workspaces...") +to_hide = [ + "DiDi Platform", "DiDi", "Accounting", "CRM", "Selling", + "Assets", "Build", "Buying", "ERPNext Integrations", "ERPNext Settings", + "Financial Reports", "Integrations", "Manufacturing", "Payables", + "Projects", "Quality", "Receivables", "Stock", "Support", +] +for ws_name in to_hide: + try: + r = api("PUT", f"/api/resource/Workspace/{requests.utils.quote(ws_name)}", {"is_hidden": 1}) + if r.status_code == 200: + print(f" Hidden: {ws_name}") + else: + print(f" Skip (not found or error): {ws_name}") + except: + pass + +# ─── 3. Create new sidebar workspaces ─── +print("\n3. Creating sidebar workspaces...") + +WORKSPACES = [ + { + "name": "Facturi", + "title": "Facturi", + "icon": "file-text", + "indicator_color": "green", + "sequence_id": 10, + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": "Facturare", "col": 12}}, + {"id": "s1", "type": "shortcut", "data": {"shortcut_name": "Toate Facturile", "col": 4}}, + {"id": "s2", "type": "shortcut", "data": {"shortcut_name": "Facturi Platite", "col": 4}}, + {"id": "s3", "type": "shortcut", "data": {"shortcut_name": "Payment Entry", "col": 4}}, + ]), + "shortcuts": [ + {"type": "DocType", "link_to": "Sales Invoice", "label": "Toate Facturile", "color": "Green", "doc_view": "List"}, + {"type": "DocType", "link_to": "Sales Invoice", "label": "Facturi Platite", "color": "Blue", "doc_view": "List"}, + {"type": "DocType", "link_to": "Payment Entry", "label": "Payment Entry", "color": "Yellow", "doc_view": "List"}, + ], + "links": [ + {"type": "Card Break", "label": "Facturi"}, + {"type": "Link", "label": "Sales Invoice", "link_to": "Sales Invoice", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Payment Entry", "link_to": "Payment Entry", "link_type": "DocType"}, + {"type": "Link", "label": "Taxe (TVA)", "link_to": "Sales Taxes and Charges Template", "link_type": "DocType"}, + ], + }, + { + "name": "Clienti", + "title": "Clienti", + "icon": "user", + "indicator_color": "blue", + "sequence_id": 20, + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": "Clienti", "col": 12}}, + {"id": "s1", "type": "shortcut", "data": {"shortcut_name": "Toti Clientii", "col": 6}}, + {"id": "s2", "type": "shortcut", "data": {"shortcut_name": "Service Agreement", "col": 6}}, + ]), + "shortcuts": [ + {"type": "DocType", "link_to": "Customer", "label": "Toti Clientii", "color": "Blue", "doc_view": "List"}, + {"type": "DocType", "link_to": "Service Agreement", "label": "Service Agreement", "color": "Purple", "doc_view": "List"}, + ], + "links": [ + {"type": "Card Break", "label": "Clienti"}, + {"type": "Link", "label": "Customer", "link_to": "Customer", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Service Agreement", "link_to": "Service Agreement", "link_type": "DocType"}, + {"type": "Link", "label": "Subscription Plan", "link_to": "Subscription Plan", "link_type": "DocType"}, + ], + }, + { + "name": "CRM DiDi", + "title": "CRM", + "icon": "share", + "indicator_color": "orange", + "sequence_id": 30, + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": "CRM & Lead Management", "col": 12}}, + {"id": "s1", "type": "shortcut", "data": {"shortcut_name": "Lead-uri", "col": 4}}, + {"id": "s2", "type": "shortcut", "data": {"shortcut_name": "Sales Stage", "col": 4}}, + {"id": "s3", "type": "shortcut", "data": {"shortcut_name": "Lead Source", "col": 4}}, + ]), + "shortcuts": [ + {"type": "DocType", "link_to": "Lead", "label": "Lead-uri", "color": "Orange", "doc_view": "List"}, + {"type": "DocType", "link_to": "Sales Stage", "label": "Sales Stage", "color": "Yellow"}, + {"type": "DocType", "link_to": "Lead Source", "label": "Lead Source", "color": "Grey"}, + ], + "links": [ + {"type": "Card Break", "label": "CRM"}, + {"type": "Link", "label": "Lead", "link_to": "Lead", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Sales Stage", "link_to": "Sales Stage", "link_type": "DocType"}, + {"type": "Link", "label": "Lead Source", "link_to": "Lead Source", "link_type": "DocType"}, + ], + }, + { + "name": "Servicii DiDi", + "title": "Servicii", + "icon": "box", + "indicator_color": "purple", + "sequence_id": 40, + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": "Servicii & Produse", "col": 12}}, + {"id": "s1", "type": "shortcut", "data": {"shortcut_name": "Articole", "col": 6}}, + {"id": "s2", "type": "shortcut", "data": {"shortcut_name": "Planuri Abonament", "col": 6}}, + ]), + "shortcuts": [ + {"type": "DocType", "link_to": "Item", "label": "Articole", "color": "Purple", "doc_view": "List"}, + {"type": "DocType", "link_to": "Subscription Plan", "label": "Planuri Abonament", "color": "Cyan"}, + ], + "links": [ + {"type": "Card Break", "label": "Catalog"}, + {"type": "Link", "label": "Item", "link_to": "Item", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Item Group", "link_to": "Item Group", "link_type": "DocType"}, + {"type": "Link", "label": "Subscription Plan", "link_to": "Subscription Plan", "link_type": "DocType"}, + ], + }, + { + "name": "Plati DiDi", + "title": "Plati", + "icon": "credit-card", + "indicator_color": "yellow", + "sequence_id": 50, + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": "Plati Stripe & Log", "col": 12}}, + {"id": "s1", "type": "shortcut", "data": {"shortcut_name": "Payment Log", "col": 6}}, + {"id": "s2", "type": "shortcut", "data": {"shortcut_name": "Analysis Report", "col": 6}}, + ]), + "shortcuts": [ + {"type": "DocType", "link_to": "Payment Log", "label": "Payment Log", "color": "Yellow", "doc_view": "List"}, + {"type": "DocType", "link_to": "Analysis Report", "label": "Analysis Report", "color": "Pink", "doc_view": "List"}, + ], + "links": [ + {"type": "Card Break", "label": "Plati"}, + {"type": "Link", "label": "Payment Log", "link_to": "Payment Log", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Analysis Report", "link_to": "Analysis Report", "link_type": "DocType"}, + ], + }, + { + "name": "Contabilitate DiDi", + "title": "Contabilitate", + "icon": "calculator", + "indicator_color": "grey", + "sequence_id": 60, + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": "Contabilitate", "col": 12}}, + {"id": "s1", "type": "shortcut", "data": {"shortcut_name": "Plan Conturi", "col": 4}}, + {"id": "s2", "type": "shortcut", "data": {"shortcut_name": "Furnizori", "col": 4}}, + {"id": "s3", "type": "shortcut", "data": {"shortcut_name": "Companie", "col": 4}}, + ]), + "shortcuts": [ + {"type": "DocType", "link_to": "Account", "label": "Plan Conturi", "color": "Grey", "doc_view": "Tree"}, + {"type": "DocType", "link_to": "Supplier", "label": "Furnizori", "color": "Blue"}, + {"type": "DocType", "link_to": "Company", "label": "Companie", "color": "Green"}, + ], + "links": [ + {"type": "Card Break", "label": "Contabilitate"}, + {"type": "Link", "label": "Account", "link_to": "Account", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Company", "link_to": "Company", "link_type": "DocType"}, + {"type": "Link", "label": "Supplier", "link_to": "Supplier", "link_type": "DocType"}, + {"type": "Link", "label": "Purchase Invoice", "link_to": "Purchase Invoice", "link_type": "DocType"}, + {"type": "Link", "label": "Sales Taxes Template", "link_to": "Sales Taxes and Charges Template", "link_type": "DocType"}, + ], + }, + { + "name": "Website CMS DiDi", + "title": "Website CMS", + "icon": "globe", + "indicator_color": "cyan", + "sequence_id": 70, + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": "Continut Website", "col": 12}}, + {"id": "s1", "type": "shortcut", "data": {"shortcut_name": "Website Content", "col": 6}}, + {"id": "s2", "type": "shortcut", "data": {"shortcut_name": "Email Template", "col": 6}}, + ]), + "shortcuts": [ + {"type": "DocType", "link_to": "Website Content", "label": "Website Content", "color": "Cyan", "doc_view": "List"}, + {"type": "DocType", "link_to": "Email Template", "label": "Email Template", "color": "Grey"}, + ], + "links": [ + {"type": "Card Break", "label": "CMS"}, + {"type": "Link", "label": "Website Content", "link_to": "Website Content", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Email Template", "link_to": "Email Template", "link_type": "DocType"}, + ], + }, +] + +for ws in WORKSPACES: + name = ws["name"] + # Check if exists + check = session.get(f"{ERPNEXT_URL}/api/resource/Workspace/{requests.utils.quote(name)}") + if check.status_code == 200: + # Update it + r = api("PUT", f"/api/resource/Workspace/{requests.utils.quote(name)}", { + "title": ws["title"], + "icon": ws["icon"], + "indicator_color": ws["indicator_color"], + "is_hidden": 0, + "public": 1, + "module": "Didi Custom", + "sequence_id": ws["sequence_id"], + "content": ws["content"], + "shortcuts": ws["shortcuts"], + "links": ws["links"], + }) + if r.status_code == 200: + print(f" Updated: {name}") + else: + print(f" Error updating {name}: {r.status_code} {r.text[:200]}") + else: + # Create it + r = api("POST", "/api/resource/Workspace", { + "name": name, + "label": name, + "title": ws["title"], + "icon": ws["icon"], + "indicator_color": ws["indicator_color"], + "is_hidden": 0, + "public": 1, + "module": "Didi Custom", + "sequence_id": ws["sequence_id"], + "content": ws["content"], + "shortcuts": ws["shortcuts"], + "links": ws["links"], + }) + if r.status_code in (200, 201): + print(f" Created: {name}") + else: + print(f" Error creating {name}: {r.status_code} {r.text[:200]}") + +print("\n4. Verifying sidebar...") +r = session.get(f"{ERPNEXT_URL}/api/resource/Workspace", params={ + "filters": json.dumps([["public", "=", 1], ["is_hidden", "=", 0]]), + "fields": json.dumps(["name", "title", "icon", "sequence_id"]), + "order_by": "sequence_id asc", + "limit_page_length": 20, +}) +for ws in r.json().get("data", []): + print(f" [{ws.get('sequence_id', '?')}] {ws['icon'] or '?'} {ws['title']}") + +print("\nDone! Refresh ERPNext in browser (Ctrl+Shift+R).") diff --git a/erp_crm/scripts/archive/translate-accounts.py b/erp_crm/scripts/archive/translate-accounts.py new file mode 100644 index 0000000..8099d65 --- /dev/null +++ b/erp_crm/scripts/archive/translate-accounts.py @@ -0,0 +1,153 @@ +"""Rename all ERPNext accounts to Romanian.""" +import requests, json, sys, io, urllib.parse +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +URL = "http://localhost:8080" +s = requests.Session() +s.post(f"{URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +print("Logged in") + +RENAMES = { + # Root groups (Level 1) + "Application of Funds (Assets)": "Active", + "Source of Funds (Liabilities)": "Pasive (Datorii)", + "Equity": "Capitaluri Proprii", + "Income": "Venituri", + "Expenses": "Cheltuieli", + + # Asset sub-groups + "Current Assets": "Active Curente", + "Cash In Hand": "Numerar", + "Cash": "Casa", + "Bank Accounts": "Conturi Bancare", + "Accounts Receivable": "Creante Clienti", + "Debtors": "Debitori", + "Stock Assets": "Active Stocuri", + "Stock In Hand": "Stoc", + "Tax Assets": "Active Fiscale", + "Loans and Advances (Assets)": "Imprumuturi si Avansuri (Active)", + "Employee Advances": "Avansuri Angajati", + "Securities and Deposits": "Garantii si Depozite", + "Earnest Money": "Garantii", + "Fixed Assets": "Imobilizari Corporale", + "Capital Equipments": "Echipamente de Capital", + "Electronic Equipments": "Echipamente Electronice", + "Furnitures and Fixtures": "Mobilier si Dotari", + "Office Equipments": "Echipamente Birou", + "Plants and Machineries": "Utilaje si Masini", + "Buildings": "Cladiri", + "Softwares": "Software", + "Accumulated Depreciation": "Amortizare Cumulata", + "CWIP Account": "Lucrari in Curs", + "Investments": "Investitii", + "Temporary Accounts": "Conturi Temporare", + "Temporary Opening": "Sold Initial Temporar", + + # Liability sub-groups + "Current Liabilities": "Datorii Curente", + "Accounts Payable": "Datorii Furnizori", + "Creditors": "Creditori", + "Payroll Payable": "Salarii de Plata", + "Stock Liabilities": "Datorii Stocuri", + "Stock Received But Not Billed": "Stoc Receptionat Nefacturat", + "Asset Received But Not Billed": "Active Receptionate Nefacturate", + "Duties and Taxes": "Taxe si Impozite", + "TDS Payable": "Taxe de Plata", + "Loans (Liabilities)": "Imprumuturi (Datorii)", + "Secured Loans": "Imprumuturi Garantate", + "Unsecured Loans": "Imprumuturi Negarantate", + "Bank Overdraft Account": "Descoperit de Cont", + + # Equity + "Capital Stock": "Capital Social", + "Dividends Paid": "Dividende Platite", + "Opening Balance Equity": "Sold Initial Capital", + "Retained Earnings": "Rezultat Reportat", + + # Income + "Direct Income": "Venituri Directe", + "Sales": "Vanzari", + "Service": "Servicii", + "Indirect Income": "Venituri Indirecte", + + # Expenses + "Direct Expenses": "Cheltuieli Directe", + "Stock Expenses": "Cheltuieli Stocuri", + "Cost of Goods Sold": "Costul Bunurilor Vandute", + "Expenses Included In Asset Valuation": "Cheltuieli Incluse in Evaluarea Activelor", + "Expenses Included In Valuation": "Cheltuieli Incluse in Evaluare", + "Stock Adjustment": "Ajustare Stoc", + "Indirect Expenses": "Cheltuieli Indirecte", + "Administrative Expenses": "Cheltuieli Administrative", + "Commission on Sales": "Comisioane Vanzari", + "Depreciation": "Amortizare", + "Entertainment Expenses": "Cheltuieli Reprezentare", + "Freight and Forwarding Charges": "Cheltuieli Transport", + "Legal Expenses": "Cheltuieli Juridice", + "Marketing Expenses": "Cheltuieli Marketing", + "Office Maintenance Expenses": "Cheltuieli Intretinere Birou", + "Office Rent": "Chirie Birou", + "Postal Expenses": "Cheltuieli Postale", + "Print and Stationery": "Tiparituri si Papetarie", + "Round Off": "Rotunjiri", + "Salary": "Salarii", + "Sales Expenses": "Cheltuieli Vanzari", + "Telephone Expenses": "Cheltuieli Telefon", + "Travel Expenses": "Cheltuieli Deplasare", + "Utility Expenses": "Cheltuieli Utilitati", + "Write Off": "Pierderi din Creante", + "Exchange Gain/Loss": "Diferente de Curs Valutar", + "Gain/Loss on Asset Disposal": "Castiguri/Pierderi din Casare Active", + "Miscellaneous Expenses": "Cheltuieli Diverse", +} + +# Get all accounts +r = s.get(f"{URL}/api/resource/Account", params={ + "filters": json.dumps([["company", "=", "TOP CLOSSERS SRL"]]), + "fields": json.dumps(["name", "account_name"]), + "limit_page_length": 200, +}) +accounts = r.json().get("data", []) + +renamed = 0 +skipped = 0 +errors = 0 + +for acc in accounts: + old_name = acc["account_name"] + if old_name not in RENAMES: + continue + + new_name = RENAMES[old_name] + full_old = acc["name"] # e.g. "1000 - Application of Funds (Assets) - TC" + + # Build new full name + parts = full_old.split(" - ") + if len(parts) >= 3: + parts[1] = new_name + full_new = " - ".join(parts) + else: + skipped += 1 + continue + + # Rename via frappe.client.rename_doc + r = s.post(f"{URL}/api/method/frappe.client.rename_doc", json={ + "doctype": "Account", + "old": full_old, + "new": full_new, + "merge": 0, + }) + + if r.status_code == 200: + renamed += 1 + print(f" OK: {old_name} -> {new_name}") + else: + errors += 1 + err_msg = r.text[:150] if r.text else str(r.status_code) + print(f" ERR: {old_name}: {err_msg}") + +print(f"\nRenamed: {renamed}, Skipped: {skipped}, Errors: {errors}") + +# Clear cache +s.post(f"{URL}/api/method/frappe.client.clear_cache") +print("Cache cleared. Refresh browser.") diff --git a/erp_crm/scripts/archive/translate-accounts2.py b/erp_crm/scripts/archive/translate-accounts2.py new file mode 100644 index 0000000..20b5770 --- /dev/null +++ b/erp_crm/scripts/archive/translate-accounts2.py @@ -0,0 +1,152 @@ +"""Translate account names by updating account_name field + adding translations.""" +import requests, json, sys, io, urllib.parse +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +URL = "http://localhost:8080" +s = requests.Session() +s.post(f"{URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +print("Logged in") + +TRANSLATIONS = { + "Application of Funds (Assets)": "Active", + "Source of Funds (Liabilities)": "Pasive (Datorii)", + "Equity": "Capitaluri Proprii", + "Income": "Venituri", + "Expenses": "Cheltuieli", + "Current Assets": "Active Curente", + "Cash In Hand": "Numerar", + "Cash": "Casa", + "Bank Accounts": "Conturi Bancare", + "Accounts Receivable": "Creante Clienti", + "Debtors": "Debitori", + "Stock Assets": "Active Stocuri", + "Stock In Hand": "Stoc", + "Tax Assets": "Active Fiscale", + "Loans and Advances (Assets)": "Imprumuturi si Avansuri", + "Employee Advances": "Avansuri Angajati", + "Securities and Deposits": "Garantii si Depozite", + "Earnest Money": "Garantii", + "Fixed Assets": "Imobilizari Corporale", + "Capital Equipments": "Echipamente de Capital", + "Electronic Equipments": "Echipamente Electronice", + "Furnitures and Fixtures": "Mobilier si Dotari", + "Office Equipments": "Echipamente Birou", + "Plants and Machineries": "Utilaje si Masini", + "Buildings": "Cladiri", + "Softwares": "Software", + "Accumulated Depreciation": "Amortizare Cumulata", + "CWIP Account": "Lucrari in Curs", + "Investments": "Investitii", + "Temporary Accounts": "Conturi Temporare", + "Temporary Opening": "Sold Initial Temporar", + "Current Liabilities": "Datorii Curente", + "Accounts Payable": "Datorii Furnizori", + "Creditors": "Creditori", + "Payroll Payable": "Salarii de Plata", + "Stock Liabilities": "Datorii Stocuri", + "Stock Received But Not Billed": "Stoc Receptionat Nefacturat", + "Asset Received But Not Billed": "Active Receptionate Nefacturate", + "Duties and Taxes": "Taxe si Impozite", + "TDS Payable": "Taxe de Plata", + "Loans (Liabilities)": "Imprumuturi (Datorii)", + "Secured Loans": "Imprumuturi Garantate", + "Unsecured Loans": "Imprumuturi Negarantate", + "Bank Overdraft Account": "Descoperit de Cont", + "Capital Stock": "Capital Social", + "Dividends Paid": "Dividende Platite", + "Opening Balance Equity": "Sold Initial Capital", + "Retained Earnings": "Rezultat Reportat", + "Direct Income": "Venituri Directe", + "Sales": "Vanzari", + "Service": "Servicii", + "Indirect Income": "Venituri Indirecte", + "Direct Expenses": "Cheltuieli Directe", + "Stock Expenses": "Cheltuieli Stocuri", + "Cost of Goods Sold": "Costul Bunurilor Vandute", + "Expenses Included In Asset Valuation": "Cheltuieli in Evaluarea Activelor", + "Expenses Included In Valuation": "Cheltuieli in Evaluare", + "Stock Adjustment": "Ajustare Stoc", + "Indirect Expenses": "Cheltuieli Indirecte", + "Administrative Expenses": "Cheltuieli Administrative", + "Commission on Sales": "Comisioane Vanzari", + "Depreciation": "Amortizare", + "Entertainment Expenses": "Cheltuieli Reprezentare", + "Freight and Forwarding Charges": "Cheltuieli Transport", + "Legal Expenses": "Cheltuieli Juridice", + "Marketing Expenses": "Cheltuieli Marketing", + "Office Maintenance Expenses": "Intretinere Birou", + "Office Rent": "Chirie Birou", + "Postal Expenses": "Cheltuieli Postale", + "Print and Stationery": "Tiparituri si Papetarie", + "Round Off": "Rotunjiri", + "Salary": "Salarii", + "Sales Expenses": "Cheltuieli Vanzari", + "Telephone Expenses": "Cheltuieli Telefon", + "Travel Expenses": "Cheltuieli Deplasare", + "Utility Expenses": "Cheltuieli Utilitati", + "Write Off": "Pierderi din Creante", + "Exchange Gain/Loss": "Diferente Curs Valutar", + "Gain/Loss on Asset Disposal": "Castig/Pierdere Casare Active", + "Miscellaneous Expenses": "Cheltuieli Diverse", +} + +# Add all as Translation entries (for the UI) +print("1. Adding account name translations...") +created = 0 +for en, ro in TRANSLATIONS.items(): + check = s.get(f"{URL}/api/resource/Translation", params={ + "filters": json.dumps([["language", "=", "ro"], ["source_text", "=", en]]), + "fields": json.dumps(["name"]), + "limit_page_length": 1, + }) + if check.json().get("data"): + continue + r = s.post(f"{URL}/api/resource/Translation", json={ + "language": "ro", + "source_text": en, + "translated_text": ro, + }) + if r.status_code in (200, 201): + created += 1 + +print(f" {created} traduceri noi") + +# Now rename accounts using bench command via docker exec +# Actually, let's use the proper rename API with positional args +print("\n2. Renaming accounts...") +renamed = 0 +errors = 0 + +r = s.get(f"{URL}/api/resource/Account", params={ + "filters": json.dumps([["company", "=", "TOP CLOSSERS SRL"]]), + "fields": json.dumps(["name", "account_name"]), + "limit_page_length": 200, +}) +accounts = r.json().get("data", []) + +for acc in accounts: + old_account_name = acc["account_name"] + if old_account_name not in TRANSLATIONS: + continue + + new_account_name = TRANSLATIONS[old_account_name] + full_name = acc["name"] + + # Update account_name field directly + r = s.put(f"{URL}/api/resource/Account/{urllib.parse.quote(full_name, safe='')}", json={ + "account_name": new_account_name, + }) + + if r.status_code == 200: + renamed += 1 + else: + errors += 1 + # Try to understand error + msg = r.text[:100] if r.text else str(r.status_code) + print(f" ERR {old_account_name}: {msg}") + +print(f" Renamed: {renamed}, Errors: {errors}") + +# Clear cache +s.post(f"{URL}/api/method/frappe.client.clear_cache") +print("\nDone! Refresh browser (Ctrl+Shift+R).") diff --git a/erp_crm/scripts/setup/01-config.py b/erp_crm/scripts/setup/01-config.py new file mode 100644 index 0000000..7184ba0 --- /dev/null +++ b/erp_crm/scripts/setup/01-config.py @@ -0,0 +1,287 @@ +""" +ERPNext Configuration Script for DiDi Project +Run with: docker exec didi-erpnext bench --site didi.localhost execute didi_setup +Or copy to container and run via bench. +""" +import frappe + +def execute(): + """Main setup function - run all configurations.""" + setup_company_details() + setup_tax_template() + setup_invoice_naming() + setup_crm_pipeline() + setup_subscription_items() + setup_email_templates() + frappe.db.commit() + print("\n=== DiDi ERPNext Configuration Complete ===\n") + + +def setup_company_details(): + """Configure company details - CUI, address, etc.""" + print("[1/6] Configuring company details...") + + company = frappe.get_doc("Company", "TOP CLOSSERS SRL") + company.tax_id = "36193026" + company.domain = "Services" + company.default_currency = "RON" + company.country = "Romania" + company.save(ignore_permissions=True) + + # Create company address + if not frappe.db.exists("Address", {"address_title": "TOP CLOSSERS SRL - Sediu"}): + addr = frappe.get_doc({ + "doctype": "Address", + "address_title": "TOP CLOSSERS SRL - Sediu", + "address_type": "Office", + "address_line1": "Str. Targovistei 15", + "address_line2": "Bl. 2 Et. 3 Ap. 22", + "city": "Ploiesti", + "state": "Prahova", + "pincode": "100299", + "country": "Romania", + "phone": "+40721063078", + "email_id": "office@clossers.com", + "is_primary_address": 1, + "links": [{"link_doctype": "Company", "link_name": "TOP CLOSSERS SRL"}] + }) + addr.insert(ignore_permissions=True) + + print(" Company details configured.") + + +def setup_tax_template(): + """Configure VAT 19% tax template for Romania.""" + print("[2/6] Configuring tax templates...") + + # Get or create default income account + company_abbr = "TC" + tax_account = f"TVA Colectata - {company_abbr}" + + # Check if tax account exists, create if not + if not frappe.db.exists("Account", tax_account): + parent_account = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "account_type": "Tax", "is_group": 1}, + "name" + ) + if not parent_account: + parent_account = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "root_type": "Liability", "is_group": 1}, + "name" + ) + + if parent_account: + tax_acc = frappe.get_doc({ + "doctype": "Account", + "account_name": "TVA Colectata", + "parent_account": parent_account, + "account_type": "Tax", + "company": "TOP CLOSSERS SRL", + "tax_rate": 19.0 + }) + tax_acc.insert(ignore_permissions=True) + + # Create Sales Tax Template + if not frappe.db.exists("Sales Taxes and Charges Template", {"title": "TVA 19% Romania"}): + template = frappe.get_doc({ + "doctype": "Sales Taxes and Charges Template", + "title": "TVA 19% Romania", + "company": "TOP CLOSSERS SRL", + "is_default": 1, + "taxes": [{ + "charge_type": "On Net Total", + "account_head": tax_account if frappe.db.exists("Account", tax_account) else "", + "description": "TVA 19%", + "rate": 19.0 + }] + }) + try: + template.insert(ignore_permissions=True) + print(" Tax template TVA 19% created.") + except Exception as e: + print(f" Tax template: {e}") + else: + print(" Tax template already exists.") + + +def setup_invoice_naming(): + """Configure Romanian invoice naming series.""" + print("[3/6] Configuring invoice naming series...") + + # Set naming series for Sales Invoice + if frappe.db.exists("DocType", "Sales Invoice"): + prop_setter_name = "Sales Invoice-naming_series-options" + if not frappe.db.exists("Property Setter", prop_setter_name): + try: + ps = frappe.get_doc({ + "doctype": "Property Setter", + "doctype_or_field": "DocType", + "doc_type": "Sales Invoice", + "field_name": "naming_series", + "property": "options", + "value": "DIDI-INV-.YYYY.-.#####\nACC-SINV-.YYYY.-", + "property_type": "Text" + }) + ps.insert(ignore_permissions=True) + print(" Invoice naming series configured: DIDI-INV-YYYY-#####") + except Exception as e: + print(f" Naming series: {e}") + else: + print(" Naming series already configured.") + + +def setup_crm_pipeline(): + """Configure CRM sales pipeline stages.""" + print("[4/6] Configuring CRM pipeline...") + + # CRM Sales Stages + stages = [ + {"stage_name": "Lead", "department": ""}, + {"stage_name": "Calificat", "department": ""}, + {"stage_name": "Demo", "department": ""}, + {"stage_name": "Client", "department": ""}, + ] + + for stage_data in stages: + if not frappe.db.exists("Sales Stage", stage_data["stage_name"]): + stage = frappe.get_doc({ + "doctype": "Sales Stage", + "stage_name": stage_data["stage_name"] + }) + stage.insert(ignore_permissions=True) + print(f" Created stage: {stage_data['stage_name']}") + + # Lead Source for website forms + sources = ["Website - Contact Form", "Website - Pricing Page", "Website - Demo Request"] + for source_name in sources: + if not frappe.db.exists("Lead Source", source_name): + source = frappe.get_doc({ + "doctype": "Lead Source", + "source_name": source_name + }) + source.insert(ignore_permissions=True) + + print(" CRM pipeline configured: Lead > Calificat > Demo > Client") + + +def setup_subscription_items(): + """Create subscription plan items.""" + print("[5/6] Creating subscription items and plans...") + + # Item Group for services + if not frappe.db.exists("Item Group", "DiDi Services"): + ig = frappe.get_doc({ + "doctype": "Item Group", + "item_group_name": "DiDi Services", + "parent_item_group": "All Item Groups" + }) + ig.insert(ignore_permissions=True) + + # Subscription Items + items = [ + { + "item_code": "DIDI-FREE", + "item_name": "Abonament DiDi Free", + "description": "Plan gratuit - acces de baza la platforma DiDi pentru analiza dezinformarii", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 0 + }, + { + "item_code": "DIDI-PAID", + "item_name": "Abonament DiDi Paid", + "description": "Plan platit - acces complet la platforma DiDi cu credite lunare pentru analize avansate", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 99 + }, + { + "item_code": "DIDI-ENTERPRISE", + "item_name": "Abonament DiDi Enterprise", + "description": "Plan enterprise - acces nelimitat la platforma DiDi, suport dedicat, API avansat", + "item_group": "DiDi Services", + "stock_uom": "Nos", + "is_stock_item": 0, + "standard_rate": 499 + }, + ] + + for item_data in items: + if not frappe.db.exists("Item", item_data["item_code"]): + item = frappe.get_doc({"doctype": "Item", **item_data}) + item.insert(ignore_permissions=True) + print(f" Created item: {item_data['item_code']} ({item_data['standard_rate']} RON)") + + # Subscription Plans + plans = [ + {"plan_name": "DiDi Free - Lunar", "item": "DIDI-FREE", "cost": 0, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Paid - Lunar", "item": "DIDI-PAID", "cost": 99, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Paid - Anual", "item": "DIDI-PAID", "cost": 999, "billing_interval": "Year", "billing_interval_count": 1}, + {"plan_name": "DiDi Enterprise - Lunar", "item": "DIDI-ENTERPRISE", "cost": 499, "billing_interval": "Month", "billing_interval_count": 1}, + {"plan_name": "DiDi Enterprise - Anual", "item": "DIDI-ENTERPRISE", "cost": 4990, "billing_interval": "Year", "billing_interval_count": 1}, + ] + + for plan_data in plans: + if not frappe.db.exists("Subscription Plan", plan_data["plan_name"]): + plan = frappe.get_doc({ + "doctype": "Subscription Plan", + "plan_name": plan_data["plan_name"], + "item": plan_data["item"], + "price_determination": "Fixed Rate", + "cost": plan_data["cost"], + "currency": "RON", + "billing_interval": plan_data["billing_interval"], + "billing_interval_count": plan_data["billing_interval_count"] + }) + plan.insert(ignore_permissions=True) + print(f" Created plan: {plan_data['plan_name']} ({plan_data['cost']} RON/{plan_data['billing_interval']})") + + print(" Subscription items and plans configured.") + + +def setup_email_templates(): + """Create email notification templates.""" + print("[6/6] Creating email templates...") + + templates = [ + { + "name": "DiDi - Confirmare Plata", + "subject": "Plata confirmata - {{ doc.name }}", + "response": """

Stimate {{ doc.customer_name }},

+

Va confirmam ca plata pentru factura {{ doc.name }} in valoare de {{ doc.grand_total }} {{ doc.currency }} a fost procesata cu succes.

+

Puteti descarca factura din dashboard-ul contului dumneavoastra.

+

Va multumim,
Echipa DiDi

""" + }, + { + "name": "DiDi - Expirare Abonament", + "subject": "Abonamentul dumneavoastra expira in curand", + "response": """

Stimate client,

+

Va informam ca abonamentul dumneavoastra DiDi expira in 7 zile.

+

Pentru a continua sa beneficiati de serviciile noastre, va rugam sa verificati metoda de plata in dashboard-ul contului.

+

Va multumim,
Echipa DiDi

""" + }, + { + "name": "DiDi - Esec Plata", + "subject": "Problema la procesarea platii", + "response": """

Stimate client,

+

Din pacate, nu am reusit sa procesam plata pentru abonamentul dumneavoastra DiDi.

+

Va rugam sa actualizati metoda de plata in dashboard-ul contului pentru a evita intreruperea serviciului.

+

Va multumim,
Echipa DiDi

""" + }, + ] + + for tmpl in templates: + if not frappe.db.exists("Email Template", tmpl["name"]): + doc = frappe.get_doc({ + "doctype": "Email Template", + "name": tmpl["name"], + "subject": tmpl["subject"], + "response": tmpl["response"], + "use_html": 1 + }) + doc.insert(ignore_permissions=True) + print(f" Created template: {tmpl['name']}") + + print(" Email templates configured.") diff --git a/erp_crm/scripts/setup/02-doctypes.py b/erp_crm/scripts/setup/02-doctypes.py new file mode 100644 index 0000000..cf7ba01 --- /dev/null +++ b/erp_crm/scripts/setup/02-doctypes.py @@ -0,0 +1,262 @@ +""" +Create custom DocTypes for DiDi Platform. +""" +import frappe +import json + + +def execute(): + create_service_agreement() + create_payment_log() + create_website_content() + add_custom_fields() + frappe.db.commit() + print("\n=== Custom DocTypes Created ===\n") + + +def create_service_agreement(): + """DocType: Service Agreement - contract auto-generat la activarea abonamentului.""" + print("[1/4] Creating Service Agreement DocType...") + + if frappe.db.exists("DocType", "Service Agreement"): + print(" Already exists, skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Service Agreement", + "module": "Didi Custom", + "custom": 0, + "is_submittable": 0, + "autoname": "DIDI-SA-.YYYY.-.#####", + "title_field": "customer_name", + "search_fields": "customer,plan,status", + "fields": [ + {"fieldname": "customer", "label": "Customer", "fieldtype": "Link", "options": "Customer", "reqd": 1, "in_list_view": 1}, + {"fieldname": "customer_name", "label": "Customer Name", "fieldtype": "Data", "fetch_from": "customer.customer_name", "read_only": 1}, + {"fieldname": "column_break_1", "fieldtype": "Column Break"}, + {"fieldname": "plan", "label": "Subscription Plan", "fieldtype": "Link", "options": "Subscription Plan", "reqd": 1, "in_list_view": 1}, + {"fieldname": "status", "label": "Status", "fieldtype": "Select", "options": "Draft\nAccepted\nExpired\nCancelled", "default": "Draft", "in_list_view": 1, "reqd": 1}, + {"fieldname": "section_acceptance", "label": "Acceptance Details", "fieldtype": "Section Break"}, + {"fieldname": "acceptance_date", "label": "Acceptance Date", "fieldtype": "Datetime"}, + {"fieldname": "client_ip", "label": "Client IP", "fieldtype": "Data", "read_only": 1}, + {"fieldname": "column_break_2", "fieldtype": "Column Break"}, + {"fieldname": "terms_version", "label": "Terms Version", "fieldtype": "Data"}, + {"fieldname": "subscription", "label": "Subscription", "fieldtype": "Link", "options": "Subscription"}, + {"fieldname": "section_document", "label": "Agreement Document", "fieldtype": "Section Break"}, + {"fieldname": "agreement_html", "label": "Agreement Content", "fieldtype": "Text Editor"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "Sales User", "read": 1, "write": 1, "create": 1}, + ] + }) + doc.insert(ignore_permissions=True) + print(" Service Agreement DocType created.") + + +def create_payment_log(): + """DocType: Payment Log - audit trail for Stripe transactions.""" + print("[2/4] Creating Payment Log DocType...") + + if frappe.db.exists("DocType", "Payment Log"): + print(" Already exists, skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Payment Log", + "module": "Didi Custom", + "custom": 0, + "is_submittable": 0, + "autoname": "DIDI-PL-.YYYY.-.#####", + "title_field": "customer_name", + "search_fields": "customer,stripe_payment_intent_id,status", + "fields": [ + {"fieldname": "customer", "label": "Customer", "fieldtype": "Link", "options": "Customer", "in_list_view": 1}, + {"fieldname": "customer_name", "label": "Customer Name", "fieldtype": "Data", "fetch_from": "customer.customer_name", "read_only": 1}, + {"fieldname": "column_break_1", "fieldtype": "Column Break"}, + {"fieldname": "event_type", "label": "Event Type", "fieldtype": "Select", + "options": "payment_intent.succeeded\ninvoice.payment_failed\ncustomer.subscription.updated\ncustomer.subscription.deleted\nrefund.created\nother", + "in_list_view": 1}, + {"fieldname": "status", "label": "Status", "fieldtype": "Select", + "options": "Succeeded\nFailed\nRefunded\nPending", + "default": "Pending", "in_list_view": 1, "reqd": 1}, + {"fieldname": "section_stripe", "label": "Stripe Details", "fieldtype": "Section Break"}, + {"fieldname": "stripe_payment_intent_id", "label": "Stripe Payment Intent ID", "fieldtype": "Data"}, + {"fieldname": "stripe_subscription_id", "label": "Stripe Subscription ID", "fieldtype": "Data"}, + {"fieldname": "column_break_2", "fieldtype": "Column Break"}, + {"fieldname": "amount", "label": "Amount", "fieldtype": "Currency", "in_list_view": 1}, + {"fieldname": "currency", "label": "Currency", "fieldtype": "Link", "options": "Currency", "default": "RON"}, + {"fieldname": "section_raw", "label": "Raw Data", "fieldtype": "Section Break", "collapsible": 1}, + {"fieldname": "raw_webhook_data", "label": "Raw Webhook Data", "fieldtype": "Code", "options": "JSON"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "Accounts User", "read": 1}, + ] + }) + doc.insert(ignore_permissions=True) + print(" Payment Log DocType created.") + + +def create_website_content(): + """DocType: Website Content - CMS for website pages managed from ERPNext.""" + print("[3/4] Creating Website Content DocType...") + + if frappe.db.exists("DocType", "Website Content"): + print(" Already exists, skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Website Content", + "module": "Didi Custom", + "custom": 0, + "is_submittable": 0, + "autoname": "format:{page_slug}-{section_key}", + "title_field": "section_key", + "search_fields": "page_slug,section_key", + "sort_field": "display_order", + "sort_order": "ASC", + "fields": [ + {"fieldname": "page_slug", "label": "Page", "fieldtype": "Select", + "options": "homepage\npricing\ncontact\nabout\nprivacy\nterms", + "reqd": 1, "in_list_view": 1}, + {"fieldname": "section_key", "label": "Section Key", "fieldtype": "Data", + "reqd": 1, "in_list_view": 1, + "description": "Unique key for this section (e.g. hero_title, feature_1, cta_text)"}, + {"fieldname": "display_order", "label": "Display Order", "fieldtype": "Int", + "default": 0, "in_list_view": 1}, + {"fieldname": "is_active", "label": "Active", "fieldtype": "Check", + "default": 1, "in_list_view": 1}, + {"fieldname": "section_content_ro", "label": "Content (Romanian)", "fieldtype": "Section Break"}, + {"fieldname": "content_ro", "label": "Content RO", "fieldtype": "Text Editor"}, + {"fieldname": "section_content_en", "label": "Content (English)", "fieldtype": "Section Break"}, + {"fieldname": "content_en", "label": "Content EN", "fieldtype": "Text Editor"}, + {"fieldname": "section_media", "label": "Media", "fieldtype": "Section Break"}, + {"fieldname": "image", "label": "Image", "fieldtype": "Attach Image"}, + {"fieldname": "column_break_media", "fieldtype": "Column Break"}, + {"fieldname": "extra_data", "label": "Extra Data (JSON)", "fieldtype": "Code", "options": "JSON", + "description": "Additional structured data (e.g. button links, feature lists)"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "Website Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + ] + }) + doc.insert(ignore_permissions=True) + + # Populate default content + _populate_default_content() + print(" Website Content DocType created with default content.") + + +def _populate_default_content(): + """Insert default website content for all pages.""" + defaults = [ + # Homepage + {"page_slug": "homepage", "section_key": "hero_title", "display_order": 1, + "content_ro": "

Combate dezinformarea cu inteligenta artificiala

", + "content_en": "

Fight disinformation with artificial intelligence

"}, + {"page_slug": "homepage", "section_key": "hero_subtitle", "display_order": 2, + "content_ro": "

DiDi analizeaza automat continut media - text, imagini, audio si video - pentru a detecta dezinformarea si a verifica informatiile in timp real.

", + "content_en": "

DiDi automatically analyzes media content - text, images, audio and video - to detect disinformation and verify information in real time.

"}, + {"page_slug": "homepage", "section_key": "hero_cta", "display_order": 3, + "content_ro": "Incearca gratuit", + "content_en": "Try for free", + "extra_data": '{"link": "/pricing", "secondary_text_ro": "Vezi planuri", "secondary_text_en": "View plans", "secondary_link": "/pricing"}'}, + {"page_slug": "homepage", "section_key": "features", "display_order": 10, + "content_ro": "

Functionalitati

", + "content_en": "

Features

", + "extra_data": json.dumps([ + {"icon": "search", "title_ro": "Analiza Text", "title_en": "Text Analysis", "desc_ro": "Detectie automata a dezinformarii din articole si postari", "desc_en": "Automatic disinformation detection from articles and posts"}, + {"icon": "image", "title_ro": "Analiza Imagine/Video", "title_en": "Image/Video Analysis", "desc_ro": "Detectie deepfake si manipulare media", "desc_en": "Deepfake and media manipulation detection"}, + {"icon": "check", "title_ro": "Fact-Checking", "title_en": "Fact-Checking", "desc_ro": "Verificare automata a afirmatiilor cu surse credibile", "desc_en": "Automatic claim verification with credible sources"}, + {"icon": "users", "title_ro": "Human-in-the-Loop", "title_en": "Human-in-the-Loop", "desc_ro": "Colaborare intre AI si experti validatori", "desc_en": "Collaboration between AI and expert validators"}, + ])}, + {"page_slug": "homepage", "section_key": "how_it_works", "display_order": 20, + "content_ro": "

Cum functioneaza

", + "content_en": "

How it works

", + "extra_data": json.dumps([ + {"step": 1, "title_ro": "Incarca continutul", "title_en": "Upload content", "desc_ro": "Text, imagine, audio sau video", "desc_en": "Text, image, audio or video"}, + {"step": 2, "title_ro": "Analiza AI automata", "title_en": "Automatic AI analysis", "desc_ro": "Multiple modele AI analizeaza continutul", "desc_en": "Multiple AI models analyze the content"}, + {"step": 3, "title_ro": "Rezultate verificate", "title_en": "Verified results", "desc_ro": "Raport detaliat cu scor de incredere", "desc_en": "Detailed report with confidence score"}, + ])}, + # Contact + {"page_slug": "contact", "section_key": "title", "display_order": 1, + "content_ro": "

Contacteaza-ne

Ai intrebari? Echipa noastra iti sta la dispozitie.

", + "content_en": "

Contact us

Have questions? Our team is here to help.

"}, + {"page_slug": "contact", "section_key": "info", "display_order": 2, + "content_ro": "", "content_en": "", + "extra_data": json.dumps({"email": "office@clossers.com", "phone": "+40721063078", "address": "Str. Targovistei 15, Ploiesti, Romania"})}, + # Privacy + {"page_slug": "privacy", "section_key": "content", "display_order": 1, + "content_ro": "

Politica de Confidentialitate

Ultima actualizare: 2026-03-30

TOP CLOSSERS SRL respecta confidentialitatea datelor dumneavoastra personale...

", + "content_en": "

Privacy Policy

Last updated: 2026-03-30

TOP CLOSSERS SRL respects the privacy of your personal data...

"}, + # Terms + {"page_slug": "terms", "section_key": "content", "display_order": 1, + "content_ro": "

Termeni si Conditii

Ultima actualizare: 2026-03-30

Prin utilizarea serviciilor DiDi, acceptati urmatorii termeni...

", + "content_en": "

Terms and Conditions

Last updated: 2026-03-30

By using DiDi services, you accept the following terms...

"}, + ] + + for item in defaults: + name = f"{item['page_slug']}-{item['section_key']}" + if not frappe.db.exists("Website Content", name): + doc = frappe.get_doc({ + "doctype": "Website Content", + "page_slug": item["page_slug"], + "section_key": item["section_key"], + "display_order": item.get("display_order", 0), + "is_active": 1, + "content_ro": item.get("content_ro", ""), + "content_en": item.get("content_en", ""), + "extra_data": item.get("extra_data", ""), + }) + doc.insert(ignore_permissions=True) + + +def add_custom_fields(): + """Add custom fields to existing DocTypes (Lead, Customer).""" + print("[4/4] Adding custom fields to Lead and Customer...") + + custom_fields = { + "Lead": [ + {"fieldname": "source_form", "label": "Source Form", "fieldtype": "Data", + "insert_after": "source", "description": "Which website form generated this lead"}, + {"fieldname": "page_origin", "label": "Page Origin", "fieldtype": "Data", + "insert_after": "source_form", "description": "URL of the page where lead was captured"}, + {"fieldname": "utm_source", "label": "UTM Source", "fieldtype": "Data", + "insert_after": "page_origin"}, + {"fieldname": "utm_medium", "label": "UTM Medium", "fieldtype": "Data", + "insert_after": "utm_source"}, + {"fieldname": "utm_campaign", "label": "UTM Campaign", "fieldtype": "Data", + "insert_after": "utm_medium"}, + ], + "Customer": [ + {"fieldname": "didi_user_id", "label": "DiDi User ID", "fieldtype": "Data", + "insert_after": "customer_name", "unique": 1, + "description": "User ID in DiDi IAM (Keycloak)"}, + {"fieldname": "iam_role", "label": "IAM Role", "fieldtype": "Select", + "options": "\nfree_tier\npaid_tier\nenterprise_tier", + "insert_after": "didi_user_id", "description": "Current role in DiDi IAM"}, + {"fieldname": "active_plan", "label": "Active Plan", "fieldtype": "Link", + "options": "Subscription Plan", "insert_after": "iam_role"}, + {"fieldname": "plan_activation_date", "label": "Plan Activation Date", + "fieldtype": "Date", "insert_after": "active_plan"}, + ], + } + + for dt, fields in custom_fields.items(): + for field_data in fields: + field_name = f"{dt}-{field_data['fieldname']}" + if not frappe.db.exists("Custom Field", field_name): + cf = frappe.get_doc({ + "doctype": "Custom Field", + "dt": dt, + **field_data + }) + cf.insert(ignore_permissions=True) + print(f" Added {dt}.{field_data['fieldname']}") + + print(" Custom fields added.") diff --git a/erp_crm/scripts/setup/03-cleanup.py b/erp_crm/scripts/setup/03-cleanup.py new file mode 100644 index 0000000..5fbe0e6 --- /dev/null +++ b/erp_crm/scripts/setup/03-cleanup.py @@ -0,0 +1,126 @@ +""" +Cleanup ERPNext: hide unused modules, configure sidebar, check users/permissions. +""" +import frappe + + +def execute(): + cleanup_modules() + cleanup_users() + setup_workspace() + frappe.db.commit() + print("\n=== Cleanup Complete ===\n") + + +def cleanup_modules(): + """Hide modules that are not needed from the sidebar.""" + print("[1/3] Hiding unused modules...") + + # Modules to KEEP visible + keep_modules = { + "Accounts", "Selling", "CRM", "Buying", + "Didi Custom", "Setup", "Core", + } + + # Modules to HIDE + hide_modules = { + "Stock", "Manufacturing", "Assets", "Projects", + "Quality Management", "Subcontracting", "Support", + "Maintenance", "EDI", "Telephony", "Portal", + "Bulk Transaction", "ERPNext Integrations", + "Social", "Website", + } + + # Use Module Profile to restrict what Administrator sees + # Actually in ERPNext v15, we use "Allowed Modules" on User + # But for Administrator we can't restrict. Instead, we hide workspaces. + + # Hide workspaces for unused modules + workspaces_to_hide = [ + "Stock", "Manufacturing", "Assets", "Projects", + "Quality", "Subcontracting", "Support", + "HR", "Payroll", "Website", + ] + + for ws_name in workspaces_to_hide: + ws_list = frappe.get_all("Workspace", filters={"name": ["like", f"%{ws_name}%"]}) + for ws in ws_list: + doc = frappe.get_doc("Workspace", ws.name) + if doc.public: + doc.public = 0 + doc.save(ignore_permissions=True) + print(f" Hidden workspace: {ws.name}") + + print(" Module cleanup done.") + + +def cleanup_users(): + """Verify users and their roles.""" + print("[2/3] Checking users...") + + users = frappe.get_all("User", + filters={"user_type": "System User", "enabled": 1}, + fields=["name", "full_name", "user_type"] + ) + + for u in users: + roles = frappe.get_all("Has Role", + filters={"parent": u.name}, + fields=["role"] + ) + role_names = [r.role for r in roles] + print(f" User: {u.name} ({u.full_name})") + print(f" Roles: {', '.join(role_names)}") + + # Ensure website_api user has minimal roles + api_user = "website_api@didi.localhost" + if frappe.db.exists("User", api_user): + user_doc = frappe.get_doc("User", api_user) + current_roles = [r.role for r in user_doc.roles] + print(f"\n API User roles: {current_roles}") + + # Remove unnecessary roles if any + wanted_roles = {"Website Integration"} + unwanted = set(current_roles) - wanted_roles - {"All", "Guest"} + if unwanted: + for role_name in unwanted: + user_doc.roles = [r for r in user_doc.roles if r.role != role_name] + user_doc.save(ignore_permissions=True) + print(f" Removed unwanted roles: {unwanted}") + + print(" Users OK.") + + +def setup_workspace(): + """Create a clean DiDi workspace with only relevant shortcuts.""" + print("[3/3] Setting up DiDi workspace...") + + ws_name = "DiDi" + + if frappe.db.exists("Workspace", ws_name): + frappe.delete_doc("Workspace", ws_name, force=True) + + ws = frappe.get_doc({ + "doctype": "Workspace", + "name": ws_name, + "label": "DiDi", + "title": "DiDi", + "module": "Didi Custom", + "icon": "home", + "public": 1, + "sequence_id": 1, + "shortcuts": [ + {"type": "DocType", "link_to": "Customer", "label": "Clienti", "color": "#2563eb"}, + {"type": "DocType", "link_to": "Lead", "label": "Lead-uri", "color": "#7c3aed"}, + {"type": "DocType", "link_to": "Sales Invoice", "label": "Facturi", "color": "#059669"}, + {"type": "DocType", "link_to": "Subscription Plan", "label": "Planuri Abonament", "color": "#d97706"}, + {"type": "DocType", "link_to": "Subscription", "label": "Subscriptii", "color": "#dc2626"}, + {"type": "DocType", "link_to": "Website Content", "label": "Continut Website (CMS)", "color": "#0891b2"}, + {"type": "DocType", "link_to": "Service Agreement", "label": "Acorduri Servicii", "color": "#4f46e5"}, + {"type": "DocType", "link_to": "Payment Log", "label": "Log Plati", "color": "#be185d"}, + {"type": "DocType", "link_to": "Supplier", "label": "Furnizori", "color": "#64748b"}, + {"type": "DocType", "link_to": "Email Template", "label": "Template-uri Email", "color": "#475569"}, + ], + }) + ws.insert(ignore_permissions=True) + print(f" Created workspace: DiDi (with shortcuts + number cards)") diff --git a/erp_crm/scripts/setup/04-populate-content.py b/erp_crm/scripts/setup/04-populate-content.py new file mode 100644 index 0000000..5d2a346 --- /dev/null +++ b/erp_crm/scripts/setup/04-populate-content.py @@ -0,0 +1,397 @@ +""" +Populate ERPNext Website Content with real DiDi platform content. +All text content is managed here - website pulls from ERPNext CMS. +""" +import frappe +import json + + +def execute(): + populate_homepage() + populate_services() + populate_about() + populate_contact() + populate_privacy() + populate_terms() + frappe.db.commit() + print("\n=== Content populated ===\n") + + +def upsert(page_slug, section_key, content_ro, content_en, display_order=0, extra_data=""): + """Create or update Website Content entry.""" + name = f"{page_slug}-{section_key}" + if frappe.db.exists("Website Content", name): + doc = frappe.get_doc("Website Content", name) + doc.content_ro = content_ro + doc.content_en = content_en + doc.display_order = display_order + doc.extra_data = extra_data + doc.is_active = 1 + doc.save(ignore_permissions=True) + else: + doc = frappe.get_doc({ + "doctype": "Website Content", + "page_slug": page_slug, + "section_key": section_key, + "content_ro": content_ro, + "content_en": content_en, + "display_order": display_order, + "extra_data": extra_data, + "is_active": 1, + }) + doc.insert(ignore_permissions=True) + print(f" {name}") + + +def populate_homepage(): + print("[1/6] Homepage...") + + upsert("homepage", "hero_title", + "Combate dezinformarea cu inteligenta artificiala", + "Fight disinformation with artificial intelligence", 1) + + upsert("homepage", "hero_subtitle", + "Platforma DiDi de la Clossers analizeaza automat continut media — text, imagini, audio si video — pentru a detecta dezinformarea si a verifica informatiile in timp real.", + "The DiDi platform by Clossers automatically analyzes media content — text, images, audio and video — to detect disinformation and verify information in real time.", 2) + + upsert("homepage", "hero_cta", + "Incepe gratuit", "Start free", 3, + json.dumps({"link": "/pricing", "secondary_text_ro": "Descopera serviciile", "secondary_text_en": "Discover services", "secondary_link": "/services"})) + + upsert("homepage", "features", + "Servicii de detectie si analiza", "Detection and analysis services", 10, + json.dumps([ + {"icon": "text", "title_ro": "Analiza Text & NLP", "title_en": "Text Analysis & NLP", + "desc_ro": "Detectie automata a propagandei, limbajului manipulativ si a informatiilor false din articole si postari", + "desc_en": "Automatic detection of propaganda, manipulative language and false information from articles and posts"}, + {"icon": "image", "title_ro": "Detectie Deepfake", "title_en": "Deepfake Detection", + "desc_ro": "Identificare imagini si videoclipuri generate sau manipulate prin AI, inclusiv face-swap si lip-sync", + "desc_en": "Identification of AI-generated or manipulated images and videos, including face-swap and lip-sync"}, + {"icon": "check", "title_ro": "Fact-Checking Automat", "title_en": "Automated Fact-Checking", + "desc_ro": "Verificare automata a afirmatiilor cu surse oficiale, baze de date credibile si articole de presa verificate", + "desc_en": "Automatic verification of claims with official sources, credible databases and verified press articles"}, + {"icon": "source", "title_ro": "Evaluare Surse", "title_en": "Source Assessment", + "desc_ro": "Scor de credibilitate pentru domenii web, publicatii si conturi social media cu analiza in profunzime", + "desc_en": "Credibility score for web domains, publications and social media accounts with in-depth analysis"}, + ])) + + upsert("homepage", "how_it_works", + "Cum functioneaza DiDi", "How DiDi works", 20, + json.dumps([ + {"step": 1, "title_ro": "Incarca continutul", "title_en": "Upload content", + "desc_ro": "Trimite un text, URL, imagine sau video pentru analiza. Suportam toate formatele majore.", "desc_en": "Submit a text, URL, image or video for analysis. We support all major formats."}, + {"step": 2, "title_ro": "Analiza AI multi-model", "title_en": "Multi-model AI analysis", + "desc_ro": "6 module AI specializate ruleaza in paralel: analiza text, detectie deepfake, fact-checking, evaluare surse, detectie tehnici de manipulare si monitorizare.", "desc_en": "6 specialized AI modules run in parallel: text analysis, deepfake detection, fact-checking, source assessment, manipulation technique detection and monitoring."}, + {"step": 3, "title_ro": "Validare Human-in-the-Loop", "title_en": "Human-in-the-Loop validation", + "desc_ro": "Expertii nostri verifica si valideaza rezultatele AI pentru acuratete maxima. Combinam viteza AI cu expertiza umana.", "desc_en": "Our experts verify and validate AI results for maximum accuracy. We combine AI speed with human expertise."}, + {"step": 4, "title_ro": "Raport detaliat", "title_en": "Detailed report", + "desc_ro": "Primesti un raport complet: scor de incredere, dovezi, surse, tehnici identificate si recomandari. Export PDF disponibil.", "desc_en": "Receive a complete report: confidence score, evidence, sources, identified techniques and recommendations. PDF export available."}, + ])) + + upsert("homepage", "stats", + "DiDi in cifre", "DiDi in numbers", 30, + json.dumps([ + {"value": "6", "label_ro": "Module AI specializate", "label_en": "Specialized AI modules"}, + {"value": "18+", "label_ro": "Tehnici de manipulare detectate", "label_en": "Manipulation techniques detected"}, + {"value": "<30s", "label_ro": "Timp mediu analiza", "label_en": "Average analysis time"}, + {"value": "99.5%", "label_ro": "Disponibilitate platforma", "label_en": "Platform availability"}, + ])) + + upsert("homepage", "use_cases", + "Cine foloseste DiDi?", "Who uses DiDi?", 40, + json.dumps([ + {"title_ro": "Redactii & Jurnalisti", "title_en": "Newsrooms & Journalists", + "desc_ro": "Verificare rapida a informatiilor inainte de publicare. Detectie surse nesigure si continut manipulat.", + "desc_en": "Quick information verification before publishing. Detection of unreliable sources and manipulated content."}, + {"title_ro": "Agentii de comunicare", "title_en": "Communication agencies", + "desc_ro": "Monitorizare spatiu mediatic, analiza narrativa si protectie brand impotriva dezinformarii.", + "desc_en": "Media space monitoring, narrative analysis and brand protection against disinformation."}, + {"title_ro": "Institutii publice", "title_en": "Public institutions", + "desc_ro": "Detectie campanii de dezinformare care vizeaza institutii, alegeri sau politici publice.", + "desc_en": "Detection of disinformation campaigns targeting institutions, elections or public policies."}, + {"title_ro": "Cercetatori", "title_en": "Researchers", + "desc_ro": "API si instrumente de analiza pentru studiul fenomenului dezinformarii in proiecte academice.", + "desc_en": "API and analysis tools for studying the disinformation phenomenon in academic projects."}, + {"title_ro": "ONG-uri & Fact-checkeri", "title_en": "NGOs & Fact-checkers", + "desc_ro": "Automatizare si accelerare a procesului de verificare cu instrumente profesionale.", + "desc_en": "Automation and acceleration of the verification process with professional tools."}, + {"title_ro": "Companii", "title_en": "Companies", + "desc_ro": "Protectia reputatiei impotriva stirilor false. Monitorizare si alerte automate.", + "desc_en": "Reputation protection against fake news. Automated monitoring and alerts."}, + ])) + + +def populate_services(): + print("[2/6] Services...") + + modules_data = [ + {"id": "text-analysis", "order": 1, + "title_ro": "Analiza Text & NLP", "title_en": "Text Analysis & NLP", + "subtitle_ro": "Detectie automata a dezinformarii din articole, postari si documente", + "subtitle_en": "Automatic detection of disinformation from articles, posts and documents", + "desc_ro": "Motorul nostru de procesare a limbajului natural analizeaza textul la nivel semantic, identificand tipare de manipulare, limbaj emotional excesiv, afirmatii neverificabile si inconsistente factuale. Suporta limba romana si engleza cu modele antrenate pe corpus media local.", + "desc_en": "Our natural language processing engine analyzes text at semantic level, identifying manipulation patterns, excessive emotional language, unverifiable claims and factual inconsistencies. Supports Romanian and English with models trained on local media corpus.", + "capabilities_ro": ["Detectie propaganda si limbaj manipulativ", "Analiza sentimentului si a tonului emotional", "Identificare afirmatii factuale vs. opinii", "Detectie clickbait si titluri inselatoare", "Analiza coerenta narativa", "Suport limba romana si engleza"], + "capabilities_en": ["Propaganda and manipulative language detection", "Sentiment and emotional tone analysis", "Factual claims vs opinions identification", "Clickbait and misleading headline detection", "Narrative coherence analysis", "Romanian and English support"]}, + + {"id": "deepfake", "order": 2, + "title_ro": "Detectie Deepfake & Manipulare Media", "title_en": "Deepfake & Media Manipulation Detection", + "subtitle_ro": "Identificare imagini si videoclipuri manipulate prin AI", + "subtitle_en": "Identification of AI-manipulated images and videos", + "desc_ro": "Modulul de analiza vizuala foloseste retele neuronale convolutionale si transformere vizuale pentru a detecta manipulari la nivel de pixel, artefacte de generare AI, inconsistente in iluminare si geometrie faciala.", + "desc_en": "The visual analysis module uses convolutional neural networks and visual transformers to detect pixel-level manipulations, AI generation artifacts, lighting inconsistencies and facial geometry.", + "capabilities_ro": ["Detectie imagini generate de AI (DALL-E, Midjourney, Stable Diffusion)", "Detectie deepfake video (face-swap, lip-sync)", "Analiza artefacte JPEG si metadata EXIF", "Detectie splice, copy-move, inpainting", "Verificare autenticitate fotografii", "Analiza consistenta iluminare si umbre"], + "capabilities_en": ["AI-generated image detection (DALL-E, Midjourney, Stable Diffusion)", "Deepfake video detection (face-swap, lip-sync)", "JPEG artifacts and EXIF metadata analysis", "Splice, copy-move, inpainting detection", "Photo authenticity verification", "Lighting and shadow consistency analysis"]}, + + {"id": "fact-checking", "order": 3, + "title_ro": "Fact-Checking Automat", "title_en": "Automated Fact-Checking", + "subtitle_ro": "Verificare automata a afirmatiilor cu surse credibile", + "subtitle_en": "Automatic claim verification with credible sources", + "desc_ro": "Sistemul de fact-checking extrage automat afirmatiile verificabile din text, le compara cu baze de date de fapte verificate, surse oficiale si articole de presa credibile. Foloseste tehnici de retrieval-augmented generation (RAG) si reranking.", + "desc_en": "The fact-checking system automatically extracts verifiable claims from text, compares them with verified fact databases, official sources and credible press articles. Uses retrieval-augmented generation (RAG) and reranking techniques.", + "capabilities_ro": ["Extractie automata a afirmatiilor verificabile", "Cautare in baze de date de fact-checking", "Verificare cu surse oficiale (INS, Eurostat, OMS)", "Scor de credibilitate cu explicatii", "Linkuri directe catre surse", "Istoric verificari"], + "capabilities_en": ["Automatic verifiable claim extraction", "Fact-checking database search", "Official source verification (INS, Eurostat, WHO)", "Credibility score with explanations", "Direct source links", "Verification history"]}, + + {"id": "source-assessment", "order": 4, + "title_ro": "Evaluare Surse & Domenii", "title_en": "Source & Domain Assessment", + "subtitle_ro": "Analiza credibilitatii surselor de informare", + "subtitle_en": "Information source credibility analysis", + "desc_ro": "Modulul de evaluare a surselor analizeaza domenii web, publicatii si conturi de social media pentru a determina nivelul de credibilitate. Combina indicatori tehnici cu indicatori editoriali.", + "desc_en": "The source assessment module analyzes web domains, publications and social media accounts to determine credibility level. Combines technical and editorial indicators.", + "capabilities_ro": ["Scor credibilitate domeniu (0-100)", "Verificare WHOIS si istoric domeniu", "Analiza retea de link-uri", "Detectie site-uri clone si typosquatting", "Monitorizare reputatie", "Baza de date surse de dezinformare"], + "capabilities_en": ["Domain credibility score (0-100)", "WHOIS and domain history verification", "Link network analysis", "Clone sites and typosquatting detection", "Reputation monitoring", "Disinformation source database"]}, + + {"id": "techniques", "order": 5, + "title_ro": "Detectie Tehnici de Manipulare", "title_en": "Manipulation Technique Detection", + "subtitle_ro": "Identificarea tehnicilor de propaganda si dezinformare", + "subtitle_en": "Identification of propaganda and disinformation techniques", + "desc_ro": "Bazat pe taxonomia tehnicilor de propaganda dezvoltata in cercetare academica, acest modul identifica si clasifica tehnicile specifice de manipulare folosite intr-un text. De la apel la emotii pana la whataboutism si cherry-picking.", + "desc_en": "Based on the propaganda techniques taxonomy developed in academic research, this module identifies and classifies specific manipulation techniques used in text. From appeal to emotions to whataboutism and cherry-picking.", + "capabilities_ro": ["Clasificare pe 18+ tehnici de propaganda", "Apel la emotii (frica, furie, empatie falsa)", "Apel la autoritate falsa sau anonima", "Generalizari pripite si stereotipuri", "Whataboutism si false echivalente", "Cherry-picking si scoatere din context"], + "capabilities_en": ["Classification of 18+ propaganda techniques", "Appeal to emotions (fear, anger, false empathy)", "Appeal to false or anonymous authority", "Hasty generalizations and stereotypes", "Whataboutism and false equivalences", "Cherry-picking and out-of-context"]}, + + {"id": "monitoring", "order": 6, + "title_ro": "Monitorizare Media & Alerte", "title_en": "Media Monitoring & Alerts", + "subtitle_ro": "Supraveghere continua a spatiului informational", + "subtitle_en": "Continuous information space surveillance", + "desc_ro": "Sistemul de monitorizare scaneaza in timp real surse media, retele sociale si site-uri de stiri pentru a identifica narrativa dezinformante emergente. Alertele automate notifica echipa cand se detecteaza campanii coordonate.", + "desc_en": "The monitoring system scans media sources, social networks and news sites in real time to identify emerging disinformation narratives. Automatic alerts notify the team when coordinated campaigns are detected.", + "capabilities_ro": ["Monitorizare 24/7 surse media si social media", "Detectie narrativa emergente si trenduri", "Alerte automate pe email si in dashboard", "Identificare campanii coordonate", "Harta de raspandire a dezinformarii", "Rapoarte periodice automate"], + "capabilities_en": ["24/7 media and social media monitoring", "Emerging narrative and trend detection", "Automatic email and dashboard alerts", "Coordinated campaign identification", "Disinformation spread map", "Automatic periodic reports"]}, + ] + + for mod in modules_data: + upsert("services", f"module_{mod['id']}", + f"

{mod['title_ro']}

{mod['subtitle_ro']}

{mod['desc_ro']}

", + f"

{mod['title_en']}

{mod['subtitle_en']}

{mod['desc_en']}

", + mod["order"], + json.dumps({ + "id": mod["id"], + "title_ro": mod["title_ro"], "title_en": mod["title_en"], + "subtitle_ro": mod["subtitle_ro"], "subtitle_en": mod["subtitle_en"], + "desc_ro": mod["desc_ro"], "desc_en": mod["desc_en"], + "capabilities_ro": mod["capabilities_ro"], + "capabilities_en": mod["capabilities_en"], + })) + + +def populate_about(): + print("[3/6] About...") + + upsert("about", "hero", + "

Despre Clossers si platforma DiDi

TOP CLOSSERS SRL dezvolta solutii digitale inovatoare pentru combaterea dezinformarii si protectia spatiului informational romanesc si european.

", + "

About Clossers and the DiDi platform

TOP CLOSSERS SRL develops innovative digital solutions for combating disinformation and protecting the Romanian and European information space.

", 1) + + upsert("about", "mission", + "

Misiunea noastra

DiDi (Platforma Digitala Inteligenta pentru Prevenirea si Combaterea Dezinformarii) este raspunsul nostru la una dintre cele mai mari provocari ale erei digitale: dezinformarea. Combinam inteligenta artificiala de ultima generatie cu expertiza umana pentru a oferi instrumente profesionale de verificare a informatiilor.

", + "

Our mission

DiDi (Intelligent Digital Platform for Preventing and Combating Disinformation) is our answer to one of the biggest challenges of the digital era: disinformation. We combine cutting-edge artificial intelligence with human expertise to provide professional information verification tools.

", 2) + + upsert("about", "project", + "

Proiect PNRR

Platforma DiDi este dezvoltata in cadrul proiectului finantat prin Planul National de Redresare si Rezilienta (PNRR), Componenta C9 - Suport pentru sectorul privat, cercetare, dezvoltare si inovare, Investitia I3 - Scheme de ajutor pentru sectorul privat. Contract de finantare nr. 11.1.i3.c9/10.05.2024.

", + "

PNRR Project

The DiDi platform is developed within the project funded by the National Recovery and Resilience Plan (NRRP), Component C9 - Support for private sector, research, development and innovation, Investment I3 - Aid schemes for private sector. Funding contract no. 11.1.i3.c9/10.05.2024.

", 3) + + upsert("about", "technology", + "

Tehnologie

Platforma DiDi integreaza multiple modele AI/ML intr-o arhitectura de orchestrare multi-model. Fiecare tip de analiza (text, imagine, video, fact-checking) este procesat de module specializate care ruleaza in paralel, oferind rezultate in timp real. Arhitectura este containerizata, scalabila si gazduita in cloud pe servere din Uniunea Europeana.

", + "

Technology

The DiDi platform integrates multiple AI/ML models in a multi-model orchestration architecture. Each type of analysis (text, image, video, fact-checking) is processed by specialized modules running in parallel, providing real-time results. The architecture is containerized, scalable and hosted in cloud on EU servers.

", 4) + + upsert("about", "company", + "", "", 5, + json.dumps({ + "name": "TOP CLOSSERS SRL", + "cui": "36193026", + "reg_com": "J2022000345035", + "address": "Str. Targovistei 15, Bl. 2, Et. 3, Ap. 22, Ploiesti, Prahova 100299", + "email": "office@clossers.com", + "phone": "+40721063078", + "website": "https://clossers.com", + })) + + +def populate_contact(): + print("[4/6] Contact...") + + upsert("contact", "title", + "

Contacteaza-ne

Ai intrebari despre platforma DiDi sau vrei sa discutam despre nevoile tale? Echipa noastra iti sta la dispozitie.

", + "

Contact us

Have questions about the DiDi platform or want to discuss your needs? Our team is here to help.

", 1) + + upsert("contact", "info", "", "", 2, + json.dumps({ + "email": "office@clossers.com", + "phone": "+40721063078", + "address_ro": "Str. Targovistei 15, Bl. 2, Et. 3, Ap. 22\nPloiesti, Prahova 100299\nRomania", + "address_en": "15 Targovistei St., Bl. 2, Fl. 3, Ap. 22\nPloiesti, Prahova 100299\nRomania", + "schedule_ro": "Luni - Vineri, 09:00 - 18:00", + "schedule_en": "Monday - Friday, 09:00 - 18:00", + })) + + +def populate_privacy(): + print("[5/6] Privacy...") + + upsert("privacy", "content", + """

Politica de Confidentialitate

+

Ultima actualizare: 30 martie 2026

+ +

1. Introducere

+

TOP CLOSSERS SRL (CUI: 36193026), cu sediul in Ploiesti, Str. Targovistei 15, opereaza platforma DiDi si site-ul clossers.com. Respectam confidentialitatea datelor dumneavoastra personale si ne conformam Regulamentului General privind Protectia Datelor (GDPR - Regulamentul UE 2016/679).

+ +

2. Date colectate

+

Colectam urmatoarele categorii de date:

+
    +
  • Date de identificare: nume, prenume, adresa email, numar telefon, denumire companie, CUI
  • +
  • Date de autentificare: gestionate prin Keycloak (Identity and Access Management) - nu stocam parole
  • +
  • Date de utilizare: continut incarcat pentru analiza, istoricul analizelor, credite consumate
  • +
  • Date de plata: gestionate de Stripe - nu stocam date de card
  • +
  • Date tehnice: adresa IP, tip browser, cookie-uri (conform politicii de cookie-uri)
  • +
+ +

3. Scopul prelucrarii

+
    +
  • Furnizarea serviciilor platformei DiDi (analiza continut media)
  • +
  • Gestionarea contului si a abonamentului
  • +
  • Facturare si evidenta contabila
  • +
  • Comunicari tranzactionale (facturi, notificari cont)
  • +
  • Imbunatatirea serviciilor si suport tehnic
  • +
+ +

4. Temeiul legal

+
    +
  • Executarea contractului (furnizarea serviciilor)
  • +
  • Obligatii legale (facturare, contabilitate)
  • +
  • Consimtamant (cookie-uri analitice, comunicari marketing)
  • +
  • Interes legitim (securitate, prevenire frauda)
  • +
+ +

5. Stocarea datelor

+

Toate datele sunt stocate exclusiv pe servere localizate in Uniunea Europeana, conform cerintelor GDPR. Datele sunt criptate in tranzit (TLS 1.2+) si la repaus (AES-256).

+ +

6. Drepturile dumneavoastra

+

Conform GDPR, aveti dreptul la:

+
    +
  • Acces - puteti vizualiza datele din dashboard-ul contului
  • +
  • Rectificare - puteti modifica datele din profilul contului
  • +
  • Stergere - puteti solicita stergerea contului din dashboard (sectiunea Profil)
  • +
  • Portabilitate - puteti exporta datele in format CSV din dashboard
  • +
  • Opozitie - puteti retrage consimtamantul pentru cookie-uri analitice
  • +
+ +

7. Contact DPO

+

Pentru exercitarea drepturilor sau intrebari privind protectia datelor: office@clossers.com

""", + + """

Privacy Policy

+

Last updated: March 30, 2026

+ +

1. Introduction

+

TOP CLOSSERS SRL (Tax ID: 36193026), headquartered in Ploiesti, 15 Targovistei St., operates the DiDi platform and clossers.com website. We respect the privacy of your personal data and comply with the General Data Protection Regulation (GDPR - EU Regulation 2016/679).

+ +

2. Data collected

+
    +
  • Identification data: name, email, phone, company name, tax ID
  • +
  • Authentication data: managed by Keycloak (IAM) - we do not store passwords
  • +
  • Usage data: content uploaded for analysis, analysis history, credits consumed
  • +
  • Payment data: managed by Stripe - we do not store card data
  • +
  • Technical data: IP address, browser type, cookies
  • +
+ +

3. Purpose

+
    +
  • Providing DiDi platform services
  • +
  • Account and subscription management
  • +
  • Invoicing and accounting
  • +
  • Transactional communications
  • +
  • Service improvement and support
  • +
+ +

4. Your rights

+

Under GDPR: access, rectification, erasure, portability, objection. Contact: office@clossers.com

""", 1) + + +def populate_terms(): + print("[6/6] Terms...") + + upsert("terms", "content", + """

Termeni si Conditii

+

Ultima actualizare: 30 martie 2026

+ +

1. Definitii

+
    +
  • Furnizor: TOP CLOSSERS SRL, CUI 36193026, operator al platformei DiDi
  • +
  • Utilizator: persoana fizica sau juridica care acceseaza si utilizeaza serviciile DiDi
  • +
  • Platforma: platforma digitala DiDi accesibila prin intermediul site-ului clossers.com
  • +
  • Servicii: serviciile de analiza si detectie a dezinformarii oferite prin platforma DiDi
  • +
+ +

2. Servicii oferite

+

Platforma DiDi ofera servicii de analiza automata a continutului media (text, imagini, audio, video) pentru detectarea dezinformarii, prin intermediul unor module AI specializate: analiza text, detectie deepfake, fact-checking automat, evaluare surse, detectie tehnici de manipulare si monitorizare media.

+ +

3. Inregistrare si cont

+

Accesul la servicii necesita crearea unui cont. Utilizatorul este responsabil pentru securitatea credentialelor de acces si pentru toate activitatile desfasurate prin contul sau.

+ +

4. Planuri si plata

+

Serviciile sunt disponibile in mai multe planuri (Free, Paid, Enterprise). Platile sunt procesate securizat prin Stripe. Facturile sunt generate automat si disponibile in dashboard.

+ +

5. Utilizare acceptabila

+

Utilizatorul se obliga sa nu foloseasca platforma pentru:

+
    +
  • Activitati ilegale sau care incalca drepturile tertilor
  • +
  • Incarcarea de continut ilegal sau care incalca drepturile de autor
  • +
  • Tentative de compromitere a securitatii platformei
  • +
  • Utilizare automata abuziva (scraping, DDoS)
  • +
+ +

6. Proprietate intelectuala

+

Platforma DiDi, algoritmii, modelele AI si documentatia sunt proprietatea TOP CLOSSERS SRL. Utilizatorul pastreaza drepturile asupra continutului incarcat pentru analiza.

+ +

7. Limitarea raspunderii

+

Rezultatele analizelor DiDi sunt furnizate ca instrumente de suport decizional si nu constituie adevar absolut. TOP CLOSSERS SRL nu raspunde pentru decizii luate exclusiv pe baza rezultatelor platformei.

+ +

8. Legislatie aplicabila

+

Prezentii termeni sunt guvernati de legislatia din Romania. Orice litigiu va fi solutionat de instantele competente din Ploiesti, Romania.

+ +

9. Contact

+

office@clossers.com | +40 721 063 078

""", + + """

Terms and Conditions

+

Last updated: March 30, 2026

+ +

1. Definitions

+
    +
  • Provider: TOP CLOSSERS SRL, Tax ID 36193026, operator of DiDi platform
  • +
  • User: person or entity accessing DiDi services
  • +
  • Platform: DiDi digital platform accessible via clossers.com
  • +
+ +

2. Services

+

DiDi provides automated media content analysis services for disinformation detection through specialized AI modules.

+ +

3. Payment

+

Services available in Free, Paid, Enterprise plans. Payments processed via Stripe. Invoices generated automatically.

+ +

4. Acceptable use

+

Users must not use the platform for illegal activities, copyright infringement, security attacks, or abusive automation.

+ +

5. Governing law

+

Romanian law applies. Disputes resolved by Ploiesti courts.

+ +

6. Contact

+

office@clossers.com

""", 1) diff --git a/erp_crm/scripts/setup/05-translations.py b/erp_crm/scripts/setup/05-translations.py new file mode 100644 index 0000000..6d00b71 --- /dev/null +++ b/erp_crm/scripts/setup/05-translations.py @@ -0,0 +1,244 @@ +""" +Set ERPNext to Romanian + add custom translations for DiDi labels. +Run: python setup-translations.py +""" +import requests +import json +import sys +import io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +URL = "http://localhost:8080" +s = requests.Session() +login = s.post(f"{URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +if login.status_code != 200: + print("Login failed"); exit(1) +print("Logged in as Administrator") + +# ── 1. Set system language to Romanian ── +print("\n1. Setting system language to Romanian...") +r = s.put(f"{URL}/api/resource/System%20Settings/System%20Settings", json={ + "language": "ro", + "date_format": "dd.mm.yyyy", + "time_format": "HH:mm:ss", + "number_format": "#.###,##", + "country": "Romania", + "first_day_of_the_week": "Monday", +}) +print(f" System Settings: {r.status_code}") + +# ── 2. Set Administrator language to RO ── +print("\n2. Setting Administrator language...") +r = s.put(f"{URL}/api/resource/User/Administrator", json={"language": "ro"}) +print(f" Admin language: {r.status_code}") + +# Also API user +r = s.put(f"{URL}/api/resource/User/website_api@didi-erp", json={"language": "ro"}) +print(f" API user language: {r.status_code}") + +# ── 3. Custom translations for DiDi labels ── +print("\n3. Adding custom translations...") + +TRANSLATIONS = { + # DocType names + "Sales Invoice": "Factura de Vanzare", + "Customer": "Client", + "Lead": "Lead", + "Payment Log": "Jurnal Plati", + "Payment Entry": "Incasare", + "Service Agreement": "Acord Servicii", + "Website Content": "Continut Website", + "Analysis Report": "Raport Analiza", + "Subscription Plan": "Plan Abonament", + "Item": "Articol / Serviciu", + "Item Group": "Grup Articole", + "Supplier": "Furnizor", + "Company": "Companie", + "Account": "Cont Contabil", + "Purchase Invoice": "Factura de Achizitie", + "Email Template": "Sablon Email", + "Sales Taxes and Charges Template": "Sablon Taxe (TVA)", + "Lead Source": "Sursa Lead", + "Sales Stage": "Etapa Vanzare", + + # Common fields + "Customer Name": "Nume Client", + "Customer Type": "Tip Client", + "Customer Group": "Grup Clienti", + "Territory": "Teritoriu", + "Grand Total": "Total General", + "Net Total": "Total Net", + "Outstanding Amount": "Suma Restanta", + "Posting Date": "Data Emiterii", + "Due Date": "Data Scadenta", + "Status": "Status", + "Currency": "Moneda", + "Paid": "Platita", + "Unpaid": "Neplatita", + "Overdue": "Restanta", + "Cancelled": "Anulata", + "Draft": "Ciorna", + "Submitted": "Confirmata", + "Amount": "Suma", + "Rate": "Pret Unitar", + "Quantity": "Cantitate", + "Qty": "Cant.", + "Description": "Descriere", + "Total": "Total", + "Tax": "Taxa", + "Taxes": "Taxe", + "Discount": "Reducere", + "Payment Type": "Tip Plata", + "Receive": "Incasare", + + # DiDi custom fields + "Analysis Consumed": "Analiza Consumata", + "Analysis Session ID": "ID Sesiune Analiza", + "DiDi User ID": "ID Utilizator DiDi", + "IAM Role": "Rol IAM", + "Active Plan": "Plan Activ", + "Plan Activation Date": "Data Activare Plan", + "Source Form": "Formular Sursa", + "Page Origin": "Pagina Origine", + + # Custom DocType fields + "Acceptance Date": "Data Acceptarii", + "Client IP": "IP Client", + "Terms Version": "Versiune Termeni", + "Agreement HTML": "Continut Acord", + "Stripe Payment Intent ID": "ID Plata Stripe", + "Stripe Session ID": "ID Sesiune Stripe", + "Raw Webhook Data": "Date Brute Webhook", + "Event Type": "Tip Eveniment", + "Page Slug": "Slug Pagina", + "Section Key": "Cheie Sectiune", + "Display Order": "Ordine Afisare", + "Is Active": "Activ", + "Content RO": "Continut RO", + "Content EN": "Continut EN", + "Extra Data": "Date Extra", + "Report Title": "Titlu Raport", + "Generated At": "Generat La", + "Result JSON": "Rezultat JSON", + "PDF File": "Fisier PDF", + "Media Type": "Tip Media", + "Component": "Componenta", + + # Workspace & navigation + "Home": "Acasa", + "Settings": "Setari", + "Search": "Cauta", + "Help": "Ajutor", + "Logout": "Deconectare", + "Save": "Salveaza", + "Submit": "Confirma", + "Cancel": "Anuleaza", + "Delete": "Sterge", + "New": "Nou", + "Edit": "Editeaza", + "Print": "Tipareste", + "Download": "Descarca", + "Filter": "Filtreaza", + "Sort By": "Sorteaza dupa", + "Created By": "Creat de", + "Modified By": "Modificat de", + "Owner": "Proprietar", + "Creation": "Data Creare", + "Modified": "Data Modificare", + "Actions": "Actiuni", + "Add Row": "Adauga Rand", + "Select All": "Selecteaza Tot", + + # Reports + "Balance Sheet": "Bilant", + "Profit and Loss Statement": "Cont Profit si Pierdere", + "General Ledger": "Registru General", + "Trial Balance": "Balanta de Verificare", + "Accounts Receivable": "Debitori", + "Accounts Payable": "Creditori", + + # CRM + "Lead Name": "Nume Lead", + "Email Address": "Adresa Email", + "Phone": "Telefon", + "Source": "Sursa", + "Campaign": "Campanie", + "Converted": "Convertit", + "Do Not Contact": "Nu Contacta", + "Replied": "Raspuns", + "Interested": "Interesat", + "Quotation": "Oferta", + "Opportunity": "Oportunitate", + + # Subscription + "Plan Name": "Nume Plan", + "Billing Interval": "Interval Facturare", + "Cost": "Cost", + "Month": "Lunar", + "Year": "Anual", + + # Common actions/buttons + "Amend": "Modifica", + "Duplicate": "Duplica", + "Rename": "Redenumeste", + "Reload": "Reincarca", + "Close": "Inchide", + "Yes": "Da", + "No": "Nu", + "Confirm": "Confirma", + "Apply": "Aplica", + "Clear": "Curata", + "Refresh": "Actualizeaza", + "Export": "Exporta", + "Import": "Importa", + + # Misc + "Romania": "Romania", + "Commercial": "Comercial", + "Individual": "Persoana Fizica", + "Company": "Companie", + "All Item Groups": "Toate Grupurile", + "DiDi Services": "Servicii DiDi", + "Succeeded": "Reusita", + "Failed": "Esuata", + "Refunded": "Rambursata", + "Pending": "In Asteptare", + "Completed": "Finalizat", + "Accepted": "Acceptat", + "Expired": "Expirat", +} + +created = 0 +skipped = 0 +for source, translated in TRANSLATIONS.items(): + # Check if translation exists + check = s.get(f"{URL}/api/resource/Translation", params={ + "filters": json.dumps([["language", "=", "ro"], ["source_text", "=", source]]), + "fields": json.dumps(["name"]), + "limit_page_length": 1, + }) + existing = check.json().get("data", []) + + if existing: + skipped += 1 + continue + + r = s.post(f"{URL}/api/resource/Translation", json={ + "language": "ro", + "source_text": source, + "translated_text": translated, + }) + if r.status_code in (200, 201): + created += 1 + else: + print(f" Error: {source} -> {r.status_code}") + +print(f" Created: {created}, Skipped (existing): {skipped}") + +# ── 4. Clear cache to apply translations ── +print("\n4. Clearing cache...") +r = s.post(f"{URL}/api/method/frappe.client.clear_cache") +print(f" Cache cleared: {r.status_code}") + +print(f"\nDone! {created} traduceri noi adaugate.") +print("Refresh ERPNext (Ctrl+Shift+R) pentru a vedea traducerile.") diff --git a/erp_crm/scripts/setup/06-sidebar.py b/erp_crm/scripts/setup/06-sidebar.py new file mode 100644 index 0000000..8be5b67 --- /dev/null +++ b/erp_crm/scripts/setup/06-sidebar.py @@ -0,0 +1,205 @@ +"""Fix sidebar workspaces — use simple names without spaces.""" +import requests +import json + +ERPNEXT_URL = "http://localhost:8080" +session = requests.Session() +login = session.post(f"{ERPNEXT_URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +if login.status_code != 200: + print(f"Login failed"); exit(1) +print("Logged in") + + +def api_post(endpoint, data): + return session.post(f"{ERPNEXT_URL}{endpoint}", json=data) + +def api_put(endpoint, data): + return session.put(f"{ERPNEXT_URL}{endpoint}", json=data) + +def api_get(endpoint): + return session.get(f"{ERPNEXT_URL}{endpoint}") + +def api_delete(endpoint): + return session.delete(f"{ERPNEXT_URL}{endpoint}") + + +# Delete broken workspaces first +print("\n1. Cleaning broken workspaces...") +for name in ["Clienti", "CRM DiDi", "Servicii DiDi", "Plati DiDi", "Contabilitate DiDi", "Website CMS DiDi"]: + r = api_delete(f"/api/resource/Workspace/{requests.utils.quote(name)}") + print(f" Delete {name}: {r.status_code}") + +# Workspace definitions — simple slugified names +WORKSPACES = [ + { + "label": "Facturi", + "title": "Facturi", + "icon": "file-text", + "indicator_color": "green", + "sequence_id": 10, + "shortcuts": [ + {"type": "DocType", "link_to": "Sales Invoice", "label": "Toate Facturile", "color": "Green", "doc_view": "List"}, + {"type": "DocType", "link_to": "Payment Entry", "label": "Payment Entry", "color": "Yellow", "doc_view": "List"}, + ], + "links": [ + {"type": "Card Break", "label": "Facturi"}, + {"type": "Link", "label": "Sales Invoice", "link_to": "Sales Invoice", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Payment Entry", "link_to": "Payment Entry", "link_type": "DocType"}, + {"type": "Link", "label": "Taxe (TVA)", "link_to": "Sales Taxes and Charges Template", "link_type": "DocType"}, + ], + }, + { + "label": "Clienti", + "title": "Clienti", + "icon": "users", + "indicator_color": "blue", + "sequence_id": 20, + "shortcuts": [ + {"type": "DocType", "link_to": "Customer", "label": "Toti Clientii", "color": "Blue", "doc_view": "List"}, + {"type": "DocType", "link_to": "Service Agreement", "label": "Acorduri Servicii", "color": "Purple", "doc_view": "List"}, + ], + "links": [ + {"type": "Card Break", "label": "Clienti"}, + {"type": "Link", "label": "Customer", "link_to": "Customer", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Service Agreement", "link_to": "Service Agreement", "link_type": "DocType"}, + {"type": "Link", "label": "Subscription Plan", "link_to": "Subscription Plan", "link_type": "DocType"}, + ], + }, + { + "label": "CRM-DiDi", + "title": "CRM", + "icon": "share", + "indicator_color": "orange", + "sequence_id": 30, + "shortcuts": [ + {"type": "DocType", "link_to": "Lead", "label": "Lead-uri", "color": "Orange", "doc_view": "List"}, + {"type": "DocType", "link_to": "Sales Stage", "label": "Sales Stage", "color": "Yellow"}, + {"type": "DocType", "link_to": "Lead Source", "label": "Lead Source", "color": "Grey"}, + ], + "links": [ + {"type": "Card Break", "label": "CRM"}, + {"type": "Link", "label": "Lead", "link_to": "Lead", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Sales Stage", "link_to": "Sales Stage", "link_type": "DocType"}, + {"type": "Link", "label": "Lead Source", "link_to": "Lead Source", "link_type": "DocType"}, + ], + }, + { + "label": "Servicii", + "title": "Servicii", + "icon": "box", + "indicator_color": "purple", + "sequence_id": 40, + "shortcuts": [ + {"type": "DocType", "link_to": "Item", "label": "Articole", "color": "Purple", "doc_view": "List"}, + {"type": "DocType", "link_to": "Subscription Plan", "label": "Planuri Abonament", "color": "Cyan"}, + ], + "links": [ + {"type": "Card Break", "label": "Catalog"}, + {"type": "Link", "label": "Item", "link_to": "Item", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Item Group", "link_to": "Item Group", "link_type": "DocType"}, + {"type": "Link", "label": "Subscription Plan", "link_to": "Subscription Plan", "link_type": "DocType"}, + ], + }, + { + "label": "Plati", + "title": "Plati", + "icon": "credit-card", + "indicator_color": "yellow", + "sequence_id": 50, + "shortcuts": [ + {"type": "DocType", "link_to": "Payment Log", "label": "Payment Log", "color": "Yellow", "doc_view": "List"}, + {"type": "DocType", "link_to": "Analysis Report", "label": "Rapoarte Analiza", "color": "Pink", "doc_view": "List"}, + ], + "links": [ + {"type": "Card Break", "label": "Plati"}, + {"type": "Link", "label": "Payment Log", "link_to": "Payment Log", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Analysis Report", "link_to": "Analysis Report", "link_type": "DocType"}, + ], + }, + { + "label": "Contabilitate", + "title": "Contabilitate", + "icon": "calculator", + "indicator_color": "", + "sequence_id": 60, + "shortcuts": [ + {"type": "DocType", "link_to": "Account", "label": "Plan Conturi", "color": "Grey", "doc_view": "Tree"}, + {"type": "DocType", "link_to": "Supplier", "label": "Furnizori", "color": "Blue"}, + {"type": "DocType", "link_to": "Company", "label": "Companie", "color": "Green"}, + ], + "links": [ + {"type": "Card Break", "label": "Contabilitate"}, + {"type": "Link", "label": "Account", "link_to": "Account", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Company", "link_to": "Company", "link_type": "DocType"}, + {"type": "Link", "label": "Supplier", "link_to": "Supplier", "link_type": "DocType"}, + {"type": "Link", "label": "Purchase Invoice", "link_to": "Purchase Invoice", "link_type": "DocType"}, + ], + }, + { + "label": "Website-CMS", + "title": "Website CMS", + "icon": "globe", + "indicator_color": "cyan", + "sequence_id": 70, + "shortcuts": [ + {"type": "DocType", "link_to": "Website Content", "label": "Continut Website", "color": "Cyan", "doc_view": "List"}, + {"type": "DocType", "link_to": "Email Template", "label": "Template Email", "color": "Grey"}, + ], + "links": [ + {"type": "Card Break", "label": "CMS"}, + {"type": "Link", "label": "Website Content", "link_to": "Website Content", "link_type": "DocType", "onboard": 1}, + {"type": "Link", "label": "Email Template", "link_to": "Email Template", "link_type": "DocType"}, + ], + }, +] + +print("\n2. Creating workspaces...") +for ws in WORKSPACES: + label = ws["label"] + # Check if already exists + check = api_get(f"/api/resource/Workspace/{requests.utils.quote(label)}") + + payload = { + "label": label, + "title": ws["title"], + "icon": ws["icon"], + "indicator_color": ws["indicator_color"], + "is_hidden": 0, + "public": 1, + "module": "Didi Custom", + "sequence_id": ws["sequence_id"], + "content": json.dumps([ + {"id": "h1", "type": "header", "data": {"text": f"{ws['title']}", "col": 12}}, + ] + [ + {"id": f"s{i}", "type": "shortcut", "data": {"shortcut_name": s["label"], "col": 4}} + for i, s in enumerate(ws["shortcuts"]) + ]), + "shortcuts": ws["shortcuts"], + "links": ws["links"], + } + + if check.status_code == 200: + r = api_put(f"/api/resource/Workspace/{requests.utils.quote(label)}", payload) + status = "Updated" if r.status_code == 200 else f"Error {r.status_code}" + else: + r = api_post("/api/resource/Workspace", payload) + status = "Created" if r.status_code in (200, 201) else f"Error {r.status_code}" + if r.status_code not in (200, 201): + print(f" {label}: {status} — {r.text[:200]}") + continue + + print(f" {label}: {status}") + +# Verify +print("\n3. Final sidebar:") +r = session.post(f"{ERPNEXT_URL}/api/method/frappe.client.get_list", json={ + "doctype": "Workspace", + "filters": [["public", "=", 1], ["is_hidden", "=", 0]], + "fields": ["name", "title", "icon", "sequence_id"], + "order_by": "sequence_id asc", + "limit_page_length": 20, +}) +for ws in r.json().get("message", []): + print(f" [{ws.get('sequence_id', '?'):>3}] {ws.get('icon', '?'):15s} {ws['title']:20s} → /app/{ws['name'].lower().replace(' ', '-')}") + +print("\nDone! Ctrl+Shift+R in browser.") diff --git a/erp_crm/scripts/setup/07-reports.py b/erp_crm/scripts/setup/07-reports.py new file mode 100644 index 0000000..09d8a6b --- /dev/null +++ b/erp_crm/scripts/setup/07-reports.py @@ -0,0 +1,92 @@ +"""Create 3 Script Reports in ERPNext for DiDi.""" +import sys, io, json, requests +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") + +URL = "http://localhost:8080" +s = requests.Session() +s.post(f"{URL}/api/method/login", data={"usr": "Administrator", "pwd": "admin"}) +print("Logged in") + +REPORTS = [ + { + "report_name": "DiDi Dashboard Financiar", + "ref_doctype": "Sales Invoice", + "report_type": "Script Report", + "is_standard": "No", + "module": "Accounts", + "report_script": """result = [] +total = frappe.db.sql("SELECT COALESCE(SUM(grand_total),0) FROM `tabSales Invoice` WHERE docstatus=1 AND status!='Cancelled'")[0][0] +result.append({"indicator": "Total Venituri (RON)", "valoare": total}) + +luna = frappe.db.sql("SELECT COALESCE(SUM(grand_total),0) FROM `tabSales Invoice` WHERE docstatus=1 AND status!='Cancelled' AND MONTH(posting_date)=MONTH(CURDATE()) AND YEAR(posting_date)=YEAR(CURDATE())")[0][0] +result.append({"indicator": "Venituri Luna Curenta (RON)", "valoare": luna}) + +nr_facturi = frappe.db.sql("SELECT COUNT(*) FROM `tabSales Invoice` WHERE docstatus=1 AND status!='Cancelled'")[0][0] +result.append({"indicator": "Nr. Facturi Emise", "valoare": nr_facturi}) + +nr_platite = frappe.db.sql("SELECT COUNT(*) FROM `tabSales Invoice` WHERE docstatus=1 AND status='Paid'")[0][0] +result.append({"indicator": "Nr. Facturi Platite", "valoare": nr_platite}) + +nr_clienti = frappe.db.sql("SELECT COUNT(DISTINCT customer) FROM `tabSales Invoice` WHERE docstatus=1")[0][0] +result.append({"indicator": "Clienti Activi", "valoare": nr_clienti}) + +columns = [{"fieldname":"indicator","label":"Indicator","fieldtype":"Data","width":300},{"fieldname":"valoare","label":"Valoare","fieldtype":"Currency","width":200}] +data = result""", + }, + { + "report_name": "DiDi Raport CRM", + "ref_doctype": "Lead", + "report_type": "Script Report", + "is_standard": "No", + "module": "CRM", + "report_script": """result = [] +total = frappe.db.sql("SELECT COUNT(*) FROM `tabLead`")[0][0] +result.append({"metric": "Total Lead-uri", "numar": total}) + +surse = frappe.db.sql("SELECT IFNULL(source,'Necunoscut') as s, COUNT(*) as c FROM `tabLead` GROUP BY source ORDER BY c DESC") +for row in surse: + result.append({"metric": f"Sursa: {row[0]}", "numar": row[1]}) + +statusuri = frappe.db.sql("SELECT status, COUNT(*) as c FROM `tabLead` GROUP BY status ORDER BY c DESC") +for row in statusuri: + result.append({"metric": f"Status: {row[0]}", "numar": row[1]}) + +columns = [{"fieldname":"metric","label":"Metric","fieldtype":"Data","width":350},{"fieldname":"numar","label":"Numar","fieldtype":"Int","width":150}] +data = result""", + }, + { + "report_name": "DiDi Clienti per Plan", + "ref_doctype": "Customer", + "report_type": "Script Report", + "is_standard": "No", + "module": "CRM", + "report_script": """result = [] +total = frappe.db.sql("SELECT COUNT(*) FROM `tabCustomer`")[0][0] +result.append({"plan": "Total Clienti", "numar": total}) + +roluri = frappe.db.sql("SELECT IFNULL(NULLIF(iam_role,''),'fara_rol') as r, COUNT(*) as c FROM `tabCustomer` GROUP BY iam_role ORDER BY c DESC") +for row in roluri: + result.append({"plan": f"Rol: {row[0]}", "numar": row[1]}) + +planuri = frappe.db.sql("SELECT IFNULL(NULLIF(active_plan,''),'fara_plan') as p, COUNT(*) as c FROM `tabCustomer` GROUP BY active_plan ORDER BY c DESC") +for row in planuri: + result.append({"plan": f"Plan: {row[0]}", "numar": row[1]}) + +columns = [{"fieldname":"plan","label":"Plan / Rol","fieldtype":"Data","width":350},{"fieldname":"numar","label":"Numar","fieldtype":"Int","width":150}] +data = result""", + }, +] + +for rpt in REPORTS: + name = rpt["report_name"] + check = s.get(f"{URL}/api/resource/Report/{requests.utils.quote(name)}") + if check.status_code == 200: + print(f" Exists: {name}") + continue + r = s.post(f"{URL}/api/resource/Report", json=rpt) + if r.status_code in (200, 201): + print(f" Created: {name}") + else: + print(f" Error {name}: {r.status_code} {r.text[:200]}") + +print("Done!") diff --git a/erp_crm/scripts/setup/08-email.py b/erp_crm/scripts/setup/08-email.py new file mode 100644 index 0000000..becdb6f --- /dev/null +++ b/erp_crm/scripts/setup/08-email.py @@ -0,0 +1,175 @@ +"""Setup email notifications in ERPNext for DiDi. + +Required env vars: + SENDGRID_API_KEY — SendGrid SMTP credential (starts with `SG.`) + ERPNEXT_ADMIN_PASSWORD — ERPNext Administrator password (default: admin, dev only) + ERPNEXT_URL — ERPNext base URL (default: http://localhost:8080) +""" +import sys, io, os, requests +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") + +SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") +if not SENDGRID_API_KEY: + print("ERROR: SENDGRID_API_KEY env var not set. Export it before running:") + print(" export SENDGRID_API_KEY='SG.your-real-key-here'") + exit(1) + +URL = os.environ.get("ERPNEXT_URL", "http://localhost:8080") +ADMIN_PWD = os.environ.get("ERPNEXT_ADMIN_PASSWORD", "admin") + +s = requests.Session() +r = s.post(f"{URL}/api/method/login", data={"usr": "Administrator", "pwd": ADMIN_PWD}) +if r.status_code != 200: + print(f"Login failed: {r.status_code} — check ERPNEXT_ADMIN_PASSWORD") + exit(1) +print("Logged in") + +def exists(doctype, name): + r = s.get(f"{URL}/api/resource/{requests.utils.quote(doctype)}/{requests.utils.quote(name)}") + return r.status_code == 200 + +# ── 1. Email Account (SendGrid) ── +print("\n1. Email Account...") +if exists("Email Account", "DiDi Outgoing"): + print(" Already exists") +else: + r = s.post(f"{URL}/api/resource/Email%20Account", json={ + "email_account_name": "DiDi Outgoing", + "email_id": "office@clossers.com", + "smtp_server": "smtp.sendgrid.net", + "smtp_port": 587, + "use_tls": 1, + "use_ssl": 0, + "login_id_for_outgoing": 1, + "login_id": "apikey", + "password": SENDGRID_API_KEY, + "default_outgoing": 1, + "enable_outgoing": 1, + "enable_incoming": 0, + "send_notifications": 1, + "always_use_account_email_id_as_sender": 1, + "always_use_account_name_as_sender_name": 1, + "append_to": "", + }) + print(f" Created: {r.status_code}") + +# ── 2. Notification: Factura Emisa ── +print("\n2. Notification: Factura Emisa...") +if exists("Notification", "DiDi - Factura Emisa"): + print(" Already exists") +else: + r = s.post(f"{URL}/api/resource/Notification", json={ + "name": "DiDi - Factura Emisa", + "subject": "Factura {{ doc.name }} - {{ doc.grand_total }} {{ doc.currency }}", + "document_type": "Sales Invoice", + "event": "Submit", + "channel": "Email", + "attach_print": 1, + "print_format": "DiDi Invoice", + "condition": "doc.docstatus == 1", + "recipients": [ + { + "receiver_by_document_field": "contact_email", + } + ], + "message": """
+
+

DiDi - Factura Emisa

+
+
+

Stimate client,

+

Va informam ca a fost emisa factura {{ doc.name }}.

+ + + + + +
Nr. Factura{{ doc.name }}
Data{{ doc.posting_date }}
Total{{ doc.grand_total }} {{ doc.currency }}
Status{{ doc.status }}
+

Factura in format PDF este atasata la acest email.

+

Va multumim,
Echipa DiDi / Clossers

+
+
+TOP CLOSSERS SRL | CUI: 36193026 | office@clossers.com +
+
""", + "enabled": 1, + }) + print(f" Created: {r.status_code}") + +# ── 3. Notification: Plata Confirmata ── +print("\n3. Notification: Plata Confirmata...") +if exists("Notification", "DiDi - Plata Confirmata"): + print(" Already exists") +else: + r = s.post(f"{URL}/api/resource/Notification", json={ + "name": "DiDi - Plata Confirmata", + "subject": "Plata confirmata - {{ doc.amount }} {{ doc.currency }}", + "document_type": "Payment Log", + "event": "New", + "channel": "Email", + "condition": "doc.status == 'Succeeded'", + "recipients": [ + { + "receiver_by_document_field": "customer", + } + ], + "message": """
+
+

Plata Confirmata

+
+
+

Stimate client,

+

Va confirmam ca plata dumneavoastra a fost procesata cu succes.

+ + + + +
Suma{{ doc.amount }} {{ doc.currency }}
Status{{ doc.status }}
Data{{ doc.creation }}
+

Factura fiscala va fi disponibila in dashboard-ul dumneavoastra.

+

Va multumim,
Echipa DiDi / Clossers

+
+
+TOP CLOSSERS SRL | CUI: 36193026 | office@clossers.com +
+
""", + "enabled": 1, + }) + print(f" Created: {r.status_code}") + +# ── 4. Notification: Expirare Abonament (7 zile inainte) ── +print("\n4. Notification: Expirare Abonament...") +if exists("Notification", "DiDi - Expirare Abonament Notificare"): + print(" Already exists") +else: + r = s.post(f"{URL}/api/resource/Notification", json={ + "name": "DiDi - Expirare Abonament Notificare", + "subject": "Abonamentul dumneavoastra expira in curand", + "document_type": "Subscription", + "event": "Days Before", + "days_in_advance": 7, + "date_changed": "current_invoice_end", + "channel": "Email", + "condition": "doc.status == 'Active'", + "recipients": [ + { + "receiver_by_document_field": "party", + } + ], + "message": """
+
+

Abonament - Expirare Apropiata

+
+
+

Stimate client,

+

Va informam ca abonamentul dumneavoastra expira pe {{ doc.current_invoice_end }}.

+

Pentru a continua sa beneficiati de serviciile DiDi, va rugam sa reinnoti abonamentul din dashboard.

+

Va multumim,
Echipa DiDi / Clossers

+
+
""", + "enabled": 1, + }) + print(f" Created: {r.status_code}") + +print("\nDone! Email infrastructure ready.") +print("Email Account 'DiDi Outgoing' configured with key from SENDGRID_API_KEY env var.") +print("To rotate the key, run this script again with a new SENDGRID_API_KEY value.") diff --git a/erp_crm/scripts/setup/09-phase2-final.py b/erp_crm/scripts/setup/09-phase2-final.py new file mode 100644 index 0000000..4b7c59f --- /dev/null +++ b/erp_crm/scripts/setup/09-phase2-final.py @@ -0,0 +1,288 @@ +""" +ERPNext Phase 2 Final Setup: Buying, API User, Reports +""" +import frappe +import json + + +def execute(): + setup_buying() + setup_api_user() + setup_print_format() + frappe.db.commit() + print("\n=== Phase 2 Final Setup Complete ===\n") + + +def setup_buying(): + """2.6 - Configure suppliers and expense categories.""" + print("[1/3] Configuring suppliers and expenses...") + + company_abbr = "TC" + + # Create expense accounts under existing parent + expenses_parent = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "root_type": "Expense", "is_group": 1, "parent_account": ["like", "%Expenses%"]}, + "name" + ) + if not expenses_parent: + expenses_parent = frappe.db.get_value("Account", + {"company": "TOP CLOSSERS SRL", "root_type": "Expense", "is_group": 1}, + "name" + ) + + if expenses_parent: + expense_accounts = [ + {"account_name": "Hosting si Infrastructura", "account_type": "Expense Account"}, + {"account_name": "Servicii Software", "account_type": "Expense Account"}, + {"account_name": "Marketing si Publicitate", "account_type": "Expense Account"}, + {"account_name": "Servicii Plati (Stripe)", "account_type": "Expense Account"}, + ] + for acc in expense_accounts: + full_name = f"{acc['account_name']} - {company_abbr}" + if not frappe.db.exists("Account", full_name): + try: + a = frappe.get_doc({ + "doctype": "Account", + "account_name": acc["account_name"], + "parent_account": expenses_parent, + "account_type": acc["account_type"], + "company": "TOP CLOSSERS SRL", + }) + a.insert(ignore_permissions=True) + print(f" Created account: {acc['account_name']}") + except Exception as e: + print(f" Account {acc['account_name']}: {e}") + + # Create suppliers + suppliers = [ + {"supplier_name": "Hetzner Online GmbH", "supplier_group": "Services", "country": "Germany", + "supplier_type": "Company"}, + {"supplier_name": "Stripe Payments Europe", "supplier_group": "Services", "country": "Ireland", + "supplier_type": "Company"}, + {"supplier_name": "Twilio SendGrid", "supplier_group": "Services", "country": "United States", + "supplier_type": "Company"}, + ] + + # Ensure supplier group exists + if not frappe.db.exists("Supplier Group", "Services"): + sg = frappe.get_doc({"doctype": "Supplier Group", "supplier_group_name": "Services"}) + sg.insert(ignore_permissions=True) + + for s in suppliers: + if not frappe.db.exists("Supplier", s["supplier_name"]): + doc = frappe.get_doc({"doctype": "Supplier", **s}) + doc.insert(ignore_permissions=True) + print(f" Created supplier: {s['supplier_name']}") + + print(" Buying configured.") + + +def setup_api_user(): + """2.9 - Create dedicated API user for website integration.""" + print("[2/3] Creating API user and role...") + + # Create custom role + if not frappe.db.exists("Role", "Website Integration"): + role = frappe.get_doc({ + "doctype": "Role", + "role_name": "Website Integration", + "desk_access": 0, + "is_custom": 1 + }) + role.insert(ignore_permissions=True) + print(" Created role: Website Integration") + + # Set permissions for the role + doctypes_read_write = [ + "Customer", "Lead", "Sales Invoice", "Payment Entry", "Subscription", + "Subscription Plan", "Website Content", "Service Agreement", + "Analysis Report", + "Payment Log", "Item", "Address" + ] + doctypes_read_only = [ + "Company", "Account", "Sales Taxes and Charges Template", + "Email Template", "Sales Stage" + ] + + for dt in doctypes_read_write: + try: + # Check if DocType exists + if not frappe.db.exists("DocType", dt): + continue + + existing_name = frappe.db.exists("Custom DocPerm", {"parent": dt, "role": "Website Integration"}) + values = { + "read": 1, + "write": 1, + "create": 1, + "delete": 0, + "email": 0, + "print": 1, + "export": 1, + "submit": 1 if dt in ("Sales Invoice", "Payment Entry") else 0, + } + + if existing_name: + frappe.db.set_value("Custom DocPerm", existing_name, values, update_modified=False) + else: + perm = frappe.get_doc({ + "doctype": "Custom DocPerm", + "parent": dt, + "parenttype": "DocType", + "parentfield": "permissions", + "role": "Website Integration", + **values, + }) + perm.insert(ignore_permissions=True) + except Exception: + pass + + for dt in doctypes_read_only: + if not frappe.db.exists("Custom DocPerm", {"parent": dt, "role": "Website Integration"}): + try: + if frappe.db.exists("DocType", dt): + perm = frappe.get_doc({ + "doctype": "Custom DocPerm", + "parent": dt, + "parenttype": "DocType", + "parentfield": "permissions", + "role": "Website Integration", + "read": 1, + "write": 0, + "create": 0, + }) + perm.insert(ignore_permissions=True) + except Exception: + pass + + # Create API user + api_user_email = "website_api@didi.localhost" + if not frappe.db.exists("User", api_user_email): + user = frappe.get_doc({ + "doctype": "User", + "email": api_user_email, + "first_name": "Website", + "last_name": "API", + "enabled": 1, + "user_type": "System User", + "roles": [ + {"role": "Website Integration"}, + ], + "new_password": "didi_api_secure_pwd_2026!", + "send_welcome_email": 0, + }) + user.insert(ignore_permissions=True) + print(f" Created API user: {api_user_email}") + + # Generate API keys + api_secret = frappe.generate_hash(length=15) + user.reload() + user.api_key = frappe.generate_hash(length=15) + user.api_secret = api_secret + user.save(ignore_permissions=True) + + print(f" API Key: {user.api_key}") + print(f" API Secret: {api_secret}") + print(f" >>> Save these credentials in website/.env.local <<<") + else: + user = frappe.get_doc("User", api_user_email) + print(f" API user already exists: {api_user_email}") + print(f" API Key: {user.api_key}") + + print(" API user configured.") + + +def setup_print_format(): + """2.3.2 - Create custom invoice PDF print format.""" + print("[3/3] Creating custom invoice print format...") + + if frappe.db.exists("Print Format", "DiDi Invoice"): + print(" Already exists, skipping.") + return + + html = """ + +
+
+
+
FACTURA
+
{{ doc.name }}
+
Data: {{ doc.posting_date }}
+
Scadenta: {{ doc.due_date }}
+
+
+
{{ doc.company }}
+
CUI: {{ frappe.db.get_value("Company", doc.company, "tax_id") }}
+
{{ frappe.db.get_value("Company", doc.company, "address") or "Str. Targovistei 15, Ploiesti" }}
+
+
+ +
+ Catre:
+ {{ doc.customer_name }}
+ {% if doc.tax_id %}CUI: {{ doc.tax_id }}
{% endif %} + {{ doc.address_display or "" }} +
+ + + + + + + + + + + + + {% for item in doc.items %} + + + + + + + + {% endfor %} + +
Nr.DescriereCant.Pret unitarTotal
{{ loop.index }}{{ item.item_name }}
{{ item.description or "" }}
{{ item.qty }}{{ frappe.format(item.rate, {"fieldtype": "Currency", "currency": doc.currency}) }}{{ frappe.format(item.amount, {"fieldtype": "Currency", "currency": doc.currency}) }}
+ + + + {% for tax in doc.taxes %} + + {% endfor %} + +
Subtotal:{{ frappe.format(doc.net_total, {"fieldtype": "Currency", "currency": doc.currency}) }}
{{ tax.description }}:{{ frappe.format(tax.tax_amount, {"fieldtype": "Currency", "currency": doc.currency}) }}
TOTAL:{{ frappe.format(doc.grand_total, {"fieldtype": "Currency", "currency": doc.currency}) }}
+ + +
+""" + + pf = frappe.get_doc({ + "doctype": "Print Format", + "name": "DiDi Invoice", + "doc_type": "Sales Invoice", + "module": "Didi Custom", + "html": html, + "print_format_type": "Jinja", + "standard": "No", + "custom_format": 1, + "default_print_language": "ro", + }) + pf.insert(ignore_permissions=True) + print(" DiDi Invoice print format created.") diff --git a/erp_crm/seed-data.sh b/erp_crm/seed-data.sh new file mode 100755 index 0000000..7fabc41 --- /dev/null +++ b/erp_crm/seed-data.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Seed date de test pentru smoke testing +# Creeaza: 1 Customer, 3 Sales Invoices (paid), 1 Payment Log, 1 Service Agreement, 1 Lead + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +source .env 2>/dev/null || true + +ERP_URL="${ERP_URL:-http://localhost:${ERPNEXT_PORT:-8080}}" +ADMIN_PWD="${ADMIN_PASSWORD:-admin}" + +log() { printf '\033[1;36m[seed]\033[0m %s\n' "$*"; } + +# Login si get cookies +COOKIES=$(mktemp) +trap "rm -f $COOKIES" EXIT +curl -sf -c "$COOKIES" -X POST "$ERP_URL/api/method/login" \ + -d "usr=Administrator&pwd=$ADMIN_PWD" >/dev/null || { + echo "Login esuat. Verifica ca ERPNext ruleaza la $ERP_URL si parola e $ADMIN_PWD" + exit 1 +} + +# Helper +erp() { + local method="$1" path="$2" body="${3:-}" + if [ -n "$body" ]; then + curl -sf -b "$COOKIES" -X "$method" "$ERP_URL$path" \ + -H "Content-Type: application/json" -d "$body" + else + curl -sf -b "$COOKIES" -X "$method" "$ERP_URL$path" + fi +} + +KEYCLOAK_ID="${KEYCLOAK_ID:-14142351-ad1e-466b-ac5d-4a7a0ff562bf}" + +# ── 1. Customer Test ──────────────────────────────────────── +log "1/5 Creez Customer 'Test User'..." +if erp GET "/api/resource/Customer/Test%20User" >/dev/null 2>&1; then + log " Test User exista deja, skip" +else + erp POST "/api/resource/Customer" "$(cat < /dev/null && log " OK" +fi + +# ── 2. Sales Invoices (3 bucati) ──────────────────────────── +log "2/5 Creez 3 Sales Invoices..." +for i in 1 2 3; do + INV_JSON=$(erp POST "/api/resource/Sales%20Invoice" "$(cat </dev/null) || { log " Esec creare factura #$i"; continue; } + + INV_NAME=$(echo "$INV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['name'])") + + # Submit + FULL=$(erp GET "/api/resource/Sales%20Invoice/$INV_NAME") + erp POST "/api/method/frappe.client.submit" "$(echo "$FULL" | python3 -c " +import sys, json +d = json.load(sys.stdin)['data'] +print(json.dumps({'doc': d})) +")" > /dev/null + + log " ${INV_NAME} - Paid" +done + +# ── 3. Mark all invoices as paid (cheat for demo) ─────────── +log "3/5 Marchez ca platite..." +DB_NAME=$(docker exec didi-erpnext python3 -c "import json; print(json.load(open('/home/frappe/frappe-bench/sites/didi-erp/site_config.json'))['db_name'])" 2>/dev/null) +docker exec didi-mariadb mariadb -uroot -p"${MYSQL_ROOT_PASSWORD:?seteaza MYSQL_ROOT_PASSWORD in .env}" "$DB_NAME" -e " +UPDATE \`tabSales Invoice\` SET status='Paid', outstanding_amount=0 WHERE customer='Test User'; +" 2>/dev/null + +# ── 4. Payment Log (Stripe simulat) ───────────────────────── +log "4/5 Creez Payment Log..." +erp POST "/api/resource/Payment%20Log" "$(cat < /dev/null && log " OK" + +# ── 5. Service Agreement ──────────────────────────────────── +log "5/5 Creez Service Agreement..." +erp POST "/api/resource/Service%20Agreement" "$(cat <Acord servicii test - generat de seed-data.sh

" +} +JSON +)" > /dev/null && log " OK" + +# ── 6. Lead (din formularul de contact) ───────────────────── +log "Bonus: Creez 1 Lead..." +erp POST "/api/resource/Lead" "$(cat < /dev/null && log " OK" + +# ── Summary ───────────────────────────────────────────────── +log "=============================================" +log "Date seedate:" +log " 1 Customer (Test User, paid_tier)" +log " 3 Sales Invoices (DIDI-INV-2026-xxxxx, Paid)" +log " 1 Payment Log (Stripe simulat)" +log " 1 Service Agreement" +log " 1 Lead (Prospect Demo)" +log "" +log "Verificare: http://localhost:${ERPNEXT_PORT:-8080}/app/customer" +log "=============================================" diff --git a/erp_crm/test-scenarii-integrare.sh b/erp_crm/test-scenarii-integrare.sh new file mode 100644 index 0000000..977a75d --- /dev/null +++ b/erp_crm/test-scenarii-integrare.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# ============================================================================ +# Scenarii de testare integrare Website/ERP-CRM <-> backend DiDi +# Contra contractelor din pachetul de integrare (SSO, credite, one-time buy, +# alocare M2M). +# +# IMPLICIT ruleaza pe MOCK-ul din pachetul de integrare (mock-server.js pe :4000) +# — porneste-l intai: node mock-server.js +# +# Pentru platforma reala, exporta variabilele inainte de rulare (valorile le +# livreaza operatorul platformei; NU se comit in git): +# export KC_URL=https:///auth +# export DIDI_FRAMEWORK_URL=http://: +# export DIDI_API_URL=http://:/api +# export DIDI_ADMIN_CLIENT_SECRET= +# export TEST_USER= TEST_PASS= +# +# Rulare: bash test-scenarii-integrare.sh +# ============================================================================ +set -uo pipefail + +# ── Config (din env, cu default-uri pe mock) ──────────────────────────────── +KC="${KC_URL:-http://localhost:4000}" +FRAMEWORK="${DIDI_FRAMEWORK_URL:-http://localhost:4000}" +AGENT="${DIDI_API_URL:-http://localhost:4000/api}" +REALM_USERS="${REALM_USERS:-didi-clients}" # realm-ul real e didi-clients (NU didi-website) +REALM_ADMIN="${REALM_ADMIN:-didi-admins}" +WEB_CLIENT="${WEB_CLIENT_ID:-didi-web-app}" # client SSO user (public) +M2M_CLIENT="${DIDI_ADMIN_CLIENT_ID:-didi-website-m2m}" # client M2M (client_credentials) +M2M_SECRET="${DIDI_ADMIN_CLIENT_SECRET:-mock-secret}" +TEST_USER="${TEST_USER:-demo@clossers.com}" +TEST_PASS="${TEST_PASS:-demo}" + +pass=0; fail=0 +ok() { echo " ✅ PASS — $1"; pass=$((pass+1)); } +ko() { echo " ❌ FAIL — $1"; fail=$((fail+1)); } +hdr() { echo; echo "════ $1"; } +jq_get() { python3 -c "import sys,json;d=json.load(sys.stdin);print(eval('d'+sys.argv[1]))" "$1" 2>/dev/null; } + +# ── Token utilizator (simuleaza login SSO) ────────────────────────────────── +USER_TOKEN=$(curl -sk -X POST "$KC/realms/$REALM_USERS/protocol/openid-connect/token" \ + -d grant_type=password -d client_id=$WEB_CLIENT -d username="$TEST_USER" -d password="$TEST_PASS" \ + | jq_get "['access_token']") +SUB=$(echo "$USER_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq_get "['sub']") + +# ── Token M2M (client_credentials, realm admin) ───────────────────────────── +M2M_TOKEN=$(curl -sk -X POST "$KC/realms/$REALM_ADMIN/protocol/openid-connect/token" \ + -d grant_type=client_credentials -d client_id=$M2M_CLIENT -d client_secret="$M2M_SECRET" \ + | jq_get "['access_token']") + +# ════════════════════════════════════════════════════════════════════════════ +hdr "S1 · SSO / IAM — autentificare utilizator (OIDC)" +[ -n "$USER_TOKEN" ] && [ "$USER_TOKEN" != "None" ] && ok "token JWT obtinut pt $TEST_USER (sub=$SUB)" || ko "nu s-a obtinut token user" + +hdr "S2 · Provisioning la primul login (register idempotent + me)" +REG=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Authorization: Bearer $USER_TOKEN" -H "Content-Type: application/json" "$FRAMEWORK/api/auth/register" -d '{}') +{ [ "$REG" = "409" ] || [ "$REG" = "200" ] || [ "$REG" = "201" ]; } && ok "register -> HTTP $REG (409=deja existent, idempotent)" || ko "register -> HTTP $REG neasteptat" +ME=$(curl -s -H "Authorization: Bearer $USER_TOKEN" "$FRAMEWORK/api/auth/me") +echo "$ME" | grep -q '"email"' && ok "GET /api/auth/me returneaza profilul" || ko "me nu returneaza profil: ${ME:0:120}" + +hdr "S3 · Credite — backend = sursa unica de adevar" +CRED=$(curl -s -H "Authorization: Bearer $USER_TOKEN" "$FRAMEWORK/api/auth/credits") +C_REMAIN=$(echo "$CRED" | jq_get "['data']['creditsRemained']") +echo "$CRED" | grep -q creditsRemained && ok "credits: remained=$C_REMAIN plan=$(echo "$CRED" | jq_get "['data']['planName']")" || ko "credits endpoint invalid" + +hdr "S4 · Cataloage (planuri abonament + produse one-time)" +count() { python3 -c "import sys,json;d=json.load(sys.stdin);print(len(d.get('data',d)) if isinstance(d.get('data',d),list) else 0)" 2>/dev/null; } +NPLANS=$(curl -s -H "Authorization: Bearer $USER_TOKEN" "$FRAMEWORK/api/subscriptions/plans" | count) +NPROD=$(curl -s -H "Authorization: Bearer $USER_TOKEN" "$FRAMEWORK/api/subscriptions/one-time-products" | count) +[ "$NPLANS" -ge 1 ] 2>/dev/null && ok "$NPLANS planuri de abonament" || ko "catalog planuri gol" +[ "$NPROD" -ge 1 ] 2>/dev/null && ok "$NPROD produse one-time" || ko "catalog produse gol" + +hdr "S5 · ONE-TIME BUY — alocare pachet dupa plata (flux M2M, punctul 4)" +[ -n "$M2M_TOKEN" ] && [ "$M2M_TOKEN" != "None" ] && ok "token M2M obtinut (client_credentials, realm $REALM_ADMIN)" || ko "M2M token esuat" +# pasul 2: rezolvare user +USR=$(curl -s -H "Authorization: Bearer $M2M_TOKEN" "$FRAMEWORK/api/admin/users?search=$TEST_USER") +IUID=$(echo "$USR" | python3 -c "import sys,json;d=json.load(sys.stdin);data=d.get('data',d);u=data[0] if isinstance(data,list) else data;print(u['internetUserId'] if 'internetUserId' in u else u['id'])" 2>/dev/null) +BEFORE=$(echo "$USR" | python3 -c "import sys,json;d=json.load(sys.stdin);data=d.get('data',d);u=data[0] if isinstance(data,list) else data;print(u['creditsRemained'])" 2>/dev/null) +[ -n "$IUID" ] && ok "user rezolvat: internetUserId=$IUID, sold curent=$BEFORE" || ko "nu s-a rezolvat userul via M2M" +# pasul 3: alocare (simulam cumparare produs 104 Techniques-Video = 5 credite) +PKG=5 +NEWBAL=$((BEFORE + PKG)) +ALLOC=$(curl -s -X PUT -H "Authorization: Bearer $M2M_TOKEN" -H "Content-Type: application/json" "$FRAMEWORK/api/admin/users/$IUID/subscription" -d "{\"planId\":1,\"creditsRemained\":$NEWBAL}") +echo "$ALLOC" | grep -q '"success": *true' && ok "alocare pachet (+$PKG credite) -> $(echo "$ALLOC" | jq_get "['message']")" || ko "alocare esuata: ${ALLOC:0:120}" +sleep 1 +AFTER=$(curl -s -H "Authorization: Bearer $USER_TOKEN" "$FRAMEWORK/api/auth/credits" | jq_get "['data']['creditsRemained']") +[ "$AFTER" = "$NEWBAL" ] && ok "soldul reflectat in dashboard: $BEFORE -> $AFTER (via /api/auth/credits)" || ko "sold nereflectat: astept $NEWBAL, primit $AFTER" + +hdr "S6 · Analiza + debitare automata credit (per componenta)" +B2=$(curl -s -H "Authorization: Bearer $USER_TOKEN" "$FRAMEWORK/api/auth/credits" | jq_get "['data']['creditsRemained']") +# reia token proaspat (poate a expirat) +UT2=$(curl -sk -X POST "$KC/realms/$REALM_USERS/protocol/openid-connect/token" -d grant_type=password -d client_id=$WEB_CLIENT -d username="$TEST_USER" -d password="$TEST_PASS" | jq_get "['access_token']") +AN=$(curl -s -X POST -H "Authorization: Bearer $UT2" -H "Content-Type: application/json" "$AGENT/v3/techniques/analyze" -d '{"text":"BREAKING: cipuri 5G in vaccinuri, distribuie urgent inainte sa fie cenzurat!","language":"ro"}') +SID=$(echo "$AN" | python3 -c "import sys,json;d=json.load(sys.stdin);dd=d.get('data',d);print(dd.get('session_id',''))" 2>/dev/null) +[ -n "$SID" ] && ok "analiza techniques pornita (session=$SID)" || ko "analiza esuata: ${AN:0:120}" +sleep 5 +A2=$(curl -s -H "Authorization: Bearer $UT2" "$FRAMEWORK/api/auth/credits" | jq_get "['data']['creditsRemained']") +[ "$A2" -lt "$B2" ] 2>/dev/null && ok "credit debitat automat de backend: $B2 -> $A2 (techniques-text=1 credit)" || echo " ⚠️ sold $B2 -> $A2 (debitarea poate intarzia; verifica manual)" + +hdr "S7 · Istoric consum" +HIST=$(curl -s -H "Authorization: Bearer $UT2" "$AGENT/v3/pipeline/history?user_id=$SUB&limit=5") +echo "$HIST" | grep -qE '"data"|"sessions"|"history"' && ok "istoric consum returnat pt sub=$SUB" || echo " ⚠️ istoric format: ${HIST:0:120}" + +# ════════════════════════════════════════════════════════════════════════════ +hdr "REZULTAT" +echo " PASS: $pass FAIL: $fail" +echo +echo " Contracte validate: SSO, provisioning, credite (sursa unica), cataloage," +echo " one-time buy + alocare M2M, analiza cu debitare automata, istoric." diff --git a/website/.dockerignore b/website/.dockerignore new file mode 100644 index 0000000..9a59c9c --- /dev/null +++ b/website/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +*.log +.env.local diff --git a/website/.env.example b/website/.env.example new file mode 100644 index 0000000..5c3c039 --- /dev/null +++ b/website/.env.example @@ -0,0 +1,61 @@ +# ============================================================================ +# Website DiDi — variabile de mediu (TEMPLATE) +# Copiaza in .env.production si completeaza. .env.production NU se comite in git. +# +# Stratul de integrare cu backend-ul DiDi e comutabil DOAR din env: +# - mod DEMO: mock-server-ul din pachetul de integrare (mock-server.js, :4000) +# - mod REAL: host-urile platformei DiDi (livrate de operatorul platformei) +# Codul ramane neschimbat la comutare. +# ============================================================================ + +# ── ERPNext ───────────────────────────────────────────────────────────────── +# server-side (DNS docker pe didi-network) +ERPNEXT_API_URL=http://didi-erpnext:8080 +# browser-facing (linkuri/PDF directe din browserul userului) +NEXT_PUBLIC_ERPNEXT_URL=http://:8080 +# generate de didi_custom.setup_website_integration.execute (user website_api@didi-erp.local) +ERPNEXT_API_KEY= +ERPNEXT_API_SECRET= + +# ── Keycloak / Auth.js (SSO utilizatori) ──────────────────────────────────── +# ⚠️ Realm-ul real e `didi-clients` (NU `didi-website` — acela nu exista). +# Client OIDC confidential, livrat de operatorul platformei. +AUTH_KEYCLOAK_ID=didi-website-server +AUTH_KEYCLOAK_SECRET= +AUTH_KEYCLOAK_ISSUER=https:///auth/realms/didi-clients +AUTH_SECRET= +AUTH_TRUST_HOST=true +AUTH_URL=http://:3000 +# doar pe medii de test cu certificat self-signed; se SCOATE la certificat valid +#NODE_TLS_REJECT_UNAUTHORIZED=0 + +# ── Backend DiDi (framework + agent) ──────────────────────────────────────── +# DEMO: DIDI_FRAMEWORK_URL=http://localhost:4000 +# DIDI_API_URL=http://localhost:4000/api +DIDI_FRAMEWORK_URL=http://: +DIDI_API_URL=http://:/api + +# ── Cont de serviciu M2M (alocare pachete dupa plata; realm didi-admins) ──── +# DEMO: DIDI_ADMIN_TOKEN_URL=http://localhost:4000/realms/didi-admins/protocol/openid-connect/token +DIDI_ADMIN_TOKEN_URL=https:///auth/realms/didi-admins/protocol/openid-connect/token +DIDI_ADMIN_CLIENT_ID=didi-website-m2m +DIDI_ADMIN_CLIENT_SECRET= +# id-urile planurilor din backend pentru abonamente (implicit 3=paid, 6=enterprise) +#DIDI_PLAN_ID_PAID=3 +#DIDI_PLAN_ID_ENTERPRISE=6 + +# ── Stripe ────────────────────────────────────────────────────────────────── +# chei TEST pentru dezvoltare; chei LIVE la productie (dashboard.stripe.com/apikeys) +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +# OBLIGATORIU — validarea semnaturii webhook (dashboard.stripe.com/webhooks) +STRIPE_WEBHOOK_SECRET=whsec_... +# price id-urile abonamentelor (Stripe Dashboard > Products) +STRIPE_PRICE_PAID_MONTHLY=price_... +STRIPE_PRICE_PAID_YEARLY=price_... +STRIPE_PRICE_ENTERPRISE_MONTHLY=price_... +STRIPE_PRICE_ENTERPRISE_YEARLY=price_... + +# ── Site ──────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_SITE_URL=http://:3000 +NEXT_PUBLIC_DEFAULT_LOCALE=ro diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..c97e86a --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +*.log +.DS_Store +.env.production +.env.local diff --git a/website/.gitlab-ci.yml b/website/.gitlab-ci.yml new file mode 100644 index 0000000..a0e17b3 --- /dev/null +++ b/website/.gitlab-ci.yml @@ -0,0 +1,151 @@ +# DiDi Website (Next.js 16) — GitLab CI/CD +# ========================================= + +stages: + - prepare + - security + - lint + - test + - build + - deploy + - release + +variables: + NODE_ENV: "test" + npm_config_cache: "$CI_PROJECT_DIR/.npm" + +.node-cache: &node-cache + cache: + key: node-${CI_COMMIT_REF_SLUG} + paths: + - .npm/ + - node_modules/ + policy: pull-push + +install: + stage: prepare + image: node:20-slim + <<: *node-cache + script: + - npm ci --prefer-offline + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == "main" + - if: $CI_COMMIT_BRANCH == "staging" + +security:audit: + stage: security + image: node:20-slim + needs: [install] + script: + - npm audit --audit-level=moderate || true + allow_failure: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == "main" + +security:gitleaks: + stage: security + image: + name: zricethezav/gitleaks:latest + entrypoint: [""] + script: + - gitleaks detect --source . --no-banner --report-format json --report-path gitleaks-report.json + artifacts: + when: on_failure + paths: [gitleaks-report.json] + expire_in: 1 month + allow_failure: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == "main" + +lint: + stage: lint + image: node:20-slim + needs: [install] + <<: *node-cache + script: + - npx tsc --noEmit + - npm run lint + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == "main" + +test: + stage: test + image: node:20-slim + needs: [install] + <<: *node-cache + script: + - npm test -- --run --coverage 2>&1 || echo "tests pending — add Vitest suite" + allow_failure: true + artifacts: + when: always + paths: [coverage/] + expire_in: 1 week + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == "main" + +build: + stage: build + image: node:20-slim + needs: [lint] + <<: *node-cache + script: + - npm run build + artifacts: + paths: [.next/] + expire_in: 1 week + rules: + - if: $CI_COMMIT_BRANCH == "main" + - if: $CI_COMMIT_BRANCH == "staging" + +build:docker: + stage: build + needs: [build] + script: + - docker build -t didi-website:${CI_COMMIT_SHORT_SHA} -t didi-website:latest . + rules: + - if: $CI_COMMIT_BRANCH == "main" + - if: $CI_COMMIT_BRANCH == "staging" + tags: [shell, docker] + +deploy:staging: + stage: deploy + needs: [build:docker] + script: + - docker compose up -d --force-recreate didi-website + environment: + name: staging + url: https://website.example.com + rules: + - if: $CI_COMMIT_BRANCH == "staging" + when: manual + tags: [shell, docker] + +deploy:prod: + stage: deploy + needs: [build:docker] + script: + - docker compose up -d --force-recreate didi-website + environment: + name: production + url: https://didi365.eu + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: manual + tags: [shell, docker] + +release: + stage: release + image: registry.gitlab.com/gitlab-org/release-cli:latest + script: + - echo "Release ${CI_COMMIT_TAG}" + release: + tag_name: '$CI_COMMIT_TAG' + name: 'Release $CI_COMMIT_TAG' + description: 'Automated release for $CI_COMMIT_TAG' + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ diff --git a/website/CONFIGURATION.md b/website/CONFIGURATION.md new file mode 100644 index 0000000..235125e --- /dev/null +++ b/website/CONFIGURATION.md @@ -0,0 +1,279 @@ +# Configurare Website DiDi + +Variabile mediu complete, integrari externe, credentiale. + +## Fisier `.env.production` + +Localizat la `~/website/.env.production`. NU se commiteaza. + +### Variabile complete + +```env +# === ERPNext === +# URL public (afisat in browser) - pentru link-uri, redirect-uri +NEXT_PUBLIC_ERPNEXT_URL=http://localhost:8080 + +# URL intern (din container) - pentru server-side API calls +ERPNEXT_API_URL=http://didi-erpnext:8080 + +# API token: ERPNext > User > API Access +ERPNEXT_API_KEY=85e12d367334775 +ERPNEXT_API_SECRET=910a2dfda16d2af + +# === Keycloak / Auth.js === +AUTH_KEYCLOAK_ID=didi-website-server +AUTH_KEYCLOAK_SECRET=fmpupO7GkBWEy42wKkgEWIV8NcOuiK2d +AUTH_KEYCLOAK_ISSUER=https:///auth/realms/didi-clients + +# Auth.js secret (>= 32 chars random) - openssl rand -base64 32 +AUTH_SECRET=didi-nextauth-secret-change-in-production-2026 + +# Trust X-Forwarded-* headers (true cand e in spatele unui reverse proxy) +AUTH_TRUST_HOST=true + +# URL pentru callback - acelasi cu cum acceseaza userul +AUTH_URL=http://:3000 + +# === DiDi Platform API === +# In mod DEMO ambele arata spre mock-server.js (http://localhost:4000[/api]). +# Agent V3 (analize) +DIDI_API_URL=http://didi-agent-v3:24803/api + +# Framework (credite, profil user, register, cataloage, alocare pachete) +DIDI_FRAMEWORK_URL=http://didi-framework:3005 + +# === DiDi M2M (alocare pachete dupa plata; realm didi-admins) === +# Flux in 3 pasi dupa confirmarea platii Stripe (vezi src/lib/didi-backend.ts): +# token client_credentials -> GET /api/admin/users?search= (campul `id`) +# -> PUT /api/admin/users/{id}/subscription cu creditsRemained ABSOLUT. +# Clientul M2M trebuie sa aiba rol realm `admin` (altfel 403). +DIDI_ADMIN_TOKEN_URL=https:///auth/realms/didi-admins/protocol/openid-connect/token +DIDI_ADMIN_CLIENT_ID=didi-website-m2m +DIDI_ADMIN_CLIENT_SECRET= +# id-urile planurilor backend pentru abonamente (implicit 3=paid, 6=enterprise) +#DIDI_PLAN_ID_PAID=3 +#DIDI_PLAN_ID_ENTERPRISE=6 + +# === Stripe === +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx + +# Stripe price IDs (subscription recurring) +STRIPE_PRICE_PAID_MONTHLY=price_xxx +STRIPE_PRICE_PAID_YEARLY=price_xxx +STRIPE_PRICE_ENTERPRISE_MONTHLY=price_xxx +STRIPE_PRICE_ENTERPRISE_YEARLY=price_xxx + +# === Site === +NEXT_PUBLIC_SITE_URL=http://:3000 +NEXT_PUBLIC_DEFAULT_LOCALE=ro +``` + +### IMPORTANT: NEXT_PUBLIC_* vs server-side + +Next.js variabilele cu prefix `NEXT_PUBLIC_*` sunt **baked into JavaScript bundle la build**. Pentru a le schimba in productie e nevoie de REBUILD container. + +Variabilele fara prefix sunt server-side - se pot schimba prin restart (env_file reload), fara rebuild. + +| Schimbare | Actiune | +|-----------|---------| +| `NEXT_PUBLIC_*` | Rebuild + restart | +| Restul | Restart | + +## ERPNext integration + +### API token + +```bash +# Generare token in ERPNext +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp execute frappe.core.doctype.user.user.generate_keys --args "[\"Administrator\"]" +' +# Output: {"api_key": "xxx", "api_secret": "yyy"} +``` + +Pune in `.env.production` la `ERPNEXT_API_KEY` + `ERPNEXT_API_SECRET`. + +### Permisiuni + +Recomandat: NU folosi Administrator. Creeaza un user dedicat: + +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp add-user website_api@didi.local \ + --first-name Website \ + --last-name API \ + --send-welcome-email 0 +' +``` + +Apoi din UI: User > website_api > Roles > Adauga "Website Integration", "Sales User", "Customer Reader". Apoi genereaza API keys pentru acest user. + +## Keycloak integration + +### Realm `didi-clients` + +Setting | Value +---|--- +Realm name | `didi-clients` +Login with email | DA +Email as username | DA +Registration | DA (clienti se inregistreaza singuri) +Reset password | DA +Brute force protection | DA +Access token lifespan | 10 min +SSO session max | 24 ore + +### Client `didi-website-server` + +Setting | Value +---|--- +Client ID | `didi-website-server` +Client Type | Confidential +Standard flow | Enabled +Direct access grants | Enabled +Service accounts | Enabled (pentru server-to-server) +Valid redirect URIs | `http://:3000/*`, `https://didi365.eu/*` +Web origins | `http://:3000`, `https://didi365.eu`, `+` +Client Secret | (in `AUTH_KEYCLOAK_SECRET`) + +### Mapping pentru email/roles + +In client > Client scopes > Dedicated, adauga mappers: +- `email` (Mapper Type: User Property, Property: email, Token claim name: email) +- `realm-roles` (built-in, vor fi propagate) + +## Stripe integration + +### Test mode + +Foloseste cheile `pk_test_*` si `sk_test_*` pentru dezvoltare. Carduri test: +- `4242 4242 4242 4242` - success +- `4000 0000 0000 9995` - declined (insufficient funds) +- Orice CVC + data viitoare + +### Webhook + +Endpoint: `https://didi.tld/api/webhooks/stripe` + +Events de subscris: +- `checkout.session.completed` (one-time + first subscription cycle) +- `customer.subscription.created` +- `customer.subscription.updated` +- `customer.subscription.deleted` +- `invoice.paid` / `invoice.payment_succeeded` (recurring billing) +- `invoice.payment_failed` + +Signing secret: Dashboard Stripe > Webhooks > [endpoint] > "Signing secret" > `STRIPE_WEBHOOK_SECRET`. + +### Idempotency + +Toate evenimentele sunt scrise in `tabPayment Log` cu `stripe_session_id` UNIQUE. Daca acelasi event vine de 2 ori, al doilea esueaza silentios la INSERT (constraint violation) - flow garantat o-singura-data. + +## DiDi platform integration + +### Agent V3 (analize) + +```env +DIDI_API_URL=http://didi-agent-v3:24803/api +``` + +Endpoints folosite: +- `POST /v3/techniques/analyze` (cu Bearer JWT) +- `POST /v3/ai-tampered/analyze` +- `POST /v3/claims/analyze` +- `POST /v3/domain/analyze` +- `POST /v3/media/upload` (multipart, pentru imagini/audio/video) +- `GET /v3/pipeline/{session_id}/queue-status` +- `GET /v3/pipeline/{session_id}/result` +- `GET /v3/pipeline/history?user_id=X` (istoric analize) + +Auth: Bearer JWT obtinut din Keycloak (din session-token). + +### Framework (credite) + +```env +DIDI_FRAMEWORK_URL=http://didi-framework:3005 +``` + +Endpoints folosite: +- `GET /api/auth/credits` - summary credite curente user +- `GET /api/auth/me` - profil complet user + +Auth: Bearer JWT. + +## i18n + +Limbi suportate: `ro` (default) + `en`. + +Fisiere: `src/i18n/ro.json`, `src/i18n/en.json`. + +Switcher in navbar: `src/components/layout/Navbar.tsx`. + +Pentru a adauga limba noua: +1. Copiaza `ro.json` -> `es.json` (sau ce vrei) +2. Tradu valorile +3. Adauga in `src/i18n/index.ts` la lista `SUPPORTED_LOCALES` +4. Adauga in Navbar la selector + +## Sesiuni Auth.js + +Sesiunile sunt **JWT (stateless)** - nu se stocheaza in DB. Cookies setate: + +| Cookie | Continut | Securitate | +|--------|----------|------------| +| `authjs.session-token` | JWT semnat cu AUTH_SECRET | HttpOnly, SameSite=Lax | +| `authjs.csrf-token` | CSRF protection | HttpOnly, SameSite=Lax | +| `authjs.callback-url` | URL post-login | HttpOnly, SameSite=Lax | +| `authjs.pkce.code_verifier` | PKCE pentru OIDC flow | HttpOnly, scurt-trait | + +Pentru productie cu HTTPS, schimba `useSecureCookies: true` in `src/lib/auth.ts`. + +## SEO + +`src/app/sitemap.ts` + `src/app/robots.ts` genereaza automat: +- `/sitemap.xml` - toate paginile publice + rute dinamice +- `/robots.txt` - permite Google, blocheaza `/dashboard/*` + `/api/*` + +Open Graph + Twitter cards sunt setate in `src/app/layout.tsx`. + +## GDPR + +Cookie consent: `src/components/ui/CookieConsent.tsx`. 3 categorii: +- Necesare (intotdeauna ON, NextAuth/CSRF) +- Functionale (preferinte limba) +- Analitice (Google Analytics - daca adaugi) + +Drepturi self-service in `/dashboard/profile`: +- Acces (Art. 15) - vezi datele tale in profile +- Rectificare (Art. 16) - editezi datele +- Portabilitate (Art. 20) - `/api/gdpr/export` +- Stergere (Art. 17) - `/api/gdpr/delete` (cu retentie fiscala 10 ani RO) + +## Securitate + +Headers HTTP setate de Next.js default: +- `Strict-Transport-Security` (la HTTPS) +- `X-Frame-Options: DENY` +- `X-Content-Type-Options: nosniff` + +Pentru CSP (Content Security Policy), edit `next.config.ts`: +```typescript +headers: async () => [{ + source: '/(.*)', + headers: [ + { key: 'Content-Security-Policy', value: "default-src 'self'; ..." } + ] +}] +``` + +## Logging + +In dev: `console.log` -> stdout container (`docker logs`). + +In productie: recomandare adaugare `pino` cu transport spre Datadog/Loki/etc. + +Erorile critice (Auth, Stripe webhook, fulfillment) sunt logate cu `console.error` si pot fi colectate via Docker logs sau via tool extern. diff --git a/website/Dockerfile b/website/Dockerfile new file mode 100644 index 0000000..75e46e4 --- /dev/null +++ b/website/Dockerfile @@ -0,0 +1,45 @@ +# ================================ +# Stage 1: Development +# ================================ +FROM node:20-alpine AS dev + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +EXPOSE 3000 +CMD ["npm", "run", "dev"] + +# ================================ +# Stage 2: Build +# ================================ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ================================ +# Stage 3: Production +# ================================ +FROM node:20-alpine AS prod + +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/next.config.ts ./next.config.ts + +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/website/INSTALL.md b/website/INSTALL.md new file mode 100644 index 0000000..08070f9 --- /dev/null +++ b/website/INSTALL.md @@ -0,0 +1,322 @@ +# Instalare Website DiDi de la 0 + +Build complet pentru website-ul Next.js, integrat cu ERPNext + Stripe + Keycloak. + +## Prerequisite + +### Software pe host + +```bash +docker --version # >= 24.0 +docker compose version # >= v2 +``` + +NU e nevoie de Node.js pe host - se construieste in container. + +### Resurse + +- 2 GB RAM +- 5 GB spatiu disc +- Port 3000 liber + +### Retea Docker + +Aceeasi retea cu ERP/CRM: + +```bash +docker network create didi-network 2>/dev/null || true +``` + +### Servicii dependinte (trebuie sa ruleze inainte) + +- **ERPNext** (`didi-erpnext:8080`) - vezi `erp_crm/INSTALL.md` +- **Keycloak** (`didi-keycloak:8080` sau extern) +- **Stripe** cont (cheile API in .env.production) +- **DiDi Platform** (agent-v3) sau accesibil prin URL + +Daca rulezi separat, website-ul porneste oricum dar paginile auth/checkout vor da erori. + +## Build automat + +```bash +cd ~/website +bash build-from-zero.sh +``` + +Scriptul: +1. Verifica `.env.production` (creeaza-l din template daca lipseste) +2. Build imagine Docker (multi-stage: node:20-alpine -> next build -> prod) +3. Start container +4. Health check pe http://localhost:3000 + +## Build manual + +### Pas 1. `.env.production` + +Editeaza `~/website/.env.production`. Variabilele critice: + +```env +# ERPNext (intern via Docker DNS) +NEXT_PUBLIC_ERPNEXT_URL=http://localhost:8080 +ERPNEXT_API_URL=http://didi-erpnext:8080 +ERPNEXT_API_KEY= +ERPNEXT_API_SECRET= + +# Keycloak +AUTH_KEYCLOAK_ID=didi-website-server +AUTH_KEYCLOAK_SECRET= +AUTH_KEYCLOAK_ISSUER=https:///auth/realms/didi-clients +AUTH_SECRET= +AUTH_TRUST_HOST=true +AUTH_URL=http://:3000 + +# DiDi API (intern) +DIDI_API_URL=http://didi-agent-v3:24803/api +DIDI_FRAMEWORK_URL=http://didi-framework:3005 + +# Stripe +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Stripe subscriptions +STRIPE_PRICE_PAID_MONTHLY=price_xxx +STRIPE_PRICE_PAID_YEARLY=price_xxx +STRIPE_PRICE_ENTERPRISE_MONTHLY=price_xxx +STRIPE_PRICE_ENTERPRISE_YEARLY=price_xxx + +# Site +NEXT_PUBLIC_SITE_URL=http://:3000 +NEXT_PUBLIC_DEFAULT_LOCALE=ro +``` + +Pentru valori complete vezi [CONFIGURATION.md](./CONFIGURATION.md). + +### Pas 2. Build + run + +```bash +cd ~/website +docker compose up -d --build +``` + +Build-ul dureaza ~30 secunde (Next.js compile + tsc + static generation). + +### Pas 3. Verificare + +```bash +sleep 10 +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000 +# Trebuie sa fie 200 +``` + +Browser: http://localhost:3000 + +## Configurare Keycloak (one-time) + +Pe platforma reala, realm-ul utilizatorilor este **`didi-clients`** (issuer cu prefix `/auth`). Pentru dezvoltare locala (mod DEMO cu mock), creeaza un realm local cu acelasi nume: + +```bash +KC_TOKEN=$(curl -s -X POST http://localhost:28080/realms/master/protocol/openid-connect/token \ + -d "client_id=admin-cli&username=admin&password=admin123&grant_type=password" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +# Realm +curl -s -X POST http://localhost:28080/admin/realms \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "realm": "didi-clients", + "enabled": true, + "registrationAllowed": true, + "resetPasswordAllowed": true + }' + +# Client confidential +curl -s -X POST http://localhost:28080/admin/realms/didi-clients/clients \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "didi-website-server", + "secret": "", + "publicClient": false, + "redirectUris": ["http://:3000/*"], + "webOrigins": ["http://:3000"], + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true + }' + +# Test user +curl -s -X POST http://localhost:28080/admin/realms/didi-clients/users \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "test@didi.local", + "email": "test@didi.local", + "firstName": "Test", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [{"type": "password", "value": "Test1234!", "temporary": false}] + }' +``` + +## Configurare Stripe (one-time) + +### Pas 1. Cont Stripe + +Creeaza cont la https://stripe.com si activeaza modul test. + +### Pas 2. Cheia API + +Dashboard > Developers > API keys > "Reveal test key" +- `pk_test_xxx` (publishable) -> `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` +- `sk_test_xxx` (secret) -> `STRIPE_SECRET_KEY` + +### Pas 3. Produse one-time + +In `src/lib/stripe.ts` exista o mapare `PRICE_MAP` cu 14 price IDs. Acestea trebuie sa existe in contul tau Stripe. Pentru a le crea automat: + +```bash +docker exec -i didi-website node -e " +const Stripe = require('stripe').default || require('stripe'); +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); +const ITEMS = [ + ['techniques-text', 'Techniques - Text', 5000], + ['techniques-image', 'Techniques - Image', 7500], + // ... vezi PRICE_MAP pentru lista completa +]; +(async () => { + for (const [key, name, amount] of ITEMS) { + const p = await stripe.products.create({ name, metadata: { service_key: key }}); + const price = await stripe.prices.create({ product: p.id, unit_amount: amount, currency: 'ron' }); + console.log(key, '->', price.id); + } +})(); +" +``` + +### Pas 4. Produse recurente (subscriptii) + +```bash +docker exec -i didi-website node -e " +const Stripe = require('stripe').default || require('stripe'); +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); +const PLANS = [ + { key: 'paid-monthly', name: 'DiDi Paid - Lunar', amount: 9900, interval: 'month' }, + { key: 'paid-yearly', name: 'DiDi Paid - Anual', amount: 99900, interval: 'year' }, + { key: 'enterprise-monthly', name: 'DiDi Enterprise - Lunar', amount: 49900, interval: 'month' }, + { key: 'enterprise-yearly', name: 'DiDi Enterprise - Anual', amount: 499000, interval: 'year' }, +]; +(async () => { + for (const p of PLANS) { + const prod = await stripe.products.create({ name: p.name, metadata: { plan_key: p.key }}); + const price = await stripe.prices.create({ + product: prod.id, unit_amount: p.amount, currency: 'ron', + recurring: { interval: p.interval } + }); + console.log(p.key, '->', price.id); + } +})(); +" +``` + +Apoi pune ID-urile in `.env.production` la `STRIPE_PRICE_PAID_MONTHLY` etc. + +### Pas 5. Webhook (la productie) + +In dev local NU primesti webhook-uri Stripe (nu poate ajunge la masina ta). + +La productie: +- Dashboard Stripe > Developers > Webhooks > Add endpoint +- URL: `https://didi.tld/api/webhooks/stripe` +- Events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed` +- Copy signing secret in `STRIPE_WEBHOOK_SECRET` + +Pentru dev local, foloseste Stripe CLI: +```bash +stripe listen --forward-to http://:3000/api/webhooks/stripe +``` + +## Configurare ERPNext API key (one-time) + +In ERPNext: Setup > User > Administrator > API Access > Generate Keys + +Salveaza API Key + Secret in `.env.production`: +```env +ERPNEXT_API_KEY=85e12d367334775 +ERPNEXT_API_SECRET=910a2dfda16d2af +``` + +## Dezvoltare locala + +Daca vrei sa schimbi codul fara rebuild de docker: + +```bash +cd ~/website +npm install +cp .env.production .env.local +# Edit .env.local pentru localhost-uri (ERPNEXT_API_URL=http://localhost:8080) +npm run dev +``` + +Acceseaza http://localhost:3000 (sau alt port daca 3000 e ocupat). + +## Probleme frecvente + +### Login: "Server error - problem with server configuration" + +Verifica: +1. `AUTH_KEYCLOAK_ISSUER` e accesibil din container (curl din container) +2. `AUTH_SECRET` setat la 32+ caractere random +3. Realm + Client exista in Keycloak +4. `redirectUris` in client include URL-ul tau + +```bash +docker exec didi-website wget -qO- https:///auth/realms/didi-clients/.well-known/openid-configuration | python3 -m json.tool +``` + +### Login redirecteaza la /login dupa Keycloak + +Cookies corupte. Hard refresh (Ctrl+Shift+R) sau modă incognito. + +### Stripe Checkout duce inapoi la /login + +`NEXT_PUBLIC_SITE_URL` are alt host decat cel din browser. Asigura-te ca acceseaza acelasi host (ex: tot `:3000`, nu mix de localhost/IP). + +### Pagina `/dashboard/credits` arata 0 + +Userul Keycloak nu are inregistrare in `bos_sysadmin.internet_user`. Trebuie seedat: + +```sql +-- PG: didiFramework DB +INSERT INTO bos_sysadmin.internet_user (internet_user_id, person_id, credits_remained, credits_spent, storage_limit_bytes) +VALUES (90000, 90000, 100, 0, 1073741824); + +INSERT INTO bos_sysadmin.user_credential (internet_user_id, email, enrollment_type, cellular_phone_no, no_attempts_failed, subscription_status, activation_date, keycloak_id) +VALUES (90000, 'test@didi.local', 1, '', 0, 1, CURRENT_DATE, ''); +``` + +Sau Auth.js auto-creeaza la primul login daca framework e configurat asa. + +### Build esueaza cu "Module not found" + +```bash +docker compose build --no-cache website +``` + +### Site afiseaza versiune veche dupa rebuild + +```bash +docker compose down website +docker image rm website-website +docker compose up -d --build website +``` + +## Reset complet + +```bash +cd ~/website +docker compose down +docker image rm website-website 2>/dev/null +bash build-from-zero.sh +``` diff --git a/website/OPERATIONS.md b/website/OPERATIONS.md new file mode 100644 index 0000000..f6a3fae --- /dev/null +++ b/website/OPERATIONS.md @@ -0,0 +1,198 @@ +# Operare Website DiDi + +## Comenzi uzuale + +### Status + +```bash +docker compose ps +docker stats --no-stream didi-website +``` + +### Restart + +```bash +docker compose restart website +# sau cu rebuild +docker compose up -d --build website +``` + +### Log-uri + +```bash +# Live log +docker compose logs -f website + +# Doar erori +docker compose logs website 2>&1 | grep -E "error|ERROR|FAIL" + +# Ultimele 100 linii +docker compose logs --tail 100 website +``` + +### Restart dupa schimbare cod (rebuild) + +```bash +cd ~/website +docker compose up -d --build website +# Verifica +docker logs --tail 10 didi-website +``` + +## Update env vars + +### Schimbare cheie Stripe / SendGrid / etc. + +1. Editeaza `.env.production` +2. Rebuild (env vars sunt baked in la build): + +```bash +docker compose up -d --build website +``` + +Pentru variabilele `NEXT_PUBLIC_*` (folosite in client), trebuie REBUILD. Pentru variabile server-side, doar restart e suficient. + +## Monitorizare + +### Health check + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000 +curl -s http://localhost:3000/api/auth/session | python3 -m json.tool +``` + +### Performance + +```bash +# Time First Byte +curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" http://localhost:3000 + +# Lighthouse (necesita Node/Chrome pe host - sau via Docker) +docker run --rm --network didi-network \ + -v $(pwd):/out \ + femtopixel/google-lighthouse \ + http://didi-website:3000 \ + --output html --output-path /out/lighthouse-report.html +``` + +## Troubleshooting + +### Site nu raspunde + +```bash +# 1. Container status +docker compose ps + +# 2. Log-uri +docker compose logs --tail 50 website + +# 3. Test conectivitate la dependinte (din container) +docker exec didi-website wget -qO- http://didi-erpnext:8080 | head -1 +docker exec didi-website wget -qO- https:///auth/realms/didi-clients | head -c 100 +``` + +### Auth nu functioneaza + +```bash +# Verifica OIDC discovery +docker exec didi-website wget -qO- https:///auth/realms/didi-clients/.well-known/openid-configuration | python3 -m json.tool + +# Verifica cookies Auth.js +# Browser DevTools > Application > Cookies > +# Trebuie sa existe: authjs.csrf-token, authjs.session-token (dupa login) +``` + +### Stripe checkout nu mai redirecteaza dupa plata + +Verifica `NEXT_PUBLIC_SITE_URL` in `.env.production` - trebuie sa fie acelasi host cu cel din browser. + +```bash +docker exec didi-website env | grep -E "AUTH_URL|NEXT_PUBLIC_SITE" +``` + +### Webhook Stripe nu firea + +Webhook-ul fireaza doar daca Stripe poate ajunge la endpoint-ul tau. In dev local: + +```bash +# Stripe CLI (instaleaza separat) +stripe listen --forward-to http://:3000/api/webhooks/stripe + +# Test manual cu curl (simuleaza un event) +curl -X POST http://:3000/api/webhooks/stripe \ + -H "Content-Type: application/json" \ + -d '{"type":"checkout.session.completed","data":{"object":{...}}}' +``` + +In productie verifica Stripe Dashboard > Webhooks > [endpoint] > "Activity feed" pentru status delivery. + +### PDF rapoarte goale / corupte + +Generarea PDF se face cu jsPDF in browser (pentru rapoarte analize) sau wkhtmltopdf in ERPNext (pentru facturi). + +Pentru facturi - verifica `host_name` in ERPNext: +```bash +docker exec didi-erpnext bash -c ' +cd /home/frappe/frappe-bench && +bench --site didi-erp set-config -g host_name "http://didi-erpnext:8080" +' +``` + +Pentru rapoarte analize - DevTools > Console pentru erori jsPDF. + +### Site nu trage continut din ERPNext CMS + +```bash +# Test API direct +curl -s -H "Authorization: token :" \ + "http://localhost:8080/api/resource/Website%20Content?fields=[%22page_slug%22,%22section_key%22]&limit_page_length=5" +``` + +Daca 401, token-ul e gresit. Daca [], DB-ul nu are inregistrari (re-ruleaza `scripts/setup/04-populate-content.py` din erp_crm). + +## Backup + +Website-ul e stateless - tot ce conteaza e in: +- Git repo (codul) +- ERPNext DB (datele clientilor + facturile) +- Stripe (platile - irretrievable backup) + +Pentru codul actual: + +```bash +cd ~ +tar czf website-backup-$(date +%Y%m%d).tar.gz \ + --exclude='node_modules' \ + --exclude='.next' \ + website/ +``` + +## Performance tuning + +### Numar workers Next.js + +Edit `Dockerfile` (sau env var pentru Next 16): + +```dockerfile +ENV NEXT_PUBLIC_WORKERS=4 +``` + +### Cache HTTP + +In productie, pune un reverse proxy (nginx/Caddy) cu: +- Cache pe static assets (`_next/static/*`, `public/*`): max-age=31536000 immutable +- Cache pe pagini SSR cu revalidate (vezi `force-dynamic` / `revalidate`) + +### Imagini + +Public images sunt cache-uite implicit cu max-age=60 in Next 16. Pentru imagini hostate la utilizator (avatar etc.), foloseste `` cu `loader` + CDN. + +## SLA + +| Severitate | Descriere | Raspuns | Rezolvare | +|------------|-----------|---------|-----------| +| Critica | Site down / login nu functioneaza | 4 ore | 24 ore | +| Majora | Functionalitate importanta (checkout, dashboard) | 8 ore | 3 zile | +| Minora | UI, cosmetic, edge case | 24 ore | 10 zile | + +Suport: office@clossers.com, zile lucratoare 09-18. diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..f0b27e6 --- /dev/null +++ b/website/README.md @@ -0,0 +1,252 @@ +# DiDi Website (Next.js) + +Componenta frontend a platformei DiDi - website public + dashboard client. Construit cu Next.js 16, integrat cu ERPNext (ERP/CRM), Stripe (plati), Keycloak (SSO) si platforma DiDi (analize AI). + +## Cuprins + +- [Arhitectura](#arhitectura) +- [Stack tehnic](#stack-tehnic) +- [Structura folder](#structura-folder) +- [Quickstart](#quickstart) +- [Pagini](#pagini) +- [API routes](#api-routes) +- [Integrari](#integrari) +- [Documentatie detaliata](#documentatie-detaliata) + +## Arhitectura + +``` + Browser (client) + | + v + +-----------------------------+ + | Website Next.js :3000 | + | (SSR + API routes) | + +-----+-------+-------+-------+ + | | | + +-------+--+ +--+----+ +-+--------+ + | ERPNext | |Stripe | | Keycloak | + | :8080 | | API | | :28080 | + | (REST) | | | | (OIDC) | + +----+-----+ +-------+ +----------+ + | + (factura, customer, CRM) + | + DiDi platform API + (analize text/image/audio/video) +``` + +## Stack tehnic + +| Componenta | Versiune | +|------------|----------| +| Next.js | 16.2.1 (App Router) | +| React | 19.2.4 | +| TypeScript | 5.x | +| Tailwind CSS | 4.x | +| NextAuth (Auth.js) | 5.0 beta | +| Stripe | 21.0 | +| Node.js | 20 (in container) | + +## Structura folder + +``` +website/ +├── README.md # Acest fisier +├── INSTALL.md # Build + deployment +├── OPERATIONS.md # Operare, log-uri, troubleshooting +├── CONFIGURATION.md # Env vars, integrari, credentiale +├── Dockerfile # Multi-stage: dev / builder / prod +├── docker-compose.yml # Stack de productie +├── .env.production # Configurare prod (NU se commiteaza) +├── package.json +├── tsconfig.json +├── next.config.ts +├── eslint.config.mjs +├── postcss.config.mjs +├── public/ # Static assets (clossersLogo2.png etc.) +├── src/ +│ ├── app/ # App Router (Next.js 16) +│ │ ├── (public)/ # Pagini publice fara auth +│ │ │ ├── page.tsx # Homepage +│ │ │ ├── about/ +│ │ │ ├── services/ +│ │ │ ├── pricing/ +│ │ │ ├── contact/ +│ │ │ ├── privacy/ +│ │ │ ├── terms/ +│ │ │ └── layout.tsx # Navbar + Footer + Cookie consent +│ │ ├── (auth)/ # Login / Register +│ │ │ ├── login/ +│ │ │ └── register/ +│ │ ├── (dashboard)/dashboard/# Zona client autentificat +│ │ │ ├── page.tsx # Overview +│ │ │ ├── analiza/ # Run analiza +│ │ │ ├── analize/ # Istoric analize + rapoarte PDF +│ │ │ ├── achizitioneaza/ # Cumparare servicii +│ │ │ ├── checkout/success/ # Stripe success callback +│ │ │ ├── credits/ # Credite + consum (real-time) +│ │ │ ├── invoices/ # Facturi cu download PDF +│ │ │ ├── profile/ # Profil + GDPR (export, delete) +│ │ │ └── subscription/ # Abonament + upgrade/downgrade +│ │ ├── api/ # API routes (server-side) +│ │ │ ├── auth/[...nextauth]/ # Auth.js handler +│ │ │ ├── analyze/ # Analiza pipeline +│ │ │ ├── analysis-reports/ +│ │ │ ├── analysis-report-pdf/ +│ │ │ ├── checkout/ # Stripe Checkout Session +│ │ │ ├── subscribe/ # Stripe Subscription +│ │ │ ├── subscription/manage/ # Upgrade/downgrade/cancel +│ │ │ ├── customer/me/ # Get current user customer +│ │ │ ├── credits/ # Credite + history +│ │ │ ├── erp/[...path]/ # Proxy ERPNext +│ │ │ ├── invoice-pdf/ # Download factura PDF +│ │ │ ├── leads/ # Lead creation (contact form) +│ │ │ ├── webhooks/stripe/ # Stripe webhook handler +│ │ │ └── gdpr/ # Export + delete account +│ │ │ ├── export/ +│ │ │ └── delete/ +│ │ ├── layout.tsx # Root layout +│ │ ├── sitemap.ts +│ │ └── robots.ts +│ ├── components/ # React components +│ │ ├── layout/ # Navbar, Footer, DashboardSidebar +│ │ ├── forms/ # ContactForm +│ │ ├── providers/ # SessionProvider +│ │ └── ui/ # CookieConsent, SignOutButton +│ ├── i18n/ # Traduceri ro.json + en.json +│ ├── lib/ # Helpers server-side +│ │ ├── auth.ts # Auth.js config (Keycloak) +│ │ ├── erpnext.ts # ERPNext REST client +│ │ ├── stripe.ts # Stripe config + price map +│ │ ├── stripe-fulfillment.ts # Webhook fulfillment logic +│ │ ├── api.ts # Client API (browser) +│ │ ├── analysis-display.ts # Format rezultate analize +│ │ ├── analysis-report-pdf.ts# Generare PDF (jsPDF) +│ │ └── analysis-report-store.ts # Salvare PDF in ERPNext +│ ├── middleware.ts # Auth middleware (/dashboard/*) +│ └── types/ # TypeScript types +└── scripts/ + └── archive/ # Scripturi generare doc-uri (one-off) +``` + +Documentatia de proiect (arhitectura, documentatia API, manuale, caiet de +sarcini) se livreaza separat, in dosarul de livrare — nu face parte din repo. + +## Quickstart + +Cere docker + docker compose v2 si reteaua `didi-network`: + +```bash +docker network create didi-network 2>/dev/null || true +``` + +Build + start: + +```bash +cd ~/website +cp .env.production.example .env.production # (daca exista, sau editeaza .env.production existent) +docker compose up -d --build +``` + +Acceseaza: http://localhost:3000 + +Pentru detalii, vezi [INSTALL.md](./INSTALL.md). + +## Pagini + +### Pagini publice (fara login) + +| Ruta | Descriere | +|------|-----------| +| `/` | Homepage (continut din ERPNext CMS) | +| `/services` | Module AI DiDi (6 servicii) | +| `/about` | Despre Clossers + DiDi | +| `/pricing` | 14 servicii one-time + 4 abonamente recurente | +| `/contact` | Formular contact (creeaza Lead in ERPNext) | +| `/privacy` | Politica de confidentialitate (GDPR) | +| `/terms` | Termeni si conditii | + +### Pagini auth + +| Ruta | Descriere | +|------|-----------| +| `/login` | Redirect la Keycloak SSO | +| `/register` | Redirect la Keycloak registration | + +### Pagini dashboard (auth obligatoriu) + +| Ruta | Descriere | +|------|-----------| +| `/dashboard` | Overview - facturi recente, credite, achizitii | +| `/dashboard/analiza` | Selecteaza factura + ruleaza analiza | +| `/dashboard/analize` | Istoric analize + descarcare rapoarte PDF | +| `/dashboard/invoices` | Facturi emise + download PDF | +| `/dashboard/credits` | Credite disponibile + istoric consum | +| `/dashboard/achizitioneaza` | Servicii one-time + abonamente | +| `/dashboard/subscription` | Plan curent + upgrade/downgrade/cancel | +| `/dashboard/profile` | Profil + GDPR (export, delete) | +| `/dashboard/checkout/success` | Callback post-Stripe | + +## API routes + +### Public +| Endpoint | Metoda | Descriere | +|----------|--------|-----------| +| `/api/leads` | POST | Creeaza Lead in ERPNext din formular contact | +| `/api/webhooks/stripe` | POST | Webhook Stripe (events) | + +### Autentificate +| Endpoint | Metoda | Descriere | +|----------|--------|-----------| +| `/api/auth/[...nextauth]` | GET/POST | Auth.js handler | +| `/api/analyze` | GET, POST | Lista analize + run + polling | +| `/api/checkout` | POST | Creeaza Stripe Checkout (one-time) | +| `/api/subscribe` | POST | Creeaza Stripe Subscription | +| `/api/subscription/manage` | POST | Upgrade/downgrade/cancel | +| `/api/customer/me` | GET | Datele Customer din ERPNext | +| `/api/credits` | GET | Credite + istoric | +| `/api/erp/[...path]` | * | Proxy autentificat catre ERPNext | +| `/api/invoice-pdf` | GET | Download factura PDF | +| `/api/analysis-reports` | GET, POST | List + salvare rapoarte | +| `/api/analysis-report-pdf` | GET | Download raport analiza PDF | +| `/api/gdpr/export` | GET | Export date utilizator (JSON) | +| `/api/gdpr/delete` | POST | Cerere stergere cont | + +## Integrari + +| Sistem | Protocol | Scop | +|--------|----------|------| +| ERPNext | REST API (token auth) | Facturi, customeri, lead-uri, CMS | +| Stripe | API + webhook | Plati one-time + recurente | +| Keycloak | OIDC | SSO (autentificare clienti) | +| DiDi platform | REST API (Bearer JWT) | Analize text/image/audio/video | +| SendGrid | (indirect prin ERPNext SMTP) | Email tranzactional | + +## Functionalitati cheie + +- Design responsive (desktop/tablet/mobile), WCAG 2.1 AA +- SEO (sitemap.xml, robots.txt, Open Graph) +- GDPR: cookie consent, export date, dreptul la stergere +- i18n RO + EN cu language switcher +- PDF generation (facturi + rapoarte analize) +- Webhook idempotency (Payment Log cu stripe_session_id unique) + +## Documentatie detaliata + +| Document | Scop | +|----------|------| +| [INSTALL.md](./INSTALL.md) | Build de la 0, dependinte, deployment | +| [OPERATIONS.md](./OPERATIONS.md) | Restart, log-uri, debug | +| [CONFIGURATION.md](./CONFIGURATION.md) | Env vars complete, integrari | + +## Test user + +Pentru testare: +- Email: `test@didi.local` +- Parola: `Test1234!` +- (creat in Keycloak local, realm `didi-clients` — acelasi nume ca pe platforma reala) + +## Suport + +Pentru intrebari tehnice: office@clossers.com diff --git a/website/build-from-zero.sh b/website/build-from-zero.sh new file mode 100755 index 0000000..e08277d --- /dev/null +++ b/website/build-from-zero.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Build de la 0 Website DiDi - one-shot installer +# Versiune 1.0, mai 2026 +# +# Cerinte: docker, docker compose v2 +# Reteaua Docker `didi-network` trebuie sa existe. + +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +log() { printf '\033[1;36m[%(%H:%M:%S)T]\033[0m %s\n' -1 "$*"; } +err() { printf '\033[1;31m[%(%H:%M:%S)T] ERROR:\033[0m %s\n' -1 "$*" >&2; exit 1; } + +# ── Verificari ────────────────────────────────────────────── +command -v docker >/dev/null || err "docker nu e instalat" +docker compose version >/dev/null || err "docker compose v2 nu e instalat" + +if ! docker network inspect didi-network >/dev/null 2>&1; then + log "Creez reteaua didi-network..." + docker network create didi-network +fi + +# ── Verificare .env.production ────────────────────────────── +if [ ! -f .env.production ]; then + err ".env.production lipseste. Vezi CONFIGURATION.md pentru variabile." +fi + +# Verifica variabilele critice +source .env.production 2>/dev/null || true +[ -n "${AUTH_KEYCLOAK_SECRET:-}" ] || err "AUTH_KEYCLOAK_SECRET nu e setat in .env.production" +[ -n "${AUTH_SECRET:-}" ] || err "AUTH_SECRET nu e setat in .env.production" +[ -n "${STRIPE_SECRET_KEY:-}" ] || err "STRIPE_SECRET_KEY nu e setat in .env.production" +[ -n "${ERPNEXT_API_KEY:-}" ] || err "ERPNEXT_API_KEY nu e setat in .env.production" + +# Verifica ERPNext disponibil +log "Verific accesibilitate ERPNext..." +if ! curl -sf -o /dev/null --max-time 5 "${NEXT_PUBLIC_ERPNEXT_URL:-http://localhost:8080}"; then + log " ATENTIE: ERPNext nu raspunde la ${NEXT_PUBLIC_ERPNEXT_URL:-http://localhost:8080}" + log " Website-ul porneste oricum, dar paginile vor da erori la API calls catre ERPNext." +fi + +# ── Build + start ─────────────────────────────────────────── +log "Build imagine Docker (~30 sec)..." +docker compose build --quiet website 2>&1 | tail -3 || err "Build a esuat" + +log "Pornire container..." +docker compose up -d website + +# ── Health check ──────────────────────────────────────────── +log "Astept ca website-ul sa fie ready..." +for i in $(seq 1 20); do + HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 || echo "000") + if [ "$HTTP" = "200" ]; then + log " UP la http://localhost:3000" + break + fi + sleep 2 +done + +if [ "$HTTP" != "200" ]; then + log " Status: $HTTP - verifica log-urile cu: docker compose logs --tail 50 website" + exit 1 +fi + +# ── Quick smoke test ──────────────────────────────────────── +log "Smoke test..." +for path in "/" "/about" "/pricing" "/contact" "/api/auth/session"; do + code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000${path}") + printf " %-25s %s\n" "$path" "$code" +done + +log "=============================================" +log " Website DiDi UP: http://localhost:3000" +log " Test login: test@didi.local / Test1234!" +log "=============================================" diff --git a/website/docker-compose.yml b/website/docker-compose.yml new file mode 100644 index 0000000..deaaee0 --- /dev/null +++ b/website/docker-compose.yml @@ -0,0 +1,24 @@ +services: + website: + build: + context: . + dockerfile: Dockerfile + target: prod + container_name: didi-website + restart: unless-stopped + ports: + - "127.0.0.1:3001:3000" + env_file: + - .env.production + networks: + - didi-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +networks: + didi-network: + external: true diff --git a/website/eslint.config.mjs b/website/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/website/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/website/next-env.d.ts b/website/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/website/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/website/next.config.ts b/website/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/website/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000..1278852 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,6993 @@ +{ + "name": "website", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "website", + "version": "0.1.0", + "dependencies": { + "@stripe/stripe-js": "^9.0.1", + "@tailwindcss/typography": "^0.5.19", + "jspdf": "^4.2.1", + "next": "16.2.1", + "next-auth": "^5.0.0-beta.30", + "react": "19.2.4", + "react-dom": "19.2.4", + "stripe": "^21.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.1", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", + "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.1.tgz", + "integrity": "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", + "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", + "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", + "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", + "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", + "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", + "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", + "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", + "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stripe/stripe-js": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.0.1.tgz", + "integrity": "sha512-un0URSosrW7wNr7xZ5iI2mC9mdeXZ3KERoVlA2RdmeLXYxHUPXq0yHzir2n/MtyXXEdSaELtz4WXGS6dzPEeKA==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz", + "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.1", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", + "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.1", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.1", + "@next/swc-darwin-x64": "16.2.1", + "@next/swc-linux-arm64-gnu": "16.2.1", + "@next/swc-linux-arm64-musl": "16.2.1", + "@next/swc-linux-x64-gnu": "16.2.1", + "@next/swc-linux-x64-musl": "16.2.1", + "@next/swc-win32-arm64-msvc": "16.2.1", + "@next/swc-win32-x64-msvc": "16.2.1", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stripe": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.1.tgz", + "integrity": "sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..bbb701b --- /dev/null +++ b/website/package.json @@ -0,0 +1,31 @@ +{ + "name": "website", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@stripe/stripe-js": "^9.0.1", + "@tailwindcss/typography": "^0.5.19", + "jspdf": "^4.2.1", + "next": "16.2.1", + "next-auth": "^5.0.0-beta.30", + "react": "19.2.4", + "react-dom": "19.2.4", + "stripe": "^21.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.1", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/website/postcss.config.mjs b/website/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/website/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/website/public/clossersLogo2.png b/website/public/clossersLogo2.png new file mode 100644 index 0000000..e56936b Binary files /dev/null and b/website/public/clossersLogo2.png differ diff --git a/website/public/file.svg b/website/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/website/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/public/globe.svg b/website/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/website/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/public/logo.png b/website/public/logo.png new file mode 100644 index 0000000..e56936b Binary files /dev/null and b/website/public/logo.png differ diff --git a/website/public/next.svg b/website/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/website/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/public/pnrr-banner.png b/website/public/pnrr-banner.png new file mode 100644 index 0000000..49431c0 Binary files /dev/null and b/website/public/pnrr-banner.png differ diff --git a/website/public/pnrr-eu-nextgen.png b/website/public/pnrr-eu-nextgen.png new file mode 100644 index 0000000..7f3c4fd Binary files /dev/null and b/website/public/pnrr-eu-nextgen.png differ diff --git a/website/public/pnrr-logo-eu.png b/website/public/pnrr-logo-eu.png new file mode 100644 index 0000000..7f9350c Binary files /dev/null and b/website/public/pnrr-logo-eu.png differ diff --git a/website/public/pnrr-logo-guv.png b/website/public/pnrr-logo-guv.png new file mode 100644 index 0000000..2fafffb Binary files /dev/null and b/website/public/pnrr-logo-guv.png differ diff --git a/website/public/pnrr-logo-pnrr.png b/website/public/pnrr-logo-pnrr.png new file mode 100644 index 0000000..8b79941 Binary files /dev/null and b/website/public/pnrr-logo-pnrr.png differ diff --git a/website/public/vercel.svg b/website/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/website/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/public/window.svg b/website/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/website/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/website/scripts/archive/generate_docs.py b/website/scripts/archive/generate_docs.py new file mode 100644 index 0000000..6c67574 --- /dev/null +++ b/website/scripts/archive/generate_docs.py @@ -0,0 +1,176 @@ +"""Generate 3 documentation Word files for DiDi platform.""" +import sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +from docx import Document +from docx.shared import Pt, Cm, RGBColor + +BASE = "C:/Users/avedev/Desktop/Achizitii TOP/livrabile/didi" + +def setup_doc(): + doc = Document() + for s in doc.sections: + s.top_margin = Cm(2.5); s.bottom_margin = Cm(2); s.left_margin = Cm(2.5); s.right_margin = Cm(2.5) + style = doc.styles['Normal']; style.font.name = 'Calibri'; style.font.size = Pt(11) + style.paragraph_format.space_after = Pt(6); style.paragraph_format.line_spacing = 1.15 + for lv, sz in [(1,16),(2,13),(3,11)]: + hs = doc.styles[f'Heading {lv}']; hs.font.name = 'Calibri'; hs.font.size = Pt(sz) + hs.font.color.rgb = RGBColor(0x0D,0x94,0x88); hs.font.bold = True + return doc + +def tbl(doc, headers, rows): + t = doc.add_table(rows=1, cols=len(headers)); t.style = 'Light Grid Accent 1' + for i, h in enumerate(headers): + t.rows[0].cells[i].text = h + for p in t.rows[0].cells[i].paragraphs: + for r in p.runs: r.bold = True; r.font.size = Pt(9) + for rd in rows: + row = t.add_row() + for i, v in enumerate(rd): + row.cells[i].text = str(v) + for p in row.cells[i].paragraphs: + for r in p.runs: r.font.size = Pt(9) + +def bl(doc, text): + p = doc.add_paragraph(style='List Bullet'); p.paragraph_format.space_after = Pt(3) + p.add_run(text).font.size = Pt(10) + +def pa(doc, text, sz=11): + p = doc.add_paragraph(); p.add_run(text).font.size = Pt(sz) + +# ═══ DOC 1: Manual Utilizare ═══ +print("1. Manual_Utilizare_Website.docx...") +d = setup_doc() +d.add_heading('Manual de Utilizare - Website DiDi', level=1) +pa(d, 'Ghid complet pentru utilizatorii platformei DiDi de la Clossers.') + +d.add_heading('1. Introducere', level=1) +pa(d, 'DiDi este o platforma digitala inteligenta pentru prevenirea si combaterea dezinformarii. Analizeaza automat continut media (text, imagini, audio, video) folosind inteligenta artificiala pentru a detecta dezinformarea si a verifica informatiile in timp real.') + +d.add_heading('2. Pagini Publice', level=1) +for t, desc in [('Homepage','Prezentare platforma: hero, 4 capabilitati AI, cum functioneaza (4 pasi), cazuri de utilizare, statistici. Continut gestionat din CMS.'),('Servicii','6 module AI detaliate: Analiza Text, Detectie Deepfake, Fact-Checking, Evaluare Surse, Tehnici Manipulare, Monitorizare Media.'),('Pricing','Grila preturi: 4 categorii servicii x tipuri media (Text, Imagine, Audio, Video). Preturi in RON. Buton achizitie cu redirect Stripe.'),('Despre','Prezentare companie, misiune, proiect PNRR, tehnologie utilizata.'),('Contact','Formular (nume, email, mesaj) care creeaza automat Lead in CRM.'),('Privacy / Termeni','Politica GDPR si Termeni si Conditii, editabile din CMS.')]: + d.add_heading(t, level=2); pa(d, desc, 10) + +d.add_heading('3. Autentificare', level=1) +bl(d,'Login prin SSO Keycloak - buton "Login" din navbar, redirect catre pagina centralizata') +bl(d,'Inregistrare - creare cont pe serverul de identitate, autentificare automata') +bl(d,'Sesiune JWT cu reinoire automata; deconectare prin buton "Sign out"') + +d.add_heading('4. Dashboard Client', level=1) +for t, items in [('4.1 Overview',['Numar facturi emise si analize achizitionate','Tabel ultimele facturi (numar, data, suma, status)','Link achizitie serviciu']),('4.2 Analize',['Lista analize disponibile (achizitionate, neconsumate)','Selectare analiza → formular input (text sau URL)','Pornire analiza → procesare AI (pana la 2 minute)','Rezultate: scor risc, concluzie, tehnici manipulare, verificare afirmatii, detectie AI, evaluare sursa, viralitate','Buton "Descarca Raport PDF" cu toate rezultatele']),('4.3 Facturi',['Lista completa cu numar, data, suma, status','Download PDF (format "DiDi Invoice" personalizat)']),('4.4 Abonament',['Plan activ si functionalitati incluse','Free: 5 analize/luna | Paid: 100/luna | Enterprise: nelimitat']),('4.5 Profil',['Editare date: companie, cod fiscal','GDPR: export date CSV, solicitare stergere cont'])]: + d.add_heading(t, level=2) + for i in items: bl(d, i) + +d.add_heading('5. Achizitie Serviciu', level=1) +for s in ['1. Pricing → selectare serviciu si tip media','2. Redirect Stripe Checkout (plata securizata PCI DSS)','3. Completare date card si plata','4. Confirmare → factura automata cu TVA 19%','5. Analiza disponibila in dashboard']: bl(d, s) + +d.add_heading('6. Cookie Consent si Limba', level=1) +pa(d,'Banner cookie cu 3 categorii: Necesare (always on), Functionale, Analitice. Comutare limba RO/EN din navbar.') + +d.save(f"{BASE}/Manual_Utilizare_Website.docx"); print(" OK") + +# ═══ DOC 2: Documentatie API ═══ +print("2. Documentatie_API.docx...") +d = setup_doc() +d.add_heading('Documentatie API - Platforma DiDi', level=1) + +d.add_heading('1. Arhitectura si Autentificare', level=1) +pa(d,'3 categorii API: Website API Routes (Next.js), ERPNext REST API (proxy server-side), DiDi Analysis API (AI).') +bl(d,'Website: sesiune NextAuth (cookie httpOnly) prin Keycloak SSO') +bl(d,'ERPNext: API Key + Secret (server-side)') +bl(d,'DiDi API: Bearer token JWT din sesiunea Keycloak') + +d.add_heading('2. Endpoint-uri Website', level=1) +tbl(d, ['Endpoint','Descriere','Request','Response'], [ + ['POST /api/checkout','Sesiune plata one-time','{ serviceKey }','{ url }'], + ['POST /api/subscribe','Subscriptie recurenta','{ planKey }','{ url }'], + ['GET /api/analyze','Lista analize disponibile','Auth required','{ data: [{invoice, component}] }'], + ['POST /api/analyze','Trimitere analiza','{invoice, component, media, text/url}','{ data: {session_id} }'], + ['GET /api/analyze?session_id=X','Polling rezultate','Auth required','{ data: {status, risk_score} }'], + ['POST /api/leads','Creare lead CRM','{lead_name, email_id, notes}','{ data: {name} }'], + ['GET /api/invoice-pdf?name=X','Download PDF factura','Auth required','PDF binary'], + ['GET /api/customer/me','Date client','Auth required','{ data: {name, iam_role} }'], + ['GET /api/analysis-reports','Lista rapoarte','Auth required','{ data: [...] }'], + ['POST /api/analysis-reports','Salvare raport','{invoiceName, sessionId, result}','{ data: {name, pdfFile} }'], + ['GET /api/analysis-report-pdf','Download PDF raport','?name=X','PDF binary'], + ['POST /api/webhooks/stripe','Webhook Stripe','Stripe signature','{ received: true }'], +]) + +d.add_heading('2.1 Evenimente Webhook Stripe', level=2) +bl(d,'checkout.session.completed → Client + Factura + Payment Entry + Service Agreement') +bl(d,'customer.subscription.updated → update iam_role pe Customer') +bl(d,'customer.subscription.deleted → downgrade free_tier') +bl(d,'invoice.payment_failed → log in Payment Log') + +d.add_heading('3. ERPNext DocTypes', level=1) +tbl(d, ['DocType','Descriere','Campuri'], [ + ['Customer','Clienti','customer_name, didi_user_id, iam_role, active_plan'], + ['Sales Invoice','Facturi','customer, grand_total, status, items, taxes'], + ['Lead','Lead-uri','lead_name, email_id, source, source_form, utm_*'], + ['Website Content','CMS','page_slug, section_key, content_ro, content_en'], + ['Service Agreement','Acorduri','customer, plan, status, acceptance_date'], + ['Payment Log','Log plati','customer, event_type, status, stripe_session_id'], + ['Analysis Report','Rapoarte','customer, sales_invoice, component, pdf_file, result_json'], + ['Item','Servicii','item_code, item_name, standard_rate'], + ['Subscription Plan','Planuri','plan_name, cost, billing_interval'], +]) + +d.add_heading('4. DiDi Analysis API', level=1) +tbl(d, ['Endpoint','Metoda','Descriere','Response'], [ + ['/v3/techniques/analyze','POST','Tehnici manipulare','risk_score, techniques_detected[]'], + ['/v3/ai-tampered/analyze','POST','Detectie AI','ai_probability, verdict'], + ['/v3/claims/analyze','POST','Fact-checking','claims[{claim, verdict}]'], + ['/v3/domain/analyze','POST','Evaluare sursa','credibility_score, category'], +]) + +d.save(f"{BASE}/Documentatie_API.docx"); print(" OK") + +# ═══ DOC 3: Ghid Admin ERPNext ═══ +print("3. Ghid_Administrare_ERPNext.docx...") +d = setup_doc() +d.add_heading('Ghid de Administrare - ERPNext DiDi', level=1) + +d.add_heading('1. Accesare si Navigare', level=1) +pa(d,'ERPNext se acceseaza prin browser. Autentificare cu contul Administrator. Sidebar cu 7 module:') +tbl(d, ['Modul','Continut'], [['Facturi','Sales Invoice, Payment Entry, TVA'],['Clienti','Customer, Service Agreement'],['CRM','Lead, Sales Stage, Lead Source'],['Servicii','Item, Subscription Plan'],['Plati','Payment Log, Analysis Report'],['Contabilitate','Account, Company, Supplier'],['Website CMS','Website Content, Email Template']]) + +d.add_heading('2. Facturi', level=1) +bl(d,'Create automat la plata Stripe. Serie: DIDI-INV-YYYY-#####') +bl(d,'TVA 19% automat. Print Format "DiDi Invoice" cu logo si date firma') +bl(d,'Statusuri: Draft → Submitted → Paid / Cancelled. Download PDF din lista.') + +d.add_heading('3. Clienti', level=1) +tbl(d, ['Camp','Descriere'], [['customer_name','Numele clientului'],['didi_user_id','UUID Keycloak'],['iam_role','free_tier / paid_tier / enterprise_tier'],['active_plan','Cod plan activ'],['plan_activation_date','Data activarii']]) + +d.add_heading('4. CRM', level=1) +bl(d,'Lead-uri automate din formular contact website') +bl(d,'Pipeline: Lead → Calificat → Demo → Client') +bl(d,'UTM tracking: utm_source, utm_medium, utm_campaign') + +d.add_heading('5. Servicii', level=1) +bl(d,'14 articole one-time: TECHNIQUES/AI_DETECTION/CLAIMS/SOURCE x TEXT/IMAGE/AUDIO/VIDEO/URL (50-200 RON)') +bl(d,'5 planuri: Free(0), Paid lunar(99)/anual(999), Enterprise lunar(499)/anual(4990) RON') + +d.add_heading('6. Plati si Rapoarte', level=1) +bl(d,'Payment Log — tranzactii Stripe: status, suma, event_type, stripe_session_id') +bl(d,'Analysis Report — rapoarte AI cu PDF atasat, rezultat JSON complet') + +d.add_heading('7. Contabilitate', level=1) +bl(d,'Plan conturi romanesc tradus. TVA 19% sablon automat.') +bl(d,'Furnizori: Hetzner (hosting), Stripe (plati), SendGrid (email)') +bl(d,'Rapoarte standard: Balanta, Profit si Pierdere, Registru General') +bl(d,'3 rapoarte custom: Dashboard Financiar, Raport CRM, Clienti per Plan') + +d.add_heading('8. Website CMS', level=1) +pa(d,'24 intrari pe 6 pagini (homepage, services, about, contact, privacy, terms). Fiecare cu content_ro, content_en, image, extra_data JSON.') + +d.add_heading('9. Notificari Email', level=1) +bl(d,'Factura Emisa — email automat la confirmare, cu PDF atasat') +bl(d,'Plata Confirmata — email la plata reusita') +bl(d,'Expirare Abonament — 7 zile inainte de expirare') +pa(d,'Email Account SendGrid configurat (necesita cheie API reala).') + +d.add_heading('10. Service Agreement', level=1) +pa(d,'Acorduri automate la achizitie: client, plan, data acceptare, versiune termeni. Statusuri: Draft → Accepted → Expired.') + +d.save(f"{BASE}/Ghid_Administrare_ERPNext.docx"); print(" OK") +print("\nAll 3 documents generated!") diff --git a/website/scripts/archive/generate_livrabile.py b/website/scripts/archive/generate_livrabile.py new file mode 100644 index 0000000..682eb23 --- /dev/null +++ b/website/scripts/archive/generate_livrabile.py @@ -0,0 +1,3782 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Generator documente livrabile PNRR - Platforma DiDi +TOP CLOSSERS SRL | CUI: 36193026 +Proiect: PNRR Contract 11.1.i3.c9/10.05.2024 + +Genereaza 4 documente DOCX profesionale: +1. Manual_Utilizare_Website.docx +2. Ghid_Administrare_ERPNext.docx +3. Documentatie_API.docx +4. Arhitectura_Detaliata.docx +""" + +import sys +import os + +# Force UTF-8 output +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +from docx import Document +from docx.shared import Inches, Pt, Cm, RGBColor, Emu +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.enum.section import WD_ORIENT +from docx.oxml.ns import qn, nsdecls +from docx.oxml import parse_xml +import datetime + +# ============================================================================== +# CONFIGURATION +# ============================================================================== + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SCREENSHOTS_DIR = os.path.join(BASE_DIR, "screenshots") +OUTPUT_DIR = BASE_DIR + +COMPANY = { + "name": "TOP CLOSSERS SRL", + "cui": "36193026", + "address": "Str. Targovistei 15, Ploiesti, 100299, Prahova", + "representative": "Alexe Vasile-Emilian", + "project_ref": "PNRR Contract 11.1.i3.c9/10.05.2024", + "platform": "DiDi - Platforma Digitala Inteligenta pentru Prevenirea si Combaterea Dezinformarii", + "date": "Aprilie 2026", +} + +HEADER_TEXT = "DiDi - Platforma Digitala Inteligenta" +FOOTER_TEXT = "TOP CLOSSERS SRL | CUI: 36193026 | Confidential" + + +# ============================================================================== +# HELPER FUNCTIONS +# ============================================================================== + + +def create_document(): + """Create a new document with base styles configured.""" + doc = Document() + + # Configure default font + style = doc.styles["Normal"] + font = style.font + font.name = "Calibri" + font.size = Pt(11) + font.color.rgb = RGBColor(0x33, 0x33, 0x33) + style.paragraph_format.space_after = Pt(6) + style.paragraph_format.line_spacing = 1.15 + + # Configure heading styles + for level, (size, color, bold) in { + "Heading 1": (Pt(20), RGBColor(0x1A, 0x3C, 0x6E), True), + "Heading 2": (Pt(16), RGBColor(0x2B, 0x57, 0x9A), True), + "Heading 3": (Pt(13), RGBColor(0x3A, 0x6E, 0xAF), True), + }.items(): + h_style = doc.styles[level] + h_style.font.name = "Calibri" + h_style.font.size = size + h_style.font.color.rgb = color + h_style.font.bold = bold + h_style.paragraph_format.space_before = Pt(18 if level == "Heading 1" else 12) + h_style.paragraph_format.space_after = Pt(8) + + # Set margins + for section in doc.sections: + section.top_margin = Cm(2.5) + section.bottom_margin = Cm(2.5) + section.left_margin = Cm(2.5) + section.right_margin = Cm(2.5) + + return doc + + +def add_header_footer(doc): + """Add header and footer to all sections.""" + for section in doc.sections: + # Header + header = section.header + header.is_linked_to_previous = False + hp = header.paragraphs[0] if header.paragraphs else header.add_paragraph() + hp.text = HEADER_TEXT + hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT + run = hp.runs[0] if hp.runs else hp.add_run() + run.font.size = Pt(9) + run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) + run.font.italic = True + + # Footer + footer = section.footer + footer.is_linked_to_previous = False + fp = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + fp.text = FOOTER_TEXT + fp.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = fp.runs[0] if fp.runs else fp.add_run() + run.font.size = Pt(8) + run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + + +def add_cover_page(doc, title, subtitle=None): + """Add a professional cover page.""" + # Add empty paragraphs for spacing + for _ in range(4): + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(0) + + # Main title + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run(title) + run.bold = True + run.font.size = Pt(28) + run.font.color.rgb = RGBColor(0x1A, 0x3C, 0x6E) + run.font.name = "Calibri" + + if subtitle: + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run(subtitle) + run.font.size = Pt(16) + run.font.color.rgb = RGBColor(0x2B, 0x57, 0x9A) + run.font.name = "Calibri" + + # Separator line + doc.add_paragraph() + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run("_" * 60) + run.font.color.rgb = RGBColor(0xCC, 0xCC, 0xCC) + + doc.add_paragraph() + + # Project info table + info_data = [ + ("Beneficiar", COMPANY["name"]), + ("CUI", COMPANY["cui"]), + ("Adresa", COMPANY["address"]), + ("Reprezentant Legal", COMPANY["representative"]), + ("Referinta Proiect", COMPANY["project_ref"]), + ("Platforma", COMPANY["platform"]), + ("Data", COMPANY["date"]), + ] + + table = doc.add_table(rows=len(info_data), cols=2) + table.alignment = WD_TABLE_ALIGNMENT.CENTER + table.style = "Table Grid" + + for i, (label, value) in enumerate(info_data): + cell_label = table.rows[i].cells[0] + cell_value = table.rows[i].cells[1] + + # Label cell + p = cell_label.paragraphs[0] + run = p.add_run(label) + run.bold = True + run.font.size = Pt(10) + run.font.name = "Calibri" + run.font.color.rgb = RGBColor(0x1A, 0x3C, 0x6E) + + # Value cell + p = cell_value.paragraphs[0] + run = p.add_run(value) + run.font.size = Pt(10) + run.font.name = "Calibri" + + # Style cells + for cell in [cell_label, cell_value]: + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + shading = parse_xml(f'') + tcPr.append(shading) + + # Set column widths + for row in table.rows: + row.cells[0].width = Inches(2.2) + row.cells[1].width = Inches(4.0) + + # Add page break after cover + doc.add_page_break() + + +def add_screenshot(doc, subfolder, filename, caption=None, width=Inches(6)): + """Add a screenshot image with caption. Skip gracefully if not found.""" + img_path = os.path.join(SCREENSHOTS_DIR, subfolder, filename) + if not os.path.exists(img_path): + p = doc.add_paragraph() + run = p.add_run(f"[Captura de ecran: {filename} - fisier indisponibil]") + run.italic = True + run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + run.font.size = Pt(9) + return + + try: + doc.add_picture(img_path, width=width) + # Center the image + last_paragraph = doc.paragraphs[-1] + last_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + + if caption: + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run(f"Figura: {caption}") + run.italic = True + run.font.size = Pt(9) + run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) + p.paragraph_format.space_after = Pt(12) + except Exception as e: + p = doc.add_paragraph() + run = p.add_run(f"[Eroare la incarcarea capturii: {filename} - {str(e)}]") + run.italic = True + run.font.color.rgb = RGBColor(0xCC, 0x00, 0x00) + run.font.size = Pt(9) + + +def add_table_with_header(doc, headers, rows, col_widths=None): + """Add a formatted table with header row.""" + table = doc.add_table(rows=1 + len(rows), cols=len(headers)) + table.style = "Table Grid" + table.alignment = WD_TABLE_ALIGNMENT.CENTER + + # Header row + for i, header in enumerate(headers): + cell = table.rows[0].cells[i] + p = cell.paragraphs[0] + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run(header) + run.bold = True + run.font.size = Pt(9) + run.font.name = "Calibri" + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + # Dark blue background + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + shading = parse_xml(f'') + tcPr.append(shading) + + # Data rows + for row_idx, row_data in enumerate(rows): + for col_idx, value in enumerate(row_data): + cell = table.rows[row_idx + 1].cells[col_idx] + p = cell.paragraphs[0] + run = p.add_run(str(value)) + run.font.size = Pt(9) + run.font.name = "Calibri" + # Alternate row shading + if row_idx % 2 == 0: + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + shading = parse_xml( + f'' + ) + tcPr.append(shading) + + # Set column widths if specified + if col_widths: + for row in table.rows: + for i, width in enumerate(col_widths): + if i < len(row.cells): + row.cells[i].width = width + + doc.add_paragraph() # Spacing after table + return table + + +def add_bullet_list(doc, items, level=0): + """Add a bulleted list.""" + for item in items: + p = doc.add_paragraph(style="List Bullet") + run = p.add_run(item) + run.font.size = Pt(10) + run.font.name = "Calibri" + if level > 0: + p.paragraph_format.left_indent = Cm(1.5 * level) + + +def add_numbered_list(doc, items): + """Add a numbered list.""" + for item in items: + p = doc.add_paragraph(style="List Number") + run = p.add_run(item) + run.font.size = Pt(10) + run.font.name = "Calibri" + + +def add_note_box(doc, text, note_type="info"): + """Add a styled note/info box using a single-cell table.""" + colors = { + "info": ("E8F0FE", "1A3C6E"), + "warning": ("FFF3CD", "856404"), + "tip": ("D4EDDA", "155724"), + } + bg_color, text_color = colors.get(note_type, colors["info"]) + + table = doc.add_table(rows=1, cols=1) + table.style = "Table Grid" + cell = table.rows[0].cells[0] + + # Set background + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + shading = parse_xml( + f'' + ) + tcPr.append(shading) + + p = cell.paragraphs[0] + prefix = {"info": "NOTA: ", "warning": "ATENTIE: ", "tip": "SFAT: "}.get( + note_type, "" + ) + run = p.add_run(prefix) + run.bold = True + run.font.size = Pt(9) + run.font.color.rgb = RGBColor( + int(text_color[0:2], 16), int(text_color[2:4], 16), int(text_color[4:6], 16) + ) + run = p.add_run(text) + run.font.size = Pt(9) + run.font.color.rgb = RGBColor( + int(text_color[0:2], 16), int(text_color[2:4], 16), int(text_color[4:6], 16) + ) + + doc.add_paragraph() # Spacing + + +def add_code_block(doc, code_text): + """Add a code block with monospace font and gray background.""" + table = doc.add_table(rows=1, cols=1) + table.style = "Table Grid" + cell = table.rows[0].cells[0] + + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + shading = parse_xml(f'') + tcPr.append(shading) + + p = cell.paragraphs[0] + for line in code_text.strip().split("\n"): + run = p.add_run(line + "\n") + run.font.name = "Consolas" + run.font.size = Pt(8.5) + run.font.color.rgb = RGBColor(0x2D, 0x2D, 0x2D) + + doc.add_paragraph() + + +def add_toc_placeholder(doc): + """Add a Table of Contents placeholder heading.""" + doc.add_heading("Cuprins", level=1) + p = doc.add_paragraph() + run = p.add_run( + "[Cuprinsul se genereaza automat in Microsoft Word: References > Table of Contents > Update Table]" + ) + run.italic = True + run.font.size = Pt(10) + run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + + # Insert TOC field + paragraph = doc.add_paragraph() + run = paragraph.add_run() + fldChar = parse_xml(f'') + run._r.append(fldChar) + run = paragraph.add_run() + instrText = parse_xml( + f' TOC \\o "1-3" \\h \\z \\u ' + ) + run._r.append(instrText) + run = paragraph.add_run() + fldChar = parse_xml(f'') + run._r.append(fldChar) + run = paragraph.add_run("Actualizati cuprinsul dupa deschiderea documentului.") + run.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + run.font.italic = True + run = paragraph.add_run() + fldChar = parse_xml(f'') + run._r.append(fldChar) + + doc.add_page_break() + + +# ============================================================================== +# DOCUMENT 1: Manual Utilizare Website +# ============================================================================== + + +def generate_manual_utilizare(): + """Generate Manual_Utilizare_Website.docx""" + print("[1/4] Generare Manual_Utilizare_Website.docx ...") + + doc = create_document() + + # Cover page + add_cover_page( + doc, + "MANUAL DE UTILIZARE", + "Website DiDi cu E-commerce", + ) + + add_header_footer(doc) + add_toc_placeholder(doc) + + # ========================================================================= + # CHAPTER 1: Introducere + # ========================================================================= + doc.add_heading("1. Introducere", level=1) + + doc.add_heading("1.1 Prezentarea Platformei DiDi", level=2) + p = doc.add_paragraph() + p.add_run( + "DiDi (Platforma Digitala Inteligenta pentru Prevenirea si Combaterea Dezinformarii) " + "este o solutie web inovatoare dezvoltata in cadrul proiectului PNRR Contract 11.1.i3.c9/10.05.2024 " + "de catre TOP CLOSSERS SRL. Platforma ofera instrumente avansate bazate pe inteligenta artificiala " + "pentru detectarea si analiza dezinformarii in continut digital." + ) + + p = doc.add_paragraph() + p.add_run( + "Platforma DiDi combina tehnologii de ultima generatie in procesarea limbajului natural (NLP), " + "detectia deepfake-urilor, verificarea faptelor (fact-checking), evaluarea credibilitatii surselor " + "si identificarea tehnicilor de manipulare. Aceste capabilitati sunt disponibile prin intermediul " + "unui website modern, intuitiv si accesibil, integrat cu un sistem de e-commerce care permite " + "achizitionarea serviciilor de analiza in mod simplu si securizat." + ) + + doc.add_heading("1.2 Scopul Documentului", level=2) + p = doc.add_paragraph() + p.add_run( + "Prezentul manual de utilizare are ca scop principal furnizarea instructiunilor detaliate pentru " + "utilizarea website-ului DiDi de catre toate categoriile de utilizatori. Documentul acopera:" + ) + add_bullet_list(doc, [ + "Navigarea pe paginile publice ale website-ului", + "Procesul de inregistrare si autentificare", + "Utilizarea dashboard-ului client pentru achizitionarea si rularea analizelor", + "Procesul de plata prin Stripe", + "Vizualizarea facturilor si gestionarea profilului", + "Functionalitati tehnice suplimentare (responsive design, multilingvism, SEO)", + ]) + + doc.add_heading("1.3 Audienta Tinta", level=2) + p = doc.add_paragraph() + p.add_run("Acest manual se adreseaza urmatoarelor categorii de utilizatori:") + add_bullet_list(doc, [ + "Clienti finali - persoane fizice sau juridice care doresc sa utilizeze serviciile de analiza DiDi pentru verificarea continutului digital", + "Administratori de continut - personal responsabil cu gestionarea informatiilor de pe website", + "Personal tehnic de suport - echipa care ofera asistenta utilizatorilor platformei", + "Evaluatori PNRR - persoane implicate in verificarea si evaluarea livrabilelor proiectului", + ]) + + doc.add_heading("1.4 Conventii Utilizate in Document", level=2) + p = doc.add_paragraph() + p.add_run("In cadrul acestui manual se utilizeaza urmatoarele conventii:") + add_bullet_list(doc, [ + "Textul bold indica elemente de interfata (butoane, meniuri, etichete)", + "Textul italic indica note importante sau observatii", + "Capturile de ecran ilustreaza starea reala a interfetei la momentul documentarii", + "Casetele colorate contin note, avertismente sau sfaturi utile", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 2: Accesare si Navigare + # ========================================================================= + doc.add_heading("2. Accesare si Navigare", level=1) + + doc.add_heading("2.1 Accesarea Website-ului", level=2) + p = doc.add_paragraph() + p.add_run( + "Website-ul DiDi este accesibil prin intermediul unui browser web modern (Google Chrome, Mozilla Firefox, " + "Microsoft Edge, Safari) la urmatoarea adresa URL:" + ) + add_code_block(doc, "https://didi365.eu") + + p = doc.add_paragraph() + p.add_run( + "Website-ul este optimizat pentru cele mai recente versiuni ale browserelor mentionate si suporta " + "atat conexiuni desktop cat si dispozitive mobile (tableta, smartphone). Conexiunea este securizata " + "prin protocol HTTPS cu certificat TLS valid." + ) + + doc.add_heading("2.2 Structura de Navigare", level=2) + p = doc.add_paragraph() + p.add_run( + "Bara de navigare principala (navbar) este afisata in partea superioara a fiecarei pagini si contine " + "urmatoarele elemente:" + ) + + add_table_with_header( + doc, + ["Element", "Descriere", "Pozitie"], + [ + ["Logo DiDi", "Logo-ul platformei, click redirectioneaza la homepage", "Stanga"], + ["Servicii", "Link catre pagina de prezentare a serviciilor de analiza", "Centru"], + ["Despre", "Link catre pagina cu informatii despre companie si platforma", "Centru"], + ["Pricing", "Link catre pagina cu preturile serviciilor", "Centru"], + ["Contact", "Link catre formularul de contact", "Centru"], + ["Language Switcher", "Comutator RO/EN pentru schimbarea limbii interfetei", "Dreapta"], + ["Autentificare / Dashboard", "Buton de login sau acces la dashboard (dupa autentificare)", "Dreapta"], + ], + col_widths=[Inches(1.5), Inches(3.5), Inches(1.0)], + ) + + doc.add_heading("2.3 Footer", level=2) + p = doc.add_paragraph() + p.add_run( + "Footer-ul website-ului contine informatii suplimentare organizate in sectiuni:" + ) + add_bullet_list(doc, [ + "Linkuri rapide catre paginile principale (Servicii, Despre, Pricing, Contact)", + "Linkuri legale: Politica de Confidentialitate, Termeni si Conditii", + "Informatii de contact: adresa email, telefon", + "Copyright si informatii companie: TOP CLOSSERS SRL", + "Referinta proiect PNRR", + ]) + + doc.add_heading("2.4 Selectarea Limbii (Language Switcher)", level=2) + p = doc.add_paragraph() + p.add_run( + "Website-ul DiDi este disponibil in doua limbi: Romana (RO) si Engleza (EN). " + "Utilizatorul poate comuta intre cele doua limbi folosind selectorul de limba " + "din coltul din dreapta sus al barei de navigare. Preferinta de limba se salveaza " + "in localStorage-ul browserului si se pastreaza la vizitele ulterioare." + ) + + add_screenshot( + doc, "website", "01-homepage.png", "Pagina principala DiDi - Navigare si structura generala" + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 3: Pagini Publice + # ========================================================================= + doc.add_heading("3. Pagini Publice", level=1) + + p = doc.add_paragraph() + p.add_run( + "Website-ul DiDi ofera mai multe pagini publice accesibile fara autentificare, " + "care prezinta informatii despre platforma, serviciile disponibile si modalitatile de contact." + ) + + # 3.1 Homepage + doc.add_heading("3.1 Homepage (Pagina Principala)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina principala este punctul de intrare al website-ului si ofera o prezentare " + "completa a platformei DiDi. Este structurata in mai multe sectiuni verticale, " + "fiecare proiectata pentru a comunica clar valoarea si capabilitatile platformei." + ) + + doc.add_heading("Hero Section", level=3) + p = doc.add_paragraph() + p.add_run( + 'Sectiunea principala afiseaza branding-ul "by Clossers / didi" impreuna cu ' + "un mesaj concis despre misiunea platformei de combatere a dezinformarii. " + "Include un buton CTA (Call-to-Action) care directioneaza utilizatorul catre " + "sectiunea de servicii sau catre inregistrare." + ) + + doc.add_heading("Sectiunea Despre Platforma", level=3) + p = doc.add_paragraph() + p.add_run( + "Prezinta pe scurt scopul platformei DiDi si contextul in care a fost dezvoltata " + "(proiect PNRR pentru digitalizarea luptei impotriva dezinformarii). Se evidentiaza " + "caracterul inovator al solutiei si tehnologiile AI utilizate." + ) + + doc.add_heading("Sectiunea Capabilitati", level=3) + p = doc.add_paragraph() + p.add_run( + "Afiseaza cele 6 module principale de analiza AI disponibile in platforma, fiecare " + "cu un icon reprezentativ, titlu si descriere scurta. Modulele acoperite sunt: " + "Analiza Text/NLP, Detectie Deepfake, Fact-Checking, Evaluare Surse, " + "Detectie Tehnici de Manipulare si Monitorizare in Timp Real." + ) + + doc.add_heading("Cum Functioneaza - 4 Pasi", level=3) + p = doc.add_paragraph() + p.add_run( + "Aceasta sectiune prezinta procesul de utilizare a platformei in 4 pasi simpli:" + ) + add_numbered_list(doc, [ + "Creaza un cont - Inregistrare rapida prin sistemul de autentificare securizat Keycloak SSO", + "Achizitioneaza o analiza - Selecteaza tipul de analiza dorit si efectueaza plata prin Stripe", + "Incarca continutul - Introdu textul, URL-ul sau incarca fisierul media (imagine, audio, video)", + "Primeste rezultatele - Obtine un raport detaliat cu scorul de risc, incredere si recomandari", + ]) + + doc.add_heading("Cazuri de Utilizare", level=3) + p = doc.add_paragraph() + p.add_run( + "Sectiunea prezinta scenarii concrete in care platforma DiDi poate fi utilizata: " + "verificarea stirilor online, analiza continutului viral pe retele sociale, " + "detectarea imaginilor sau videoclipurilor manipulate, evaluarea credibilitatii " + "surselor de informatii si monitorizarea campaniilor de dezinformare." + ) + + doc.add_heading("Call-to-Action Final", level=3) + p = doc.add_paragraph() + p.add_run( + "Pagina se incheie cu o sectiune CTA care invita utilizatorii sa creeze un cont " + "si sa inceapa sa utilizeze platforma, oferind un buton de inregistrare prominent." + ) + + add_screenshot(doc, "website", "01-homepage.png", "Homepage - Pagina principala a website-ului DiDi") + + doc.add_page_break() + + # 3.2 Servicii + doc.add_heading("3.2 Servicii", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina de Servicii prezinta in detaliu cele 6 module de analiza AI disponibile " + "in platforma DiDi. Fiecare modul este descris cu functionalitati specifice, " + "tipuri de continut suportate si beneficii pentru utilizator." + ) + + doc.add_heading("Modulele de Analiza AI", level=3) + + services_data = [ + ("Analiza Text / NLP", "Procesare avansata a limbajului natural pentru detectarea elementelor de dezinformare in texte. Identifica manipulari lingvistice, bias-uri si inconsistente factuale."), + ("Detectie Deepfake", "Algoritmi de inteligenta artificiala pentru detectarea continutului media manipulat (imagini, audio, video). Identifica semne de alterare digitala si generare AI."), + ("Fact-Checking", "Verificare automata a afirmatiilor din continut prin comparare cu surse credibile si baze de date verificate. Evalueaza veridicitatea informatiilor."), + ("Evaluare Surse", "Analiza credibilitatii si reputatiei surselor de informatii. Evalueaza domenii web, publicatii si autori pe baza unui scoring multi-criterial."), + ("Detectie Tehnici de Manipulare", "Identificarea tehnicilor specifice de manipulare si propaganda utilizate in continut: apel la emotie, cherry-picking, whataboutism, etc."), + ("Monitorizare in Timp Real", "Monitorizare continua a fluxurilor de informatii pentru identificarea rapida a campaniilor de dezinformare si a trendurilor de manipulare."), + ] + + for name, desc in services_data: + doc.add_heading(name, level=3) + p = doc.add_paragraph() + p.add_run(desc) + + p = doc.add_paragraph() + p.add_run( + "Fiecare serviciu este disponibil pentru diferite tipuri de media: text, imagine, audio, video " + "si URL. Preturile variaza in functie de complexitatea analizei si tipul de continut procesat." + ) + + add_screenshot(doc, "website", "02-services.png", "Pagina Servicii - Modulele de analiza AI DiDi") + + doc.add_page_break() + + # 3.3 Despre + doc.add_heading("3.3 Despre", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Despre ofera informatii detaliate despre compania Clossers si platforma DiDi. " + "Sectiunea prezinta misiunea companiei, viziunea pe termen lung in domeniul combaterii " + "dezinformarii si echipa de specialisti implicata in dezvoltarea platformei." + ) + + p = doc.add_paragraph() + p.add_run( + "Se evidentiaza contextul dezvoltarii platformei in cadrul programului PNRR, obiectivele " + "urmarite si impactul asteptat asupra ecosistemului informational din Romania. Pagina include " + "de asemenea informatii despre metodologia de lucru si tehnologiile utilizate." + ) + + add_screenshot(doc, "website", "03-about.png", "Pagina Despre - Informatii companie si echipa") + + # 3.4 Pricing + doc.add_heading("3.4 Pricing (Preturi)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Pricing afiseaza preturile serviciilor de analiza organizate pe 4 categorii de servicii " + "si tipuri de media. Preturile sunt exprimate in RON (lei romanesti) si includ TVA." + ) + + doc.add_heading("Categorii de Servicii si Preturi", level=3) + add_table_with_header( + doc, + ["Categorie", "Text", "Imagine", "Audio", "Video", "URL"], + [ + ["Detectie Tehnici Manipulare", "9,99 RON", "14,99 RON", "19,99 RON", "24,99 RON", "9,99 RON"], + ["AI Detection (Deepfake)", "9,99 RON", "14,99 RON", "19,99 RON", "24,99 RON", "9,99 RON"], + ["Fact-Checking", "9,99 RON", "14,99 RON", "19,99 RON", "24,99 RON", "9,99 RON"], + ["Evaluare Surse", "9,99 RON", "14,99 RON", "19,99 RON", "24,99 RON", "9,99 RON"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Toate serviciile sunt de tip one-time (plata unica per analiza). Fiecare achizitie ofera " + "dreptul de a efectua o singura analiza pe tipul de continut si categoria selectata. " + "Serviciile achizitionate nu expira si pot fi utilizate oricand." + ) + + add_screenshot(doc, "website", "04-pricing.png", "Pagina Pricing - Preturi servicii analiza") + + doc.add_page_break() + + # 3.5 Contact + doc.add_heading("3.5 Contact", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina de Contact pune la dispozitia vizitatorilor un formular simplu si intuitiv " + "pentru transmiterea mesajelor catre echipa DiDi. Formularul contine urmatoarele campuri:" + ) + + add_table_with_header( + doc, + ["Camp", "Tip", "Obligatoriu", "Descriere"], + [ + ["Nume", "Text", "Da", "Numele complet al persoanei de contact"], + ["Email", "Email", "Da", "Adresa de email pentru raspuns"], + ["Mesaj", "Textarea", "Da", "Continutul mesajului (intrebare, solicitare, feedback)"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "La trimiterea formularului, datele sunt procesate automat si se creaza un Lead in sistemul " + "CRM ERPNext. Acest mecanism asigura ca fiecare solicitare este inregistrata si urmarita " + "de echipa de vanzari sau suport. Formularul suporta si parametri UTM pentru tracking-ul " + "sursei vizitatorului (sursa, mediu, campanie)." + ) + + add_note_box( + doc, + "Toate datele transmise prin formularul de contact sunt prelucrate in conformitate " + "cu Regulamentul General privind Protectia Datelor (GDPR). Consultati Politica de " + "Confidentialitate pentru detalii complete.", + "info", + ) + + add_screenshot(doc, "website", "05-contact.png", "Pagina Contact - Formularul de contact") + + # 3.6 Confidentialitate + doc.add_heading("3.6 Politica de Confidentialitate", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina de Politica de Confidentialitate contine documentul complet privind protectia " + "datelor cu caracter personal, in conformitate cu Regulamentul (UE) 2016/679 (GDPR). " + "Documentul acopera urmatoarele aspecte principale:" + ) + + add_bullet_list(doc, [ + "Identitatea si datele de contact ale operatorului de date (TOP CLOSSERS SRL)", + "Categoriile de date cu caracter personal colectate", + "Scopurile si temeiurile juridice ale prelucrarii datelor", + "Durata de pastrare a datelor personale", + "Drepturile persoanelor vizate (acces, rectificare, stergere, portabilitate, opozitie)", + "Informatii privind utilizarea cookie-urilor si tehnologiilor de urmarire", + "Masurile de securitate implementate pentru protectia datelor", + "Procedura de depunere a plangerilor la Autoritatea Nationala de Supraveghere (ANSPDCP)", + ]) + + add_screenshot(doc, "website", "06-privacy.png", "Pagina Politica de Confidentialitate") + + doc.add_page_break() + + # 3.7 Termeni si Conditii + doc.add_heading("3.7 Termeni si Conditii", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Termeni si Conditii contine acordul legal complet care guverneaza utilizarea " + "platformei DiDi. Documentul stabileste drepturile si obligatiile utilizatorilor, " + "precum si ale operatorului platformei. Principalele capitole includ:" + ) + + add_bullet_list(doc, [ + "Definitii si terminologie utilizata in cadrul acordului", + "Conditii de utilizare a platformei si serviciilor", + "Procesul de inregistrare si obligatiile utilizatorului privind securitatea contului", + "Descrierea serviciilor oferite si limitarile acestora", + "Politica de preturi, plati si rambursari", + "Proprietatea intelectuala si drepturile de autor", + "Limitarea raspunderii operatorului platformei", + "Procedura de rezolvare a disputelor", + "Legislatia aplicabila si jurisdictia competenta", + ]) + + add_screenshot(doc, "website", "07-terms.png", "Pagina Termeni si Conditii") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 4: Autentificare + # ========================================================================= + doc.add_heading("4. Autentificare si Gestionarea Contului", level=1) + + p = doc.add_paragraph() + p.add_run( + "Platforma DiDi utilizeaza sistemul de autentificare Keycloak SSO (Single Sign-On) " + "pentru gestionarea securizata a conturilor utilizatorilor. Acest sistem ofera un nivel " + "ridicat de securitate si o experienta consistenta de autentificare." + ) + + doc.add_heading("4.1 Crearea Contului (Inregistrare)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pentru a utiliza serviciile platite ale platformei DiDi, utilizatorul trebuie sa isi " + "creeze un cont. Procesul de inregistrare se desfasoara in urmatorii pasi:" + ) + + add_numbered_list(doc, [ + 'Accesati website-ul DiDi si apasati butonul "Autentificare" sau "Inregistrare" din bara de navigare', + "Veti fi redirectionat automat catre pagina de autentificare Keycloak SSO (didi365.eu/didi-auth)", + 'Apasati linkul "Inregistrare" de sub formularul de login', + "Completati formularul de inregistrare cu urmatoarele informatii: prenume, nume, adresa de email, parola (minim 8 caractere)", + "Confirmati adresa de email prin accesarea linkului primit in casuta de email", + "Dupa confirmare, contul este activ si puteti accesa dashboard-ul client", + ]) + + add_note_box( + doc, + "La prima autentificare, sistemul creeaza automat un profil de client in ERPNext " + "asociat cu ID-ul unic Keycloak (UUID). Acest profil va fi utilizat pentru facturare, " + "istoricul achizitiilor si gestionarea serviciilor.", + "info", + ) + + doc.add_heading("4.2 Autentificare (Login)", level=2) + p = doc.add_paragraph() + p.add_run("Procesul de autentificare se desfasoara astfel:") + + add_numbered_list(doc, [ + 'Apasati butonul "Autentificare" din bara de navigare a website-ului', + "Sunteti redirectionat catre pagina de login Keycloak SSO", + "Introduceti adresa de email si parola contului dvs.", + 'Apasati butonul "Conectare"', + "Dupa autentificarea reusita, sunteti redirectionat inapoi pe website cu o sesiune activa", + "In bara de navigare va aparea optiunea de acces la Dashboard in locul butonului de login", + ]) + + p = doc.add_paragraph() + p.add_run( + "Sesiunea de autentificare este gestionata prin JWT (JSON Web Token) cu durata limitata. " + "Sesiunea se reinnoieste automat prin mecanismul de refresh token, asigurand o experienta " + "fluida fara deconectari frecvente." + ) + + doc.add_heading("4.3 Deconectare (Logout)", level=2) + p = doc.add_paragraph() + p.add_run( + 'Pentru a va deconecta din platforma, apasati butonul "Deconectare" disponibil in ' + "bara de navigare sau in meniul de profil din dashboard. La deconectare:" + ) + + add_bullet_list(doc, [ + "Sesiunea locala este invalidata", + "Token-ul JWT este revocat", + "Sunteti redirectionat catre pagina principala a website-ului", + "Pentru a accesa din nou dashboard-ul, va trebui sa va autentificati din nou", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 5: Dashboard Client + # ========================================================================= + doc.add_heading("5. Dashboard Client", level=1) + + p = doc.add_paragraph() + p.add_run( + "Dashboard-ul client este zona privata a platformei, accesibila doar utilizatorilor autentificati. " + "Ofera acces la toate functionalitatile de achizitionare, rulare analize, vizualizare rezultate " + "si gestionare a contului." + ) + + # 5.1 Overview + doc.add_heading("5.1 Overview (Pagina Principala Dashboard)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Overview este prima pagina afisata dupa autentificare si ofera o vedere de ansamblu " + "asupra contului si activitatii utilizatorului. Elementele principale includ:" + ) + + doc.add_heading("Salut Personalizat", level=3) + p = doc.add_paragraph() + p.add_run( + "In partea superioara se afiseaza un mesaj de bun venit personalizat cu numele utilizatorului " + 'autentificat (exemplu: "Buna ziua, Vasile!").' + ) + + doc.add_heading("Carduri Statistici", level=3) + p = doc.add_paragraph() + p.add_run("Dashboard-ul afiseaza carduri informative cu urmatoarele statistici:") + add_bullet_list(doc, [ + "Achizitii totale - numarul total de servicii de analiza achizitionate", + "Disponibile - numarul de analize achizitionate dar inca neutilizate", + "Rezumat - prezentare generala a activitatii contului", + ]) + + doc.add_heading("Ultimele Facturi", level=3) + p = doc.add_paragraph() + p.add_run( + "In sectiunea inferioara se afiseaza o lista cu ultimele facturi emise, permitand " + "vizualizarea rapida a tranzactiilor recente." + ) + + add_screenshot(doc, "dashboard", "01-overview.png", "Dashboard - Pagina Overview cu statistici si rezumat") + + doc.add_page_break() + + # 5.2 Achizitioneaza Analiza + doc.add_heading("5.2 Achizitioneaza Analiza", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Achizitioneaza Analiza permite utilizatorilor sa cumpere servicii de analiza AI. " + "Serviciile sunt organizate pe 4 categorii principale, fiecare disponibila pentru " + "diferite tipuri de media." + ) + + doc.add_heading("Categorii de Servicii Disponibile", level=3) + add_table_with_header( + doc, + ["Categorie", "Cod Intern", "Descriere"], + [ + ["Detectie Tehnici Manipulare", "DIDI-TECHNIQUES-*", "Identificarea tehnicilor de propaganda si manipulare in continut"], + ["AI Detection (Deepfake)", "DIDI-AI_DETECTION-*", "Detectarea continutului generat sau modificat de AI"], + ["Fact-Checking", "DIDI-CLAIMS-*", "Verificarea automata a afirmatiilor si informatiilor"], + ["Evaluare Surse", "DIDI-SOURCE-*", "Analiza credibilitatii domeniilor si surselor de informatii"], + ], + ) + + doc.add_heading("Tipuri de Media Acceptate", level=3) + add_table_with_header( + doc, + ["Tip Media", "Format", "Descriere"], + [ + ["Text", "Text liber", "Continut text introdus direct in platforma"], + ["Imagine", "PNG, JPG, WEBP", "Fisiere imagine incarcate de utilizator"], + ["Audio", "MP3, WAV, OGG", "Fisiere audio incarcate de utilizator"], + ["Video", "MP4, MOV, AVI", "Fisiere video incarcate de utilizator"], + ["URL", "Link web", "Adresa URL a continutului de analizat"], + ], + ) + + doc.add_heading("Procesul de Achizitie", level=3) + p = doc.add_paragraph() + p.add_run("Pentru a achizitiona o analiza, urmati pasii:") + add_numbered_list(doc, [ + "Navigati la sectiunea 'Achizitioneaza Analiza' din meniul lateral al dashboard-ului", + "Selectati categoria de analiza dorita (Detectie Tehnici, AI Detection, Fact-Checking, Evaluare Surse)", + "Selectati tipul de media (Text, Imagine, Audio, Video, URL)", + "Verificati pretul afisat pentru combinatia selectata", + 'Apasati butonul "Cumpara" pentru a initia procesul de plata', + "Veti fi redirectionat catre pagina de plata Stripe (detalii in Capitolul 6)", + ]) + + add_screenshot(doc, "dashboard", "03-achizitioneaza.png", "Dashboard - Pagina de achizitie servicii analiza") + + doc.add_page_break() + + # 5.3 Analize + doc.add_heading("5.3 Analize (Lista Analize)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Analize afiseaza toate analizele asociate contului utilizatorului, organizate " + "in doua categorii:" + ) + + doc.add_heading("Analize Disponibile (Neconsumate)", level=3) + p = doc.add_paragraph() + p.add_run( + "Analizele achizitionate dar inca neutilizate sunt afisate ca disponibile. Pentru fiecare " + "analiza disponibila se afiseaza:" + ) + add_bullet_list(doc, [ + "Tipul analizei (categorie + tip media)", + "Data achizitiei", + "Numarul facturii asociate", + 'Buton "Ruleaza Analiza" pentru a incepe procesul de analiza', + ]) + + doc.add_heading("Rapoarte Salvate", level=3) + p = doc.add_paragraph() + p.add_run( + "Analizele deja efectuate sunt afisate ca rapoarte salvate. Pentru fiecare raport " + "se afiseaza:" + ) + add_bullet_list(doc, [ + "Titlul raportului si categoria analizei", + "Data efectuarii analizei", + "Scorul de risc si nivelul de incredere", + 'Buton "Descarca PDF" pentru generarea si descarcarea raportului complet', + ]) + + add_screenshot(doc, "dashboard", "02-analize.png", "Dashboard - Lista analize disponibile si rapoarte salvate") + + # 5.4 Rulare Analiza + doc.add_heading("5.4 Rularea unei Analize", level=2) + p = doc.add_paragraph() + p.add_run( + "Procesul de rulare a unei analize este nucleul functionalitatii platformei DiDi. " + "Dupa achizitionarea unui serviciu de analiza, utilizatorul poate rula analiza " + "pe continutul dorit." + ) + + doc.add_heading("Pasii pentru Rularea unei Analize", level=3) + add_numbered_list(doc, [ + "Din pagina Analize, selectati o analiza disponibila (neConsumata) si apasati 'Ruleaza Analiza'", + "Se deschide interfata de introducere a continutului, adaptata tipului de media al analizei", + "Pentru analize de tip Text: introduceti textul de analizat in campul dedicat", + "Pentru analize de tip URL: introduceti adresa web a continutului de analizat", + "Pentru analize de tip Imagine/Audio/Video: incarcati fisierul media folosind butonul de upload", + 'Apasati butonul "Porneste analiza" pentru a initia procesarea', + "Platforma afiseaza un indicator de progres in timp ce analiza este procesata", + "Dupa finalizare, rezultatele sunt afisate direct in interfata", + ]) + + doc.add_heading("Structura Rezultatelor", level=3) + p = doc.add_paragraph() + p.add_run("Rezultatele unei analize includ urmatoarele informatii:") + + add_table_with_header( + doc, + ["Element", "Descriere"], + [ + ["Scor de Risc", "Valoare numerica (0-100%) care indica nivelul de risc de dezinformare detectat"], + ["Nivel de Incredere", "Gradul de incredere al algoritmului in rezultatul furnizat"], + ["Categorie de Risc", "Clasificare: Scazut / Mediu / Ridicat / Critic"], + ["Tehnici Detectate", "Lista tehnicilor de manipulare identificate (pentru categoria Detectie Tehnici)"], + ["Verdict", "Concluzia generala a analizei in format text descriptiv"], + ["Afirmatii Verificate", "Lista afirmatiilor verificate si statusul lor (pentru Fact-Checking)"], + ["Scor AI Tampered", "Probabilitatea ca media sa fie generata/modificata de AI (pentru AI Detection)"], + ["Evaluare Domeniu", "Scorul de credibilitate al domeniului analizat (pentru Evaluare Surse)"], + ], + ) + + doc.add_heading("Procesare Sincrona vs. Asincrona", level=3) + p = doc.add_paragraph() + p.add_run( + "In functie de tipul de continut, analiza poate fi procesata sincron sau asincron:" + ) + add_bullet_list(doc, [ + "Sincron (rezultat imediat): analize de tip Text, URL si Imagine - rezultatele sunt returnate in cateva secunde", + "Asincron (cu polling): analize de tip Audio si Video - procesarea poate dura mai mult, iar platforma verifica periodic statusul analizei (polling) si afiseaza rezultatele cand acestea sunt disponibile", + ]) + + doc.add_heading("Rezultate pentru Diferite Tipuri de Media", level=3) + p = doc.add_paragraph() + p.add_run( + "Mai jos sunt prezentate exemple de rezultate pentru fiecare tip de media analizat:" + ) + + add_screenshot(doc, "dashboard", "08-rezultat-text.png", "Rezultat analiza text - Scor de risc si tehnici detectate") + add_screenshot(doc, "dashboard", "09-rezultat-imagine.png", "Rezultat analiza imagine - Detectie AI/manipulare") + add_screenshot(doc, "dashboard", "10-rezultat-audio.png", "Rezultat analiza audio - Evaluare continut audio") + add_screenshot(doc, "dashboard", "11-rezultat-video.png", "Rezultat analiza video - Detectie deepfake") + + doc.add_heading("Descarcare Raport PDF", level=3) + p = doc.add_paragraph() + p.add_run( + "Dupa finalizarea analizei, utilizatorul poate descarca un raport complet in format PDF " + "care contine toate informatiile analizei, inclusiv detalii tehnice, grafice si recomandari. " + 'Raportul se genereaza prin apasarea butonului "Descarca PDF" disponibil in pagina de rezultate.' + ) + + doc.add_page_break() + + # 5.5 Facturi + doc.add_heading("5.5 Facturi", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Facturi ofera acces la toate facturile emise pentru achizitiile efectuate. " + "Facturile sunt generate automat in sistemul ERPNext la finalizarea fiecarei plati." + ) + + doc.add_heading("Informatii Afisate", level=3) + p = doc.add_paragraph() + p.add_run("Pentru fiecare factura se afiseaza urmatoarele informatii:") + + add_table_with_header( + doc, + ["Camp", "Descriere"], + [ + ["Numar Factura", "Identificator unic in formatul DIDI-INV-YYYY-##### (exemplu: DIDI-INV-2026-00001)"], + ["Data Emitere", "Data la care factura a fost emisa"], + ["Suma", "Valoarea totala a facturii (inclusiv TVA 19%)"], + ["Status", "Starea facturii: Platita, Anulata"], + ["Actiuni", "Buton pentru descarcarea facturii in format PDF"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Facturile pot fi filtrate si sortate dupa data sau status. Fiecare factura poate fi " + "descarcata in format PDF cu branding DiDi, incluzand toate detaliile legale necesare " + "(date companie, CUI, numar de inregistrare, adresa)." + ) + + add_screenshot(doc, "dashboard", "04-facturi.png", "Dashboard - Lista facturi cu status si actiuni") + + # 5.6 Profil + doc.add_heading("5.6 Profil Utilizator", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina Profil permite utilizatorului sa vizualizeze si sa editeze informatiile " + "asociate contului sau. Informatiile disponibile includ:" + ) + + add_bullet_list(doc, [ + "Datele personale: nume, prenume, adresa de email (sincronizate din Keycloak)", + "Date companie: numele companiei, CUI (pentru facturare persoane juridice)", + "Planul activ: tipul de abonament curent (Free Tier, Paid Tier, Enterprise Tier)", + "Optiuni GDPR: gestionarea consimtamintelor pentru prelucrarea datelor personale", + "Posibilitatea de a solicita exportul sau stergerea datelor personale", + ]) + + p = doc.add_paragraph() + p.add_run( + "Modificarile la datele de companie si CUI se sincronizeaza automat cu fisa clientului " + "din ERPNext, asigurand consistenta datelor de facturare." + ) + + add_screenshot(doc, "dashboard", "05-profil.png", "Dashboard - Pagina de profil utilizator") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 6: Plata cu Stripe + # ========================================================================= + doc.add_heading("6. Plata cu Stripe", level=1) + + p = doc.add_paragraph() + p.add_run( + "Platforma DiDi utilizeaza serviciul de plati online Stripe pentru procesarea securizata " + "a tranzactiilor. Stripe este un procesator de plati certificat PCI DSS Level 1, " + "garantand cel mai inalt nivel de securitate pentru datele cardului." + ) + + doc.add_heading("6.1 Fluxul de Plata", level=2) + p = doc.add_paragraph() + p.add_run( + "Procesul de plata se desfasoara in urmatorii pasi:" + ) + + add_numbered_list(doc, [ + "Selectie serviciu: din pagina Achizitioneaza Analiza, selectati serviciul dorit si apasati 'Cumpara'", + "Creare sesiune Stripe: website-ul creeaza automat o sesiune de checkout Stripe pe server (API route /api/checkout)", + "Redirect la Stripe: sunteti redirectionat catre pagina securizata Stripe Checkout", + "Completare date card: introduceti numarul cardului, data expirare, CVV si numele titularului", + "Procesare plata: Stripe proceseaza plata si valideaza tranzactia", + "Redirect success: dupa plata reusita, sunteti redirectionat inapoi pe website la pagina de confirmare", + "Confirmare: se afiseaza referinta platii si numarul facturii generate automat in ERPNext", + ]) + + doc.add_heading("6.2 Procesare Automata (Webhook Fulfillment)", level=2) + p = doc.add_paragraph() + p.add_run( + "La confirmarea platii, Stripe trimite automat o notificare (webhook) catre platforma DiDi. " + "Aceasta notificare declanseaza urmatorul proces automat:" + ) + + add_bullet_list(doc, [ + "Verificarea si validarea platii prin semnatura webhook-ului Stripe", + "Crearea sau identificarea clientului in ERPNext (pe baza ID-ului Keycloak)", + "Generarea automata a facturii (Sales Invoice) cu seria DIDI-INV-YYYY-#####", + "Inregistrarea automata a platii (Payment Entry) in contabilitatea ERPNext", + "Crearea acordului de servicii (Service Agreement) care activeaza analiza achizitionata", + "Actualizarea planului clientului daca este cazul", + "Inregistrarea tranzactiei in jurnalul de plati (Payment Log) pentru audit", + "Trimiterea automata a facturii prin email catre client", + ]) + + doc.add_heading("6.3 Carduri Acceptate", level=2) + p = doc.add_paragraph() + p.add_run( + "Stripe accepta urmatoarele metode de plata: Visa, Mastercard, American Express, " + "Discover si alte carduri bancare internationale. De asemenea, sunt suportate " + "portofelele digitale Apple Pay si Google Pay (in functie de browser si dispozitiv)." + ) + + add_note_box( + doc, + "Datele cardului dvs. sunt procesate exclusiv de Stripe si nu sunt niciodata " + "stocate pe serverele platformei DiDi. Platforma nu are acces la numarul " + "complet al cardului sau la codul CVV.", + "info", + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 7: Design Responsive + # ========================================================================= + doc.add_heading("7. Design Responsive", level=1) + + p = doc.add_paragraph() + p.add_run( + "Website-ul DiDi este dezvoltat cu un design responsive care se adapteaza automat " + "la dimensiunea ecranului dispozitivului utilizat. Interfata a fost optimizata " + "pentru trei categorii principale de dispozitive:" + ) + + doc.add_heading("7.1 Desktop (> 1024px)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pe ecranele de desktop, website-ul afiseaza continutul la latimea completa, " + "cu bara de navigare orizontala vizibila permanent, layout pe coloane multiple " + "si imagini la rezolutie completa. Aceasta este experienta optima recomanda." + ) + + doc.add_heading("7.2 Tableta (768px - 1024px)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pe tablete, layout-ul se reorganizeaza pentru a se adapta la ecranul mai mic. " + "Coloanele se reduc, iar unele elemente se stivuiesc vertical. Bara de navigare " + "poate trece in modul hamburger menu." + ) + + add_screenshot(doc, "website", "09-homepage-tablet.png", "Website - Vizualizare pe tableta") + + doc.add_heading("7.3 Mobil (< 768px)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pe dispozitive mobile, interfata se optimizeaza complet: navbar-ul devine un " + "meniu hamburger, continutul se afiseaza pe o singura coloana, butoanele si " + "elementele interactive sunt dimensionate pentru interactiunea tactila, iar " + "textul se redimensioneaza pentru lizibilitate optima." + ) + + add_screenshot(doc, "website", "08-homepage-mobile.png", "Website - Vizualizare pe mobil (smartphone)") + add_screenshot(doc, "dashboard", "06-overview-mobile.png", "Dashboard - Vizualizare pe mobil") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 8: Functionalitati Suplimentare + # ========================================================================= + doc.add_heading("8. Functionalitati Suplimentare", level=1) + + doc.add_heading("8.1 Cookie Consent (Consimtamant Cookie-uri)", level=2) + p = doc.add_paragraph() + p.add_run( + "La prima vizita pe website, utilizatorului i se prezinta un banner de consimtamant " + "pentru cookie-uri, in conformitate cu legislatia europeana. Utilizatorul poate " + "gestiona urmatoarele categorii de cookie-uri:" + ) + + add_table_with_header( + doc, + ["Categorie", "Obligatoriu", "Descriere"], + [ + ["Necesare", "Da", "Cookie-uri esentiale pentru functionarea website-ului (sesiune, securitate)"], + ["Functionale", "Nu", "Cookie-uri care imbunatatesc experienta (preferinte limba, tema)"], + ["Analitice", "Nu", "Cookie-uri pentru colectarea datelor de utilizare si performanta"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Cookie-urile necesare nu pot fi dezactivate deoarece sunt esentiale pentru " + "functionarea corecta a website-ului. Cookie-urile functionale si analitice " + "pot fi activate sau dezactivate individual de catre utilizator." + ) + + doc.add_heading("8.2 Internationalizare (i18n) - RO/EN", level=2) + p = doc.add_paragraph() + p.add_run( + "Website-ul suporta doua limbi: Romana (RO) si Engleza (EN). Continutul tuturor " + "paginilor publice este disponibil in ambele limbi, inclusiv: texte interfata, " + "etichete butoane, mesaje de eroare si notificari, continut pagini (gestionat " + "prin CMS ERPNext). Preferinta de limba se salveaza in browserul utilizatorului " + "(localStorage) si este pastrata la vizitele ulterioare." + ) + + doc.add_heading("8.3 Sitemap.xml si SEO", level=2) + p = doc.add_paragraph() + p.add_run( + "Website-ul genereaza automat un fisier sitemap.xml care contine toate paginile " + "publice ale website-ului. Acest fisier este utilizat de motoarele de cautare " + "(Google, Bing) pentru indexarea corecta a continutului." + ) + + p = doc.add_paragraph() + p.add_run("Optimizarile SEO implementate includ:") + add_bullet_list(doc, [ + "Metadata (title, description) unice pentru fiecare pagina", + "Tag-uri Open Graph pentru partajarea pe retele sociale", + "Structura semantica HTML5 corecta (headings, sections, articles)", + "Fisier robots.txt configurat corect", + "URL-uri curate si descriptive", + "Performanta optimizata (Lighthouse SEO score: 100/100)", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 9: Depanare si Contact + # ========================================================================= + doc.add_heading("9. Depanare si Contact Suport", level=1) + + doc.add_heading("9.1 Probleme Frecvente", level=2) + + add_table_with_header( + doc, + ["Problema", "Cauza Posibila", "Solutie"], + [ + ["Nu pot accesa website-ul", "Conexiune internet instabila sau URL incorect", "Verificati conexiunea si accesati https://didi365.eu"], + ["Nu ma pot autentifica", "Credentiale incorecte sau cont neconfirmat", "Verificati email-ul de confirmare sau resetati parola"], + ["Plata a esuat", "Card expirat, fonduri insuficiente sau eroare Stripe", "Verificati datele cardului sau incercati alt card"], + ["Analiza nu porneste", "Nu exista analize disponibile (neachizitionate)", "Achizitionati o analiza noua din pagina dedicata"], + ["Rezultatele intarzie", "Analiza asincrona (audio/video) in procesare", "Asteptati finalizarea - platforma verifica automat statusul"], + ["Nu pot descarca factura", "Eroare temporara de conectare la ERPNext", "Reincarcati pagina sau incercati mai tarziu"], + ["Website-ul se afiseaza incorect", "Browser vechi sau cache corupt", "Actualizati browserul si stergeti cache-ul"], + ], + ) + + doc.add_heading("9.2 Contact Suport Tehnic", level=2) + p = doc.add_paragraph() + p.add_run( + "Pentru asistenta suplimentara sau probleme nerezolvate, puteti contacta " + "echipa de suport prin urmatoarele canale:" + ) + + add_table_with_header( + doc, + ["Canal", "Detalii"], + [ + ["Email", "office@clossers.com"], + ["Formular Contact", "Accesibil pe pagina Contact a website-ului"], + ["Program Suport", "Luni - Vineri, 09:00 - 18:00"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Cand contactati echipa de suport, va rugam sa includeti urmatoarele informatii " + "pentru a facilita rezolvarea rapida a problemei:" + ) + add_bullet_list(doc, [ + "Adresa de email asociata contului DiDi", + "Descrierea detaliata a problemei intampinate", + "Pasii parcursi inainte de aparitia problemei", + "Capturi de ecran relevante (daca este posibil)", + "Browserul si versiunea utilizata", + "Dispozitivul folosit (desktop, tableta, mobil)", + ]) + + # Save document + output_path = os.path.join(OUTPUT_DIR, "Manual_Utilizare_Website.docx") + doc.save(output_path) + print(f" -> Salvat: {output_path}") + return output_path + + +# ============================================================================== +# DOCUMENT 2: Ghid Administrare ERPNext +# ============================================================================== + + +def generate_ghid_erpnext(): + """Generate Ghid_Administrare_ERPNext.docx""" + print("[2/4] Generare Ghid_Administrare_ERPNext.docx ...") + + doc = create_document() + + add_cover_page( + doc, + "GHID DE ADMINISTRARE", + "ERPNext ERP & CRM", + ) + + add_header_footer(doc) + add_toc_placeholder(doc) + + # ========================================================================= + # CHAPTER 1: Introducere + # ========================================================================= + doc.add_heading("1. Introducere", level=1) + + doc.add_heading("1.1 Scopul Documentului", level=2) + p = doc.add_paragraph() + p.add_run( + "Acest ghid de administrare ofera instructiuni detaliate pentru utilizarea si " + "gestionarea sistemului ERPNext ERP & CRM integrat in platforma DiDi. Documentul " + "se adreseaza administratorilor de sistem si personalului responsabil cu operatiunile " + "de back-office ale platformei." + ) + + doc.add_heading("1.2 Despre ERPNext", level=2) + p = doc.add_paragraph() + p.add_run( + "ERPNext este o solutie open-source de tip Enterprise Resource Planning (ERP) si " + "Customer Relationship Management (CRM), dezvoltata pe framework-ul Frappe. " + "Platforma DiDi utilizeaza ERPNext versiunea 15, configurata si personalizata " + "pentru nevoile specifice ale proiectului." + ) + + p = doc.add_paragraph() + p.add_run("ERPNext ofera urmatoarele module integrate utilizate in cadrul platformei DiDi:") + add_bullet_list(doc, [ + "Facturare (Sales Invoice) - generarea si gestionarea facturilor fiscale", + "CRM (Lead Management) - gestionarea lead-urilor si pipeline-ului de vanzari", + "Gestiune Clienti (Customer) - evidenta clientilor cu date personalizate", + "Plati (Payment Entry, Payment Log) - inregistrarea si urmarirea platilor", + "Rapoarte Analiza (Analysis Report) - stocarea rezultatelor analizelor AI", + "Servicii (Item, Service Agreement) - catalogul de servicii si acordurile active", + "CMS (Website Content) - gestionarea continutului website-ului bilingv", + "Notificari Email - comunicare automata cu clientii", + "Contabilitate - plan de conturi Romania cu TVA 19%", + ]) + + doc.add_heading("1.3 Deployment Docker", level=2) + p = doc.add_paragraph() + p.add_run( + "ERPNext este rulat intr-un mediu containerizat Docker, asigurand izolarea " + "serviciilor, reproducibilitate si usurinta in administrare. Stiva de containere " + "include: ERPNext web, MariaDB (baza de date), Redis Cache, Redis Queue, " + "3 workeri pentru procesare asincrona si un scheduler pentru taskuri planificate." + ) + + doc.add_heading("1.4 URL de Acces", level=2) + add_code_block(doc, "http://localhost:8080") + + add_note_box( + doc, + "Accesul la interfata de administrare ERPNext este disponibil exclusiv din " + "reteaua interna sau prin tunel securizat. Nu expuneti portul 8080 direct " + "pe internet fara masuri de securitate suplimentare (firewall, VPN).", + "warning", + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 2: Acces si Autentificare + # ========================================================================= + doc.add_heading("2. Acces si Autentificare", level=1) + + doc.add_heading("2.1 Autentificare Administrator", level=2) + p = doc.add_paragraph() + p.add_run( + "Pentru a accesa interfata de administrare ERPNext, navigati la adresa de acces " + "si utilizati credentialele de administrator:" + ) + + add_table_with_header( + doc, + ["Camp", "Valoare"], + [ + ["URL", "http://localhost:8080"], + ["Utilizator", "Administrator"], + ["Parola", "admin (de schimbat la prima utilizare)"], + ], + ) + + add_note_box( + doc, + "La prima accesare, este obligatorie schimbarea parolei implicite 'admin' " + "cu o parola complexa (minim 12 caractere, litere mari si mici, cifre, " + "caractere speciale).", + "warning", + ) + + doc.add_heading("2.2 Interfata in Limba Romana", level=2) + p = doc.add_paragraph() + p.add_run( + "ERPNext a fost configurat cu limba romana ca limba principala a interfetei. " + "Toate meniurile, etichetele si mesajele de sistem sunt afisate in limba romana. " + "Limba poate fi modificata din setarile profilului utilizatorului daca este necesar." + ) + + doc.add_heading("2.3 Navigarea in Interfata", level=2) + p = doc.add_paragraph() + p.add_run( + "Interfata ERPNext este organizata pe baza de Workspace-uri (spatii de lucru). " + "Fiecare workspace grupeaza functionalitatile relevante pentru un domeniu specific. " + "Navigarea se face prin:" + ) + add_bullet_list(doc, [ + "Sidebar (bara laterala stanga) - acces rapid la workspace-uri si module", + "Awesome Bar (bara de cautare) - cautare rapida documente, rapoarte, pagini (Ctrl+K)", + "Breadcrumbs - navigare ierarhica in structura de documente", + "Shortcuts (scurtaturi) - acces rapid la documentele frecvent utilizate", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 3: Workspace DiDi Platform + # ========================================================================= + doc.add_heading("3. Workspace DiDi Platform", level=1) + + p = doc.add_paragraph() + p.add_run( + "Workspace-ul DiDi Platform este spatiul de lucru personalizat care centralizeaza " + "toate functionalitatile specifice platformei DiDi. Acesta ofera scurtaturi rapide " + "catre cele mai utilizate documente si rapoarte." + ) + + doc.add_heading("3.1 Scurtaturi Disponibile", level=2) + + add_table_with_header( + doc, + ["Scurtatura", "DocType / Modul", "Descriere"], + [ + ["Facturi", "Sales Invoice", "Lista si gestionarea facturilor emise"], + ["Clienti", "Customer", "Evidenta clientilor platformei"], + ["CRM", "Lead", "Gestionarea lead-urilor si pipeline vanzari"], + ["Servicii", "Item", "Catalogul de servicii DiDi"], + ["Plati", "Payment Log", "Jurnalul de plati Stripe"], + ["Contabilitate", "Account", "Plan de conturi si rapoarte financiare"], + ["Website CMS", "Website Content", "Gestionarea continutului website-ului"], + ["Rapoarte Analiza", "Analysis Report", "Rezultatele analizelor AI efectuate"], + ["Acorduri Servicii", "Service Agreement", "Acorduri active de prestare servicii"], + ["Incasari", "Payment Entry", "Inregistrari plati in contabilitate"], + ["Notificari", "Notification", "Configurare notificari email automate"], + ], + ) + + add_screenshot(doc, "erpnext", "01-workspace.png", "Workspace DiDi Platform - Panou central de administrare") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 4: Gestiune Facturi + # ========================================================================= + doc.add_heading("4. Gestiune Facturi (Facturare)", level=1) + + p = doc.add_paragraph() + p.add_run( + "Modulul de facturare gestioneaza toate facturile emise prin platforma DiDi. " + "Facturile sunt generate automat la finalizarea platilor prin Stripe si " + "urmeaza seria de numerotare DIDI-INV-YYYY-#####." + ) + + doc.add_heading("4.1 Lista Facturi", level=2) + p = doc.add_paragraph() + p.add_run( + "Lista facturilor afiseaza toate facturile emise in sistem. Interfata permite:" + ) + add_bullet_list(doc, [ + "Filtrare dupa status (Platita, Anulata, Ciorna), client, data, suma", + "Sortare dupa orice coloana (numar, data, client, suma, status)", + "Cautare rapida dupa numarul facturii sau numele clientului", + "Export lista in format CSV sau Excel", + "Creare manuala de facturi noi (pentru cazuri speciale)", + ]) + + doc.add_heading("Statusuri Factura", level=3) + add_table_with_header( + doc, + ["Status", "Cod Culoare", "Descriere"], + [ + ["Ciorna (Draft)", "Gri", "Factura creata dar nevalidata - poate fi editata"], + ["Trimisa (Submitted)", "Albastru", "Factura validata si trimisa clientului"], + ["Platita (Paid)", "Verde", "Factura a fost achitata integral"], + ["Anulata (Cancelled)", "Rosu", "Factura a fost anulata (storno)"], + ], + ) + + add_screenshot(doc, "erpnext", "02-facturi.png", "Lista facturi - Vizualizare si filtrare") + + doc.add_heading("4.2 Detaliu Factura", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina de detaliu a unei facturi contine toate informatiile asociate tranzactiei:" + ) + + add_bullet_list(doc, [ + "Date client: nume, adresa, CUI (pentru persoane juridice)", + "Articole facturate: serviciul DiDi achizitionat, cantitate, pret unitar", + "TVA 19%: calculat automat conform legislatiei fiscale romanesti", + "Totaluri: subtotal, TVA, total de plata", + "Referinte: numar factura, seria, data emitere, data scadenta", + "Camp custom 'Analiza Consumata': indica daca analiza asociata a fost utilizata", + "Camp custom 'Session ID': identificatorul sesiunii de analiza AI (daca a fost rulata)", + "Istoric: timeline cu toate actiunile efectuate asupra facturii", + ]) + + add_screenshot(doc, "erpnext", "03-factura-detaliu.png", "Detaliu factura - Informatii complete despre tranzactie") + + doc.add_heading("4.3 Print Format DiDi Invoice", level=2) + p = doc.add_paragraph() + p.add_run( + "Facturile DiDi utilizeaza un format de tiparire (Print Format) personalizat " + "care include branding-ul platformei si toate informatiile legale obligatorii:" + ) + + add_bullet_list(doc, [ + "Logo si branding DiDi / TOP CLOSSERS SRL", + "Date complete emitent: nume companie, CUI, numar registru comert, adresa, cont bancar", + "Date complete beneficiar: nume, CUI (daca exista), adresa", + "Tabel articole cu descriere detaliata, cantitate, pret unitar, valoare", + "Totaluri: subtotal, TVA 19%, total de plata", + "Seria si numarul facturii, data emitere", + "Mentiuni legale obligatorii conform legislatiei fiscale romanesti", + ]) + + add_screenshot(doc, "erpnext", "13-print-format-factura.png", "Print Format - Factura PDF cu branding DiDi") + + doc.add_heading("4.4 Serie Facturi", level=2) + p = doc.add_paragraph() + p.add_run( + "Toate facturile emise in platforma DiDi utilizeaza seria de numerotare " + "DIDI-INV-YYYY-##### unde YYYY este anul curent si ##### este numarul " + "secvential (cu 5 cifre, completat cu zero-uri). Exemplu: DIDI-INV-2026-00042." + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 5: Gestiune Clienti + # ========================================================================= + doc.add_heading("5. Gestiune Clienti", level=1) + + doc.add_heading("5.1 Fisa Client", level=2) + p = doc.add_paragraph() + p.add_run( + "Fiecare client al platformei DiDi are o fisa detaliata in ERPNext care contine " + "atat informatiile standard ale unui client ERPNext cat si campuri personalizate " + "specifice platformei." + ) + + doc.add_heading("Campuri Standard", level=3) + add_table_with_header( + doc, + ["Camp", "Descriere"], + [ + ["Customer Name", "Numele complet al clientului"], + ["Customer Type", "Tip client: Individual sau Company"], + ["Customer Group", "Grupul de clienti (ex: Individual, Corporate)"], + ["Territory", "Teritoriul geografic (ex: Romania)"], + ["Email", "Adresa de email principala"], + ["Mobile No", "Numar de telefon mobil"], + ], + ) + + doc.add_heading("5.2 Campuri Personalizate (Custom Fields)", level=2) + + add_table_with_header( + doc, + ["Camp", "Fieldname", "Tip", "Descriere"], + [ + ["ID Utilizator DiDi", "didi_user_id", "Data", "UUID Keycloak - legatura unica cu contul de autentificare"], + ["Rol IAM", "iam_role", "Select", "Rol acces: free_tier / paid_tier / enterprise_tier"], + ["Plan Activ", "active_plan", "Data", "Planul de abonament activ al clientului"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Campul didi_user_id este esential pentru integrarea cu sistemul de autentificare " + "Keycloak. Acest camp stocheaza UUID-ul unic al utilizatorului din Keycloak si este " + "folosit de website pentru a identifica clientul ERPNext asociat sesiunii autentificate." + ) + + add_screenshot(doc, "erpnext", "04-client.png", "Fisa client - Campuri standard si personalizate") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 6: CRM - Gestiune Lead-uri + # ========================================================================= + doc.add_heading("6. CRM - Gestiune Lead-uri", level=1) + + p = doc.add_paragraph() + p.add_run( + "Modulul CRM din ERPNext gestioneaza pipeline-ul de vanzari al platformei DiDi, " + "de la captarea lead-urilor pana la conversia acestora in clienti activi." + ) + + doc.add_heading("6.1 Pipeline Vanzari", level=2) + p = doc.add_paragraph() + p.add_run( + "Lead-urile parcurg urmatorul pipeline de vanzari in cadrul platformei DiDi:" + ) + + add_table_with_header( + doc, + ["Etapa", "Status", "Descriere", "Actiune Urmatoare"], + [ + ["1. Lead", "Open", "Lead nou capturat prin formular contact sau pagina pricing", "Calificare"], + ["2. Calificat", "Replied", "Lead calificat de echipa de vanzari", "Programare demo"], + ["3. Demo", "Opportunity", "Demo programat sau efectuat cu potentialul client", "Conversie"], + ["4. Client", "Converted", "Lead convertit in client activ cu cont pe platforma", "Finalizat"], + ], + ) + + doc.add_heading("6.2 Surse Lead-uri (Lead Sources)", level=2) + p = doc.add_paragraph() + p.add_run( + "Platforma DiDi capteaza lead-uri din urmatoarele surse, fiecare configurata " + "in ERPNext pentru tracking precis:" + ) + + add_table_with_header( + doc, + ["Sursa", "Descriere", "Mecanism Captura"], + [ + ["Website Contact Form", "Formularul de contact de pe pagina Contact", "API POST /api/leads automat"], + ["Pricing Page", "Vizitatori care interactioneaza cu pagina de preturi", "Tracking UTM + formular"], + ["Demo Request", "Solicitari directe de demonstratie a platformei", "Email sau formular dedicat"], + ], + ) + + doc.add_heading("6.3 UTM Tracking", level=2) + p = doc.add_paragraph() + p.add_run( + "Toate lead-urile captate prin website includ parametri UTM pentru identificarea " + "sursei de trafic. Parametrii UTM stocati in fisa lead-ului sunt:" + ) + + add_bullet_list(doc, [ + "UTM Source - sursa traficului (ex: google, facebook, newsletter)", + "UTM Medium - mediul de marketing (ex: cpc, organic, email, social)", + "UTM Campaign - campania specifica (ex: launch_2026, pnrr_awareness)", + ]) + + add_screenshot(doc, "erpnext", "05-leaduri-crm.png", "CRM - Lista lead-uri si pipeline vanzari") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 7: Plati si Incasari + # ========================================================================= + doc.add_heading("7. Plati si Incasari", level=1) + + doc.add_heading("7.1 Payment Log (Jurnal Plati)", level=2) + p = doc.add_paragraph() + p.add_run( + "Payment Log este un DocType custom dezvoltat special pentru platforma DiDi, care " + "inregistreaza un audit trail complet al tuturor tranzactiilor Stripe. Fiecare " + "intrare in jurnal contine:" + ) + + add_table_with_header( + doc, + ["Camp", "Descriere"], + [ + ["Customer", "Referinta catre clientul ERPNext asociat platii"], + ["Event Type", "Tipul evenimentului Stripe (ex: checkout.session.completed)"], + ["Status", "Statusul tranzactiei (Success, Failed, Pending)"], + ["Amount", "Suma platita (in RON)"], + ["Stripe Payment ID", "Identificatorul unic al platii in Stripe (pi_xxx)"], + ["Stripe Session ID", "Identificatorul sesiunii de checkout Stripe (cs_xxx)"], + ["Invoice Reference", "Referinta catre factura ERPNext generata"], + ["Timestamp", "Data si ora exacta a tranzactiei"], + ["Raw Webhook Data", "Datele brute primite de la Stripe (JSON) pentru audit complet"], + ], + ) + + add_screenshot(doc, "erpnext", "06-payment-logs.png", "Payment Log - Jurnalul de plati Stripe") + + doc.add_heading("7.2 Payment Entry (Incasari)", level=2) + p = doc.add_paragraph() + p.add_run( + "Payment Entry este documentul standard ERPNext pentru inregistrarea incasarilor " + "in contabilitate. Fiecare plata Stripe reusita genereaza automat un Payment Entry " + "care reconciliaza factura emisa cu suma incasata." + ) + + p = doc.add_paragraph() + p.add_run("Informatii stocate in Payment Entry:") + add_bullet_list(doc, [ + "Tipul platii: Receive (incasare)", + "Modul de plata: Stripe (plata online)", + "Cont incasari: contul bancar configurat pentru Stripe", + "Referinta: legatura cu factura (Sales Invoice) corespunzatoare", + "Suma: valoarea incasata in RON", + "Data platii: data la care a fost procesata plata de Stripe", + ]) + + add_screenshot(doc, "erpnext", "11-plati-incasari.png", "Payment Entry - Incasari inregistrate automat") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 8: Rapoarte Analiza + # ========================================================================= + doc.add_heading("8. Rapoarte Analiza", level=1) + + p = doc.add_paragraph() + p.add_run( + "Analysis Report este un DocType custom care stocheaza rezultatele complete ale " + "analizelor AI efectuate prin platforma DiDi." + ) + + doc.add_heading("8.1 Lista Rapoarte", level=2) + p = doc.add_paragraph() + p.add_run("Fiecare raport de analiza contine urmatoarele informatii:") + + add_table_with_header( + doc, + ["Camp", "Descriere"], + [ + ["Titlu", "Titlul descriptiv al raportului de analiza"], + ["Client", "Referinta catre clientul care a solicitat analiza"], + ["Factura", "Referinta catre factura asociata achizitiei"], + ["Status", "Statusul raportului (Finalizat, In Procesare, Eroare)"], + ["Session ID", "Identificatorul sesiunii de analiza din DiDi API"], + ["Tip Analiza", "Categoria analizei (Tehnici, AI Detection, Claims, Source)"], + ["Tip Media", "Tipul de continut analizat (Text, Imagine, Audio, Video, URL)"], + ["Data Creare", "Data si ora la care a fost generat raportul"], + ], + ) + + add_screenshot(doc, "erpnext", "07-rapoarte-analiza.png", "Lista rapoarte analiza - Vizualizare si filtrare") + + doc.add_heading("8.2 Detaliu Raport", level=2) + p = doc.add_paragraph() + p.add_run( + "Pagina de detaliu a unui raport de analiza contine informatiile complete " + "ale rezultatului, inclusiv:" + ) + + add_bullet_list(doc, [ + "Result JSON - rezultatul complet al analizei in format JSON structurat, incluzand: scor de risc, nivel de incredere, categorie de risc, tehnici detectate, verdict, afirmatii verificate", + "PDF Atasat - raportul generat in format PDF care poate fi descarcat sau trimis clientului", + "Metadate - informatii despre procesarea analizei: durata, timestamp-uri, versiunea API", + "Legatura cu factura si acordul de servicii asociat", + ]) + + add_screenshot(doc, "erpnext", "14-raport-analiza-detaliu.png", "Detaliu raport analiza - Rezultat JSON si PDF") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 9: Servicii si Produse + # ========================================================================= + doc.add_heading("9. Servicii si Produse", level=1) + + doc.add_heading("9.1 Items DiDi (Catalogul de Servicii)", level=2) + p = doc.add_paragraph() + p.add_run( + "Catalogul de servicii DiDi contine 17 articole (Items) care corespund tuturor " + "combinatiilor de categorii de analiza si tipuri de media disponibile. " + "Fiecare articol are un cod unic, un pret stabilit si este asociat cu un " + "price ID Stripe pentru procesarea platilor." + ) + + doc.add_heading("Lista Completa Servicii", level=3) + add_table_with_header( + doc, + ["Cod Articol", "Categorie", "Tip Media", "Pret (RON)"], + [ + ["DIDI-TECHNIQUES-TEXT", "Detectie Tehnici", "Text", "9,99"], + ["DIDI-TECHNIQUES-IMAGE", "Detectie Tehnici", "Imagine", "14,99"], + ["DIDI-TECHNIQUES-AUDIO", "Detectie Tehnici", "Audio", "19,99"], + ["DIDI-TECHNIQUES-VIDEO", "Detectie Tehnici", "Video", "24,99"], + ["DIDI-AI_DETECTION-TEXT", "AI Detection", "Text", "9,99"], + ["DIDI-AI_DETECTION-IMAGE", "AI Detection", "Imagine", "14,99"], + ["DIDI-AI_DETECTION-AUDIO", "AI Detection", "Audio", "19,99"], + ["DIDI-AI_DETECTION-VIDEO", "AI Detection", "Video", "24,99"], + ["DIDI-CLAIMS-TEXT", "Fact-Checking", "Text", "9,99"], + ["DIDI-CLAIMS-IMAGE", "Fact-Checking", "Imagine", "14,99"], + ["DIDI-CLAIMS-AUDIO", "Fact-Checking", "Audio", "19,99"], + ["DIDI-CLAIMS-VIDEO", "Fact-Checking", "Video", "24,99"], + ["DIDI-SOURCE-TEXT", "Evaluare Surse", "Text", "9,99"], + ["DIDI-SOURCE-IMAGE", "Evaluare Surse", "Imagine", "14,99"], + ["DIDI-SOURCE-AUDIO", "Evaluare Surse", "Audio", "19,99"], + ["DIDI-SOURCE-VIDEO", "Evaluare Surse", "Video", "24,99"], + ["DIDI-SOURCE-URL", "Evaluare Surse", "URL", "9,99"], + ], + ) + + add_screenshot(doc, "erpnext", "08-servicii-didi.png", "Lista servicii DiDi - Catalogul complet de articole") + + doc.add_heading("9.2 Acorduri Servicii (Service Agreement)", level=2) + p = doc.add_paragraph() + p.add_run( + "Service Agreement este un DocType custom care inregistreaza dreptul clientului " + "de a utiliza un serviciu de analiza achizitionat. Fiecare acord contine:" + ) + + add_bullet_list(doc, [ + "Referinta client - clientul beneficiar al acordului", + "Serviciu - articolul DiDi achizitionat (ex: DIDI-TECHNIQUES-TEXT)", + "Factura de origine - factura care a generat acordul", + "Status - Activ (neutilizat) / Consumat (analiza efectuata) / Expirat", + "Data creare - momentul activarii acordului (la confirmarea platii)", + "Data expirare - data limita de utilizare (daca aplicabil)", + ]) + + p = doc.add_paragraph() + p.add_run( + "Cand un client ruleaza o analiza, acordul de servicii corespunzator este marcat " + "automat ca 'Consumat', iar factura asociata primeste referinta la sesiunea de " + "analiza (analysis_session_id) si flagul analysis_consumed." + ) + + add_screenshot(doc, "erpnext", "09-acorduri-servicii.png", "Acorduri servicii - Evidenta drepturilor de utilizare") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 10: CMS - Continut Website + # ========================================================================= + doc.add_heading("10. CMS - Continut Website", level=1) + + doc.add_heading("10.1 Website Content", level=2) + p = doc.add_paragraph() + p.add_run( + "Website Content este un DocType custom care permite gestionarea continutului " + "paginilor website-ului DiDi direct din interfata de administrare ERPNext. " + "Acest mecanism CMS elimina necesitatea modificarii codului sursa pentru " + "actualizarea continutului." + ) + + p = doc.add_paragraph() + p.add_run("Paginile gestionate prin CMS:") + add_table_with_header( + doc, + ["Pagina", "Slug", "Descriere"], + [ + ["Homepage", "homepage", "Continutul paginii principale (hero, features, how it works)"], + ["Servicii", "services", "Descrierile detaliate ale celor 6 module de analiza"], + ["Despre", "about", "Informatii despre companie, echipa si viziune"], + ["Pricing", "pricing", "Descrieri categorii si mesaje comerciale"], + ["Contact", "contact", "Informatii de contact si text introductiv formular"], + ["Privacy", "privacy", "Politica de confidentialitate GDPR completa"], + ["Terms", "terms", "Termenii si conditiile de utilizare complete"], + ], + ) + + doc.add_heading("Continut Bilingv (RO/EN)", level=3) + p = doc.add_paragraph() + p.add_run( + "Fiecare intrare Website Content contine campuri separate pentru limba romana " + "si limba engleza. Atunci cand website-ul solicita continutul unei pagini, " + "API-ul ERPNext returneaza textul in limba solicitata de utilizator. " + "Modificarile de continut se fac direct din ERPNext fara deployment nou." + ) + + add_screenshot(doc, "erpnext", "10-cms-content.png", "CMS Website Content - Gestionare continut bilingv") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 11: Notificari Email + # ========================================================================= + doc.add_heading("11. Notificari Email", level=1) + + doc.add_heading("11.1 Email-uri Automate Configurate", level=2) + p = doc.add_paragraph() + p.add_run( + "Platforma DiDi are configurate urmatoarele notificari email automate care " + "se trimit in mod automat la producerea evenimentelor asociate:" + ) + + add_table_with_header( + doc, + ["Notificare", "Eveniment Declansator", "Destinatar", "Continut"], + [ + ["Factura Emisa", "Creare Sales Invoice (Submit)", "Client (email)", "Factura in format PDF atasata, detalii tranzactie"], + ["Plata Confirmata", "Payment Entry creat cu succes", "Client (email)", "Confirmare plata, referinta tranzactie, acces serviciu"], + ["Expirare Abonament", "Service Agreement aproape de expirare", "Client (email)", "Avertisment expirare, link reinnoire"], + ], + ) + + add_screenshot(doc, "erpnext", "12-notificari-email.png", "Configurare notificari email automate") + + doc.add_heading("11.2 Email Account - DiDi Outgoing", level=2) + p = doc.add_paragraph() + p.add_run( + "Trimiterea email-urilor se face prin contul de email configurat cu SendGrid SMTP. " + "Configurarea include:" + ) + + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Provider", "SendGrid"], + ["Protocol", "SMTP (TLS)"], + ["Server", "smtp.sendgrid.net"], + ["Port", "587"], + ["Autentificare", "API Key SendGrid"], + ["Email Expeditor", "noreply@didi365.eu"], + ["Nume Expeditor", "DiDi Platform"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 12: Contabilitate + # ========================================================================= + doc.add_heading("12. Contabilitate", level=1) + + doc.add_heading("12.1 Plan de Conturi Romania", level=2) + p = doc.add_paragraph() + p.add_run( + "ERPNext a fost configurat cu planul de conturi conform reglementarilor contabile " + "din Romania. Principalele conturi utilizate in operatiunile platformei DiDi includ:" + ) + + add_table_with_header( + doc, + ["Cont", "Tip", "Descriere"], + [ + ["Venituri din Servicii", "Venit", "Venituri din prestarea serviciilor de analiza DiDi"], + ["TVA Colectat", "Pasiv", "TVA 19% colectat la facturarea serviciilor"], + ["Cont Stripe", "Activ (Banca)", "Incasari din platile Stripe"], + ["Cheltuieli Hosting", "Cheltuiala", "Costuri server Hetzner"], + ["Cheltuieli Software", "Cheltuiala", "Licente si abonamente software"], + ["Cheltuieli Marketing", "Cheltuiala", "Costuri campanii marketing"], + ["Comisioane Stripe", "Cheltuiala", "Comisioanele procesatorului de plati Stripe"], + ], + ) + + doc.add_heading("12.2 TVA 19%", level=2) + p = doc.add_paragraph() + p.add_run( + "Toate facturile emise prin platforma DiDi includ TVA 19% conform legislatiei " + "fiscale romanesti. TVA-ul este calculat automat de ERPNext pe baza template-ului " + "de taxe configurat. Preturile afisate pe website includ TVA." + ) + + doc.add_heading("12.3 Furnizori", level=2) + p = doc.add_paragraph() + p.add_run( + "In contabilitatea ERPNext sunt configurati urmatorii furnizori principali " + "ai platformei:" + ) + + add_table_with_header( + doc, + ["Furnizor", "Serviciu", "Tip Cheltuiala"], + [ + ["Hetzner Online GmbH", "Hosting servere (VPS, Cloud)", "Cheltuieli Hosting"], + ["Stripe, Inc.", "Procesare plati online", "Comisioane Stripe"], + ["SendGrid (Twilio)", "Serviciu email transactional SMTP", "Cheltuieli Software"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 13: Docker si Mentenanta + # ========================================================================= + doc.add_heading("13. Docker si Mentenanta", level=1) + + doc.add_heading("13.1 Pornirea Serviciilor", level=2) + p = doc.add_paragraph() + p.add_run( + "ERPNext ruleaza intr-un mediu Docker Compose. Pentru pornirea intregului " + "stack de servicii se utilizeaza comanda:" + ) + add_code_block(doc, "docker compose up -d") + + p = doc.add_paragraph() + p.add_run("Pentru oprirea serviciilor:") + add_code_block(doc, "docker compose down") + + p = doc.add_paragraph() + p.add_run("Pentru vizualizarea log-urilor:") + add_code_block(doc, "docker compose logs -f erpnext") + + doc.add_heading("13.2 Containere", level=2) + p = doc.add_paragraph() + p.add_run( + "Stack-ul Docker Compose include urmatoarele containere:" + ) + + add_table_with_header( + doc, + ["Container", "Serviciu", "Port", "Descriere"], + [ + ["erpnext", "ERPNext Web", "8080", "Interfata web si API ERPNext"], + ["mariadb", "MariaDB", "3306", "Baza de date relationala"], + ["redis-cache", "Redis Cache", "6379", "Cache pentru sesiuni si date frecvente"], + ["redis-queue", "Redis Queue", "6380", "Coada de mesaje pentru joburi asincrone"], + ["worker-default", "Worker Default", "-", "Procesare joburi standard"], + ["worker-short", "Worker Short", "-", "Procesare joburi rapide"], + ["worker-long", "Worker Long", "-", "Procesare joburi de durata lunga"], + ["scheduler", "Scheduler", "-", "Planificator taskuri recurente (cron-like)"], + ], + ) + + doc.add_heading("13.3 Backup si Restaurare", level=2) + p = doc.add_paragraph() + p.add_run( + "Datele ERPNext sunt persistate in volume Docker, asigurand pastrarea informatiilor " + "intre repornirile containerelor. Pentru backup se recomanda:" + ) + + add_bullet_list(doc, [ + "Backup automat zilnic al bazei de date MariaDB folosind cron job-ul integrat in scheduler", + "Export periodic al fisierelor uploadate (volume Docker)", + "Stocare backup-uri pe un server extern sau serviciu cloud (offsite backup)", + "Testarea periodica a procesului de restaurare din backup", + ]) + + p = doc.add_paragraph() + p.add_run("Comanda pentru backup manual:") + add_code_block(doc, "docker compose exec erpnext bench --site frontend backup --with-files") + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 14: Securitate + # ========================================================================= + doc.add_heading("14. Securitate", level=1) + + doc.add_heading("14.1 API User (Utilizator pentru Integrari)", level=2) + p = doc.add_paragraph() + p.add_run( + "Comunicarea intre website-ul Next.js si ERPNext se realizeaza printr-un " + "utilizator API dedicat, configurat cu permisiuni minimale (principiul " + "least privilege):" + ) + + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Utilizator", "website_api@didi.localhost"], + ["Tip", "API User (System User fara Desk Access)"], + ["Rol", "Website Integration"], + ["Autentificare", "API Key + API Secret (token-based)"], + ["Desk Access", "Dezactivat (fara acces la interfata web ERPNext)"], + ], + ) + + doc.add_heading("14.2 Rol Website Integration", level=2) + p = doc.add_paragraph() + p.add_run( + "Rolul Website Integration a fost creat special pentru utilizatorul API si ofera " + "acces limitat doar la operatiunile necesare integrarii:" + ) + + add_bullet_list(doc, [ + "Customer - citire si creare (pentru inregistrarea clientilor noi)", + "Sales Invoice - citire si creare (pentru generarea facturilor)", + "Payment Entry - citire si creare (pentru inregistrarea platilor)", + "Service Agreement - citire, creare si modificare (pentru gestionarea drepturilor)", + "Payment Log - citire si creare (pentru audit trail plati)", + "Analysis Report - citire, creare si modificare (pentru rapoarte analiza)", + "Website Content - doar citire (pentru afisarea continutului pe website)", + "Lead - citire si creare (pentru captarea lead-urilor din formular contact)", + "Item - doar citire (pentru afisarea catalogului de servicii)", + ]) + + doc.add_heading("14.3 Recomandari de Securitate", level=2) + add_bullet_list(doc, [ + "Schimbati parola implicita 'admin' la prima accesare", + "Activati autentificarea in doi factori (2FA) pentru contul Administrator", + "Revizuiti periodic permisiunile rolurilor si utilizatorilor", + "Monitorizati log-urile de acces pentru activitati suspecte", + "Actualizati ERPNext la cele mai recente versiuni de securitate", + "Nu expuneti portul 8080 direct pe internet - utilizati reverse proxy cu HTTPS", + "Rotiti periodic API Key-urile utilizatorului de integrare", + ]) + + # Save document + output_path = os.path.join(OUTPUT_DIR, "Ghid_Administrare_ERPNext.docx") + doc.save(output_path) + print(f" -> Salvat: {output_path}") + return output_path + + +# ============================================================================== +# DOCUMENT 3: Documentatie API +# ============================================================================== + + +def generate_documentatie_api(): + """Generate Documentatie_API.docx""" + print("[3/4] Generare Documentatie_API.docx ...") + + doc = create_document() + + add_cover_page( + doc, + "DOCUMENTATIE TEHNICA API", + "Integrari Website DiDi", + ) + + add_header_footer(doc) + add_toc_placeholder(doc) + + # ========================================================================= + # CHAPTER 1: Introducere + # ========================================================================= + doc.add_heading("1. Introducere", level=1) + + doc.add_heading("1.1 Scopul Documentului", level=2) + p = doc.add_paragraph() + p.add_run( + "Aceasta documentatie tehnica descrie in detaliu toate intergrarile API utilizate " + "de website-ul DiDi. Documentul acopera: rutele API ale website-ului Next.js, " + "integrarea cu ERPNext, integrarea cu DiDi AI API, integrarea cu Stripe si " + "integrarea cu Keycloak SSO." + ) + + doc.add_heading("1.2 Audienta Tinta", level=2) + p = doc.add_paragraph() + p.add_run( + "Documentul se adreseaza dezvoltatorilor software, arhitectilor de sistem si " + "personalului tehnic responsabil cu mentenanta si extinderea platformei DiDi." + ) + + doc.add_heading("1.3 Conventii", level=2) + add_bullet_list(doc, [ + "Endpoint-urile sunt descrise in formatul: METODA /cale", + "Parametrii obligatorii sunt marcati cu * (asterisk)", + "Raspunsurile sunt in format JSON daca nu se specifica altfel", + "Codurile de status HTTP urmeaza standardul RFC 7231", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 2: Arhitectura Generala + # ========================================================================= + doc.add_heading("2. Arhitectura Generala", level=1) + + p = doc.add_paragraph() + p.add_run( + "Arhitectura de integrari a platformei DiDi urmeaza un model hub-and-spoke, " + "in care website-ul Next.js actioneaza ca hub central, comunicand cu toate " + "serviciile externe prin API routes server-side." + ) + + doc.add_heading("2.1 Diagrama Integrari", level=2) + add_code_block( + doc, + """ ++-------------------+ +-------------------+ +| | OIDC | | +| Client Browser +--------->+ Keycloak SSO | +| (React/Next.js) | JWT | didi365.eu/auth | +| |<---------+ | ++--------+----------+ +-------------------+ + | + | HTTPS (API Routes) + v ++--------+----------+ +-------------------+ +| | REST | | +| Next.js Server +--------->+ ERPNext v15 API | +| (API Routes) | Token | localhost:8080 | +| |<---------+ | ++--------+----------+ +-------------------+ + | | + | | Webhook + | v + | +----+-------------+ + | | | + | | Stripe API | + | | Checkout + | + | | Webhooks | + | +------------------+ + | + | REST API + v ++---+---------------+ +-------------------+ +| | | | +| DiDi AI API +--------->+ MinIO Storage | +| didi365.eu/api | | (Media Upload) | +| | +-------------------+ ++-------------------+ +""", + ) + + doc.add_heading("2.2 Principii de Design", level=2) + add_bullet_list(doc, [ + "Server-side proxy: toate apelurile catre servicii externe se fac din server-ul Next.js, nu din browser. Credentialele (API keys, secrets) nu sunt niciodata expuse in client-side code.", + "Token-based auth: comunicarea cu ERPNext utilizeaza API Key + Secret, comunicarea cu Keycloak utilizeaza JWT tokens, comunicarea cu Stripe utilizeaza Secret Key.", + "Webhook verification: toate webhook-urile primite (Stripe) sunt verificate prin semnatura criptografica inainte de procesare.", + "Error handling: toate erorile sunt tratate graceful cu raspunsuri HTTP corespunzatoare si mesaje descriptive.", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 3: Autentificare + # ========================================================================= + doc.add_heading("3. Autentificare", level=1) + + doc.add_heading("3.1 Keycloak SSO", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Server", "https://didi365.eu/didi-auth"], + ["Realm", "didi-website"], + ["Client ID", "didi-website-server"], + ["Client Type", "Confidential (cu client secret)"], + ["Protocol", "OpenID Connect"], + ["Grant Type", "Authorization Code Flow"], + ["Token Endpoint", "/realms/didi-website/protocol/openid-connect/token"], + ["Auth Endpoint", "/realms/didi-website/protocol/openid-connect/auth"], + ["Userinfo Endpoint", "/realms/didi-website/protocol/openid-connect/userinfo"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Autentificarea pe website este gestionata prin Auth.js v5 (NextAuth) cu " + "provider-ul Keycloak. La autentificarea reusita, Auth.js creeaza o sesiune " + "server-side care contine: access_token, id_token, refresh_token si datele " + "utilizatorului (sub, name, email)." + ) + + doc.add_heading("3.2 ERPNext API Authentication", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Metoda", "Token-based (API Key + API Secret)"], + ["User", "website_api@didi.localhost"], + ["Rol", "Website Integration"], + ["Header", "Authorization: token api_key:api_secret"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Fiecare request catre ERPNext API include header-ul Authorization cu token-ul " + "format din API Key si API Secret separate prin doua puncte (:). Acest mecanism " + "este configurat in fisierul src/lib/erpnext.ts." + ) + + doc.add_heading("3.3 Stripe Authentication", level=2) + add_table_with_header( + doc, + ["Parametru", "Utilizare"], + [ + ["Publishable Key (pk_test_*)", "Client-side: initializare Stripe.js (nu se foloseste direct)"], + ["Secret Key (sk_test_*)", "Server-side: creare sesiuni checkout, gestionare plati"], + ["Webhook Secret (whsec_*)", "Server-side: verificare semnatura webhook-uri"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 4: API Routes Website (Next.js) + # ========================================================================= + doc.add_heading("4. API Routes Website (Next.js)", level=1) + + p = doc.add_paragraph() + p.add_run( + "Website-ul Next.js expune urmatoarele API routes server-side. Toate rutele " + "sunt definite in directorul src/app/api/ si ruleaza exclusiv pe server." + ) + + # 4.1 + doc.add_heading("4.1 /api/auth/[...nextauth]", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/auth/[...nextauth]"], + ["Metode", "GET, POST"], + ["Autentificare", "Nu (este endpoint-ul de autentificare)"], + ["Descriere", "Handler Auth.js pentru fluxul de autentificare Keycloak"], + ], + ) + p = doc.add_paragraph() + p.add_run( + "Acest endpoint gestioneaza intregul flux de autentificare: redirect la Keycloak, " + "callback cu authorization code, obtinere tokens, creare sesiune, refresh tokens " + "si deconectare. Nu necesita implementare manuala - este gestionat complet de Auth.js." + ) + + # 4.2 + doc.add_heading("4.2 /api/erp/[...path]", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/erp/[...path]"], + ["Metode", "GET, POST, PUT"], + ["Autentificare", "Sesiune Auth.js obligatorie"], + ["Descriere", "Proxy server-side catre ERPNext API"], + ], + ) + p = doc.add_paragraph() + p.add_run( + "Actioneaza ca un proxy transparent intre client-side si ERPNext. Primeste request-uri " + "de la browser, adauga header-ul de autentificare ERPNext (API Key:Secret) si " + "retrimite request-ul catre ERPNext. Raspunsul ERPNext este returnat clientului. " + "Acest mecanism asigura ca credentialele ERPNext nu sunt niciodata expuse in browser." + ) + + # 4.3 + doc.add_heading("4.3 /api/checkout", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/checkout"], + ["Metoda", "POST"], + ["Autentificare", "Sesiune Auth.js obligatorie"], + ["Content-Type", "application/json"], + ], + ) + + doc.add_heading("Request Body", level=3) + add_code_block( + doc, + """ +{ + "priceId": "price_xxx", // Stripe Price ID + "itemCode": "DIDI-TECHNIQUES-TEXT", // Cod articol ERPNext + "customerName": "John Doe", // Numele clientului + "customerEmail": "john@example.com" // Email client +} +""", + ) + + doc.add_heading("Response (200)", level=3) + add_code_block( + doc, + """ +{ + "sessionId": "cs_xxx", // Stripe Checkout Session ID + "url": "https://checkout.stripe.com/..." // URL redirect Stripe +} +""", + ) + + p = doc.add_paragraph() + p.add_run( + "Creeaza o sesiune Stripe Checkout cu pretul si detaliile serviciului selectat. " + "Returneaza URL-ul de redirect catre pagina de plata Stripe." + ) + + doc.add_page_break() + + # 4.4 + doc.add_heading("4.4 /api/webhooks/stripe", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/webhooks/stripe"], + ["Metoda", "POST"], + ["Autentificare", "Stripe Webhook Signature (whsec_*)"], + ["Content-Type", "application/json (raw body)"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Primeste notificari webhook de la Stripe la finalizarea platilor. Procesul de " + "fulfillment declansat include:" + ) + + add_numbered_list(doc, [ + "Verificarea semnaturii webhook-ului pentru autenticitate", + "Extragerea datelor tranzactiei din evenimentul checkout.session.completed", + "Crearea sau identificarea clientului in ERPNext (pe baza didi_user_id)", + "Crearea facturii (Sales Invoice) cu articolul achizitionat, auto-submit", + "Crearea inregistrarii de plata (Payment Entry) pentru reconciliere contabila", + "Crearea acordului de servicii (Service Agreement) care activeaza analiza", + "Actualizarea planului clientului (iam_role) daca este necesar", + "Crearea intrarii in jurnalul de plati (Payment Log) pentru audit", + ]) + + # 4.5 + doc.add_heading("4.5 /api/analyze", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/analyze"], + ["Metode", "GET, POST"], + ["Autentificare", "Sesiune Auth.js obligatorie"], + ], + ) + + doc.add_heading("GET /api/analyze", level=3) + p = doc.add_paragraph() + p.add_run( + "Returneaza lista analizelor disponibile (Service Agreements active) ale clientului " + "curent. Daca se furnizeaza parametrul session_id, returneaza statusul/rezultatul " + "unei analize in procesare (polling)." + ) + + doc.add_heading("POST /api/analyze", level=3) + p = doc.add_paragraph() + p.add_run( + "Porneste o noua analiza. Accepta doua formate de input:" + ) + + add_bullet_list(doc, [ + "JSON body: { text, language, category, mediaType, serviceAgreement } - pentru analize text/URL", + "FormData (multipart): file + category + mediaType + serviceAgreement - pentru upload fisiere media", + ]) + + add_code_block( + doc, + """ +// Exemplu request text: +POST /api/analyze +Content-Type: application/json +{ + "text": "Textul de analizat...", + "language": "ro", + "category": "techniques", + "mediaType": "text", + "serviceAgreement": "SA-00001" +} + +// Exemplu request media (upload fisier): +POST /api/analyze +Content-Type: multipart/form-data +- file: [fisier imagine/audio/video] +- category: "ai_detection" +- mediaType: "image" +- serviceAgreement: "SA-00002" +""", + ) + + doc.add_page_break() + + # 4.6 + doc.add_heading("4.6 /api/analysis-reports", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/analysis-reports"], + ["Metode", "GET, POST"], + ["Autentificare", "Sesiune Auth.js obligatorie"], + ], + ) + p = doc.add_paragraph() + p.add_run( + "GET: Returneaza lista rapoartelor de analiza salvate ale clientului curent. " + "POST: Creeaza un nou raport de analiza in ERPNext cu rezultatele obtinute de la DiDi API. " + "Raportul include: titlu, client, factura, session_id, result_json, status." + ) + + # 4.7 + doc.add_heading("4.7 /api/analysis-report-pdf", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/analysis-report-pdf"], + ["Metoda", "GET"], + ["Autentificare", "Sesiune Auth.js obligatorie"], + ["Parametri", "?report=REPORT_NAME"], + ["Response", "application/pdf"], + ], + ) + p = doc.add_paragraph() + p.add_run( + "Genereaza si returneaza un fisier PDF cu raportul complet al analizei, " + "formatat profesional cu rezultatele, scorurile si recomandarile." + ) + + # 4.8 + doc.add_heading("4.8 /api/customer/me", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/customer/me"], + ["Metoda", "GET"], + ["Autentificare", "Sesiune Auth.js obligatorie"], + ], + ) + p = doc.add_paragraph() + p.add_run( + "Identifica si returneaza clientul ERPNext asociat sesiunii Keycloak curente. " + "Cautarea se face pe baza campului didi_user_id (UUID Keycloak) din fisa clientului ERPNext." + ) + + # 4.9 + doc.add_heading("4.9 /api/leads", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/leads"], + ["Metoda", "POST"], + ["Autentificare", "Nu (endpoint public)"], + ["Content-Type", "application/json"], + ], + ) + + add_code_block( + doc, + """ +{ + "name": "Ion Popescu", + "email": "ion@example.com", + "message": "Doresc o demonstratie a platformei", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "launch_2026" +} +""", + ) + + p = doc.add_paragraph() + p.add_run( + "Creeaza un Lead in ERPNext CRM cu datele din formularul de contact. " + "Include parametrii UTM pentru tracking-ul sursei de trafic." + ) + + # 4.10 + doc.add_heading("4.10 /api/invoice-pdf", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Cale", "/api/invoice-pdf"], + ["Metoda", "GET"], + ["Autentificare", "Sesiune Auth.js obligatorie"], + ["Parametri", "?invoice=SINV-XXXXX"], + ["Response", "application/pdf"], + ], + ) + p = doc.add_paragraph() + p.add_run( + "Descarca factura in format PDF de la ERPNext, folosind print format-ul " + "personalizat DiDi Invoice. Verifica ca factura apartine clientului curent " + "inainte de returnare." + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 5: Integrare DiDi API + # ========================================================================= + doc.add_heading("5. Integrare DiDi API", level=1) + + p = doc.add_paragraph() + p.add_run( + "DiDi API este serviciul de inteligenta artificiala care proceseaza analizele de " + "continut. API-ul este disponibil la adresa https://didi365.eu/didi-api si ofera " + "endpoint-uri pentru upload media si 4 tipuri de analiza." + ) + + doc.add_heading("5.1 Upload Media", level=2) + add_table_with_header( + doc, + ["Proprietate", "Valoare"], + [ + ["Endpoint", "POST /v3/media/upload"], + ["Content-Type", "multipart/form-data"], + ["Camp fisier", "file"], + ["Raspuns", "{ public_url: 'https://...' }"], + ], + ) + p = doc.add_paragraph() + p.add_run( + "Incarca un fisier media (imagine, audio, video) in storage-ul MinIO si returneaza " + "URL-ul public al fisierului. Acest URL este apoi utilizat in request-urile de analiza." + ) + + doc.add_heading("5.2 Endpoint-uri de Analiza", level=2) + add_table_with_header( + doc, + ["Endpoint", "Categorie", "Descriere"], + [ + ["POST /v3/techniques/analyze", "Detectie Tehnici", "Identificare tehnici manipulare si propaganda"], + ["POST /v3/ai-tampered/analyze", "AI Detection", "Detectie continut generat/modificat de AI"], + ["POST /v3/claims/analyze", "Fact-Checking", "Verificare afirmatii si informatii"], + ["POST /v3/domain/analyze", "Evaluare Surse", "Analiza credibilitate domenii web"], + ], + ) + + doc.add_heading("5.3 Payload pentru Text", level=2) + add_code_block( + doc, + """ +POST /v3/techniques/analyze +Content-Type: application/json + +{ + "text": "Textul de analizat pentru detectarea tehnicilor de manipulare...", + "language": "ro" +} +""", + ) + + doc.add_heading("5.4 Payload pentru Media", level=2) + add_code_block( + doc, + """ +POST /v3/ai-tampered/analyze +Content-Type: application/json + +{ + "media_type": "image", // "image" | "audio" | "video" + "media_url": "https://storage.didi365.eu/uploads/file.jpg" +} +""", + ) + + doc.add_heading("5.5 Raspuns Sincron (Text/URL/Imagine)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pentru analizele de tip text, URL si imagine, API-ul returneaza rezultatul " + "direct in raspunsul HTTP cu status 200:" + ) + add_code_block( + doc, + """ +HTTP 200 OK +{ + "risk_score": 0.78, + "confidence": 0.92, + "risk_category": "high", + "techniques": [ + { "name": "appeal_to_emotion", "score": 0.85 }, + { "name": "cherry_picking", "score": 0.72 } + ], + "verdict": "Continutul prezinta indicii puternice de manipulare...", + "claims": [...], + "ai_tampered": { "probability": 0.15, "verdict": "authentic" }, + "domain": { "credibility_score": 0.45, "category": "unreliable" } +} +""", + ) + + doc.add_page_break() + + doc.add_heading("5.6 Raspuns Asincron (Audio/Video)", level=2) + p = doc.add_paragraph() + p.add_run( + "Pentru analizele de tip audio si video, API-ul returneaza un status 202 " + "cu un session_id pentru polling ulterior:" + ) + add_code_block( + doc, + """ +HTTP 202 Accepted +{ + "session_id": "sess_abc123def456", + "status": "queued", + "message": "Analysis queued for processing" +} +""", + ) + + doc.add_heading("5.7 Polling pentru Rezultate", level=2) + p = doc.add_paragraph() + p.add_run( + "Dupa primirea unui raspuns 202, clientul trebuie sa faca polling pentru " + "a verifica statusul procesarii:" + ) + + add_code_block( + doc, + """ +// Pasul 1: Verificare status +GET /v3/pipeline/{session_id}/queue-status + +Raspunsuri posibile: +{ "status": "queued" } // In coada de procesare +{ "status": "processing" } // In curs de procesare +{ "status": "completed" } // Procesare finalizata + +// Pasul 2: Obtinere rezultat (dupa status "completed") +GET /v3/pipeline/{session_id}/result + +Raspuns: aceeasi structura ca la raspunsul sincron (5.5) +""", + ) + + doc.add_heading("5.8 Structura Rezultat", level=2) + add_table_with_header( + doc, + ["Camp", "Tip", "Descriere"], + [ + ["risk_score", "float (0-1)", "Scorul de risc de dezinformare (0=fara risc, 1=risc maxim)"], + ["confidence", "float (0-1)", "Nivelul de incredere al algoritmului in rezultat"], + ["risk_category", "string", "Categorie: low / medium / high / critical"], + ["techniques", "array", "Lista tehnicilor de manipulare detectate cu scoruri individuale"], + ["verdict", "string", "Concluzia generala a analizei in text descriptiv"], + ["claims", "array", "Lista afirmatiilor verificate (fact-checking)"], + ["ai_tampered", "object", "Rezultat detectie AI: probability, verdict"], + ["domain", "object", "Evaluare domeniu: credibility_score, category"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 6: Integrare Stripe + # ========================================================================= + doc.add_heading("6. Integrare Stripe", level=1) + + doc.add_heading("6.1 PRICE_MAP - Mapare Servicii", level=2) + p = doc.add_paragraph() + p.add_run( + "Fiecare serviciu DiDi este mapat la un Stripe Price ID prin constanta PRICE_MAP " + "definita in configuratia aplicatiei. Aceasta mapare asociaza 16 combinatii de " + "categorii si tipuri de media cu price ID-urile corespunzatoare din Stripe:" + ) + + add_code_block( + doc, + """ +const PRICE_MAP = { + "DIDI-TECHNIQUES-TEXT": "price_techniques_text", + "DIDI-TECHNIQUES-IMAGE": "price_techniques_image", + "DIDI-TECHNIQUES-AUDIO": "price_techniques_audio", + "DIDI-TECHNIQUES-VIDEO": "price_techniques_video", + "DIDI-AI_DETECTION-TEXT": "price_ai_detection_text", + "DIDI-AI_DETECTION-IMAGE": "price_ai_detection_image", + "DIDI-AI_DETECTION-AUDIO": "price_ai_detection_audio", + "DIDI-AI_DETECTION-VIDEO": "price_ai_detection_video", + "DIDI-CLAIMS-TEXT": "price_claims_text", + "DIDI-CLAIMS-IMAGE": "price_claims_image", + "DIDI-CLAIMS-AUDIO": "price_claims_audio", + "DIDI-CLAIMS-VIDEO": "price_claims_video", + "DIDI-SOURCE-TEXT": "price_source_text", + "DIDI-SOURCE-IMAGE": "price_source_image", + "DIDI-SOURCE-AUDIO": "price_source_audio", + "DIDI-SOURCE-VIDEO": "price_source_video" +}; +""", + ) + + doc.add_heading("6.2 Checkout Flow", level=2) + p = doc.add_paragraph() + p.add_run( + "Fluxul de checkout Stripe se desfasoara in 3 etape principale:" + ) + + add_numbered_list(doc, [ + "Creare sesiune: POST /api/checkout creeaza un Stripe Checkout Session cu price ID-ul, datele clientului si URL-urile de redirect (success/cancel)", + "Redirect Stripe: utilizatorul este redirectionat la checkout.stripe.com unde completeaza datele de plata in mediul securizat Stripe", + "Webhook fulfillment: dupa plata reusita, Stripe trimite evenimentul checkout.session.completed la /api/webhooks/stripe care declanseaza procesul de fulfillment (creare factura, plata, acord servicii)", + ]) + + doc.add_heading("6.3 Webhook Events", level=2) + add_table_with_header( + doc, + ["Eveniment", "Actiune"], + [ + ["checkout.session.completed", "Fulfillment complet: factura + plata + acord servicii + payment log"], + ], + ) + + doc.add_heading("6.4 Fulfillment (Procesare Automata)", level=2) + p = doc.add_paragraph() + p.add_run( + "La primirea evenimentului checkout.session.completed, webhook-ul declanseaza " + "urmatoarele operatiuni in ordine secventiala:" + ) + + add_numbered_list(doc, [ + "Creare/identificare customer - Cautare client ERPNext dupa didi_user_id (Keycloak UUID). Daca nu exista, se creeaza automat cu datele din sesiunea Stripe.", + "Creare factura (Sales Invoice) - Se genereaza automat cu articolul achizitionat, pret, TVA 19%. Factura se valideaza automat (auto-submit) pentru a fi considerata emisa.", + "Creare incasare (Payment Entry) - Se inregistreaza plata in contabilitate, reconciliata cu factura emisa. Contul de incasari: Stripe.", + "Creare acord servicii (Service Agreement) - Se activeaza dreptul clientului de a utiliza serviciul achizitionat. Status: Activ.", + "Actualizare plan client - Se actualizeaza campul iam_role al clientului (paid_tier daca era free_tier).", + "Creare Payment Log - Se inregistreaza tranzactia in jurnalul de plati cu toate detaliile Stripe pentru audit complet.", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 7: Integrare ERPNext + # ========================================================================= + doc.add_heading("7. Integrare ERPNext", level=1) + + doc.add_heading("7.1 DocTypes Custom", level=2) + p = doc.add_paragraph() + p.add_run( + "Aplicatia custom didi_custom adauga 4 DocTypes noi in ERPNext, specifice " + "functionalitatilor platformei DiDi:" + ) + + add_table_with_header( + doc, + ["DocType", "Modul", "Descriere"], + [ + ["Service Agreement", "DiDi", "Acord prestare servicii - activeaza dreptul de a rula o analiza"], + ["Payment Log", "DiDi", "Jurnal plati Stripe - audit trail complet al tranzactiilor"], + ["Website Content", "DiDi", "Continut website CMS - texte pagini bilingv RO/EN"], + ["Analysis Report", "DiDi", "Raport analiza AI - rezultate complete analiza cu JSON si PDF"], + ], + ) + + doc.add_heading("7.2 Custom Fields pe DocTypes Existente", level=2) + + doc.add_heading("Customer (Client)", level=3) + add_table_with_header( + doc, + ["Fieldname", "Label", "Tip", "Descriere"], + [ + ["didi_user_id", "ID Utilizator DiDi", "Data", "UUID Keycloak pentru legatura cu contul de autentificare"], + ["iam_role", "Rol IAM", "Select", "Nivelul de acces: free_tier / paid_tier / enterprise_tier"], + ["active_plan", "Plan Activ", "Data", "Planul de abonament curent"], + ], + ) + + doc.add_heading("Lead", level=3) + add_table_with_header( + doc, + ["Fieldname", "Label", "Tip", "Descriere"], + [ + ["utm_source", "UTM Source", "Data", "Sursa traficului (google, facebook, etc.)"], + ["utm_medium", "UTM Medium", "Data", "Mediul de marketing (cpc, organic, email)"], + ["utm_campaign", "UTM Campaign", "Data", "Campania specifica"], + ], + ) + + doc.add_heading("Sales Invoice (Factura)", level=3) + add_table_with_header( + doc, + ["Fieldname", "Label", "Tip", "Descriere"], + [ + ["analysis_consumed", "Analiza Consumata", "Check", "Indica daca analiza asociata a fost rulata"], + ["analysis_session_id", "Session ID Analiza", "Data", "ID-ul sesiunii de analiza DiDi API"], + ], + ) + + doc.add_heading("7.3 Endpoint-uri ERPNext Utilizate", level=2) + add_table_with_header( + doc, + ["Endpoint", "Metoda", "Descriere"], + [ + ["/api/resource/{DocType}", "GET", "Listare si cautare documente"], + ["/api/resource/{DocType}/{name}", "GET", "Obtinere detalii document"], + ["/api/resource/{DocType}", "POST", "Creare document nou"], + ["/api/resource/{DocType}/{name}", "PUT", "Actualizare document existent"], + ["/api/method/upload_file", "POST", "Upload fisier (atasament)"], + ["/api/method/frappe.client.submit", "POST", "Validare (submit) document"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 8: Fisiere Cheie Cod Sursa + # ========================================================================= + doc.add_heading("8. Fisiere Cheie Cod Sursa", level=1) + + doc.add_heading("8.1 Librarii Partajate (src/lib/)", level=2) + add_table_with_header( + doc, + ["Fisier", "Descriere"], + [ + ["src/lib/auth.ts", "Configurare Auth.js v5 cu Keycloak provider, callbacks JWT/session"], + ["src/lib/erpnext.ts", "Client ERPNext API: functii CRUD, autentificare token, error handling"], + ["src/lib/stripe.ts", "Initializare Stripe SDK cu secret key"], + ["src/lib/stripe-fulfillment.ts", "Logica completa de fulfillment: creare customer, invoice, payment, SA"], + ["src/lib/api.ts", "Client DiDi API: upload media, analiza, polling, parsing rezultate"], + ["src/lib/analysis-report-pdf.ts", "Generator PDF rapoarte analiza cu formatare profesionala"], + ["src/lib/analysis-report-store.ts", "CRUD operatii pentru Analysis Report in ERPNext"], + ], + ) + + doc.add_heading("8.2 API Routes (src/app/api/)", level=2) + add_table_with_header( + doc, + ["Fisier", "Route", "Descriere"], + [ + ["src/app/api/auth/[...nextauth]/route.ts", "/api/auth/*", "Handler Auth.js complet"], + ["src/app/api/erp/[...path]/route.ts", "/api/erp/*", "Proxy ERPNext server-side"], + ["src/app/api/checkout/route.ts", "/api/checkout", "Creare Stripe Checkout Session"], + ["src/app/api/webhooks/stripe/route.ts", "/api/webhooks/stripe", "Procesare webhook Stripe"], + ["src/app/api/analyze/route.ts", "/api/analyze", "Rulare si polling analize AI"], + ["src/app/api/analysis-reports/route.ts", "/api/analysis-reports", "CRUD rapoarte analiza"], + ["src/app/api/analysis-report-pdf/route.ts", "/api/analysis-report-pdf", "Download PDF raport"], + ["src/app/api/customer/me/route.ts", "/api/customer/me", "Identificare client curent"], + ["src/app/api/leads/route.ts", "/api/leads", "Creare Lead CRM"], + ["src/app/api/invoice-pdf/route.ts", "/api/invoice-pdf", "Download PDF factura"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 9: Variabile de Mediu + # ========================================================================= + doc.add_heading("9. Variabile de Mediu", level=1) + + p = doc.add_paragraph() + p.add_run( + "Urmatoarele variabile de mediu trebuie configurate in fisierul .env.local " + "pentru functionarea corecta a website-ului:" + ) + + add_table_with_header( + doc, + ["Variabila", "Descriere", "Exemplu"], + [ + ["NEXTAUTH_URL", "URL-ul de baza al website-ului", "https://didi365.eu"], + ["NEXTAUTH_SECRET", "Secret pentru criptarea sesiunilor Auth.js", "[random string 32+ chars]"], + ["KEYCLOAK_ISSUER", "URL-ul issuer-ului Keycloak", "https://didi365.eu/didi-auth/realms/didi-website"], + ["KEYCLOAK_CLIENT_ID", "Client ID pentru Keycloak", "didi-website-server"], + ["KEYCLOAK_CLIENT_SECRET", "Client Secret pentru Keycloak", "[UUID from Keycloak]"], + ["ERPNEXT_URL", "URL-ul instanta ERPNext", "http://localhost:8080"], + ["ERPNEXT_API_KEY", "API Key utilizator integrare", "[from ERPNext]"], + ["ERPNEXT_API_SECRET", "API Secret utilizator integrare", "[from ERPNext]"], + ["STRIPE_SECRET_KEY", "Stripe Secret Key", "sk_test_..."], + ["STRIPE_PUBLISHABLE_KEY", "Stripe Publishable Key", "pk_test_..."], + ["STRIPE_WEBHOOK_SECRET", "Secret verificare webhook", "whsec_..."], + ["DIDI_API_URL", "URL-ul DiDi AI API", "https://didi365.eu/didi-api"], + ["DIDI_API_KEY", "Cheie acces DiDi API", "[API key]"], + ], + ) + + add_note_box( + doc, + "Fisierul .env.local nu trebuie niciodata comis in repository-ul Git. " + "Asigurati-va ca este inclus in .gitignore. Valorile secrete trebuie " + "stocate intr-un manager de secrete (ex: Docker secrets, Vault).", + "warning", + ) + + # Save document + output_path = os.path.join(OUTPUT_DIR, "Documentatie_API.docx") + doc.save(output_path) + print(f" -> Salvat: {output_path}") + return output_path + + +# ============================================================================== +# DOCUMENT 4: Arhitectura Detaliata +# ============================================================================== + + +def generate_arhitectura(): + """Generate Arhitectura_Detaliata.docx""" + print("[4/4] Generare Arhitectura_Detaliata.docx ...") + + doc = create_document() + + add_cover_page( + doc, + "ARHITECTURA DETALIATA", + "Platforma DiDi Website + ERP/CRM", + ) + + add_header_footer(doc) + add_toc_placeholder(doc) + + # ========================================================================= + # CHAPTER 1: Prezentare Generala + # ========================================================================= + doc.add_heading("1. Prezentare Generala", level=1) + + p = doc.add_paragraph() + p.add_run( + "Platforma DiDi este o solutie digitala complexa dezvoltata in cadrul proiectului " + "PNRR Contract 11.1.i3.c9/10.05.2024, care integreaza multiple componente tehnologice " + "pentru a oferi servicii de detectare si combatere a dezinformarii." + ) + + p = doc.add_paragraph() + p.add_run("Platforma este compusa din urmatoarele componente principale:") + + add_bullet_list(doc, [ + "Website Next.js 16 - interfata publica si dashboard client, cu e-commerce integrat", + "ERPNext v15 ERP/CRM - sistem de management resurse, facturare, CRM si gestiune clienti", + "Keycloak SSO - server de autentificare centralizata cu OpenID Connect", + "Stripe - procesator de plati online pentru e-commerce", + "DiDi AI API - motor de inteligenta artificiala pentru analiza continutului digital", + "SendGrid - serviciu email transactional pentru notificari automate", + ]) + + p = doc.add_paragraph() + p.add_run( + "Toate componentele sunt integrate prin API-uri REST si comunica prin protocoale " + "securizate (HTTPS, JWT, API tokens). Arhitectura urmeaza principiile microserviciilor, " + "cu fiecare componenta avand responsabilitati bine definite si independenta operationala." + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 2: Diagrama Componente + # ========================================================================= + doc.add_heading("2. Diagrama Componente", level=1) + + doc.add_heading("2.1 Diagrama de Nivel Inalt", level=2) + add_code_block( + doc, + """ ++===========================================================+ +| CLIENT LAYER | +| +-----------------------------------------------------+ | +| | Client Browser (Desktop/Mobile) | | +| | React Components + Tailwind CSS | | +| +---------------------------+-------------------------+ | ++===========================================================+ + | + | HTTPS + v ++===========================================================+ +| APPLICATION LAYER | +| +-----------------------------------------------------+ | +| | Next.js 16 Website (App Router) | | +| | | | +| | +-------------+ +------------+ +--------------+ | | +| | | Public Pages| | Dashboard | | API Routes | | | +| | | (SSR/SSG) | | (Auth Req) | | (Server-side)| | | +| | +-------------+ +------------+ +--------------+ | | +| +-----------------------------------------------------+ | ++===========================================================+ + | | | | + v v v v ++==============+ +=============+ +============+ +============+ +| Keycloak SSO | | ERPNext v15 | | Stripe API | | DiDi AI API| +| (didi365.eu | | (Docker) | | (Payments) | | (didi365.eu| +| /didi-auth) | | | | | | /didi-api) | ++==============+ +=============+ +============+ +============+ + | | + v v + +=============+ +============+ + | MariaDB | | MinIO | + | Redis x2 | | RabbitMQ | + | Workers x3 | | Redis | + | Scheduler | | PostgreSQL | + +=============+ +============+ +""", + ) + + doc.add_heading("2.2 Fluxul de Comunicare", level=2) + p = doc.add_paragraph() + p.add_run( + "Toate comunicarile intre componente se realizeaza prin protocolul HTTPS, cu exceptia " + "comunicarilor interne intre containerele Docker din acelasi stack (ERPNext <-> MariaDB, " + "DiDi API <-> PostgreSQL) care utilizeaza retele Docker interne. Fiecare integrare " + "utilizeaza mecanisme de autentificare specifice (JWT, API tokens, webhook signatures)." + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 3: Componenta Website + # ========================================================================= + doc.add_heading("3. Componenta Website (Next.js 16)", level=1) + + doc.add_heading("3.1 Stack Tehnologic", level=2) + add_table_with_header( + doc, + ["Tehnologie", "Versiune", "Utilizare"], + [ + ["Next.js", "16", "Framework React cu App Router, SSR, SSG, API Routes"], + ["TypeScript", "5.x", "Tipizare statica pentru calitate si mentenabilitate cod"], + ["Tailwind CSS", "3.x", "Framework CSS utility-first pentru styling rapid si consistent"], + ["Auth.js", "v5 (NextAuth)", "Librarie autentificare cu provider Keycloak"], + ["Stripe SDK", "Latest", "SDK oficial pentru integrarea platilor Stripe"], + ["React", "19.x", "Librarie UI componente reactive"], + ], + ) + + doc.add_heading("3.2 Autentificare (Auth.js v5 cu Keycloak)", level=2) + p = doc.add_paragraph() + p.add_run( + "Autentificarea este gestionata prin Auth.js v5 (fostul NextAuth.js) cu provider-ul " + "Keycloak. Configurarea include:" + ) + add_bullet_list(doc, [ + "Keycloak Provider cu OpenID Connect flow (Authorization Code)", + "JWT callback pentru extragerea si stocarea tokens-urilor in sesiune", + "Session callback pentru expunerea datelor utilizatorului catre componente", + "Refresh token rotation pentru reinnoirea automata a sesiunilor expirate", + "Configurare realm: didi-website, client: didi-website-server (confidential)", + ]) + + doc.add_heading("3.3 Structura Routing", level=2) + p = doc.add_paragraph() + p.add_run( + "Aplicatia Next.js utilizeaza App Router cu grupuri de rute (route groups) " + "pentru organizarea logica a paginilor:" + ) + + add_table_with_header( + doc, + ["Grup", "Prefix", "Autentificare", "Pagini"], + [ + ["(public)", "/", "Nu", "Homepage, Servicii, Despre, Pricing, Contact, Privacy, Terms"], + ["(auth)", "/auth/*", "Nu (redirect daca autentificat)", "Login, Register, Callback"], + ["(dashboard)", "/dashboard/*", "Da (redirect la login daca nu)", "Overview, Analize, Achizitii, Facturi, Profil"], + ], + ) + + doc.add_heading("3.4 API Routes Server-Side", level=2) + p = doc.add_paragraph() + p.add_run( + "Toate intergrarile cu serviciile externe (ERPNext, Stripe, DiDi API, Keycloak) " + "se realizeaza prin API routes server-side. Acest design asigura ca:" + ) + add_bullet_list(doc, [ + "Credentialele (API keys, secrets, tokens) nu sunt niciodata expuse in codul client-side", + "CORS nu este o problema deoarece request-urile se fac server-to-server", + "Logica de business complexa (fulfillment, polling, PDF generation) ruleaza pe server", + "Se poate implementa rate limiting si validare la nivel de server", + ]) + + doc.add_heading("3.5 Internationalizare (i18n)", level=2) + p = doc.add_paragraph() + p.add_run( + "Website-ul suporta doua limbi (RO/EN) implementate prin:" + ) + add_bullet_list(doc, [ + "Continut pagini gestionat prin CMS ERPNext (Website Content) cu campuri bilingve", + "Etichete UI stocate in fisiere de traducere locale (JSON)", + "Selectorul de limba persista preferinta in localStorage", + "Limba implicita: Romana (RO)", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 4: Componenta ERP/CRM + # ========================================================================= + doc.add_heading("4. Componenta ERP/CRM (ERPNext v15)", level=1) + + doc.add_heading("4.1 Infrastructura Docker", level=2) + p = doc.add_paragraph() + p.add_run( + "ERPNext ruleaza intr-un stack Docker Compose cu 8 containere configurate " + "pentru performanta si fiabilitate:" + ) + + add_table_with_header( + doc, + ["Container", "Imagine", "Resurse", "Rol"], + [ + ["erpnext", "frappe/erpnext:v15", "Web + API", "Serveste interfata web si raspunde la API requests"], + ["mariadb", "mariadb:10.11", "Baza de date", "Stocheaza toate datele ERPNext (documente, configurari)"], + ["redis-cache", "redis:7", "Cache", "Cache pentru sesiuni, metadata si date frecvent accesate"], + ["redis-queue", "redis:7", "Message Queue", "Coada de mesaje pentru job-uri asincrone (Frappe RQ)"], + ["worker-default", "frappe/erpnext:v15", "Background Jobs", "Proceseaza job-uri standard (email, notificari)"], + ["worker-short", "frappe/erpnext:v15", "Background Jobs", "Proceseaza job-uri rapide (sub 5 minute)"], + ["worker-long", "frappe/erpnext:v15", "Background Jobs", "Proceseaza job-uri de durata lunga (rapoarte, export)"], + ["scheduler", "frappe/erpnext:v15", "Task Scheduler", "Planifica si declanseaza taskuri recurente"], + ], + ) + + doc.add_heading("4.2 Aplicatie Custom: didi_custom", level=2) + p = doc.add_paragraph() + p.add_run( + "Aplicatia Frappe custom didi_custom extinde ERPNext cu functionalitatile specifice " + "platformei DiDi. Include 4 DocTypes noi si custom fields pe DocTypes existente:" + ) + + add_table_with_header( + doc, + ["DocType", "Tip", "Campuri Principale"], + [ + ["Service Agreement", "Custom", "customer, item, invoice, status, created_date, expiry_date"], + ["Payment Log", "Custom", "customer, event_type, status, amount, stripe_payment_id, stripe_session_id, invoice, raw_data"], + ["Website Content", "Custom", "page_slug, content_ro, content_en, section, published"], + ["Analysis Report", "Custom", "title, customer, invoice, session_id, analysis_type, media_type, result_json, status, pdf_attachment"], + ], + ) + + doc.add_heading("4.3 Configurare Companie", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Companie", "TOP CLOSSERS SRL"], + ["CUI", "36193026"], + ["Adresa", "Str. Targovistei 15, Ploiesti, 100299, Prahova"], + ["Plan Conturi", "Romania Standard"], + ["TVA", "19% (conform legislatie romaneasca)"], + ["Moneda", "RON (Leu Romanesc)"], + ["Serie Facturi", "DIDI-INV-YYYY-#####"], + ["An Fiscal", "Ianuarie - Decembrie"], + ], + ) + + doc.add_heading("4.4 API User pentru Integrare", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Utilizator", "website_api@didi.localhost"], + ["Tip", "System User (fara Desk Access)"], + ["Rol", "Website Integration"], + ["Autentificare", "API Key + API Secret"], + ["Principiu", "Least Privilege - acces minim necesar"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 5: Integrari Externe + # ========================================================================= + doc.add_heading("5. Integrari Externe", level=1) + + doc.add_heading("5.1 Keycloak SSO", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Server", "https://didi365.eu/didi-auth"], + ["Realm", "didi-website"], + ["Protocol", "OpenID Connect (OIDC)"], + ["Client", "didi-website-server (Confidential)"], + ["Flow", "Authorization Code Flow"], + ["Token Format", "JWT (JSON Web Token)"], + ["Token Lifetime", "Access: 5 min, Refresh: 30 min, Session: 8 ore"], + ], + ) + + p = doc.add_paragraph() + p.add_run( + "Keycloak gestioneaza identitatea utilizatorilor, procesul de inregistrare, " + "autentificare si managementul sesiunilor. Fiecare utilizator primeste un UUID " + "unic care este utilizat ca didi_user_id in ERPNext pentru corelarea conturilor." + ) + + doc.add_heading("5.2 Stripe (Plati Online)", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Tip Integrare", "Stripe Checkout (Hosted Payment Page)"], + ["Tip Plati", "One-time (plata unica per serviciu)"], + ["Moneda", "RON"], + ["Webhook", "/api/webhooks/stripe"], + ["Verificare", "Stripe Webhook Signature (HMAC SHA-256)"], + ["Certificare", "PCI DSS Level 1"], + ], + ) + + doc.add_heading("5.3 DiDi AI API", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["URL", "https://didi365.eu/didi-api"], + ["Versiune", "v3"], + ["Autentificare", "API Key in header"], + ["Endpoint-uri", "4 categorii analiza + upload media"], + ["Storage", "MinIO (object storage S3-compatible)"], + ["Queue", "RabbitMQ (procesare asincrona audio/video)"], + ["Baza de date", "PostgreSQL + Redis"], + ], + ) + + doc.add_heading("5.4 SendGrid (Email Transactional)", level=2) + add_table_with_header( + doc, + ["Parametru", "Valoare"], + [ + ["Provider", "SendGrid (Twilio)"], + ["Protocol", "SMTP over TLS"], + ["Server", "smtp.sendgrid.net:587"], + ["Expeditor", "noreply@didi365.eu"], + ["Utilizare", "Notificari automate: facturi, confirmari plata, expirari"], + ], + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 6: Flux de Date + # ========================================================================= + doc.add_heading("6. Flux de Date", level=1) + + doc.add_heading("6.1 Flux Autentificare", level=2) + add_code_block( + doc, + """ +[Browser] --click Login--> [Next.js] +[Next.js] --redirect--> [Keycloak Login Page] +[User] --credentials--> [Keycloak] +[Keycloak] --authorization code--> [Next.js callback] +[Next.js] --exchange code--> [Keycloak Token Endpoint] +[Keycloak] --JWT tokens--> [Next.js] +[Next.js] --create session--> [Auth.js Session] +[Next.js] --GET /api/customer/me--> [ERPNext] +[ERPNext] --customer data (by didi_user_id)--> [Next.js] +[Next.js] --render Dashboard--> [Browser] +""", + ) + + doc.add_heading("6.2 Flux Achizitie Serviciu", level=2) + add_code_block( + doc, + """ +[Browser] --selecteaza serviciu--> [Dashboard Achizitii] +[Dashboard] --POST /api/checkout--> [Next.js API] +[Next.js] --create Checkout Session--> [Stripe API] +[Stripe] --session URL--> [Next.js] +[Next.js] --redirect--> [Stripe Checkout Page] +[User] --completeaza plata--> [Stripe] +[Stripe] --redirect success--> [Website Success Page] +[Stripe] --webhook POST--> [/api/webhooks/stripe] +[Webhook] --create customer--> [ERPNext] +[Webhook] --create invoice (auto-submit)--> [ERPNext] +[Webhook] --create payment entry--> [ERPNext] +[Webhook] --create service agreement--> [ERPNext] +[Webhook] --update customer plan--> [ERPNext] +[Webhook] --create payment log--> [ERPNext] +[ERPNext] --send invoice email--> [SendGrid] --> [Client Email] +""", + ) + + doc.add_page_break() + + doc.add_heading("6.3 Flux Analiza Text (Sincron)", level=2) + add_code_block( + doc, + """ +[Browser] --selecteaza analiza--> [Dashboard Analize] +[Browser] --introduce text--> [Formular Analiza] +[Browser] --POST /api/analyze--> [Next.js API] +[Next.js] --POST /v3/techniques/analyze--> [DiDi API] +[DiDi API] --procesare AI--> [Rezultat] +[DiDi API] --200 OK + result JSON--> [Next.js] +[Next.js] --mark SA consumed--> [ERPNext] +[Next.js] --create Analysis Report--> [ERPNext] +[Next.js] --result JSON--> [Browser] +[Browser] --afiseaza rezultat--> [Dashboard] +""", + ) + + doc.add_heading("6.4 Flux Analiza Media (Asincron)", level=2) + add_code_block( + doc, + """ +[Browser] --upload fisier--> [FormData] +[Browser] --POST /api/analyze--> [Next.js API] +[Next.js] --POST /v3/media/upload--> [DiDi API] +[DiDi API] --stocare MinIO--> [public_url] +[DiDi API] --public_url--> [Next.js] +[Next.js] --POST /v3/ai-tampered/analyze { media_url }--> [DiDi API] +[DiDi API] --202 Accepted { session_id }--> [Next.js] +[Next.js] --session_id--> [Browser] +[Browser] --polling GET /api/analyze?session_id--> [Next.js] +[Next.js] --GET /v3/pipeline/{id}/queue-status--> [DiDi API] +[DiDi API] --"completed"--> [Next.js] +[Next.js] --GET /v3/pipeline/{id}/result--> [DiDi API] +[DiDi API] --result JSON--> [Next.js] +[Next.js] --persist result--> [ERPNext Analysis Report] +[Next.js] --result--> [Browser] +""", + ) + + doc.add_heading("6.5 Flux CMS (Continut Website)", level=2) + add_code_block( + doc, + """ +[Admin] --editare continut--> [ERPNext Website Content] +[ERPNext] --salveaza RO + EN--> [MariaDB] +[Browser] --acceseaza pagina--> [Next.js] +[Next.js] --GET /api/erp/Website Content?slug=homepage--> [ERPNext API] +[ERPNext] --content_ro / content_en--> [Next.js] +[Next.js] --selecteaza limba curenta--> [Render pagina] +[Next.js] --HTML--> [Browser] +""", + ) + + doc.add_heading("6.6 Flux CRM (Lead Capture)", level=2) + add_code_block( + doc, + """ +[Vizitator] --completeaza formular contact--> [Browser] +[Browser] --POST /api/leads { name, email, message, utm_* }--> [Next.js] +[Next.js] --POST /api/resource/Lead--> [ERPNext API] +[ERPNext] --create Lead (status: Open)--> [MariaDB] +[ERPNext] --notify sales team--> [Email Notification] +""", + ) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 7: Securitate + # ========================================================================= + doc.add_heading("7. Securitate", level=1) + + doc.add_heading("7.1 Transport Layer Security (TLS)", level=2) + p = doc.add_paragraph() + p.add_run( + "Toate comunicarile externe utilizeaza HTTPS cu certificate TLS valide. " + "Conexiunile HTTP sunt redirectionate automat catre HTTPS prin configurarea " + "Nginx reverse proxy." + ) + + doc.add_heading("7.2 Autentificare si Autorizare", level=2) + add_table_with_header( + doc, + ["Mecanism", "Componenta", "Descriere"], + [ + ["JWT Tokens", "Keycloak", "Tokens cu durata limitata, semnate criptografic, refresh automat"], + ["API Token", "ERPNext", "API Key + Secret, utilizator cu permisiuni minimale"], + ["Webhook Signature", "Stripe", "HMAC SHA-256 pentru verificarea autenticitatii webhook-urilor"], + ["API Key", "DiDi API", "Cheie de acces in header pentru autentificarea request-urilor"], + ["Session Cookie", "Auth.js", "Cookie securizat (HttpOnly, Secure, SameSite) pentru sesiuni web"], + ], + ) + + doc.add_heading("7.3 RBAC (Role-Based Access Control)", level=2) + p = doc.add_paragraph() + p.add_run( + "Platforma implementeaza 3 niveluri de acces (tiers) pentru utilizatori:" + ) + + add_table_with_header( + doc, + ["Tier", "iam_role", "Acces", "Descriere"], + [ + ["Free", "free_tier", "Limitat", "Cont creat, fara achizitii. Acces la pagini publice si dashboard de baza"], + ["Paid", "paid_tier", "Standard", "Cel putin o achizitie efectuata. Acces complet la analize si rapoarte"], + ["Enterprise", "enterprise_tier", "Extins", "Client enterprise cu acces la functionalitati avansate si suport dedicat"], + ], + ) + + doc.add_heading("7.4 Server-Side API Proxy", level=2) + p = doc.add_paragraph() + p.add_run( + "Toate apelurile catre servicii externe se fac exclusiv din server-ul Next.js. " + "Credentialele (API keys, secrets, tokens) sunt stocate in variabile de mediu " + "server-side si nu sunt niciodata incluse in bundle-ul JavaScript trimis la client. " + "Aceasta arhitectura previne expunerea credentialelor prin inspectia codului sursa " + "sau interceptarea traficului de retea din browser." + ) + + doc.add_heading("7.5 Stripe PCI DSS Compliance", level=2) + p = doc.add_paragraph() + p.add_run( + "Integrarea cu Stripe utilizeaza Stripe Checkout (hosted payment page), ceea ce " + "inseamna ca datele de card sunt introduse exclusiv pe serverele Stripe si nu " + "tranziteaza niciodata serverele platformei DiDi. Aceasta abordare asigura " + "conformitatea cu standardul PCI DSS fara a necesita certificare PCI pe " + "infrastructura proprie." + ) + + doc.add_heading("7.6 GDPR Compliance", level=2) + add_bullet_list(doc, [ + "Cookie consent: banner de consimtamant la prima vizita cu optiuni granulare (necesar, functional, analitic)", + "Politica de confidentialitate: document complet GDPR accesibil pe website", + "Dreptul la acces: utilizatorii pot vizualiza datele personale din profilul dashboard", + "Dreptul la rectificare: utilizatorii pot edita datele personale din profil", + "Dreptul la stergere: utilizatorii pot solicita stergerea contului si a datelor asociate", + "Dreptul la portabilitate: export date in format standard", + "Baza legala: consimtamant explicit pentru prelucrare date", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 8: Deployment + # ========================================================================= + doc.add_heading("8. Deployment", level=1) + + doc.add_heading("8.1 Docker Compose", level=2) + p = doc.add_paragraph() + p.add_run( + "Intreaga platforma este containerizata cu Docker si orchestrata prin Docker Compose. " + "Fiecare componenta ruleaza in containere izolate cu volume persistente pentru date." + ) + + doc.add_heading("Stack-ul de Containere ERPNext", level=3) + add_code_block( + doc, + """ +services: + erpnext: + image: frappe/erpnext:v15 + ports: ["8080:8080"] + volumes: [sites:/home/frappe/frappe-bench/sites] + + mariadb: + image: mariadb:10.11 + volumes: [mariadb-data:/var/lib/mysql] + + redis-cache: + image: redis:7 + + redis-queue: + image: redis:7 + + worker-default: + image: frappe/erpnext:v15 + command: bench worker --queue default + + worker-short: + image: frappe/erpnext:v15 + command: bench worker --queue short + + worker-long: + image: frappe/erpnext:v15 + command: bench worker --queue long + + scheduler: + image: frappe/erpnext:v15 + command: bench schedule +""", + ) + + doc.add_heading("8.2 Volume Persistente", level=2) + add_table_with_header( + doc, + ["Volume", "Container", "Date Stocate"], + [ + ["sites", "erpnext", "Configurari site, fisiere uploadate, backup-uri"], + ["mariadb-data", "mariadb", "Baza de date completa ERPNext"], + ], + ) + + doc.add_heading("8.3 Nginx Reverse Proxy", level=2) + p = doc.add_paragraph() + p.add_run( + "Un server Nginx actioneaza ca reverse proxy in fata tuturor serviciilor, oferind:" + ) + add_bullet_list(doc, [ + "Terminare TLS/SSL cu certificate Let's Encrypt", + "Rutare trafic catre serviciile corespunzatoare pe baza path-ului URL", + "Compresie gzip pentru optimizarea transferului", + "Buffering si caching la nivel de proxy", + "Rate limiting si protectie DDoS de baza", + ]) + + doc.add_heading("8.4 Backup Automat Zilnic", level=2) + p = doc.add_paragraph() + p.add_run( + "Sistemul de backup include:" + ) + add_bullet_list(doc, [ + "Backup automat zilnic al bazei de date MariaDB (via ERPNext scheduler)", + "Backup incrementali ale fisierelor uploadate", + "Retentie: 7 backup-uri zilnice, 4 saptamanale, 3 lunare", + "Stocare offsite pe server extern pentru protectie la dezastre", + "Testare periodica a restaurarii din backup", + ]) + + doc.add_page_break() + + # ========================================================================= + # CHAPTER 9: Cerinte Non-Functionale Indeplinite + # ========================================================================= + doc.add_heading("9. Cerinte Non-Functionale Indeplinite", level=1) + + doc.add_heading("9.1 Performanta", level=2) + p = doc.add_paragraph() + p.add_run( + "Website-ul DiDi a fost optimizat pentru performanta si a obtinut urmatoarele " + "scoruri in testele Google Lighthouse:" + ) + + add_table_with_header( + doc, + ["Metrica", "Scor / Valoare", "Obiectiv", "Status"], + [ + ["Lighthouse Performance", "78/100", "> 70/100", "Indeplinit"], + ["First Contentful Paint (FCP)", "0.8s", "< 1.8s", "Indeplinit"], + ["Largest Contentful Paint (LCP)", "1.2s", "< 2.5s", "Indeplinit"], + ["Best Practices", "100/100", "> 90/100", "Indeplinit"], + ["Time to Interactive (TTI)", "1.5s", "< 3.8s", "Indeplinit"], + ], + ) + + doc.add_heading("9.2 Accesibilitate", level=2) + add_table_with_header( + doc, + ["Metrica", "Scor / Valoare", "Standard", "Status"], + [ + ["Lighthouse Accessibility", "91/100", "WCAG 2.1 AA", "Indeplinit"], + ["Contrast culori", "Conform", "WCAG 2.1 AA (4.5:1)", "Indeplinit"], + ["Navigare keyboard", "Complet", "WCAG 2.1 AA", "Indeplinit"], + ["Alt text imagini", "Complet", "WCAG 2.1 AA", "Indeplinit"], + ["ARIA labels", "Complet", "WCAG 2.1 AA", "Indeplinit"], + ], + ) + + doc.add_heading("9.3 SEO", level=2) + add_table_with_header( + doc, + ["Metrica", "Scor / Valoare", "Status"], + [ + ["Lighthouse SEO", "100/100", "Indeplinit"], + ["sitemap.xml", "Generat automat", "Indeplinit"], + ["robots.txt", "Configurat", "Indeplinit"], + ["Meta tags (title, description)", "Unice per pagina", "Indeplinit"], + ["Open Graph tags", "Complete", "Indeplinit"], + ["Structured data", "Schema.org", "Indeplinit"], + ["URL-uri semantice", "Curate si descriptive", "Indeplinit"], + ], + ) + + doc.add_heading("9.4 Securitate", level=2) + add_table_with_header( + doc, + ["Metrica", "Valoare", "Status"], + [ + ["npm audit vulnerabilitati", "0 vulnerabilitati", "Indeplinit"], + ["HTTPS/TLS", "Certificate valide pe toate endpoint-urile", "Indeplinit"], + ["Headers securitate", "CSP, HSTS, X-Frame-Options, X-Content-Type-Options", "Indeplinit"], + ["Cookie flags", "HttpOnly, Secure, SameSite=Lax", "Indeplinit"], + ["Input validation", "Server-side validation pe toate API routes", "Indeplinit"], + ["SQL Injection", "Protectie prin ORM (Frappe/ERPNext)", "Indeplinit"], + ["XSS", "Protectie prin React auto-escaping + CSP", "Indeplinit"], + ], + ) + + doc.add_heading("9.5 Sumar Lighthouse", level=2) + p = doc.add_paragraph() + p.add_run( + "Rezultatele complete ale auditului Google Lighthouse pentru pagina principala " + "a website-ului DiDi confirma indeplinirea tuturor cerintelor non-functionale " + "stabilite in caietul de sarcini al proiectului PNRR:" + ) + + add_table_with_header( + doc, + ["Categorie Lighthouse", "Scor", "Calificativ"], + [ + ["Performance", "78/100", "Bun"], + ["Accessibility", "91/100", "Excelent"], + ["Best Practices", "100/100", "Perfect"], + ["SEO", "100/100", "Perfect"], + ], + ) + + # Save document + output_path = os.path.join(OUTPUT_DIR, "Arhitectura_Detaliata.docx") + doc.save(output_path) + print(f" -> Salvat: {output_path}") + return output_path + + +# ============================================================================== +# MAIN +# ============================================================================== + +if __name__ == "__main__": + print("=" * 70) + print(" Generator Documente Livrabile PNRR - Platforma DiDi") + print(f" TOP CLOSSERS SRL | CUI: {COMPANY['cui']}") + print(f" Proiect: {COMPANY['project_ref']}") + print("=" * 70) + print() + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + results = [] + + try: + results.append(generate_manual_utilizare()) + except Exception as e: + print(f" EROARE Document 1: {e}") + import traceback + traceback.print_exc() + + try: + results.append(generate_ghid_erpnext()) + except Exception as e: + print(f" EROARE Document 2: {e}") + import traceback + traceback.print_exc() + + try: + results.append(generate_documentatie_api()) + except Exception as e: + print(f" EROARE Document 3: {e}") + import traceback + traceback.print_exc() + + try: + results.append(generate_arhitectura()) + except Exception as e: + print(f" EROARE Document 4: {e}") + import traceback + traceback.print_exc() + + print() + print("=" * 70) + print(f" Finalizat! {len(results)} documente generate cu succes.") + print("=" * 70) + for r in results: + print(f" - {r}") + print() diff --git a/website/scripts/archive/generate_propunere.py b/website/scripts/archive/generate_propunere.py new file mode 100644 index 0000000..f257568 --- /dev/null +++ b/website/scripts/archive/generate_propunere.py @@ -0,0 +1,875 @@ +import sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +from docx import Document +from docx.shared import Inches, Pt, Cm, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.enum.section import WD_ORIENT +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import datetime + +doc = Document() + +# ── Page margins ── +for section in doc.sections: + section.top_margin = Cm(2.5) + section.bottom_margin = Cm(2) + section.left_margin = Cm(2.5) + section.right_margin = Cm(2.5) + +style = doc.styles['Normal'] +font = style.font +font.name = 'Calibri' +font.size = Pt(11) +font.color.rgb = RGBColor(0x33, 0x33, 0x33) +style.paragraph_format.space_after = Pt(6) +style.paragraph_format.line_spacing = 1.15 + +# Heading styles +for level, size, color in [(1, 16, 0x0D9488), (2, 13, 0x0F766E), (3, 11, 0x115E59)]: + hs = doc.styles[f'Heading {level}'] + hs.font.name = 'Calibri' + hs.font.size = Pt(size) + hs.font.color.rgb = RGBColor((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF) + hs.font.bold = True + hs.paragraph_format.space_before = Pt(18 if level == 1 else 14) + hs.paragraph_format.space_after = Pt(8) + +def add_bullet(text, bold_prefix=None, level=0): + p = doc.add_paragraph(style='List Bullet') + p.paragraph_format.left_indent = Cm(1.2 + level * 0.8) + p.paragraph_format.space_after = Pt(3) + if bold_prefix: + run = p.add_run(bold_prefix) + run.bold = True + run.font.size = Pt(10) + run2 = p.add_run(text) + run2.font.size = Pt(10) + else: + run = p.add_run(text) + run.font.size = Pt(10) + return p + +def add_para(text, bold=False, italic=False, size=11, align=None, space_after=6): + p = doc.add_paragraph() + run = p.add_run(text) + run.font.size = Pt(size) + run.bold = bold + run.italic = italic + if align: + p.alignment = align + p.paragraph_format.space_after = Pt(space_after) + return p + +def add_table_row(table, cells_data, bold=False, bg_color=None): + row = table.add_row() + for i, text in enumerate(cells_data): + cell = row.cells[i] + cell.text = '' + p = cell.paragraphs[0] + run = p.add_run(str(text)) + run.font.size = Pt(9) + run.bold = bold + if bg_color: + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), bg_color) + shading.set(qn('w:val'), 'clear') + cell._tc.get_or_add_tcPr().append(shading) + return row + +def set_table_header_bg(table, color='0D9488'): + for cell in table.rows[0].cells: + shading = OxmlElement('w:shd') + shading.set(qn('w:fill'), color) + shading.set(qn('w:val'), 'clear') + cell._tc.get_or_add_tcPr().append(shading) + for p in cell.paragraphs: + for run in p.runs: + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + run.bold = True + +# ═══════════════════════════════════════════════════════ +# COVER PAGE +# ═══════════════════════════════════════════════════════ +for _ in range(6): + doc.add_paragraph() + +add_para('PROPUNERE TEHNICA', bold=True, size=24, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4) +add_para('RAPORT INTERMEDIAR DE IMPLEMENTARE', bold=True, size=14, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=20) + +# Separator line +p = doc.add_paragraph() +p.alignment = WD_ALIGN_PARAGRAPH.CENTER +run = p.add_run('_' * 60) +run.font.color.rgb = RGBColor(0x0D, 0x94, 0x88) +p.paragraph_format.space_after = Pt(20) + +add_para('Dezvoltare website cu e-commerce,\ncu ERP si CRM in cloud', bold=True, size=13, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=8) +add_para('in cadrul proiectului', italic=True, size=10, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4) +add_para('PLATFORMA DIGITALA INTELIGENTA PENTRU\nPREVENIREA SI COMBATEREA DEZINFORMARII', bold=True, size=12, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=30) + +add_para(f'Data document: {datetime.date.today().strftime("%d.%m.%Y")}', size=10, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4) +add_para('Stadiu: Implementare in curs', italic=True, size=10, align=WD_ALIGN_PARAGRAPH.CENTER) + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# CUPRINS +# ═══════════════════════════════════════════════════════ +doc.add_heading('CUPRINS', level=1) +cuprins = [ + '1. Scopul documentului', + '2. Stiva tehnologica propusa', + '3. Arhitectura generala si interconectare componente', + '4. Componenta Website — Stadiu implementare', + '5. Componenta ERP/CRM — Stadiu implementare', + '6. Integrari realizate si flux de date', + '7. Securitate si protectia datelor (GDPR)', + '8. Stadiu general si pasi urmatori', + '9. Tabel de conformitate — stadiu curent', +] +for item in cuprins: + add_para(item, size=11, space_after=4) + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 1. SCOPUL DOCUMENTULUI +# ═══════════════════════════════════════════════════════ +doc.add_heading('1. Scopul documentului', level=1) +add_para( + 'Prezentul document constituie un raport intermediar de implementare al solutiei tehnice ' + 'pentru dezvoltarea website-ului cu e-commerce, ERP si CRM in cloud. ' + 'Documentul prezinta stadiul actual al dezvoltarii, arhitectura implementata, ' + 'tehnologiile utilizate, functionalitatile livrate pana in acest moment si pasii urmatori.' +) +add_para( + 'Scopul este de a oferi Beneficiarului o imagine clara asupra progresului tehnic, ' + 'a deciziilor arhitecturale luate si a modului in care solutia propusa raspunde ' + 'cerintelor din Caietul de Sarcini (Capitolul II).' +) +add_para( + 'Acest document nu reprezinta livrabilul final — este un punct de referinta intermediar ' + 'care permite validarea directiei tehnice si colectarea de feedback inainte de finalizare.', + italic=True +) + +# ═══════════════════════════════════════════════════════ +# 2. STIVA TEHNOLOGICA +# ═══════════════════════════════════════════════════════ +doc.add_heading('2. Stiva tehnologica propusa', level=1) +add_para( + 'Solutia a fost construita folosind o stiva tehnologica moderna, bazata exclusiv pe ' + 'tehnologii open-source si frameworkuri cu suport activ pe termen lung. ' + 'Alegerea fiecarei componente a fost facuta in concordanta cu cerintele din Caietul de Sarcini ' + 'si cu principiile de modularitate, scalabilitate si securitate.' +) + +doc.add_heading('2.1. Componente principale', level=2) + +table = doc.add_table(rows=1, cols=4) +table.style = 'Light Grid Accent 1' +table.alignment = WD_TABLE_ALIGNMENT.CENTER +hdr = table.rows[0].cells +hdr[0].text = 'Componenta' +hdr[1].text = 'Tehnologie' +hdr[2].text = 'Versiune' +hdr[3].text = 'Rol in solutie' +set_table_header_bg(table) + +tech_rows = [ + ['Website (Frontend)', 'Next.js (React)', 'v16', 'Website prezentare, e-commerce, dashboard client; Server-Side Rendering pentru SEO'], + ['Website (Backend API)', 'Node.js / API Routes', 'v22', 'API-uri server-side pentru checkout, facturare, comunicare cu servicii externe'], + ['Limbaj programare', 'TypeScript', 'v5', 'Tipizare statica pe intregul proiect, reducerea erorilor la compilare'], + ['Stilizare', 'Tailwind CSS', 'v4', 'Framework CSS utility-first, design responsive, componente consistente'], + ['ERP + CRM', 'ERPNext', 'v15', 'Contabilitate, facturare, CRM, rapoarte; solutie open-source GPL, configurata pentru legislatia RO'], + ['Baza de date ERP', 'MariaDB', 'v10.11', 'Baza de date relationala pentru ERPNext, optimizata pentru tranzactii'], + ['Cache si cozi', 'Redis', 'v7 (Alpine)', 'Cache pentru performanta si coada de taskuri asincrone ERPNext'], + ['Autentificare (IAM)', 'Keycloak (SSO)', '-', 'Autentificare unificata prin OpenID Connect, gestionata de platforma existenta'], + ['Procesor plati', 'Stripe', 'API v2024', 'Plati securizate cu card, checkout sessions, webhook-uri pentru confirmare'], + ['Containerizare', 'Docker + Compose', '-', 'Izolare servicii, deployment reproducibil, orchestrare multi-container'], + ['Reverse proxy', 'Nginx', '-', 'Rutare trafic, servire assets statice, proxy catre aplicatie'], + ['Internationalizare', 'Custom i18n', '-', 'Suport complet romana (RO) si engleza (EN), comutare dinamica'], +] +for row_data in tech_rows: + add_table_row(table, row_data) + +doc.add_heading('2.2. Justificarea alegerii tehnologiilor', level=2) + +add_para('Next.js (React)', bold=True, size=10, space_after=2) +add_para( + 'Framework-ul Next.js a fost ales pentru capacitatea de Server-Side Rendering (SSR) si ' + 'Static Site Generation (SSG), esentiale pentru performanta si SEO. App Router-ul permite ' + 'o structura clara a rutelor, iar React ofera un ecosistem matur de componente reutilizabile. ' + 'Ref. CS: II.6.1 — "Website dezvoltat cu tehnologii web moderne".', + size=10 +) + +add_para('ERPNext v15', bold=True, size=10, space_after=2) +add_para( + 'ERPNext este o solutie ERP open-source (licenta GPL) cu module native de contabilitate, ' + 'facturare si CRM. Versiunea 15 ofera API REST complet, suport pentru custom DocTypes ' + 'si un sistem de permisiuni granulare. Configurarea pentru legislatia romaneasca (plan de conturi, ' + 'TVA 19%, serie facturi) este realizata prin scripturi de setup automate. ' + 'Ref. CS: II.6.2 — "Solutia ERP/CRM gazduita in cloud, configurabila".', + size=10 +) + +add_para('Stripe', bold=True, size=10, space_after=2) +add_para( + 'Stripe a fost ales ca procesor de plati pentru suportul nativ de plati recurente (subscriptii), ' + 'webhook-uri pentru confirmare in timp real, compatibilitate cu piata din Romania si conformitate PCI DSS. ' + 'Ref. CS: II.6.3 — "Procesor de plati cu suport plati recurente, compatibil Romania".', + size=10 +) + +add_para('Docker', bold=True, size=10, space_after=2) +add_para( + 'Containerizarea cu Docker asigura un mediu de deployment reproductibil si izolat. ' + 'Fiecare componenta (ERPNext, MariaDB, Redis, workers) ruleaza in container propriu, ' + 'facilitand scalarea independenta si deployment-ul in cloud EU. ' + 'Ref. CS: II.3 — "Cloud hosting EU", II.6.2 — "Backup automat".', + size=10 +) + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 3. ARHITECTURA +# ═══════════════════════════════════════════════════════ +doc.add_heading('3. Arhitectura generala si interconectare componente', level=1) + +add_para( + 'Arhitectura solutiei este modulara, cu separare clara intre componenta de prezentare (website), ' + 'componenta de business logic (ERP/CRM) si serviciile externe (autentificare, plati, email). ' + 'Comunicarea intre componente se realizeaza exclusiv prin API-uri REST securizate.' +) + +doc.add_heading('3.1. Diagrama de interconectare', level=2) +add_para( + 'Schema de mai jos ilustreaza fluxul de date intre componentele principale:', + size=10, space_after=10 +) + +# ASCII-style architecture diagram +arch_text = """ +┌─────────────────────────────────────────────────────────────┐ +│ UTILIZATOR (Browser) │ +│ Desktop / Tableta / Mobil │ +└──────────────────────────┬──────────────────────────────────┘ + │ HTTPS +┌──────────────────────────▼──────────────────────────────────┐ +│ WEBSITE (Next.js / React) │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ Pagini │ │ Dashboard │ │ API Routes │ │ +│ │ Publice │ │ Client │ │ (Server-side) │ │ +│ │ (SSR/SSG) │ │ (Protejat) │ │ │ │ +│ └──────────────┘ └──────────────┘ └─────┬─────────────┘ │ +└────────────────────────────────────────────┬┼────────────────┘ + ┌────────────────────────┘│ + │ ┌──────────┘ + API REST │ │ API REST + Webhooks +┌───────────────────▼───┐ ┌────▼──────────────────────────┐ +│ ERP/CRM (ERPNext) │ │ SERVICII EXTERNE │ +│ ┌─────────────────┐ │ │ ┌────────────────────────┐ │ +│ │ Contabilitate │ │ │ │ IAM (SSO/OpenID) │ │ +│ │ Facturare │ │ │ │ Autentificare unificata│ │ +│ │ CRM / Pipeline │ │ │ └────────────────────────┘ │ +│ │ CMS (Continut) │ │ │ ┌────────────────────────┐ │ +│ │ DocTypes Custom │ │ │ │ Procesor Plati │ │ +│ └────────┬────────┘ │ │ │ Checkout + Webhooks │ │ +│ ┌────────▼────────┐ │ │ └────────────────────────┘ │ +│ │ MariaDB + Redis │ │ │ ┌────────────────────────┐ │ +│ │ (Persistenta) │ │ │ │ API Platforma DiDi │ │ +│ └─────────────────┘ │ │ │ Analize + Credite │ │ +└───────────────────────┘ └───────────────────────────────┘ +""" +p = doc.add_paragraph() +run = p.add_run(arch_text) +run.font.name = 'Consolas' +run.font.size = Pt(7.5) +p.paragraph_format.space_after = Pt(12) + +doc.add_heading('3.2. Principii arhitecturale implementate', level=2) + +add_bullet('Comunicarea intre website si ERP/CRM se realizeaza prin proxy server-side — ' + 'credentialele API nu sunt niciodata expuse in browser', bold_prefix='Securitate API: ') +add_bullet('Website-ul (frontend + API) si ERPNext (ERP/CRM) ruleaza in containere Docker separate, ' + 'cu retea interna partajata; pot fi deployate pe servere diferite', bold_prefix='Separare componente: ') +add_bullet('Continutul paginilor publice (homepage, servicii, despre, privacy, termeni) este gestionat ' + 'din interfata ERPNext si preluat dinamic de website prin API', bold_prefix='CMS centralizat: ') +add_bullet('Toate paginile publice beneficiaza de Server-Side Rendering pentru indexare optima ' + 'de catre motoarele de cautare', bold_prefix='SEO prin SSR: ') +add_bullet('Suport complet pentru romana si engleza, cu posibilitate de extindere; ' + 'comutare dinamica din interfata', bold_prefix='Internationalizare: ') +add_bullet('Utilizator unic autentificat prin SSO — o singura sesiune valida ' + 'pentru website, dashboard si platforma', bold_prefix='Autentificare unificata: ') + +doc.add_heading('3.3. Fluxuri de date principale', level=2) + +add_para('Flux 1 — Autentificare utilizator', bold=True, size=10, space_after=2) +add_para( + 'Utilizatorul acceseaza pagina de login → este redirectionat catre serverul IAM (OpenID Connect) ' + '→ se autentifica → primeste un token JWT → este redirectionat inapoi in website cu sesiune activa ' + '→ middleware-ul protejeaza rutele din dashboard pe baza sesiunii.', + size=10 +) + +add_para('Flux 2 — Achizitie serviciu (one-time)', bold=True, size=10, space_after=2) +add_para( + 'Utilizatorul selecteaza un serviciu din pagina de pricing → este redirectionat catre checkout-ul ' + 'procesorului de plati → efectueaza plata → procesorul trimite un webhook la API-ul website-ului ' + '→ API-ul creeaza automat: clientul in ERP (daca nu exista), factura fiscala (Sales Invoice), ' + 'log-ul tranzactiei → factura este disponibila in dashboard pentru vizualizare si download PDF.', + size=10 +) + +add_para('Flux 3 — Analiza continut (DiDi API)', bold=True, size=10, space_after=2) +add_para( + 'Utilizatorul acceseaza sectiunea de analize din dashboard → selecteaza un serviciu achizitionat ' + 'si neconsmat → introduce textul sau URL-ul de analizat → API-ul website-ului trimite cererea catre ' + 'API-ul platformei DiDi → rezultatele sunt preluate prin polling → sunt afisate detaliat ' + '(scor de risc, tehnici detectate, verificare afirmatii, detectie AI, credibilitate sursa).', + size=10 +) + +add_para('Flux 4 — Lead din formular contact', bold=True, size=10, space_after=2) +add_para( + 'Vizitatorul completeaza formularul de contact de pe website → datele sunt trimise catre API-ul ' + 'website-ului → API-ul creeaza automat un Lead in modulul CRM al ERPNext cu sursa, ' + 'pagina de origine si parametri UTM → Lead-ul intra in pipeline-ul de vanzari.', + size=10 +) + +add_para('Flux 5 — Gestiune continut CMS', bold=True, size=10, space_after=2) +add_para( + 'Administratorul editeaza continutul paginilor (texte, imagini) din interfata ERPNext ' + '→ website-ul preia automat continutul actualizat prin API REST ' + '→ paginile publice se actualizeaza fara interventie tehnica (fara deploy).', + size=10 +) + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 4. WEBSITE +# ═══════════════════════════════════════════════════════ +doc.add_heading('4. Componenta Website — Stadiu implementare', level=1) + +add_para( + 'Website-ul a fost dezvoltat integral cu Next.js (React) si TypeScript, folosind App Router ' + 'pentru structurarea rutelor si Tailwind CSS pentru stilizare. Toate paginile sunt responsive ' + 'si au suport bilingv (RO/EN).' +) + +doc.add_heading('4.1. Pagini publice implementate', level=2) +add_para('Ref. CS: II.5.1 — Website de prezentare', italic=True, size=9, space_after=4) + +table = doc.add_table(rows=1, cols=3) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Pagina' +hdr[1].text = 'Functionalitate' +hdr[2].text = 'Sursa continut' +set_table_header_bg(table) + +pages_data = [ + ['Homepage', 'Sectiune hero, prezentare servicii (carduri), cum functioneaza (pasi), statistici platforma, cazuri de utilizare, apel la actiune', 'CMS din ERP (dinamic)'], + ['Servicii', '6 module AI detaliate cu capabilitati tehnice: analiza text, deepfake, fact-checking, evaluare surse, tehnici manipulare, monitorizare media', 'CMS din ERP (dinamic)'], + ['Despre', 'Prezentare companie, misiune, proiect, tehnologie utilizata', 'CMS din ERP (dinamic)'], + ['Pricing', 'Grila de preturi pentru 4 tipuri de servicii x 4 tipuri media (text, imagine, audio, video); buton achizitie integrat cu procesorul de plati', 'Configuratie locala + ERP'], + ['Contact', 'Formular de contact (nume, email, mesaj) cu trimitere automata in CRM; informatii de contact companie', 'CMS din ERP + formular'], + ['Politica confidentialitate', 'Pagina GDPR completa cu descrierea datelor colectate, scopuri, drepturi utilizator, stocare', 'CMS din ERP (dinamic)'], + ['Termeni si conditii', 'Definitii, servicii, plati, proprietate intelectuala, raspundere, legislatie aplicabila', 'CMS din ERP (dinamic)'], +] +for row_data in pages_data: + add_table_row(table, row_data) + +doc.add_heading('4.2. Zona client autentificata (Dashboard)', level=2) +add_para('Ref. CS: II.4 — Zona client dashboard, II.5.2 — Gestiune abonamente', italic=True, size=9, space_after=4) + +table = doc.add_table(rows=1, cols=3) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Pagina' +hdr[1].text = 'Functionalitate' +hdr[2].text = 'Sursa date' +set_table_header_bg(table) + +dash_data = [ + ['Overview', 'Sumar: numar facturi, analize achizitionate, ultimele facturi emise, acces rapid la achizitie', 'ERP (API REST)'], + ['Facturi', 'Lista completa facturi cu numar, data, suma, status; download PDF pentru fiecare factura', 'ERP (Sales Invoice)'], + ['Analize', 'Lista servicii achizitionate si neconsumate; selectare si rulare analiza; afisare rezultate detaliate (scor risc, tehnici, verificari, detectie AI)', 'ERP + API Platforma'], + ['Profil', 'Vizualizare si editare date personale (companie, cod fiscal); sectiune GDPR cu export date si solicitare stergere', 'ERP (Customer)'], + ['Abonament', 'Detalii plan activ, lista functionalitati per nivel, data activare', 'ERP (Customer)'], + ['Achizitioneaza', 'Grila servicii disponibile cu preturi; flux de achizitie integrat cu procesorul de plati', 'Configuratie + Stripe'], + ['Checkout', 'Flux in 2 pasi: selectare plan + date facturare cu acceptare termeni; creare acord de servicii', 'ERP + Stripe'], + ['Credite', 'Afisare credite disponibile si consum (pregatit pentru integrare cu API platforma)', 'API Platforma (partial)'], +] +for row_data in dash_data: + add_table_row(table, row_data) + +doc.add_heading('4.3. API Routes implementate (Server-side)', level=2) + +table = doc.add_table(rows=1, cols=3) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Endpoint' +hdr[1].text = 'Metoda' +hdr[2].text = 'Functionalitate' +set_table_header_bg(table) + +api_data = [ + ['Autentificare', 'GET/POST', 'Handler OpenID Connect — login, callback, refresh token, logout'], + ['Proxy ERP', 'GET/POST/PUT', 'Proxy server-side catre API-ul ERP; credentialele nu ajung in browser'], + ['Lead-uri', 'POST', 'Creare Lead in CRM din formularul de contact website'], + ['Checkout', 'POST', 'Creare sesiune de plata in procesorul de plati; redirect catre pagina de plata'], + ['Webhook plati', 'POST', 'Procesare confirmare plata; creare automata client, factura si log tranzactie in ERP'], + ['PDF Factura', 'GET', 'Download factura in format PDF din ERP, livrata prin proxy securizat'], + ['Analize', 'GET/POST', 'Listare analize disponibile, trimitere cerere analiza, preluare rezultate'], +] +for row_data in api_data: + add_table_row(table, row_data) + +doc.add_heading('4.4. Functionalitati transversale', level=2) + +add_bullet('Suport complet romana (RO) si engleza (EN) cu comutare din navbar; ' + 'dictionare JSON pentru toate textele interfetei', bold_prefix='Internationalizare (i18n): ') +add_bullet('Banner cookie consent cu 3 categorii (necesare, functionale, analitice); ' + 'preferintele se salveaza in browser si se respecta la navigare', bold_prefix='Cookie consent GDPR: ') +add_bullet('Generare automata sitemap.xml si robots.txt; meta tags SEO pe toate paginile; ' + 'Open Graph tags pentru partajare pe retele sociale', bold_prefix='SEO: ') +add_bullet('Navbar responsive cu meniu hamburger pe mobil; footer cu linkuri utile; ' + 'sidebar dashboard pe desktop cu navigare vizuala', bold_prefix='Componente layout: ') +add_bullet('Rutele din zona client sunt protejate prin middleware — ' + 'utilizatorii neautentificati sunt redirectionati automat la pagina de login', bold_prefix='Protectie rute: ') + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 5. ERP/CRM +# ═══════════════════════════════════════════════════════ +doc.add_heading('5. Componenta ERP/CRM — Stadiu implementare', level=1) + +add_para( + 'Componenta ERP/CRM este construita pe ERPNext v15 (open-source, licenta GPL), ' + 'containerizata cu Docker si configurata specific pentru legislatia romaneasca si ' + 'nevoile proiectului. Intreaga configurare este automatizata prin scripturi Python ' + 'reproductibile.' +) + +doc.add_heading('5.1. Infrastructura Docker', level=2) +add_para('Ref. CS: II.6.2 — ERP/CRM gazduita in cloud', italic=True, size=9, space_after=4) + +add_para('Mediul ERP/CRM este compus din 7 containere Docker orchestrate prin Docker Compose:') + +table = doc.add_table(rows=1, cols=3) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Serviciu' +hdr[1].text = 'Tehnologie' +hdr[2].text = 'Rol' +set_table_header_bg(table) + +docker_data = [ + ['Aplicatie ERP', 'ERPNext v15 (Frappe)', 'Aplicatia principala ERP/CRM; interfata web de administrare; API REST'], + ['Baza de date', 'MariaDB 10.11', 'Stocare persistenta; configurata cu InnoDB, UTF8MB4, buffer pool optimizat'], + ['Cache', 'Redis (Alpine)', 'Cache pentru performanta — reduce timpul de raspuns al interfetei ERP'], + ['Coada de taskuri', 'Redis (Alpine)', 'Coada pentru procesare asincrona (emailuri, taskuri background)'], + ['Worker scurt', 'Frappe Worker', 'Procesare taskuri rapide (notificari, actualizari cache)'], + ['Worker lung', 'Frappe Worker', 'Procesare taskuri de durata (rapoarte, import-uri, backup)'], + ['Scheduler', 'Frappe Scheduler', 'Executie taskuri programate (facturare recurenta, cleanup)'], +] +for row_data in docker_data: + add_table_row(table, row_data) + +add_para( + 'Toate containerele comunica pe o retea Docker interna izolata. ' + 'Reverse proxy-ul Nginx gestioneaza traficul HTTP, serveste asseturile statice ' + 'si ofera suport WebSocket pentru interfata in timp real.', + size=10 +) + +doc.add_heading('5.2. Configurare contabilitate si facturare', level=2) +add_para('Ref. CS: II.5.3, II.5.4 — Facturare automata, Componenta ERP', italic=True, size=9, space_after=4) + +add_bullet('Plan de conturi adaptat legislatiei romanesti (OMFP 1802/2014)', bold_prefix='Plan conturi: ') +add_bullet('TVA 19% Romania — aplicat automat pe net total la emitere factura', bold_prefix='Taxe: ') +add_bullet('Format personalizat cu serie anuala secventiala (format: PREFIX-YYYY-#####)', bold_prefix='Serie facturi: ') +add_bullet('Template PDF personalizat cu antet, date companie, detaliere articole, ' + 'subtotal + TVA + total, nota legala conform art. 106 alin. 2 din Legea 227/2015', + bold_prefix='Print Format custom: ') +add_bullet('Conturi de cheltuieli configurate: hosting, servicii software, marketing, comisioane plati', + bold_prefix='Conturi cheltuieli: ') +add_bullet('Evidenta furnizori principali (hosting EU, procesor plati, email tranzactional)', + bold_prefix='Furnizori: ') + +doc.add_heading('5.3. Configurare CRM', level=2) +add_para('Ref. CS: II.5.5 — Componenta CRM', italic=True, size=9, space_after=4) + +add_bullet('4 etape configurate: Lead → Calificat → Demo → Client', bold_prefix='Pipeline vanzari: ') +add_bullet('Surse configurate: formular contact website, pagina pricing, cerere demo', + bold_prefix='Surse lead-uri: ') +add_bullet('Campuri pentru sursa formular, pagina de origine si parametri UTM ' + '(sursa, mediu, campanie) — permit analiza eficientei canalelor de marketing', + bold_prefix='Campuri custom pe Lead: ') +add_bullet('Campuri pentru ID utilizator in platforma IAM, rol activ (free/paid/enterprise), ' + 'plan activ si data activarii', + bold_prefix='Campuri custom pe Client: ') + +doc.add_heading('5.4. DocTypes custom create', level=2) +add_para('Ref. CS: II.5.6 — Gestiune contractuala, II.8 — Audit', italic=True, size=9, space_after=4) + +add_para( + 'Pe langa configurarea modulelor standard ERPNext, au fost create 3 DocTypes (entitati de date) ' + 'custom, specifice nevoilor proiectului:' +) + +add_para('Acord de Servicii (Service Agreement)', bold=True, size=10, space_after=2) +add_para( + 'Se genereaza automat la activarea unui serviciu platit. Contine: clientul, planul selectat, ' + 'status-ul acordului (Draft / Acceptat / Expirat / Anulat), data si ora acceptarii, ' + 'versiunea termenilor, continutul HTML al acordului si legatura catre abonament. ' + 'Numerotare automata secventiala. Asigura trasabilitate completa (audit trail) conform cerintei II.5.6.', + size=10 +) + +add_para('Log Plati (Payment Log)', bold=True, size=10, space_after=2) +add_para( + 'Inregistreaza fiecare eveniment de plata receptionat prin webhook de la procesorul de plati. ' + 'Contine: clientul, tipul evenimentului (plata reusita, esec, rambursare, modificare abonament), ' + 'status-ul, suma, moneda si datele brute ale webhook-ului in format JSON. ' + 'Permite audit complet al tranzactiilor financiare conform cerintei II.8.', + size=10 +) + +add_para('Continut Website / CMS (Website Content)', bold=True, size=10, space_after=2) +add_para( + 'Permite administratorului sa editeze continutul tuturor paginilor publice direct din interfata ERP, ' + 'fara interventie tehnica. Fiecare intrare are: pagina (homepage, pricing, contact, despre, privacy, termeni), ' + 'cheie sectiune, ordine afisare, status activ/inactiv, continut in limba romana, continut in limba engleza, ' + 'imagine si date structurate suplimentare in format JSON. ' + 'In prezent sunt configurate 26 de intrari CMS corespunzand tuturor sectiunilor de pe paginile publice.', + size=10 +) + +doc.add_heading('5.5. Catalog servicii', level=2) +add_para( + 'In ERP au fost create 14 articole (Item) corespunzand serviciilor platformei, ' + 'organizate pe 4 categorii de servicii x tipuri de media suportate:' +) + +table = doc.add_table(rows=1, cols=2) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Categorie serviciu' +hdr[1].text = 'Tipuri media' +set_table_header_bg(table) + +items_data = [ + ['Detectie tehnici de manipulare', 'Text, Imagine, Audio, Video'], + ['Detectie AI si Deepfake', 'Text, Imagine, Audio, Video'], + ['Verificare automata afirmatii (Fact-checking)', 'Text, Imagine, Audio, Video'], + ['Evaluare sursa si domeniu', 'Text, URL'], +] +for row_data in items_data: + add_table_row(table, row_data) + +doc.add_heading('5.6. Utilizator API dedicat', level=2) +add_para( + 'Pentru comunicarea securizata intre website si ERP, a fost creat un utilizator API dedicat ' + 'cu rol custom ("Website Integration") si permisiuni limitate strict la operatiile necesare:', + size=10 +) + +add_bullet('Citire si scriere: Client, Lead, Factura, Abonament, Continut Website, Acord Servicii, Log Plati, Articol', + bold_prefix='Permisiuni read/write: ') +add_bullet('Doar citire: Companie, Conturi, Taxe, Template-uri email, Etape vanzari', + bold_prefix='Permisiuni read-only: ') + +add_para( + 'Autentificarea se face prin API Key + Secret, transmise exclusiv server-side (nu ajung in browser).', + size=10, italic=True +) + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 6. INTEGRARI +# ═══════════════════════════════════════════════════════ +doc.add_heading('6. Integrari realizate si flux de date', level=1) +add_para('Ref. CS: II.6.3 — Integrari', italic=True, size=9, space_after=4) + +doc.add_heading('6.1. Autentificare IAM (SSO / OpenID Connect)', level=2) +add_para( + 'Website-ul este integrat cu componenta IAM a platformei existente prin protocolul OpenID Connect. ' + 'Fluxul de autentificare:', + size=10 +) +add_bullet('Utilizatorul apasa butonul de login pe website') +add_bullet('Este redirectionat catre serverul IAM (pagina de autentificare centralizata)') +add_bullet('Dupa autentificare, primeste un token JWT (cu roluri din realm)') +add_bullet('Este redirectionat inapoi pe website cu sesiune activa') +add_bullet('Token-ul se reinnoieste automat (refresh token) fara interventie') +add_bullet('Middleware-ul Next.js protejeaza toate rutele din zona client') +add_para( + 'Rolurile din IAM (free_tier, paid_tier, enterprise_tier) sunt mapate in sesiunea website ' + 'si utilizate pentru controlul accesului bazat pe roluri (RBAC).', + size=10 +) + +doc.add_heading('6.2. Procesor plati (Stripe)', level=2) +add_para( + 'Integrarea cu procesorul de plati este realizata prin doua mecanisme complementare:', + size=10 +) + +add_para('Checkout Sessions', bold=True, size=10, space_after=2) +add_para( + 'La initierea unei achizitii, API-ul website-ului creeaza o sesiune de checkout securizata ' + 'pe serverele procesorului de plati. Utilizatorul este redirectionat catre pagina de plata ' + '(gazduita de procesorul de plati — conformitate PCI DSS) si, dupa plata reusita, ' + 'revine pe website pe pagina de confirmare.', + size=10 +) + +add_para('Webhook-uri', bold=True, size=10, space_after=2) +add_para( + 'La confirmarea platii, procesorul de plati trimite un webhook (notificare HTTP) catre API-ul website-ului. ' + 'Webhook-ul declanseaza automat:', + size=10 +) +add_bullet('Crearea sau identificarea clientului in ERP (pe baza ID-ului din IAM)') +add_bullet('Crearea facturii fiscale (Sales Invoice) cu articolele achizitionate si TVA') +add_bullet('Inregistrarea tranzactiei in Log Plati (Payment Log) pentru audit') +add_bullet('Crearea articolului in catalog daca nu exista deja') + +add_para( + 'Au fost configurate 14 produse in procesorul de plati, corespunzand celor 14 articole din ERP, ' + 'cu preturi in RON.', + size=10 +) + +doc.add_heading('6.3. Website ↔ ERP/CRM (API REST)', level=2) +add_para( + 'Comunicarea intre website si ERP se realizeaza prin API REST, cu un proxy server-side ' + 'implementat in Next.js. Aceasta abordare ofera doua avantaje majore:', + size=10 +) +add_bullet('Credentialele API nu sunt niciodata expuse in browserul utilizatorului') +add_bullet('Website-ul poate adauga logica intermediara (validare, transformare date, caching)') + +add_para('Operatii implementate:', bold=True, size=10, space_after=2) +add_bullet('Citire continut CMS (Homepage, Servicii, Despre, Privacy, Termeni) → cache 60 secunde') +add_bullet('Creare Lead din formular contact (cu sursa, pagina, UTM)') +add_bullet('Citire facturi client din ERP + download PDF') +add_bullet('Citire/actualizare date profil client') +add_bullet('Creare Acord de Servicii la checkout') +add_bullet('Creare factura si client la confirmare plata') + +doc.add_heading('6.4. API Platforma DiDi (Analize)', level=2) +add_para( + 'Website-ul este integrat cu API-ul platformei DiDi pentru submisia si preluarea analizelor. ' + 'Integrarea acopera 4 endpoint-uri corespunzand celor 4 categorii de servicii:', + size=10 +) + +table = doc.add_table(rows=1, cols=2) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Serviciu' +hdr[1].text = 'Endpoint API' +set_table_header_bg(table) + +api_didi = [ + ['Detectie tehnici de manipulare', '/v3/techniques/analyze'], + ['Detectie AI si deepfake', '/v3/ai-tampered/analyze'], + ['Verificare afirmatii', '/v3/claims/analyze'], + ['Evaluare sursa/domeniu', '/v3/domain/analyze'], +] +for row_data in api_didi: + add_table_row(table, row_data) + +add_para( + 'Autentificarea catre API-ul platformei se face cu Bearer token (obtinut din sesiunea IAM). ' + 'Rezultatele analizelor includ: scor de risc, nivel de incredere, tehnici de manipulare detectate, ' + 'verificarea afirmatiilor (adevarat/fals/neverificat), probabilitate de generare AI, ' + 'scor de credibilitate domeniu si factori de viralitate.', + size=10 +) + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 7. SECURITATE +# ═══════════════════════════════════════════════════════ +doc.add_heading('7. Securitate si protectia datelor (GDPR)', level=1) +add_para('Ref. CS: II.8 — Securitate cibernetica si protectia datelor', italic=True, size=9, space_after=4) + +doc.add_heading('7.1. Masuri de securitate implementate', level=2) + +table = doc.add_table(rows=1, cols=3) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Categorie' +hdr[1].text = 'Masura' +hdr[2].text = 'Implementare' +set_table_header_bg(table) + +sec_data = [ + ['Autentificare', 'SSO / OpenID Connect', 'Autentificare centralizata prin IAM; token JWT cu expirare si refresh automat'], + ['Autorizare', 'RBAC (Role-Based Access Control)', 'Roluri mapate din IAM: free_tier, paid_tier, enterprise_tier; middleware pe rute protejate'], + ['Transport', 'HTTPS obligatoriu', 'Certificat SSL; comunicatie criptata TLS 1.2+ pe toate endpoint-urile'], + ['API Security', 'Proxy server-side', 'Credentialele ERP nu ajung in browser; validare pe server a tuturor cererilor'], + ['Plati', 'PCI DSS compliance', 'Datele cardurilor nu tranziteaza serverele noastre — plata se face pe pagina procesorului de plati'], + ['Webhook-uri', 'Validare semnatura', 'Fiecare webhook de la procesorul de plati este validat prin semnatura criptografica'], + ['Sesiuni', 'JWT + Refresh Token', 'Token-uri cu expirare scurta, reinnoite automat; sesiune invalidata la logout'], + ['API ERP', 'Utilizator dedicat cu permisiuni minime', 'Principiul least privilege: acces doar la DocType-urile necesare, fara acces admin'], +] +for row_data in sec_data: + add_table_row(table, row_data) + +doc.add_heading('7.2. Conformitate GDPR', level=2) + +add_bullet('Pagina completa de politica de confidentialitate, editabila din CMS', + bold_prefix='Politica confidentialitate: ') +add_bullet('Pagina completa de termeni si conditii, editabila din CMS', + bold_prefix='Termeni si conditii: ') +add_bullet('Banner cookie consent cu 3 categorii: necesare (always on), functionale, analitice; ' + 'preferintele se salveaza si se respecta', + bold_prefix='Cookie consent: ') +add_bullet('Buton "Exporta datele mele" in pagina de profil din dashboard (format CSV)', + bold_prefix='Drept de portabilitate: ') +add_bullet('Buton "Solicita stergerea contului" in pagina de profil din dashboard', + bold_prefix='Drept la stergere: ') +add_bullet('Checkbox obligatoriu de acceptare a termenilor in fluxul de checkout, ' + 'cu inregistrare data, ora si versiune termeni', + bold_prefix='Consimtamant explicit: ') +add_bullet('Toate datele sunt stocate pe servere localizate in Uniunea Europeana', + bold_prefix='Stocare exclusiv in EU: ') + +doc.add_heading('7.3. Audit si trasabilitate', level=2) +add_para( + 'Toate actiunile critice sunt inregistrate si trasabile:', + size=10 +) +add_bullet('Fiecare tranzactie financiara este logata in DocType-ul Payment Log cu date brute webhook') +add_bullet('Fiecare acord de servicii inregistreaza data acceptarii, versiunea termenilor') +add_bullet('Lead-urile captate inregistreaza sursa, pagina de origine si parametri UTM') +add_bullet('ERPNext ofera nativ audit trail pe toate modificarile de documente (Version Log)') + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 8. STADIU GENERAL +# ═══════════════════════════════════════════════════════ +doc.add_heading('8. Stadiu general si pasi urmatori', level=1) + +doc.add_heading('8.1. Ce este implementat si functional', level=2) + +add_bullet('Infrastructura Docker completa (7 containere ERP + website)') +add_bullet('Website cu 7 pagini publice, toate responsive si bilingve (RO/EN)') +add_bullet('Dashboard client cu 8 pagini functionale') +add_bullet('7 API routes server-side') +add_bullet('ERP configurat: contabilitate RO, TVA, serie facturi, print format PDF custom') +add_bullet('CRM configurat: pipeline 4 etape, campuri custom pe Lead si Client') +add_bullet('3 DocTypes custom: Acord Servicii, Log Plati, Continut Website (CMS)') +add_bullet('26 intrari CMS populate (toate paginile publice, RO + EN)') +add_bullet('14 articole servicii in catalog ERP') +add_bullet('Integrare IAM/SSO functionala (OpenID Connect)') +add_bullet('Integrare procesor plati functionala (checkout + webhook)') +add_bullet('Integrare API platforma DiDi pentru analize (4 endpoint-uri)') +add_bullet('Proxy API securizat (credentiale server-side)') +add_bullet('Cookie consent, sitemap.xml, robots.txt, SEO metadata') +add_bullet('i18n complet (romana + engleza)') + +doc.add_heading('8.2. In curs de finalizare', level=2) + +table = doc.add_table(rows=1, cols=3) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Element' +hdr[1].text = 'Stadiu' +hdr[2].text = 'Detalii' +set_table_header_bg(table) + +wip_data = [ + ['Interfata admin ERP (workspace-uri)', 'In lucru', 'Organizare sidebar ERP cu sectiuni clare: Facturi, Clienti, CRM, Plati, CMS'], + ['Email tranzactional (SendGrid)', 'De implementat', 'Trimitere automata factura PDF pe email la emitere; notificari expirare/esec'], + ['Rapoarte ERP custom', 'De implementat', 'Dashboard financiar (MRR, churn), rapoarte CRM (conversie, timp mediu)'], + ['Sincronizare credite/consum DiDi API', 'Partial', 'Endpoint-uri testate si functionale; necesita configurare acces retea'], + ['Design si assets vizuale', 'De rafinat', 'Adaugare imagini, ilustratii, animatii pe paginile publice'], + ['Testare WCAG 2.1 AA', 'De realizat', 'Audit automat (axe/Lighthouse) + verificare manuala navigare tastatura'], + ['Testare securitate OWASP', 'De realizat', 'Scan vulnerabilitati, verificare CSRF/XSS/SQLi, rate limiting'], + ['Testare cross-browser', 'De realizat', 'Chrome, Firefox, Safari, Edge — ultimele 2 versiuni; responsive pe 5 rezolutii'], + ['Deployment productie', 'De realizat', 'VPS-uri cloud EU, Nginx, SSL, backup automat zilnic'], + ['Documentatie si training', 'De realizat', 'Manual utilizare, documentatie API, ghid admin ERP, 2 sesiuni training'], +] +for row_data in wip_data: + add_table_row(table, row_data) + +doc.add_page_break() + +# ═══════════════════════════════════════════════════════ +# 9. TABEL CONFORMITATE +# ═══════════════════════════════════════════════════════ +doc.add_heading('9. Tabel de conformitate — stadiu curent', level=1) +add_para( + 'Tabelul de mai jos prezinta stadiul de implementare pentru fiecare cerinta din Caietul de Sarcini ' + '(Capitolul II.10). Stadiul "Implementat" indica functionalitatile livrate si testate; ' + '"Partial" indica functionalitatile in curs; "Planificat" indica elementele programate.', + size=10 +) + +table = doc.add_table(rows=1, cols=4) +table.style = 'Light Grid Accent 1' +hdr = table.rows[0].cells +hdr[0].text = 'Nr.' +hdr[1].text = 'Cerinta CS' +hdr[2].text = 'Stadiu' +hdr[3].text = 'Observatii' +set_table_header_bg(table) + +conf_data = [ + ['1', 'Website prezentare firma si servicii', 'Implementat', '7 pagini publice, continut dinamic din CMS'], + ['2', 'Design responsive (desktop, tableta, mobil)', 'Implementat', 'Tailwind CSS, layout-uri responsive pe toate paginile'], + ['3', 'Accesibilitate WCAG 2.1 nivel AA', 'Partial', 'Structura semantica implementata; audit formal planificat'], + ['4', 'Modul e-commerce cu plati recurente', 'Implementat', 'Checkout integrat cu procesor plati; servicii one-time functionale'], + ['5', 'Gestiune abonamente/servicii', 'Implementat', 'Planuri configurate in ERP; sincronizare cu IAM'], + ['6', 'Facturare automata conform legislatiei RO', 'Implementat', 'Facturi automate la plata; serie, TVA 19%, PDF custom'], + ['7', 'Componenta ERP — contabilitate si gestiune', 'Implementat', 'Plan conturi RO, conturi cheltuieli, furnizori, jurnale'], + ['8', 'Componenta CRM — clienti si pipeline', 'Implementat', 'Pipeline 4 etape, lead capture din website, campuri custom'], + ['9', 'Integrare IAM platforma (SSO)', 'Implementat', 'OpenID Connect functional; sesiune, token refresh, RBAC'], + ['10', 'Sincronizare abonamente si credite API', 'Partial', 'Endpoint-uri testate; configurare acces retea in curs'], + ['11', 'Integrare procesor de plati', 'Implementat', '14 produse configurate; checkout + webhook functional'], + ['12', 'Integrare email tranzactional', 'Planificat', 'Template-uri email create in ERP; integrare SendGrid planificata'], + ['13', 'Integrare website — ERP/CRM', 'Implementat', 'API REST proxy; facturare, lead-uri, CMS, facturi PDF'], + ['14', 'Conformitate GDPR', 'Implementat', 'Cookie consent, privacy policy, export date, stergere cont'], + ['15', 'Hosting in EU', 'Planificat', 'Infrastructura Docker pregatita; deployment pe VPS EU planificat'], + ['16', 'Disponibilitate minim 99.5% uptime', 'Planificat', 'Se va valida in productie cu monitorizare'], + ['17', 'Backup automat zilnic', 'Planificat', 'Procedura definita; implementare la deployment productie'], + ['18', 'Documentatie utilizare si tehnica', 'Planificat', 'Manual utilizare, documentatie API, ghid admin'], + ['19', 'Training echipa', 'Planificat', '2 sesiuni planificate (website + ERP), cu inregistrare video'], + ['20', 'Garantie si suport minim 36 luni', 'Conform oferta', 'SLA definit: Critica 4h/24h, Majora 8h/3zile, Minora 24h/10zile'], +] +for row_data in conf_data: + add_table_row(table, row_data) + +# ── Footer note ── +doc.add_paragraph() +p = doc.add_paragraph() +run = p.add_run( + 'Acest document a fost generat ca raport intermediar de progres. ' + 'Versiunea finala a propunerii tehnice va include toate livrabilele complete, ' + 'rapoartele de testare si documentatia aferenta.' +) +run.font.size = Pt(9) +run.italic = True +p.alignment = WD_ALIGN_PARAGRAPH.CENTER + +# ── Save ── +output_path = r'C:\Users\avedev\Desktop\Achizitii TOP\livrabile\didi\Propunere_Tehnica_Raport_Intermediar.docx' +doc.save(output_path) +print(f'Document salvat: {output_path}') diff --git a/website/src/app/(auth)/layout.tsx b/website/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..bde65ee --- /dev/null +++ b/website/src/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/website/src/app/(auth)/login/page.tsx b/website/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..912d42b --- /dev/null +++ b/website/src/app/(auth)/login/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation"; +import { auth, signIn } from "@/lib/auth"; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ callbackUrl?: string }>; +}) { + const session = await auth(); + const { callbackUrl } = await searchParams; + const redirectTo = callbackUrl || "/dashboard"; + + if (session) redirect(redirectTo); + + return ( +
+

Autentificare

+

Conecteaza-te la contul tau Clossers

+ +
{ + "use server"; + await signIn("keycloak", { redirectTo }); + }} + > + +
+ +

+ Vei fi redirectionat catre pagina de autentificare securizata. +

+ +

+ Nu ai cont? Inregistreaza-te gratuit +

+
+ ); +} diff --git a/website/src/app/(auth)/register/page.tsx b/website/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..83b82b6 --- /dev/null +++ b/website/src/app/(auth)/register/page.tsx @@ -0,0 +1,48 @@ +import { redirect } from "next/navigation"; +import { auth, signIn } from "@/lib/auth"; + +export default async function RegisterPage() { + const session = await auth(); + if (session) redirect("/dashboard"); + + // Keycloak handles registration - same flow, user clicks "Register" on Keycloak login page + return ( +
+

Inregistrare

+

Creeaza un cont DiDi gratuit

+ +
{ + "use server"; + await signIn("keycloak", { redirectTo: "/dashboard" }); + }} + > + +
+ +

+ Vei fi redirectionat catre pagina de inregistrare securizata Clossers. +

+ +
+

Ce primesti gratuit?

+
    +
  • 5 analize text pe luna
  • +
  • Acces dashboard complet
  • +
  • Istoric analize + export PDF
  • +
  • Suport email
  • +
+
+ +

+ Ai deja cont? Conecteaza-te +

+
+ ); +} diff --git a/website/src/app/(dashboard)/dashboard/achizitioneaza/page.tsx b/website/src/app/(dashboard)/dashboard/achizitioneaza/page.tsx new file mode 100644 index 0000000..3950a05 --- /dev/null +++ b/website/src/app/(dashboard)/dashboard/achizitioneaza/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; + +const oneTimeServices = [ + { + component: "techniques", + label: "Detectie Tehnici de Manipulare", + description: "Identificare tehnici de propaganda si dezinformare din continut media", + prices: [ + { type: "text", label: "Text", ron: 50, eur: 10 }, + { type: "image", label: "Imagine", ron: 75, eur: 15 }, + { type: "audio", label: "Audio", ron: 100, eur: 20 }, + { type: "video", label: "Video", ron: 200, eur: 40 }, + ], + }, + { + component: "ai_detection", + label: "AI Detection & Deepfake", + description: "Detectie continut generat sau manipulat de AI, inclusiv deepfake", + prices: [ + { type: "text", label: "Text", ron: 50, eur: 10 }, + { type: "image", label: "Imagine", ron: 75, eur: 15 }, + { type: "audio", label: "Audio", ron: 100, eur: 20 }, + { type: "video", label: "Video", ron: 200, eur: 40 }, + ], + }, + { + component: "claims", + label: "Fact-Checking", + description: "Verificare automata a afirmatiilor cu surse credibile si baze de date", + prices: [ + { type: "text", label: "Text", ron: 50, eur: 10 }, + { type: "image", label: "Imagine", ron: 75, eur: 15 }, + { type: "audio", label: "Audio", ron: 75, eur: 15 }, + { type: "video", label: "Video", ron: 150, eur: 30 }, + ], + }, + { + component: "source", + label: "Evaluare Surse", + description: "Analiza credibilitate domenii web, publicatii si conturi social media", + prices: [ + { type: "text", label: "Text", ron: 50, eur: 10 }, + { type: "url", label: "URL", ron: 50, eur: 10 }, + ], + }, +]; + +export default function AchizitioneazaPage() { + const [loading, setLoading] = useState(null); + + async function handleBuy(serviceKey: string) { + setLoading(serviceKey); + try { + const res = await fetch("/api/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ serviceKey }), + }); + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } + } catch { + alert("Eroare la procesarea platii. Incearca din nou."); + } finally { + setLoading(null); + } + } + + return ( +
+

Achizitioneaza analiza

+

+ Plateste per analiza. Fara abonament. Alege serviciul si tipul de continut. +

+ +
+ {oneTimeServices.map((svc) => ( +
+
+

{svc.label}

+

{svc.description}

+
+
+ {svc.prices.map((p) => { + const key = `${svc.component}-${p.type}`; + const isLoading = loading === key; + return ( +
+

{p.label}

+

{p.ron}

+

RON

+ +
+ ); + })} + {svc.prices.length < 4 && + Array.from({ length: 4 - svc.prices.length }).map((_, i) => ( +
+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/website/src/app/(dashboard)/dashboard/analiza/page.tsx b/website/src/app/(dashboard)/dashboard/analiza/page.tsx new file mode 100644 index 0000000..598e44c --- /dev/null +++ b/website/src/app/(dashboard)/dashboard/analiza/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AnalyzaRedirect() { + redirect("/dashboard/analize"); +} diff --git a/website/src/app/(dashboard)/dashboard/analize/page.tsx b/website/src/app/(dashboard)/dashboard/analize/page.tsx new file mode 100644 index 0000000..f25ab6b --- /dev/null +++ b/website/src/app/(dashboard)/dashboard/analize/page.tsx @@ -0,0 +1,664 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { + buildAnalysisReportFileName, + buildAnalysisReportPdf, +} from "@/lib/analysis-report-pdf"; +import { formatAnalysisLabel } from "@/lib/analysis-display"; +import { useLocale } from "@/i18n/useLocale"; + +interface AvailableAnalysis { + invoice: string; + item_code: string; + item_name: string; + component: string; + media: string; + posting_date: string; + amount: number; + currency: string; + // present only for subscription (credit-based) entries + plan?: string; + credits_available?: number; +} + +interface SavedAnalysisReport { + name: string; + sales_invoice: string; + component: string; + media_type: string; + status: string; + pdf_file?: string | null; + generated_at?: string | null; + creation?: string; +} + +type Phase = "list" | "input" | "running" | "results"; + +const SERVICE_LABELS: Record = { + TECHNIQUES: "Detectie Tehnici de Manipulare", + AI_DETECTION: "AI Detection & Deepfake", + CLAIMS: "Verificare Afirmatii (Fact-Checking)", + SOURCE: "Evaluare Surse & Domeniu", +}; + +const MEDIA_LABELS: Record = { + TEXT: "Text", + IMAGE: "Imagine", + AUDIO: "Audio", + VIDEO: "Video", + URL: "URL", +}; + +const COMPONENT_ICONS: Record = { + TECHNIQUES: "🔍", + AI_DETECTION: "🤖", + CLAIMS: "✅", + SOURCE: "🌐", +}; + +const FILE_MEDIA_TYPES = new Set(["IMAGE", "AUDIO", "VIDEO"]); + +const ACCEPT_MAP: Record = { + IMAGE: "image/*,.png,.jpg,.jpeg,.webp,.gif", + AUDIO: "audio/*,.mp3,.wav,.ogg,.m4a,.mp4", + VIDEO: "video/*,.mp4,.webm,.mov,.avi", +}; + +function hasFinalResult(data: unknown) { + if (!data || typeof data !== "object") return false; + const result = data as Record; + return ( + result.status === "completed" || + typeof result.risk_score === "number" || + !!result.result || + !!result.verdict || + !!result.techniques || + !!result.claims || + !!result.ai_tampered || + !!result.domain || + !!result.source_assessment + ); +} + +export default function AnalizePage() { + const { locale } = useLocale(); + const [phase, setPhase] = useState("list"); + const [available, setAvailable] = useState([]); + const [reports, setReports] = useState([]); + const [selected, setSelected] = useState(null); + const [inputText, setInputText] = useState(""); + const [inputUrl, setInputUrl] = useState(""); + const [inputFile, setInputFile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [pollCount, setPollCount] = useState(0); + const [result, setResult] = useState | null>(null); + const pollRef = useRef | null>(null); + + const fetchAvailable = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await fetch("/api/analyze"); + const data = await res.json(); + setAvailable(data.data || []); + } catch { + setError("Nu s-au putut incarca analizele disponibile."); + } finally { + setLoading(false); + } + }, []); + + const fetchReports = useCallback(async () => { + try { + const res = await fetch("/api/analysis-reports"); + const data = await res.json(); + setReports(data.data || []); + } catch { + setReports([]); + } + }, []); + + const persistReport = useCallback( + async (analysis: AvailableAnalysis, payload: Record) => { + const sessionId = String(payload.session_id || ""); + if (!sessionId) return; + + try { + await fetch("/api/analysis-reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + invoiceName: analysis.invoice, + sessionId, + component: analysis.component, + media: analysis.media, + locale, + status: "Completed", + result: payload, + }), + }); + } finally { + fetchReports(); + } + }, + [fetchReports], + ); + + useEffect(() => { + fetchAvailable(); + fetchReports(); + }, [fetchAvailable, fetchReports]); + + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + function handleSelect(analysis: AvailableAnalysis) { + setSelected(analysis); + setInputText(""); + setInputUrl(""); + setInputFile(null); + setError(""); + setPhase("input"); + } + + function handleBack() { + if (pollRef.current) clearInterval(pollRef.current); + setPhase("list"); + setSelected(null); + setResult(null); + setError(""); + fetchAvailable(); + fetchReports(); + } + + function downloadPDF() { + if (!result || !selected) return; + + const pdf = buildAnalysisReportPdf(result, { + invoice: selected.invoice, + component: selected.component, + media: selected.media, + locale, + }); + const blob = new Blob([pdf], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = buildAnalysisReportFileName({ + invoice: selected.invoice, + component: selected.component, + media: selected.media, + }); + link.click(); + URL.revokeObjectURL(url); + } + + async function handleSubmit() { + if (!selected) return; + + const needsUrl = selected.media === "URL"; + const needsFile = FILE_MEDIA_TYPES.has(selected.media); + + if (needsFile && !inputFile) { + setError("Selecteaza un fisier de analizat."); + return; + } + if (!needsFile && !needsUrl && !inputText.trim()) { + setError("Introdu textul de analizat."); + return; + } + if (needsUrl && !inputUrl.trim()) { + setError("Introdu URL-ul de analizat."); + return; + } + + setError(""); + setPhase("running"); + setPollCount(0); + + try { + let res: Response; + + if (needsFile && inputFile) { + const formData = new FormData(); + formData.append("invoice", selected.invoice); + formData.append("component", selected.component); + formData.append("media", selected.media); + formData.append("language", locale); + formData.append("file", inputFile); + + res = await fetch("/api/analyze", { + method: "POST", + body: formData, + }); + } else { + const payload: Record = { + invoice: selected.invoice, + component: selected.component, + media: selected.media, + language: locale, + }; + if (needsUrl) payload.url = inputUrl.trim(); + else payload.text = inputText.trim(); + + res = await fetch("/api/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || `Eroare API: ${res.status}`); + setPhase("input"); + return; + } + + if (hasFinalResult(data.data)) { + setResult(data.data); + setPhase("results"); + await persistReport(selected, data.data as Record); + return; + } + + const sessionId = data.data?.session_id || data.session_id; + if (sessionId) { + startPolling(sessionId, selected); + } else { + setResult(data.data || data); + setPhase("results"); + } + } catch { + setError("Eroare la trimiterea analizei."); + setPhase("input"); + } + } + + function startPolling(sessionId: string, analysis: AvailableAnalysis) { + if (pollRef.current) clearInterval(pollRef.current); + let count = 0; + + pollRef.current = setInterval(async () => { + count++; + setPollCount(count); + + if (count > 200) { + if (pollRef.current) clearInterval(pollRef.current); + setError("Timeout - analiza dureaza prea mult."); + setPhase("input"); + return; + } + + try { + const params = new URLSearchParams({ + session_id: sessionId, + invoice: analysis.invoice, + component: analysis.component, + media: analysis.media, + language: locale, + }); + const res = await fetch(`/api/analyze?${params.toString()}`); + const data = await res.json(); + const current = data?.data || data; + + if (hasFinalResult(current)) { + if (pollRef.current) clearInterval(pollRef.current); + setResult(current); + setPhase("results"); + await persistReport(analysis, current as Record); + return; + } + + if (current?.status === "failed") { + if (pollRef.current) clearInterval(pollRef.current); + setError("Analiza a esuat pe server."); + setPhase("input"); + } + } catch { + // keep polling + } + }, 3000); + } + + if (phase === "list") { + return ( +
+

Analize

+

Selecteaza o analiza achizitionata pentru a o rula.

+ {error &&
{error}
} + + {loading ? ( +
+ + ) : ( +
+ {available.length === 0 ? ( +
+

Nicio analiza disponibila.

+

Analizele achizitionate si neconsumate vor aparea aici.

+ + Achizitioneaza o analiza + +
+ ) : ( +
+ {available.map((analysis) => ( + + ))} +
+ )} + +
+

Rapoarte salvate

+

PDF-urile generate sunt salvate in ERP si pot fi descarcate oricand.

+ + {reports.length === 0 ? ( +

Niciun raport salvat inca.

+ ) : ( +
+ {reports.map((report) => ( +
+
+

+ {SERVICE_LABELS[report.component] || report.component} - {MEDIA_LABELS[report.media_type] || report.media_type} +

+

+ {report.sales_invoice ? `Factura: ${report.sales_invoice}` : "Abonament"} · Generat la {String(report.generated_at || report.creation || "").replace("T", " ").slice(0, 16)} +

+
+ + Download PDF + +
+ ))} +
+ )} +
+
+ )} +
+ ); + } + + if (phase === "input" && selected) { + const needsUrl = selected.media === "URL"; + const needsFile = FILE_MEDIA_TYPES.has(selected.media); + return ( +
+ + +
+
+ {COMPONENT_ICONS[selected.component] || "📊"} +
+

+ {SERVICE_LABELS[selected.component]} - {MEDIA_LABELS[selected.media]} +

+

Factura: {selected.invoice}

+
+
+ + {error &&
{error}
} + + {needsFile ? ( +
+ +
+ setInputFile(e.target.files?.[0] || null)} + className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm file:mr-4 file:rounded-lg file:border-0 file:bg-teal-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-teal-700 hover:file:bg-teal-100 focus:border-teal-500 focus:outline-none focus:ring-1 focus:ring-teal-500" + /> +
+ {inputFile && ( +

+ {inputFile.name} ({(inputFile.size / 1024 / 1024).toFixed(1)} MB) +

+ )} + {(selected.media === "AUDIO" || selected.media === "VIDEO") && ( +

+ {selected.media === "AUDIO" ? "Procesarea audio dureaza 2-5 minute (transcriere + analiza)." : "Procesarea video dureaza 5-10 minute (frames + audio + analiza)."} +

+ )} +
+ ) : needsUrl ? ( +
+ + setInputUrl(e.target.value)} + placeholder="https://exemplu.com/articol" + className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-teal-500 focus:outline-none focus:ring-1 focus:ring-teal-500" + /> +
+ ) : ( +
+ +