livrare website cu erp si crm

This commit is contained in:
DP Software services 2026-07-28 14:47:28 +03:00
parent 28773e3a72
commit 5c7bf7c295
257 changed files with 31929 additions and 0 deletions

27
erp_crm/.env.example Normal file
View file

@ -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=<schimba-ma>
DB_PASSWORD=<schimba-ma>
# ERPNext
ERPNEXT_VERSION=v15
FRAPPE_VERSION=v15
SITE_NAME=didi-erp
ADMIN_PASSWORD=<schimba-ma>
# 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=<your-sendgrid-key-starts-with-SG>
ERPNEXT_ADMIN_PASSWORD=<schimba-ma>
ERPNEXT_URL=http://localhost:8080

5
erp_crm/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.log
.DS_Store
.env

102
erp_crm/.gitlab-ci.yml Normal file
View file

@ -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]+$/

253
erp_crm/CONFIGURATION.md Normal file
View file

@ -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=<schimba-ma>
DB_PASSWORD=<schimba-ma>
# ERPNext
ERPNEXT_VERSION=v15
FRAPPE_VERSION=v15
SITE_NAME=didi-erp
ADMIN_PASSWORD=<schimba-ma>
# 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 <api_key>:<api_secret>
```
## 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=<api_key_generat>
ERPNEXT_API_SECRET=<api_secret_generat>
```
**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 "[\"<user_email>\"]"
'
```
**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.

300
erp_crm/INSTALL.md Normal file
View file

@ -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 <parola-root-db> \
--admin-password <parola-admin> \
--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<parola-root-db> -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 <modul.functie>`.
```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<parola-root-db> $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.

309
erp_crm/OPERATIONS.md Normal file
View file

@ -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<parola-root-db> --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 <parola-root-db>
'
```
## 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<parola-root-db> -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<parola-root-db> $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.

153
erp_crm/README.md Normal file
View file

@ -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=<parola-root-db>
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 | `<parola-root-db>` |
| Site name (intern) | `didi-erp` |
## Suport
Pentru intrebari tehnice: office@clossers.com

30
erp_crm/bin/entrypoint.sh Executable file
View file

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

59
erp_crm/bin/setup-site.sh Executable file
View file

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

View file

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

188
erp_crm/build-from-zero.sh Executable file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
__version__ = "0.0.1"

View file

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

View file

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

View file

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

View file

@ -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]}")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": """<p>Stimate {{ doc.customer_name }},</p>
<p>Va confirmam ca plata pentru factura <strong>{{ doc.name }}</strong> in valoare de <strong>{{ doc.grand_total }} {{ doc.currency }}</strong> a fost procesata cu succes.</p>
<p>Puteti descarca factura din dashboard-ul contului dumneavoastra.</p>
<p>Va multumim,<br>Echipa DiDi</p>"""
},
{
"name": "DiDi - Expirare Abonament",
"subject": "Abonamentul dumneavoastra expira in curand",
"response": """<p>Stimate client,</p>
<p>Va informam ca abonamentul dumneavoastra DiDi expira in <strong>7 zile</strong>.</p>
<p>Pentru a continua sa beneficiati de serviciile noastre, va rugam sa verificati metoda de plata in dashboard-ul contului.</p>
<p>Va multumim,<br>Echipa DiDi</p>"""
},
{
"name": "DiDi - Esec Plata",
"subject": "Problema la procesarea platii",
"response": """<p>Stimate client,</p>
<p>Din pacate, nu am reusit sa procesam plata pentru abonamentul dumneavoastra DiDi.</p>
<p>Va rugam sa actualizati metoda de plata in dashboard-ul contului pentru a evita intreruperea serviciului.</p>
<p>Va multumim,<br>Echipa DiDi</p>"""
},
]
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.")

View file

@ -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", {
// });

View file

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

View file

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

View file

@ -0,0 +1,7 @@
# Copyright (c) 2026, TOP CLOSSERS SRL and Contributors
# See license.txt
def test_placeholder():
assert True

View file

@ -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) {
// },
// });

View file

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

View file

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

View file

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

View file

@ -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) {
// },
// });

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {
// },
// });

View file

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

View file

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

View file

@ -0,0 +1,82 @@
{
"charts": [],
"content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"<span class=\\\"h4\\\"><b>Clienti</b></span>\", \"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"
}

View file

@ -0,0 +1,99 @@
{
"charts": [],
"content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"<span class=\\\"h4\\\"><b>Contabilitate</b></span>\", \"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"
}

View file

@ -0,0 +1,89 @@
{
"charts": [],
"content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"<span class=\\\"h4\\\"><b>CRM</b></span>\", \"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"
}

View file

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

View file

@ -0,0 +1,214 @@
{
"charts": [],
"content": "[ {\"id\":\"h1\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Acces rapid</b></span>\",\"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\":\"<span class=\\\"h4\\\"><b>Module</b></span>\",\"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"
}

View file

@ -0,0 +1,82 @@
{
"charts": [],
"content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"<span class=\\\"h4\\\"><b>Facturi</b></span>\", \"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"
}

View file

@ -0,0 +1,72 @@
{
"charts": [],
"content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"<span class=\\\"h4\\\"><b>Plati</b></span>\", \"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"
}

View file

@ -0,0 +1,82 @@
{
"charts": [],
"content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"<span class=\\\"h4\\\"><b>Servicii</b></span>\", \"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"
}

View file

@ -0,0 +1,72 @@
{
"charts": [],
"content": "[{\"id\": \"h1\", \"type\": \"header\", \"data\": {\"text\": \"<span class=\\\"h4\\\"><b>Website CMS</b></span>\", \"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"
}

View file

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

View file

@ -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/<path>)
# ---------------------------------------------------------
# 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 <head>
# ------------------
# 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 = []

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"<p>Buna ziua,</p>"
f"<p>Abonamentul dumneavoastra DiDi ({cust.active_plan}) a expirat. "
f"Contul a fost retrogradat la planul Free.</p>"
f"<p>Pentru reactivare, va rugam vizitati <a href='https://didi365.eu/pricing'>didi365.eu/pricing</a>.</p>"
f"<p>Echipa DiDi / Clossers</p>"
),
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"<p>Bună ziua,</p>"
f"<p>Abonamentul dumneavoastră DiDi ({cust.active_plan}) expiră în "
f"<strong>{days_left} zile</strong>.</p>"
f"<p>Pentru a evita întreruperea serviciilor, verificați metoda de plată "
f"sau reînnoiți din <a href='https://didi365.eu/pricing'>pagina de planuri</a>.</p>"
f"<p>Echipa DiDi / Clossers</p>"
),
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

View file

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

View file

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

View file

@ -0,0 +1 @@
Didi Custom

View file

@ -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"<p>Suma: <strong>{float(amount):.2f} {currency}</strong>.</p>" if amount else ""
)
frappe.sendmail(
recipients=[email],
subject="DiDi — Plata nu a putut fi procesată",
message=(
f"<p>Bună ziua,</p>"
f"<p>Încercarea de plată pentru abonamentul DiDi a eșuat.</p>"
f"{amount_line}"
f"<p>Vă rugăm să verificați metoda de plată și să "
f"<a href='{link}'>reactivați abonamentul</a>. Vom reîncerca automat plata "
f"în următoarele zile.</p>"
f"<p>Echipa DiDi</p>"
),
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"<p>Bună ziua, {doc.customer_name},</p>"
f"<p>Vă mulțumim pentru plată. Găsiți atașată factura "
f"<strong>{invoice_name}</strong> în valoare de "
f"<strong>{doc.grand_total:.2f} {doc.currency}</strong>.</p>"
f"<p>Serviciul achiziționat este activ în contul dumneavoastră DiDi.</p>"
f"<p>Echipa DiDi</p>"
),
attachments=[pdf],
reference_doctype="Sales Invoice",
reference_name=invoice_name,
delayed=False,
)
frappe.db.commit()
return {"sent": True, "to": customer_email, "invoice": invoice_name}

View file

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

View file

@ -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"<h3>{mod['title_ro']}</h3><p class='subtitle'>{mod['subtitle_ro']}</p><p>{mod['desc_ro']}</p>",
f"<h3>{mod['title_en']}</h3><p class='subtitle'>{mod['subtitle_en']}</p><p>{mod['desc_en']}</p>",
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",
"<h1>Despre Clossers si platforma DiDi</h1><p>TOP CLOSSERS SRL dezvolta solutii digitale inovatoare pentru combaterea dezinformarii si protectia spatiului informational romanesc si european.</p>",
"<h1>About Clossers and the DiDi platform</h1><p>TOP CLOSSERS SRL develops innovative digital solutions for combating disinformation and protecting the Romanian and European information space.</p>", 1)
upsert("about", "mission",
"<h2>Misiunea noastra</h2><p>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.</p>",
"<h2>Our mission</h2><p>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.</p>", 2)
upsert("about", "project",
"<h2>Proiect PNRR</h2><p>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.</p>",
"<h2>PNRR Project</h2><p>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.</p>", 3)
upsert("about", "technology",
"<h2>Tehnologie</h2><p>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.</p>",
"<h2>Technology</h2><p>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.</p>", 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",
"<h1>Contacteaza-ne</h1><p>Ai intrebari despre platforma DiDi sau vrei sa discutam despre nevoile tale? Echipa noastra iti sta la dispozitie.</p>",
"<h1>Contact us</h1><p>Have questions about the DiDi platform or want to discuss your needs? Our team is here to help.</p>", 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",
"""<h1>Politica de Confidentialitate</h1>
<p><em>Ultima actualizare: 30 martie 2026</em></p>
<h2>1. Introducere</h2>
<p>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).</p>
<h2>2. Date colectate</h2>
<p>Colectam urmatoarele categorii de date:</p>
<ul>
<li><strong>Date de identificare:</strong> nume, prenume, adresa email, numar telefon, denumire companie, CUI</li>
<li><strong>Date de autentificare:</strong> gestionate prin Keycloak (Identity and Access Management) - nu stocam parole</li>
<li><strong>Date de utilizare:</strong> continut incarcat pentru analiza, istoricul analizelor, credite consumate</li>
<li><strong>Date de plata:</strong> gestionate de Stripe - nu stocam date de card</li>
<li><strong>Date tehnice:</strong> adresa IP, tip browser, cookie-uri (conform politicii de cookie-uri)</li>
</ul>
<h2>3. Scopul prelucrarii</h2>
<ul>
<li>Furnizarea serviciilor platformei DiDi (analiza continut media)</li>
<li>Gestionarea contului si a abonamentului</li>
<li>Facturare si evidenta contabila</li>
<li>Comunicari tranzactionale (facturi, notificari cont)</li>
<li>Imbunatatirea serviciilor si suport tehnic</li>
</ul>
<h2>4. Temeiul legal</h2>
<ul>
<li>Executarea contractului (furnizarea serviciilor)</li>
<li>Obligatii legale (facturare, contabilitate)</li>
<li>Consimtamant (cookie-uri analitice, comunicari marketing)</li>
<li>Interes legitim (securitate, prevenire frauda)</li>
</ul>
<h2>5. Stocarea datelor</h2>
<p>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).</p>
<h2>6. Drepturile dumneavoastra</h2>
<p>Conform GDPR, aveti dreptul la:</p>
<ul>
<li><strong>Acces</strong> - puteti vizualiza datele din dashboard-ul contului</li>
<li><strong>Rectificare</strong> - puteti modifica datele din profilul contului</li>
<li><strong>Stergere</strong> - puteti solicita stergerea contului din dashboard (sectiunea Profil)</li>
<li><strong>Portabilitate</strong> - puteti exporta datele in format CSV din dashboard</li>
<li><strong>Opozitie</strong> - puteti retrage consimtamantul pentru cookie-uri analitice</li>
</ul>
<h2>7. Contact DPO</h2>
<p>Pentru exercitarea drepturilor sau intrebari privind protectia datelor: <strong>office@clossers.com</strong></p>""",
"""<h1>Privacy Policy</h1>
<p><em>Last updated: March 30, 2026</em></p>
<h2>1. Introduction</h2>
<p>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).</p>
<h2>2. Data collected</h2>
<ul>
<li><strong>Identification data:</strong> name, email, phone, company name, tax ID</li>
<li><strong>Authentication data:</strong> managed by Keycloak (IAM) - we do not store passwords</li>
<li><strong>Usage data:</strong> content uploaded for analysis, analysis history, credits consumed</li>
<li><strong>Payment data:</strong> managed by Stripe - we do not store card data</li>
<li><strong>Technical data:</strong> IP address, browser type, cookies</li>
</ul>
<h2>3. Purpose</h2>
<ul>
<li>Providing DiDi platform services</li>
<li>Account and subscription management</li>
<li>Invoicing and accounting</li>
<li>Transactional communications</li>
<li>Service improvement and support</li>
</ul>
<h2>4. Your rights</h2>
<p>Under GDPR: access, rectification, erasure, portability, objection. Contact: office@clossers.com</p>""", 1)
def populate_terms():
print("[6/6] Terms...")
upsert("terms", "content",
"""<h1>Termeni si Conditii</h1>
<p><em>Ultima actualizare: 30 martie 2026</em></p>
<h2>1. Definitii</h2>
<ul>
<li><strong>Furnizor:</strong> TOP CLOSSERS SRL, CUI 36193026, operator al platformei DiDi</li>
<li><strong>Utilizator:</strong> persoana fizica sau juridica care acceseaza si utilizeaza serviciile DiDi</li>
<li><strong>Platforma:</strong> platforma digitala DiDi accesibila prin intermediul site-ului clossers.com</li>
<li><strong>Servicii:</strong> serviciile de analiza si detectie a dezinformarii oferite prin platforma DiDi</li>
</ul>
<h2>2. Servicii oferite</h2>
<p>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.</p>
<h2>3. Inregistrare si cont</h2>
<p>Accesul la servicii necesita crearea unui cont. Utilizatorul este responsabil pentru securitatea credentialelor de acces si pentru toate activitatile desfasurate prin contul sau.</p>
<h2>4. Planuri si plata</h2>
<p>Serviciile sunt disponibile in mai multe planuri (Free, Paid, Enterprise). Platile sunt procesate securizat prin Stripe. Facturile sunt generate automat si disponibile in dashboard.</p>
<h2>5. Utilizare acceptabila</h2>
<p>Utilizatorul se obliga sa nu foloseasca platforma pentru:</p>
<ul>
<li>Activitati ilegale sau care incalca drepturile tertilor</li>
<li>Incarcarea de continut ilegal sau care incalca drepturile de autor</li>
<li>Tentative de compromitere a securitatii platformei</li>
<li>Utilizare automata abuziva (scraping, DDoS)</li>
</ul>
<h2>6. Proprietate intelectuala</h2>
<p>Platforma DiDi, algoritmii, modelele AI si documentatia sunt proprietatea TOP CLOSSERS SRL. Utilizatorul pastreaza drepturile asupra continutului incarcat pentru analiza.</p>
<h2>7. Limitarea raspunderii</h2>
<p>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.</p>
<h2>8. Legislatie aplicabila</h2>
<p>Prezentii termeni sunt guvernati de legislatia din Romania. Orice litigiu va fi solutionat de instantele competente din Ploiesti, Romania.</p>
<h2>9. Contact</h2>
<p>office@clossers.com | +40 721 063 078</p>""",
"""<h1>Terms and Conditions</h1>
<p><em>Last updated: March 30, 2026</em></p>
<h2>1. Definitions</h2>
<ul>
<li><strong>Provider:</strong> TOP CLOSSERS SRL, Tax ID 36193026, operator of DiDi platform</li>
<li><strong>User:</strong> person or entity accessing DiDi services</li>
<li><strong>Platform:</strong> DiDi digital platform accessible via clossers.com</li>
</ul>
<h2>2. Services</h2>
<p>DiDi provides automated media content analysis services for disinformation detection through specialized AI modules.</p>
<h2>3. Payment</h2>
<p>Services available in Free, Paid, Enterprise plans. Payments processed via Stripe. Invoices generated automatically.</p>
<h2>4. Acceptable use</h2>
<p>Users must not use the platform for illegal activities, copyright infringement, security attacks, or abusive automation.</p>
<h2>5. Governing law</h2>
<p>Romanian law applies. Disputes resolved by Ploiesti courts.</p>
<h2>6. Contact</h2>
<p>office@clossers.com</p>""", 1)

Some files were not shown because too many files have changed in this diff Show more