feat: inline editing, search, notifications + full repo cleanup
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing - Client search by name, account ID, and legacy_customer_id (or_filters) - SMS/Email notification panel on ContactCard via n8n webhooks - Ticket reply thread via Communication docs - All migration scripts (51 files) now tracked - Client portal and field tech app added to monorepo - README rewritten with full feature list, migration summary, architecture - CHANGELOG updated with all recent work - ROADMAP updated with current completion status - Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN) - .gitignore updated (docker/, .claude/, exports/, .quasar/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13
.gitignore
vendored
|
|
@ -11,6 +11,16 @@ node_modules/
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
docker/
|
||||||
|
|
||||||
|
# Quasar dev cache
|
||||||
|
apps/**/.quasar/
|
||||||
|
|
||||||
|
# Claude workspace (local only)
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Data exports (may contain PII)
|
||||||
|
exports/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
@ -19,3 +29,6 @@ Thumbs.db
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Playwright snapshots
|
||||||
|
.playwright-mcp/
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,9 @@ gigafibre-fsm/
|
||||||
|
|
||||||
All API calls use token auth via `authFetch()`:
|
All API calls use token auth via `authFetch()`:
|
||||||
```js
|
```js
|
||||||
Authorization: token b273a666c86d2d0:06120709db5e414
|
Authorization: token $ERP_SERVICE_TOKEN // see server .env
|
||||||
```
|
```
|
||||||
Authentik SSO protects the ops app at Traefik level (forwardAuth). The token is baked into the build via `VITE_ERP_TOKEN`.
|
Authentik SSO protects the ops app at Traefik level (forwardAuth). The token is injected server-side by the nginx proxy (or via `VITE_ERP_TOKEN` in dev).
|
||||||
|
|
||||||
## Tag/Skill System — Auto-Dispatch Logic (designed, not yet wired)
|
## Tag/Skill System — Auto-Dispatch Logic (designed, not yet wired)
|
||||||
|
|
||||||
|
|
|
||||||
217
README.md
|
|
@ -1,71 +1,198 @@
|
||||||
# Gigafibre
|
# Gigafibre FSM
|
||||||
|
|
||||||
Plateforme complète pour Gigafibre ISP (marque consommateur de TARGO).
|
Complete operations platform for **Gigafibre** (consumer brand of TARGO Internet), a fiber ISP in Quebec. Replaces a legacy PHP/MariaDB billing system with ERPNext v16 + custom Vue.js (Quasar) apps.
|
||||||
|
|
||||||
## Structure du monorepo
|
## What This Repo Contains
|
||||||
|
|
||||||
|
| Directory | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `apps/ops/` | **Targo Ops** — main operations PWA (Vue 3 / Quasar v2) |
|
||||||
|
| `apps/field/` | **Targo Field** — mobile app for technicians (barcode, diagnostics, offline) |
|
||||||
|
| `apps/client/` | **Gigafibre Portal** — customer self-service portal |
|
||||||
|
| `apps/website/` | **www.gigafibre.ca** — marketing site (React / Vite / Tailwind) |
|
||||||
|
| `apps/dispatch/` | Legacy dispatch app (replaced by `apps/ops/` dispatch module) |
|
||||||
|
| `apps/portal/` | Customer portal deploy configs |
|
||||||
|
| `erpnext/` | ERPNext custom doctype setup scripts |
|
||||||
|
| `scripts/migration/` | 51 Python scripts for legacy-to-ERPNext data migration |
|
||||||
|
| `scripts/` | Utility scripts (bulk submit, PostgreSQL fixes) |
|
||||||
|
| `docs/` | Architecture, infrastructure, migration plan, changelog |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
gigafibre-fsm/
|
96.125.196.67 (Proxmox VM, Ubuntu 24.04)
|
||||||
apps/
|
|
|
||||||
dispatch/ Vue 3 / Quasar / Pinia — PWA de dispatch terrain
|
Traefik v2.11 (TLS via Let's Encrypt)
|
||||||
website/ React / Vite / Tailwind — www.gigafibre.ca
|
|
|
||||||
erpnext/
|
+-- erp.gigafibre.ca ERPNext v16.10.1 (PostgreSQL, 9 containers)
|
||||||
setup_fsm_doctypes.py Setup des doctypes FSM dans ERPNext
|
+-- erp.gigafibre.ca/ops/ Ops PWA (Quasar/Vue3, Authentik SSO)
|
||||||
docs/
|
+-- id.gigafibre.ca Authentik SSO (customer-facing)
|
||||||
ARCHITECTURE.md Modèle de données, stack technique
|
+-- auth.targo.ca Authentik SSO (staff, federated to id.gigafibre.ca)
|
||||||
INFRASTRUCTURE.md Serveur, DNS, auth, APIs, gotchas
|
+-- n8n.gigafibre.ca n8n workflow automation
|
||||||
ROADMAP.md Plan d'implémentation en 5 phases
|
+-- git.targo.ca Gitea
|
||||||
COMPETITIVE-ANALYSIS.md Analyse concurrentielle
|
+-- www.gigafibre.ca Marketing site + address API
|
||||||
|
+-- oss.gigafibre.ca Oktopus CE (TR-069 CPE management)
|
||||||
|
+-- tracker.targointernet.com Traccar GPS tracking
|
||||||
```
|
```
|
||||||
|
|
||||||
## Apps
|
## Features
|
||||||
|
|
||||||
### Dispatch PWA (`apps/dispatch/`)
|
### Ops App (`apps/ops/`)
|
||||||
Interface de répartition terrain : timeline drag-drop, carte Mapbox avec GPS temps réel (Traccar), gestion techniciens.
|
|
||||||
|
- **Client Management** — customer list with search by name, account ID, legacy ID. Inline editing (double-click any field to edit, saves to ERPNext in background)
|
||||||
|
- **Client Detail** — full customer view with contact, billing KPIs, service locations, subscriptions, equipment, tickets, invoices, payments, notes
|
||||||
|
- **Inline Editing** — Odoo-style double-click-to-edit on all fields (locations, equipment, tickets, invoices). Uses `InlineField` component + `useInlineEdit` composable
|
||||||
|
- **Dispatch Timeline** — drag-drop job scheduling with Mapbox map, GPS tracking (Traccar), technician management, tag/skill system
|
||||||
|
- **Ticket Management** — list with inline status/priority editing, detail modal with reply thread
|
||||||
|
- **Equipment Tracking** — serial/MAC, status lifecycle, OLT info, per-location grouping
|
||||||
|
- **SMS/Email Notifications** — send from contact card via n8n webhooks (Twilio SMS, Mailjet email)
|
||||||
|
- **Invoice OCR** — scan paper bills using Ollama Vision (llama3.2-vision)
|
||||||
|
- **PWA** — installable, offline-capable with Workbox
|
||||||
|
|
||||||
|
### Legacy Migration (completed)
|
||||||
|
|
||||||
|
Migrated from a 15-year-old PHP/MariaDB billing system:
|
||||||
|
|
||||||
|
| Data | Volume | Status |
|
||||||
|
|------|--------|--------|
|
||||||
|
| Customers | 6,667 (active + terminated) | Migrated |
|
||||||
|
| Contacts + Addresses | ~6,600 each | Migrated |
|
||||||
|
| Service Locations | ~17,000 | Migrated |
|
||||||
|
| Subscriptions | 21,876 (with RADIUS credentials) | Migrated |
|
||||||
|
| Items (products/plans) | 833 | Migrated |
|
||||||
|
| Sales Invoices | 115,000+ | Migrated |
|
||||||
|
| Payments | 99,000+ | Migrated with invoice references |
|
||||||
|
| Tickets (Issues) | 242,000+ (parent/child hierarchy) | Migrated |
|
||||||
|
| Ticket Messages (Communications) | 784,000+ | Migrated |
|
||||||
|
| Customer Memos (Comments) | 29,000+ | Migrated with real dates |
|
||||||
|
| Employees | 45 ERPNext Users from legacy staff | Migrated |
|
||||||
|
|
||||||
|
### Bugs Fixed From Legacy System
|
||||||
|
|
||||||
|
- **Date corruption** — Unix timestamps stored as strings; fixed during import with timezone-aware conversion
|
||||||
|
- **Invoice outstanding amounts** — payment_item references broken in legacy; reconciled during migration
|
||||||
|
- **Customer links** — orphan records (invoices/payments without valid customer); rebuilt relationships
|
||||||
|
- **Subscription details** — missing service_location links, incorrect billing frequencies; corrected via analysis scripts
|
||||||
|
- **Reversal transactions** — credit notes improperly recorded; mapped to ERPNext return invoices
|
||||||
|
- **Annual billing dates** — yearly subscriptions had wrong period boundaries; recalculated
|
||||||
|
- **Duplicate customer IDs** — legacy allowed duplicate customer_id; resolved with rename script
|
||||||
|
- **Staff/ticket ownership** — legacy used numeric IDs for assignment; mapped to ERPNext User emails
|
||||||
|
|
||||||
|
### ERPNext Adjustments for Import
|
||||||
|
|
||||||
|
- **Direct PostgreSQL inserts** — Frappe ORM too slow for 100K+ records; used psycopg2 with proper `tabXxx` schema
|
||||||
|
- **Scheduler paused** — disabled auto-invoicing during import to prevent 21K subscriptions from generating invoices
|
||||||
|
- **Custom fields on Customer** — `legacy_account_id`, `legacy_customer_id`, `ppa_enabled`, `stripe_id`, date fields
|
||||||
|
- **Custom fields on Item** — `legacy_product_id`, download/upload speeds, quotas, OLT profiles
|
||||||
|
- **Custom fields on Subscription** — `radius_user`, `radius_pwd`, `legacy_service_id`
|
||||||
|
- **Custom fields on Issue** — `legacy_ticket_id`, `assigned_staff`, `opened_by_staff`, `issue_type`, `is_important`, `service_location`
|
||||||
|
- **PostgreSQL GROUP BY patch** — ERPNext v16 generates MySQL-style queries; patched `number_card.py` and PLE reports
|
||||||
|
- **Subscription API unlock** — removed `read_only` and `set_only_once` restrictions on Subscription fields for REST API updates
|
||||||
|
- **Portal auth bridge** — server script for legacy MD5 password migration to PBKDF2
|
||||||
|
|
||||||
|
## ERPNext Custom Doctypes
|
||||||
|
|
||||||
|
### Field Service Management
|
||||||
|
|
||||||
|
| Doctype | ID Pattern | Purpose |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| Service Location | LOC-##### | Customer premises (address, GPS, OLT port, network config) |
|
||||||
|
| Service Equipment | EQP-##### | Deployed hardware (ONT, router, TV box — serial, MAC, IP) |
|
||||||
|
| Service Subscription | SUB-##### | Active service plans (speed, price, billing cycle, RADIUS) |
|
||||||
|
|
||||||
|
### Dispatch
|
||||||
|
|
||||||
|
| Doctype | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| Dispatch Job | Work orders with equipment, materials, checklist, photos, signature |
|
||||||
|
| Dispatch Technician | Tech profiles with GPS link (Traccar), skills, color coding |
|
||||||
|
| Dispatch Tag | Categorization with skill levels (Fibre, TV, Telephonie, etc.) |
|
||||||
|
| Dispatch Tag Link | Child table linking tags to jobs/techs with level + required flag |
|
||||||
|
|
||||||
|
### Child Tables
|
||||||
|
|
||||||
|
Equipment Move Log, Job Equipment Item, Job Material Used, Job Checklist Item, Job Photo, Dispatch Job Assistant, Checklist Template + Items
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/dispatch
|
# Ops app
|
||||||
|
cd apps/ops
|
||||||
npm install
|
npm install
|
||||||
npx quasar dev # dev local
|
npx quasar dev
|
||||||
DEPLOY_BASE=/ npx quasar build -m pwa # build prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### Site web (`apps/website/`)
|
# Website
|
||||||
Site vitrine www.gigafibre.ca : qualification d'adresse (5.2M adresses QC), formulaire contact, capture leads.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/website
|
cd apps/website
|
||||||
npm install
|
npm install
|
||||||
npm run dev # dev local
|
npm run dev
|
||||||
npm run build # build prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## ERPNext — Doctypes FSM
|
### Deploy Ops to Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker cp erpnext/setup_fsm_doctypes.py erpnext-backend-1:/home/frappe/frappe-bench/apps/frappe/frappe/
|
cd apps/ops
|
||||||
docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute frappe.setup_fsm_doctypes.create_all
|
bash deploy.sh # builds PWA + deploys to server via SSH
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Migration Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scripts run inside the ERPNext backend container
|
||||||
|
docker cp scripts/migration/migrate_all.py frappe_docker-backend-1:/tmp/
|
||||||
|
docker exec frappe_docker-backend-1 bench --site erp.gigafibre.ca execute /tmp/migrate_all.main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup FSM Doctypes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp erpnext/setup_fsm_doctypes.py frappe_docker-backend-1:/home/frappe/frappe-bench/apps/frappe/frappe/
|
||||||
|
docker exec frappe_docker-backend-1 bench --site erp.gigafibre.ca execute frappe.setup_fsm_doctypes.create_all
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Contenu |
|
| Document | Content |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Modèle de données, stack, auth flow |
|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Data model, tech stack, authentication flow, doctype reference |
|
||||||
| [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) | Serveur, DNS, Traefik, Authentik, Docker, gotchas |
|
| [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) | Server, DNS, Traefik, Authentik, Docker, n8n, gotchas |
|
||||||
| [ROADMAP.md](docs/ROADMAP.md) | 5 phases d'implémentation |
|
| [MIGRATION-PLAN.md](docs/MIGRATION-PLAN.md) | Legacy system portrait, mapping, phases, risks |
|
||||||
| [COMPETITIVE-ANALYSIS.md](docs/COMPETITIVE-ANALYSIS.md) | Gaiia, Odoo, Zuper, Salesforce, ServiceTitan |
|
| [CHANGELOG.md](docs/CHANGELOG.md) | Detailed migration log with volumes and methods |
|
||||||
|
| [ROADMAP.md](docs/ROADMAP.md) | 5-phase implementation plan |
|
||||||
|
| [MIGRATION_MAP.md](scripts/migration/MIGRATION_MAP.md) | Field-level mapping legacy tables to ERPNext |
|
||||||
|
| [COMPETITIVE-ANALYSIS.md](docs/COMPETITIVE-ANALYSIS.md) | Comparison with Gaiia, Odoo, Zuper, Salesforce, ServiceTitan |
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
Voir [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) pour le schéma complet. En résumé :
|
| Service | URL | Technology |
|
||||||
|
|---------|-----|------------|
|
||||||
|
| ERP | erp.gigafibre.ca | ERPNext v16.10.1 (Frappe, PostgreSQL) |
|
||||||
|
| Ops | erp.gigafibre.ca/ops/ | Quasar v2 PWA |
|
||||||
|
| SSO (staff) | auth.targo.ca | Authentik |
|
||||||
|
| SSO (customers) | id.gigafibre.ca | Authentik (federated from auth.targo.ca) |
|
||||||
|
| Workflows | n8n.gigafibre.ca | n8n |
|
||||||
|
| Git | git.targo.ca | Gitea |
|
||||||
|
| GPS | tracker.targointernet.com | Traccar |
|
||||||
|
| Website | www.gigafibre.ca | React / Vite / Tailwind |
|
||||||
|
| CPE Mgmt | oss.gigafibre.ca | Oktopus CE (TR-069) |
|
||||||
|
| DNS | Cloudflare | gigafibre.ca (DNS-only, Traefik handles TLS) |
|
||||||
|
| Email | Mailjet | noreply@targo.ca |
|
||||||
|
| SMS | Twilio | +1 438 231-3838 |
|
||||||
|
|
||||||
- **Serveur:** 96.125.196.67 (Proxmox VM, Ubuntu 24.04)
|
## Tech Stack
|
||||||
- **Proxy:** Traefik v2.11 avec Let's Encrypt
|
|
||||||
- **Auth:** Authentik SSO (auth.targo.ca) via forwardAuth
|
| Layer | Technology |
|
||||||
- **ERP:** ERPNext v16 (erp.gigafibre.ca)
|
|-------|-----------|
|
||||||
- **GPS:** Traccar (tracker.targointernet.com)
|
| Backend | ERPNext v16 / Frappe (Python) on PostgreSQL |
|
||||||
- **Workflows:** n8n (n8n.gigafibre.ca)
|
| Frontend (ops) | Vue 3, Quasar v2, Pinia, Vite |
|
||||||
- **DNS:** Cloudflare (gigafibre.ca)
|
| Frontend (website) | React, Vite, Tailwind, shadcn/ui |
|
||||||
- **Email:** Mailjet (noreply@targo.ca)
|
| Maps | Mapbox GL JS + Directions API |
|
||||||
- **SMS:** Twilio (+1 438 231-3838)
|
| GPS | Traccar (REST + WebSocket) |
|
||||||
|
| Auth | Authentik SSO (forwardAuth via Traefik) |
|
||||||
|
| Proxy | Traefik v2.11 (Let's Encrypt) |
|
||||||
|
| Automation | n8n webhooks |
|
||||||
|
| SMS | Twilio (via n8n) |
|
||||||
|
| Email | Mailjet (via n8n) |
|
||||||
|
| OCR | Ollama (llama3.2-vision) |
|
||||||
|
| Hosting | Proxmox VM, Ubuntu 24.04, Docker |
|
||||||
|
|
|
||||||
52
apps/client/deploy.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# deploy.sh — Build Gigafibre Client Portal and deploy to ERPNext container
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh # deploy to remote server (production)
|
||||||
|
# ./deploy.sh local # deploy to local Docker (development)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
SERVER="root@96.125.196.67"
|
||||||
|
SSH_KEY="$HOME/.ssh/proxmox_vm"
|
||||||
|
DEST="/home/frappe/frappe-bench/sites/assets/client-app"
|
||||||
|
|
||||||
|
echo "==> Installing dependencies..."
|
||||||
|
npm ci --silent
|
||||||
|
|
||||||
|
echo "==> Building PWA (base=/assets/client-app/)..."
|
||||||
|
VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/assets/client-app/ npx quasar build -m pwa
|
||||||
|
|
||||||
|
if [ "$1" = "local" ]; then
|
||||||
|
CONTAINER=$(docker ps --format '{{.Names}}' | grep -E 'frontend' | grep -v ops | head -1)
|
||||||
|
[ -z "$CONTAINER" ] && echo "ERROR: ERPNext frontend container not found" && exit 1
|
||||||
|
echo "==> Deploying to local container ($CONTAINER)..."
|
||||||
|
docker exec "$CONTAINER" sh -c "rm -rf $DEST && mkdir -p $DEST"
|
||||||
|
docker cp "$SCRIPT_DIR/dist/pwa/." "$CONTAINER:$DEST/"
|
||||||
|
echo ""
|
||||||
|
echo "Done! Client Portal: http://localhost:8080/assets/client-app/"
|
||||||
|
else
|
||||||
|
echo "==> Packaging..."
|
||||||
|
tar czf /tmp/client-pwa.tar.gz -C dist/pwa .
|
||||||
|
|
||||||
|
echo "==> Deploying to $SERVER..."
|
||||||
|
cat /tmp/client-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \
|
||||||
|
"cat > /tmp/client.tar.gz && \
|
||||||
|
CONTAINER=\$(docker ps --format '{{.Names}}' | grep erpnext-frontend | head -1) && \
|
||||||
|
echo \" Using container: \$CONTAINER\" && \
|
||||||
|
docker exec -u root \$CONTAINER sh -c 'rm -rf $DEST && mkdir -p $DEST' && \
|
||||||
|
TMPDIR=\$(mktemp -d) && \
|
||||||
|
cd \$TMPDIR && tar xzf /tmp/client.tar.gz && \
|
||||||
|
docker cp \$TMPDIR/. \$CONTAINER:$DEST/ && \
|
||||||
|
docker exec -u root \$CONTAINER chown -R frappe:frappe $DEST && \
|
||||||
|
rm -rf \$TMPDIR /tmp/client.tar.gz"
|
||||||
|
|
||||||
|
rm -f /tmp/client-pwa.tar.gz
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Client Portal: https://client.gigafibre.ca/"
|
||||||
|
fi
|
||||||
19
apps/client/index.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Gigafibre</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="description" content="Portail client Gigafibre">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<meta name="msapplication-tap-highlight" content="no">
|
||||||
|
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
||||||
|
<link rel="icon" type="image/ico" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- quasar:entry-point -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9996
apps/client/package-lock.json
generated
Normal file
36
apps/client/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "gigafibre-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gigafibre Customer Portal",
|
||||||
|
"productName": "Gigafibre",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "quasar dev",
|
||||||
|
"build": "quasar build",
|
||||||
|
"lint": "eslint --ext .js,.vue ./src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"quasar": "^2.16.10",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@quasar/app-vite": "^1.10.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.24.0",
|
||||||
|
"sass": "^1.72.0",
|
||||||
|
"workbox-build": "7.0.x",
|
||||||
|
"workbox-cacheable-response": "7.0.x",
|
||||||
|
"workbox-core": "7.0.x",
|
||||||
|
"workbox-expiration": "7.0.x",
|
||||||
|
"workbox-precaching": "7.0.x",
|
||||||
|
"workbox-routing": "7.0.x",
|
||||||
|
"workbox-strategies": "7.0.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20 || ^18",
|
||||||
|
"npm": ">= 6.13.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/client/postcss.config.cjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
BIN
apps/client/public/icons/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/client/public/icons/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/client/public/icons/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/client/public/icons/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/client/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/client/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/client/public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/client/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/client/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/client/public/icons/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
apps/client/public/icons/safari-pinned-tab.svg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
66
apps/client/quasar.config.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/* eslint-env node */
|
||||||
|
const { configure } = require('quasar/wrappers')
|
||||||
|
|
||||||
|
module.exports = configure(function () {
|
||||||
|
return {
|
||||||
|
boot: ['pinia'],
|
||||||
|
|
||||||
|
css: ['app.scss'],
|
||||||
|
|
||||||
|
extras: ['roboto-font', 'material-icons'],
|
||||||
|
|
||||||
|
build: {
|
||||||
|
target: {
|
||||||
|
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||||
|
node: 'node20',
|
||||||
|
},
|
||||||
|
vueRouterMode: 'hash',
|
||||||
|
extendViteConf (viteConf) {
|
||||||
|
viteConf.base = process.env.DEPLOY_BASE || '/assets/client-app/'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
open: false,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 9002,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://erp.gigafibre.ca',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
framework: {
|
||||||
|
config: {},
|
||||||
|
plugins: ['Notify', 'Loading', 'Dialog'],
|
||||||
|
},
|
||||||
|
|
||||||
|
animations: [],
|
||||||
|
|
||||||
|
pwa: {
|
||||||
|
workboxMode: 'generateSW',
|
||||||
|
injectPwaMetaTags: true,
|
||||||
|
swFilename: 'sw.js',
|
||||||
|
manifestFilename: 'manifest.json',
|
||||||
|
useCredentialForManifestTag: false,
|
||||||
|
workboxOptions: {
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
|
},
|
||||||
|
extendManifestJson (json) {
|
||||||
|
json.name = 'Gigafibre'
|
||||||
|
json.short_name = 'Gigafibre'
|
||||||
|
json.description = 'Portail client Gigafibre'
|
||||||
|
json.display = 'standalone'
|
||||||
|
json.background_color = '#ffffff'
|
||||||
|
json.theme_color = '#0ea5e9'
|
||||||
|
json.start_url = '.'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
30
apps/client/src-pwa/custom-service-worker.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/* eslint-env serviceworker */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file (which will be your service worker)
|
||||||
|
* is picked up by the build system ONLY if
|
||||||
|
* quasar.config.js > pwa > workboxMode is set to "injectManifest"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
|
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching'
|
||||||
|
import { registerRoute, NavigationRoute } from 'workbox-routing'
|
||||||
|
|
||||||
|
self.skipWaiting()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
// Use with precache injection
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
|
||||||
|
cleanupOutdatedCaches()
|
||||||
|
|
||||||
|
// Non-SSR fallback to index.html
|
||||||
|
// Production SSR fallback to offline.html (except for dev)
|
||||||
|
if (process.env.MODE !== 'ssr' || process.env.PROD) {
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(
|
||||||
|
createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
|
||||||
|
{ denylist: [/sw\.js$/, /workbox-(.)*\.js$/] }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
32
apps/client/src-pwa/manifest.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#027be3",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
apps/client/src-pwa/pwa-flag.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
|
||||||
|
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
|
||||||
|
import "quasar/dist/types/feature-flag";
|
||||||
|
|
||||||
|
declare module "quasar/dist/types/feature-flag" {
|
||||||
|
interface QuasarFeatureFlags {
|
||||||
|
pwa: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/client/src-pwa/register-service-worker.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { register } from 'register-service-worker'
|
||||||
|
|
||||||
|
// The ready(), registered(), cached(), updatefound() and updated()
|
||||||
|
// events passes a ServiceWorkerRegistration instance in their arguments.
|
||||||
|
// ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
|
||||||
|
|
||||||
|
register(process.env.SERVICE_WORKER_FILE, {
|
||||||
|
// The registrationOptions object will be passed as the second argument
|
||||||
|
// to ServiceWorkerContainer.register()
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
|
||||||
|
|
||||||
|
// registrationOptions: { scope: './' },
|
||||||
|
|
||||||
|
ready (/* registration */) {
|
||||||
|
// console.log('Service worker is active.')
|
||||||
|
},
|
||||||
|
|
||||||
|
registered (/* registration */) {
|
||||||
|
// console.log('Service worker has been registered.')
|
||||||
|
},
|
||||||
|
|
||||||
|
cached (/* registration */) {
|
||||||
|
// console.log('Content has been cached for offline use.')
|
||||||
|
},
|
||||||
|
|
||||||
|
updatefound (/* registration */) {
|
||||||
|
// console.log('New content is downloading.')
|
||||||
|
},
|
||||||
|
|
||||||
|
updated (/* registration */) {
|
||||||
|
// console.log('New content is available; please refresh.')
|
||||||
|
},
|
||||||
|
|
||||||
|
offline () {
|
||||||
|
// console.log('No internet connection found. App is running in offline mode.')
|
||||||
|
},
|
||||||
|
|
||||||
|
error (/* err */) {
|
||||||
|
// console.error('Error during service worker registration:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
11
apps/client/src/App.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
|
||||||
|
const store = useCustomerStore()
|
||||||
|
onMounted(() => store.init())
|
||||||
|
</script>
|
||||||
38
apps/client/src/api/auth.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
|
||||||
|
|
||||||
|
export function authFetch (url, opts = {}) {
|
||||||
|
opts.headers = {
|
||||||
|
...opts.headers,
|
||||||
|
Authorization: 'token ' + SERVICE_TOKEN,
|
||||||
|
}
|
||||||
|
opts.redirect = 'manual'
|
||||||
|
if (opts.method && opts.method !== 'GET') {
|
||||||
|
opts.credentials = 'omit'
|
||||||
|
}
|
||||||
|
return fetch(url, opts).then(res => {
|
||||||
|
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
|
||||||
|
window.location.reload()
|
||||||
|
return new Response('{}', { status: 401 })
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoggedUser () {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
|
||||||
|
headers: { Authorization: 'token ' + SERVICE_TOKEN },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || 'authenticated'
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return 'authenticated'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout () {
|
||||||
|
window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/'
|
||||||
|
}
|
||||||
118
apps/client/src/api/portal.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { authFetch } from './auth'
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
async function apiGet (path) {
|
||||||
|
const res = await authFetch(BASE_URL + path)
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}: ${path}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.exc) throw new Error(data.exc)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current portal user info from Authentik headers.
|
||||||
|
* Returns { email, customer_id, customer_name }
|
||||||
|
*/
|
||||||
|
export async function getPortalUser () {
|
||||||
|
const data = await apiGet('/api/method/client_portal_get_user_info')
|
||||||
|
return data.message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch paginated Sales Invoices for a customer.
|
||||||
|
*/
|
||||||
|
export async function fetchInvoices (customer, { page = 1, pageSize = 20 } = {}) {
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
const filters = JSON.stringify([
|
||||||
|
['customer', '=', customer],
|
||||||
|
['docstatus', '=', 1],
|
||||||
|
])
|
||||||
|
const fields = JSON.stringify([
|
||||||
|
'name', 'posting_date', 'due_date', 'grand_total',
|
||||||
|
'outstanding_amount', 'status', 'currency',
|
||||||
|
])
|
||||||
|
const path = `/api/resource/Sales Invoice?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=posting_date desc&limit_page_length=${pageSize}&limit_start=${start}`
|
||||||
|
const data = await apiGet(path)
|
||||||
|
return data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count total invoices for pagination.
|
||||||
|
*/
|
||||||
|
export async function countInvoices (customer) {
|
||||||
|
const filters = JSON.stringify([
|
||||||
|
['customer', '=', customer],
|
||||||
|
['docstatus', '=', 1],
|
||||||
|
])
|
||||||
|
const data = await apiGet(`/api/method/frappe.client.get_count?doctype=Sales Invoice&filters=${encodeURIComponent(filters)}`)
|
||||||
|
return data.message || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download invoice PDF.
|
||||||
|
*/
|
||||||
|
export async function fetchInvoicePDF (invoiceName, format = 'Facture TARGO') {
|
||||||
|
const url = `${BASE_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=${encodeURIComponent(invoiceName)}&format=${encodeURIComponent(format)}`
|
||||||
|
const res = await authFetch(url)
|
||||||
|
if (!res.ok) throw new Error('PDF download failed')
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch customer's Issues/Tickets.
|
||||||
|
*/
|
||||||
|
export async function fetchTickets (customer) {
|
||||||
|
const filters = JSON.stringify([['customer', '=', customer]])
|
||||||
|
const fields = JSON.stringify([
|
||||||
|
'name', 'subject', 'status', 'priority', 'creation',
|
||||||
|
'issue_type', 'sla_resolution_date',
|
||||||
|
])
|
||||||
|
const path = `/api/resource/Issue?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=50`
|
||||||
|
const data = await apiGet(path)
|
||||||
|
return data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new support ticket.
|
||||||
|
*/
|
||||||
|
export async function createTicket (customer, subject, description) {
|
||||||
|
const res = await authFetch(BASE_URL + '/api/resource/Issue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer,
|
||||||
|
subject,
|
||||||
|
description,
|
||||||
|
issue_type: 'Support',
|
||||||
|
priority: 'Medium',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to create ticket')
|
||||||
|
const data = await res.json()
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch customer profile with addresses.
|
||||||
|
*/
|
||||||
|
export async function fetchProfile (customer) {
|
||||||
|
const data = await apiGet(`/api/resource/Customer/${encodeURIComponent(customer)}`)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch addresses linked to customer.
|
||||||
|
*/
|
||||||
|
export async function fetchAddresses (customer) {
|
||||||
|
const filters = JSON.stringify([
|
||||||
|
['Dynamic Link', 'link_doctype', '=', 'Customer'],
|
||||||
|
['Dynamic Link', 'link_name', '=', customer],
|
||||||
|
])
|
||||||
|
const fields = JSON.stringify([
|
||||||
|
'name', 'address_title', 'address_line1', 'address_line2',
|
||||||
|
'city', 'state', 'pincode', 'country', 'is_primary_address',
|
||||||
|
])
|
||||||
|
const path = `/api/resource/Address?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}`
|
||||||
|
const data = await apiGet(path)
|
||||||
|
return data.data || []
|
||||||
|
}
|
||||||
6
apps/client/src/boot/pinia.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { boot } from 'quasar/wrappers'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
|
app.use(createPinia())
|
||||||
|
})
|
||||||
24
apps/client/src/composables/useFormatters.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export function useFormatters () {
|
||||||
|
function formatDate (d) {
|
||||||
|
if (!d) return ''
|
||||||
|
return new Date(d).toLocaleDateString('fr-CA', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate (d) {
|
||||||
|
if (!d) return ''
|
||||||
|
return new Date(d).toLocaleDateString('fr-CA', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney (v) {
|
||||||
|
if (v == null) return ''
|
||||||
|
return Number(v).toLocaleString('fr-CA', {
|
||||||
|
style: 'currency', currency: 'CAD',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { formatDate, formatShortDate, formatMoney }
|
||||||
|
}
|
||||||
1
apps/client/src/config/erpnext.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const BASE_URL = ''
|
||||||
65
apps/client/src/css/app.scss
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Gigafibre Client Portal — Consumer branding
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--gf-primary: #0ea5e9; // sky-500
|
||||||
|
--gf-primary-dark: #0284c7; // sky-600
|
||||||
|
--gf-accent: #06b6d4; // cyan-500
|
||||||
|
--gf-bg: #f8fafc; // slate-50
|
||||||
|
--gf-surface: #ffffff;
|
||||||
|
--gf-text: #1e293b; // slate-800
|
||||||
|
--gf-text-secondary: #64748b; // slate-500
|
||||||
|
--gf-border: #e2e8f0; // slate-200
|
||||||
|
--gf-success: #22c55e;
|
||||||
|
--gf-warning: #f59e0b;
|
||||||
|
--gf-danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--gf-bg);
|
||||||
|
color: var(--gf-text);
|
||||||
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-drawer {
|
||||||
|
background: var(--gf-surface) !important;
|
||||||
|
border-right: 1px solid var(--gf-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-card {
|
||||||
|
background: var(--gf-surface);
|
||||||
|
border: 1px solid var(--gf-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header {
|
||||||
|
background: linear-gradient(135deg, var(--gf-primary), var(--gf-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status chips
|
||||||
|
.status-paid, .status-closed, .status-resolved {
|
||||||
|
color: var(--gf-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-unpaid, .status-overdue {
|
||||||
|
color: var(--gf-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-open {
|
||||||
|
color: var(--gf-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary card number
|
||||||
|
.summary-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gf-text);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
72
apps/client/src/layouts/PortalLayout.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<template>
|
||||||
|
<q-layout view="lHh Lpr lFf">
|
||||||
|
<q-header class="portal-header" elevated>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-btn flat dense round icon="menu" @click="drawer = !drawer" class="lt-md" />
|
||||||
|
<q-toolbar-title class="text-weight-bold">
|
||||||
|
Gigafibre
|
||||||
|
</q-toolbar-title>
|
||||||
|
<q-space />
|
||||||
|
<span v-if="store.customerName" class="text-body2 q-mr-md gt-sm">
|
||||||
|
{{ store.customerName }}
|
||||||
|
</span>
|
||||||
|
<q-btn flat round icon="logout" @click="doLogout" title="Déconnexion" />
|
||||||
|
</q-toolbar>
|
||||||
|
</q-header>
|
||||||
|
|
||||||
|
<q-drawer v-model="drawer" :width="240" :breakpoint="1024" bordered>
|
||||||
|
<q-list padding>
|
||||||
|
<q-item-label header class="text-weight-bold q-pb-sm">
|
||||||
|
Mon portail
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<q-item v-for="link in navLinks" :key="link.to"
|
||||||
|
clickable v-ripple :to="link.to" active-class="text-primary bg-blue-1">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="link.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>{{ link.label }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-drawer>
|
||||||
|
|
||||||
|
<q-page-container>
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="store.loading" class="flex flex-center" style="min-height: 60vh">
|
||||||
|
<q-spinner-dots size="48px" color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="store.error" class="flex flex-center" style="min-height: 60vh">
|
||||||
|
<div class="text-center">
|
||||||
|
<q-icon name="error_outline" size="64px" color="negative" />
|
||||||
|
<div class="text-h6 q-mt-md">{{ store.error }}</div>
|
||||||
|
<q-btn class="q-mt-lg" color="primary" label="Réessayer" @click="store.init()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<router-view v-else />
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { logout } from 'src/api/auth'
|
||||||
|
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const drawer = ref(true)
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
|
||||||
|
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
|
||||||
|
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
|
||||||
|
{ to: '/me', icon: 'person', label: 'Mon compte' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function doLogout () {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
63
apps/client/src/pages/AccountPage.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="page-title">Mon compte</div>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<!-- Customer info -->
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="text-subtitle1 text-weight-medium q-mb-md">Informations</div>
|
||||||
|
<div class="q-gutter-sm">
|
||||||
|
<div><strong>Nom:</strong> {{ store.customerName }}</div>
|
||||||
|
<div><strong>Courriel:</strong> {{ store.email }}</div>
|
||||||
|
<div><strong>No. client:</strong> {{ store.customerId }}</div>
|
||||||
|
<div v-if="profile"><strong>Langue:</strong> {{ profile.language || 'fr' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Addresses -->
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="text-subtitle1 text-weight-medium q-mb-md">Adresses de service</div>
|
||||||
|
<div v-if="!addresses.length" class="text-grey-6">Aucune adresse enregistrée</div>
|
||||||
|
<q-list v-else separator>
|
||||||
|
<q-item v-for="addr in addresses" :key="addr.name">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ addr.address_title || addr.address_line1 }}</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
{{ addr.address_line1 }}
|
||||||
|
<span v-if="addr.address_line2">, {{ addr.address_line2 }}</span>
|
||||||
|
<br>{{ addr.city }} {{ addr.state }} {{ addr.pincode }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side v-if="addr.is_primary_address">
|
||||||
|
<q-badge color="primary" label="Principal" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { fetchProfile, fetchAddresses } from 'src/api/portal'
|
||||||
|
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const profile = ref(null)
|
||||||
|
const addresses = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.customerId) return
|
||||||
|
const [p, a] = await Promise.all([
|
||||||
|
fetchProfile(store.customerId),
|
||||||
|
fetchAddresses(store.customerId),
|
||||||
|
])
|
||||||
|
profile.value = p
|
||||||
|
addresses.value = a
|
||||||
|
})
|
||||||
|
</script>
|
||||||
96
apps/client/src/pages/DashboardPage.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="page-title">Bonjour, {{ store.customerName }}</div>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<!-- Unpaid invoices -->
|
||||||
|
<div class="col-12 col-sm-6 col-md-4">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="text-caption text-grey-7">Factures impayées</div>
|
||||||
|
<div class="summary-value">{{ unpaidCount }}</div>
|
||||||
|
<div v-if="unpaidTotal > 0" class="text-body2 text-grey-7">
|
||||||
|
{{ formatMoney(unpaidTotal) }} à payer
|
||||||
|
</div>
|
||||||
|
<q-btn flat color="primary" label="Voir les factures" to="/invoices" class="q-mt-sm" no-caps />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open tickets -->
|
||||||
|
<div class="col-12 col-sm-6 col-md-4">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="text-caption text-grey-7">Tickets ouverts</div>
|
||||||
|
<div class="summary-value">{{ openTickets }}</div>
|
||||||
|
<q-btn flat color="primary" label="Voir le support" to="/tickets" class="q-mt-sm" no-caps />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick action -->
|
||||||
|
<div class="col-12 col-sm-6 col-md-4">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="text-caption text-grey-7">Besoin d'aide?</div>
|
||||||
|
<div class="text-h6 q-mt-xs">Contactez-nous</div>
|
||||||
|
<q-btn flat color="primary" label="Nouveau ticket" to="/tickets" class="q-mt-sm" no-caps icon="add" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent invoices -->
|
||||||
|
<div v-if="recentInvoices.length" class="q-mt-lg">
|
||||||
|
<div class="text-subtitle1 text-weight-medium q-mb-sm">Dernières factures</div>
|
||||||
|
<q-list bordered separator class="rounded-borders bg-white">
|
||||||
|
<q-item v-for="inv in recentInvoices" :key="inv.name" clickable @click="$router.push('/invoices')">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ inv.name }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ formatDate(inv.posting_date) }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-item-label>{{ formatMoney(inv.grand_total) }}</q-item-label>
|
||||||
|
<q-item-label caption :class="statusClass(inv.status)">
|
||||||
|
{{ inv.status }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { fetchInvoices, fetchTickets } from 'src/api/portal'
|
||||||
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const { formatDate, formatMoney } = useFormatters()
|
||||||
|
|
||||||
|
const recentInvoices = ref([])
|
||||||
|
const allTickets = ref([])
|
||||||
|
|
||||||
|
const unpaidCount = computed(() =>
|
||||||
|
recentInvoices.value.filter(i => i.outstanding_amount > 0).length,
|
||||||
|
)
|
||||||
|
const unpaidTotal = computed(() =>
|
||||||
|
recentInvoices.value.filter(i => i.outstanding_amount > 0)
|
||||||
|
.reduce((sum, i) => sum + i.outstanding_amount, 0),
|
||||||
|
)
|
||||||
|
const openTickets = computed(() =>
|
||||||
|
allTickets.value.filter(t => t.status === 'Open').length,
|
||||||
|
)
|
||||||
|
|
||||||
|
function statusClass (status) {
|
||||||
|
if (status === 'Paid') return 'status-paid'
|
||||||
|
if (status === 'Unpaid' || status === 'Overdue') return 'status-unpaid'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.customerId) return
|
||||||
|
const [invoices, tickets] = await Promise.all([
|
||||||
|
fetchInvoices(store.customerId, { pageSize: 5 }),
|
||||||
|
fetchTickets(store.customerId),
|
||||||
|
])
|
||||||
|
recentInvoices.value = invoices
|
||||||
|
allTickets.value = tickets
|
||||||
|
})
|
||||||
|
</script>
|
||||||
132
apps/client/src/pages/InvoicesPage.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="page-title">Factures</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
:rows="invoices"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="name"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
@request="onRequest"
|
||||||
|
flat bordered
|
||||||
|
class="bg-white"
|
||||||
|
no-data-label="Aucune facture"
|
||||||
|
>
|
||||||
|
<template #body-cell-posting_date="props">
|
||||||
|
<q-td :props="props">{{ formatShortDate(props.value) }}</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-grand_total="props">
|
||||||
|
<q-td :props="props" class="text-right">{{ formatMoney(props.value) }}</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-outstanding_amount="props">
|
||||||
|
<q-td :props="props" class="text-right">
|
||||||
|
<span :class="props.value > 0 ? 'status-unpaid' : 'status-paid'">
|
||||||
|
{{ formatMoney(props.value) }}
|
||||||
|
</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-status="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-badge :color="statusColor(props.value)" :label="statusLabel(props.value)" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-actions="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn flat dense round icon="picture_as_pdf" color="primary"
|
||||||
|
@click="downloadPDF(props.row.name)" :loading="downloading === props.row.name" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { fetchInvoices, countInvoices, fetchInvoicePDF } from 'src/api/portal'
|
||||||
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const { formatShortDate, formatMoney } = useFormatters()
|
||||||
|
|
||||||
|
const invoices = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const downloading = ref(null)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
rowsPerPage: 20,
|
||||||
|
rowsNumber: 0,
|
||||||
|
sortBy: 'posting_date',
|
||||||
|
descending: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'name', label: 'No.', field: 'name', align: 'left', sortable: true },
|
||||||
|
{ name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true },
|
||||||
|
{ name: 'grand_total', label: 'Total', field: 'grand_total', align: 'right', sortable: true },
|
||||||
|
{ name: 'outstanding_amount', label: 'Solde', field: 'outstanding_amount', align: 'right', sortable: true },
|
||||||
|
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
|
||||||
|
{ name: 'actions', label: '', field: 'actions', align: 'center' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusColor (s) {
|
||||||
|
if (s === 'Paid') return 'positive'
|
||||||
|
if (s === 'Overdue') return 'negative'
|
||||||
|
if (s === 'Unpaid') return 'warning'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel (s) {
|
||||||
|
const map = { Paid: 'Payée', Unpaid: 'Impayée', Overdue: 'En retard', 'Partly Paid': 'Partielle' }
|
||||||
|
return map[s] || s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage (page, pageSize) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchInvoices(store.customerId, { page, pageSize })
|
||||||
|
invoices.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRequest (props) {
|
||||||
|
const { page, rowsPerPage } = props.pagination
|
||||||
|
await loadPage(page, rowsPerPage)
|
||||||
|
pagination.value.page = page
|
||||||
|
pagination.value.rowsPerPage = rowsPerPage
|
||||||
|
pagination.value.rowsNumber = totalCount.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPDF (name) {
|
||||||
|
downloading.value = name
|
||||||
|
try {
|
||||||
|
const blob = await fetchInvoicePDF(name)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${name}.pdf`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('PDF download failed:', e)
|
||||||
|
} finally {
|
||||||
|
downloading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!store.customerId) return
|
||||||
|
totalCount.value = await countInvoices(store.customerId)
|
||||||
|
pagination.value.rowsNumber = totalCount.value
|
||||||
|
await loadPage(1, 20)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
129
apps/client/src/pages/TicketsPage.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div class="flex items-center justify-between q-mb-md">
|
||||||
|
<div class="page-title q-mb-none">Support</div>
|
||||||
|
<q-btn color="primary" icon="add" label="Nouveau ticket" no-caps @click="showCreate = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
:rows="tickets"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="name"
|
||||||
|
:loading="loading"
|
||||||
|
flat bordered
|
||||||
|
class="bg-white"
|
||||||
|
no-data-label="Aucun ticket"
|
||||||
|
:pagination="{ rowsPerPage: 50 }"
|
||||||
|
>
|
||||||
|
<template #body-cell-creation="props">
|
||||||
|
<q-td :props="props">{{ formatShortDate(props.value) }}</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-status="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-badge :color="statusColor(props.value)" :label="statusLabel(props.value)" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-priority="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-badge :color="priorityColor(props.value)" :label="props.value" outline />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
<!-- New ticket dialog -->
|
||||||
|
<q-dialog v-model="showCreate" persistent>
|
||||||
|
<q-card style="min-width: 400px; max-width: 600px">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Nouveau ticket de support</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-input v-model="newTicket.subject" label="Sujet" outlined class="q-mb-md"
|
||||||
|
:rules="[v => !!v || 'Le sujet est requis']" />
|
||||||
|
<q-input v-model="newTicket.description" label="Description" outlined type="textarea"
|
||||||
|
rows="4" :rules="[v => !!v || 'La description est requise']" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Annuler" @click="showCreate = false" />
|
||||||
|
<q-btn color="primary" label="Envoyer" :loading="creating"
|
||||||
|
@click="submitTicket" :disable="!newTicket.subject || !newTicket.description" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import { useCustomerStore } from 'src/stores/customer'
|
||||||
|
import { fetchTickets, createTicket } from 'src/api/portal'
|
||||||
|
import { useFormatters } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
const store = useCustomerStore()
|
||||||
|
const { formatShortDate } = useFormatters()
|
||||||
|
|
||||||
|
const tickets = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const newTicket = ref({ subject: '', description: '' })
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'name', label: 'No.', field: 'name', align: 'left' },
|
||||||
|
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
|
||||||
|
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
|
||||||
|
{ name: 'priority', label: 'Priorité', field: 'priority', align: 'center' },
|
||||||
|
{ name: 'creation', label: 'Créé le', field: 'creation', align: 'left' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusColor (s) {
|
||||||
|
if (s === 'Open') return 'primary'
|
||||||
|
if (s === 'Closed' || s === 'Resolved') return 'positive'
|
||||||
|
if (s === 'Replied') return 'info'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel (s) {
|
||||||
|
const map = { Open: 'Ouvert', Closed: 'Fermé', Resolved: 'Résolu', Replied: 'Répondu' }
|
||||||
|
return map[s] || s
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityColor (p) {
|
||||||
|
if (p === 'High' || p === 'Urgent') return 'negative'
|
||||||
|
if (p === 'Medium') return 'warning'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTickets () {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
tickets.value = await fetchTickets(store.customerId)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTicket () {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
await createTicket(store.customerId, newTicket.value.subject, newTicket.value.description)
|
||||||
|
showCreate.value = false
|
||||||
|
newTicket.value = { subject: '', description: '' }
|
||||||
|
$q.notify({ type: 'positive', message: 'Ticket créé avec succès' })
|
||||||
|
await loadTickets()
|
||||||
|
} catch (e) {
|
||||||
|
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (store.customerId) loadTickets()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
19
apps/client/src/router/index.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('layouts/PortalLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'dashboard', component: () => import('pages/DashboardPage.vue') },
|
||||||
|
{ path: 'invoices', name: 'invoices', component: () => import('pages/InvoicesPage.vue') },
|
||||||
|
{ path: 'tickets', name: 'tickets', component: () => import('pages/TicketsPage.vue') },
|
||||||
|
{ path: 'me', name: 'account', component: () => import('pages/AccountPage.vue') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
29
apps/client/src/stores/customer.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getPortalUser } from 'src/api/portal'
|
||||||
|
|
||||||
|
export const useCustomerStore = defineStore('customer', () => {
|
||||||
|
const email = ref('')
|
||||||
|
const customerId = ref('')
|
||||||
|
const customerName = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
async function init () {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const user = await getPortalUser()
|
||||||
|
email.value = user.email
|
||||||
|
customerId.value = user.customer_id
|
||||||
|
customerName.value = user.customer_name
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to resolve customer:', e)
|
||||||
|
error.value = e.message || 'Impossible de charger votre compte'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { email, customerId, customerName, loading, error, init }
|
||||||
|
})
|
||||||
|
|
@ -13,6 +13,6 @@
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/icon-128x128.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="icons/icon-128x128.png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="q-app"></div>
|
<!-- quasar:entry-point -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
11154
apps/field/package-lock.json
generated
Normal file
|
|
@ -10,13 +10,15 @@
|
||||||
"lint": "eslint --ext .js,.vue src"
|
"lint": "eslint --ext .js,.vue src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@quasar/cli": "^3.0.0",
|
||||||
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
"quasar": "^2.16.10",
|
"quasar": "^2.16.10",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"pinia": "^2.1.7",
|
"workbox-build": "^7.0.0"
|
||||||
"@quasar/extras": "^1.16.12",
|
|
||||||
"html5-qrcode": "^2.3.8",
|
|
||||||
"idb-keyval": "^6.2.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@quasar/app-vite": "^1.10.0",
|
"@quasar/app-vite": "^1.10.0",
|
||||||
|
|
|
||||||
10
apps/field/src-pwa/pwa-flag.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
|
||||||
|
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
|
||||||
|
import "quasar/dist/types/feature-flag";
|
||||||
|
|
||||||
|
declare module "quasar/dist/types/feature-flag" {
|
||||||
|
interface QuasarFeatureFlags {
|
||||||
|
pwa: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { BASE_URL } from 'src/config/erpnext'
|
||||||
import { authFetch } from './auth'
|
import { authFetch } from './auth'
|
||||||
|
|
||||||
// List documents with filters, fields, pagination
|
// List documents with filters, fields, pagination
|
||||||
export async function listDocs (doctype, { filters = {}, fields = ['name'], limit = 20, offset = 0, orderBy = 'creation desc' } = {}) {
|
export async function listDocs (doctype, { filters = {}, or_filters, fields = ['name'], limit = 20, offset = 0, orderBy = 'creation desc' } = {}) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
fields: JSON.stringify(fields),
|
fields: JSON.stringify(fields),
|
||||||
filters: JSON.stringify(filters),
|
filters: JSON.stringify(filters),
|
||||||
|
|
@ -11,6 +11,7 @@ export async function listDocs (doctype, { filters = {}, fields = ['name'], limi
|
||||||
limit_start: offset,
|
limit_start: offset,
|
||||||
order_by: orderBy,
|
order_by: orderBy,
|
||||||
})
|
})
|
||||||
|
if (or_filters) params.set('or_filters', JSON.stringify(or_filters))
|
||||||
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '?' + params)
|
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '?' + params)
|
||||||
if (!res.ok) throw new Error('API error: ' + res.status)
|
if (!res.ok) throw new Error('API error: ' + res.status)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -64,11 +65,12 @@ export async function updateDoc (doctype, name, data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count documents
|
// Count documents
|
||||||
export async function countDocs (doctype, filters = {}) {
|
export async function countDocs (doctype, filters = {}, or_filters) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
doctype,
|
doctype,
|
||||||
filters: JSON.stringify(filters),
|
filters: JSON.stringify(filters),
|
||||||
})
|
})
|
||||||
|
if (or_filters) params.set('or_filters', JSON.stringify(or_filters))
|
||||||
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
|
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
|
||||||
if (!res.ok) return 0
|
if (!res.ok) return 0
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
|
||||||
25
apps/ops/src/api/sms.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { authFetch } from './auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a test SMS notification via ERPNext server script.
|
||||||
|
* Falls back to logging if Twilio is not configured.
|
||||||
|
*
|
||||||
|
* @param {string} phone - Phone number (e.g. +15145551234)
|
||||||
|
* @param {string} message - SMS body
|
||||||
|
* @param {string} customer - Customer ID (e.g. CUST-4)
|
||||||
|
* @returns {Promise<{ok: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
export async function sendTestSms (phone, message, customer) {
|
||||||
|
const res = await authFetch(BASE_URL + '/api/method/send_sms_notification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ phone, message, customer }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text().catch(() => 'Unknown error')
|
||||||
|
throw new Error('SMS failed: ' + err)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
return data.message || { ok: true, message: 'Sent' }
|
||||||
|
}
|
||||||
|
|
@ -40,10 +40,153 @@
|
||||||
<span class="text-caption text-grey-6">Stripe: {{ customer.stripe_id }}</span>
|
<span class="text-caption text-grey-6">Stripe: {{ customer.stripe_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification section -->
|
||||||
|
<div class="notify-section q-mt-sm">
|
||||||
|
<div class="notify-header" @click="notifyExpanded = !notifyExpanded">
|
||||||
|
<q-icon :name="notifyExpanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" />
|
||||||
|
<q-icon name="notifications" size="16px" color="indigo-5" class="q-mr-xs" />
|
||||||
|
<span class="text-caption text-weight-medium text-grey-7">Envoyer notification</span>
|
||||||
|
<q-space />
|
||||||
|
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
|
||||||
|
</div>
|
||||||
|
<div v-show="notifyExpanded" class="notify-body">
|
||||||
|
<!-- Channel toggle -->
|
||||||
|
<q-btn-toggle v-model="channel" no-caps dense unelevated size="sm" class="q-mb-xs full-width"
|
||||||
|
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
|
||||||
|
:options="[
|
||||||
|
{ label: 'SMS', value: 'sms', icon: 'sms' },
|
||||||
|
{ label: 'Email', value: 'email', icon: 'email' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Recipient -->
|
||||||
|
<q-select v-if="channel === 'sms'" v-model="smsTo" dense outlined emit-value map-options
|
||||||
|
:options="phoneOptions" label="Envoyer à" style="font-size:0.82rem" class="q-mb-xs" />
|
||||||
|
<q-select v-else v-model="emailTo" dense outlined emit-value map-options
|
||||||
|
:options="emailOptions" label="Envoyer à" style="font-size:0.82rem" class="q-mb-xs" />
|
||||||
|
|
||||||
|
<!-- Subject (email only) -->
|
||||||
|
<q-input v-if="channel === 'email'" v-model="emailSubject" dense outlined
|
||||||
|
placeholder="Sujet" class="q-mb-xs" :input-style="{ fontSize: '0.82rem' }" />
|
||||||
|
|
||||||
|
<!-- Message body -->
|
||||||
|
<q-input v-model="notifyMessage" dense outlined type="textarea" autogrow
|
||||||
|
placeholder="Message..."
|
||||||
|
:input-style="{ fontSize: '0.82rem', minHeight: '45px', maxHeight: '120px' }" />
|
||||||
|
|
||||||
|
<div class="row items-center q-mt-xs">
|
||||||
|
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
|
||||||
|
<q-space />
|
||||||
|
<q-btn unelevated dense size="sm" :label="channel === 'sms' ? 'Envoyer SMS' : 'Envoyer Email'"
|
||||||
|
color="indigo-6" icon="send"
|
||||||
|
:disable="!canSend" :loading="sending" @click="sendNotification" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({ customer: { type: Object, required: true } })
|
import { ref, computed } from 'vue'
|
||||||
|
import { sendTestSms } from 'src/api/sms'
|
||||||
|
|
||||||
|
const props = defineProps({ customer: { type: Object, required: true } })
|
||||||
defineEmits(['save'])
|
defineEmits(['save'])
|
||||||
|
|
||||||
|
// Notification state
|
||||||
|
const notifyExpanded = ref(false)
|
||||||
|
const channel = ref('sms')
|
||||||
|
const notifyMessage = ref('Bonjour, ceci est une notification de Gigafibre.')
|
||||||
|
const emailSubject = ref('Notification Gigafibre')
|
||||||
|
const smsTo = ref('')
|
||||||
|
const emailTo = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
const lastSentLabel = ref('')
|
||||||
|
|
||||||
|
const phoneOptions = computed(() => {
|
||||||
|
const opts = []
|
||||||
|
if (props.customer.cell_phone) opts.push({ label: `Cell: ${props.customer.cell_phone}`, value: props.customer.cell_phone })
|
||||||
|
if (props.customer.tel_home) opts.push({ label: `Maison: ${props.customer.tel_home}`, value: props.customer.tel_home })
|
||||||
|
if (props.customer.tel_office) opts.push({ label: `Bureau: ${props.customer.tel_office}`, value: props.customer.tel_office })
|
||||||
|
if (!opts.length) opts.push({ label: 'Aucun numéro — ajouter ci-dessus', value: '', disable: true })
|
||||||
|
if (opts.length && opts[0].value && !smsTo.value) smsTo.value = opts[0].value
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
|
||||||
|
const emailOptions = computed(() => {
|
||||||
|
const opts = []
|
||||||
|
if (props.customer.email_billing) opts.push({ label: props.customer.email_billing, value: props.customer.email_billing })
|
||||||
|
if (!opts.length) opts.push({ label: 'Aucun email — ajouter ci-dessus', value: '', disable: true })
|
||||||
|
if (opts.length && opts[0].value && !emailTo.value) emailTo.value = opts[0].value
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSend = computed(() => {
|
||||||
|
if (channel.value === 'sms') return !!smsTo.value && !!notifyMessage.value.trim()
|
||||||
|
return !!emailTo.value && !!notifyMessage.value.trim() && !!emailSubject.value.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sendNotification () {
|
||||||
|
if (!canSend.value || sending.value) return
|
||||||
|
sending.value = true
|
||||||
|
lastSentLabel.value = ''
|
||||||
|
try {
|
||||||
|
if (channel.value === 'sms') {
|
||||||
|
const result = await sendTestSms(smsTo.value, notifyMessage.value.trim(), props.customer.name)
|
||||||
|
lastSentLabel.value = result?.simulated ? 'Simulé' : 'SMS envoyé'
|
||||||
|
} else {
|
||||||
|
// Email via same endpoint pattern — n8n webhook
|
||||||
|
const { authFetch } = await import('src/api/auth')
|
||||||
|
const { BASE_URL } = await import('src/config/erpnext')
|
||||||
|
const res = await authFetch(BASE_URL + '/api/method/send_email_notification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: emailTo.value,
|
||||||
|
subject: emailSubject.value.trim(),
|
||||||
|
message: notifyMessage.value.trim(),
|
||||||
|
customer: props.customer.name,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Email failed: ' + (await res.text()))
|
||||||
|
const data = await res.json()
|
||||||
|
lastSentLabel.value = data.message?.simulated ? 'Simulé' : 'Email envoyé'
|
||||||
|
}
|
||||||
|
const { Notify } = await import('quasar')
|
||||||
|
Notify?.create?.({ type: 'positive', message: lastSentLabel.value, timeout: 3000 })
|
||||||
|
setTimeout(() => { lastSentLabel.value = '' }, 5000)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Notification error:', e)
|
||||||
|
const { Notify } = await import('quasar')
|
||||||
|
Notify?.create?.({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notify-section {
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-header:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-body {
|
||||||
|
padding: 6px 0 2px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
|
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="text-h5 text-weight-bold">{{ customer.customer_name }}</div>
|
<div class="text-h5 text-weight-bold">
|
||||||
|
<InlineField :value="customer.customer_name" field="customer_name" doctype="Customer" :docname="customer.name"
|
||||||
|
placeholder="Nom du client" @saved="v => customer.customer_name = v.value" />
|
||||||
|
</div>
|
||||||
<div class="text-caption text-grey-6 row items-center no-wrap q-gutter-x-xs">
|
<div class="text-caption text-grey-6 row items-center no-wrap q-gutter-x-xs">
|
||||||
<span>{{ customer.name }}</span>
|
<span>{{ customer.name }}</span>
|
||||||
<template v-if="customer.legacy_customer_id"><span>· Legacy: {{ customer.legacy_customer_id }}</span></template>
|
<template v-if="customer.legacy_customer_id"><span>· Legacy: {{ customer.legacy_customer_id }}</span></template>
|
||||||
|
|
@ -38,6 +41,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
customer: { type: Object, required: true },
|
customer: { type: Object, required: true },
|
||||||
customerGroups: { type: Array, default: () => ['Commercial', 'Individual', 'Government', 'Non Profit'] },
|
customerGroups: { type: Array, default: () => ['Commercial', 'Individual', 'Government', 'Non Profit'] },
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,10 @@
|
||||||
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
|
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
|
||||||
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-indigo-6 cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
|
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-indigo-6 cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="doc.remarks && doc.remarks !== 'No Remarks'" class="q-mt-md">
|
<div class="q-mt-md">
|
||||||
<div class="info-block-title">Remarques</div>
|
<div class="info-block-title">Remarques</div>
|
||||||
<div class="modal-desc text-grey-8" style="white-space:pre-line">{{ doc.remarks }}</div>
|
<InlineField :value="doc.remarks === 'No Remarks' ? '' : doc.remarks" field="remarks" doctype="Sales Invoice" :docname="docName"
|
||||||
|
type="textarea" placeholder="Ajouter des remarques..." @saved="v => doc.remarks = v.value" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="doc.items?.length" class="q-mt-md">
|
<div v-if="doc.items?.length" class="q-mt-md">
|
||||||
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
|
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
|
||||||
|
|
@ -83,15 +84,33 @@
|
||||||
<!-- ═══ Issue / Ticket ═══ -->
|
<!-- ═══ Issue / Ticket ═══ -->
|
||||||
<template v-else-if="doctype === 'Issue'">
|
<template v-else-if="doctype === 'Issue'">
|
||||||
<div class="modal-field-grid">
|
<div class="modal-field-grid">
|
||||||
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="ticketStatusClass(doc.status)">{{ doc.status }}</span></div>
|
<div class="mf"><span class="mf-label">Statut</span>
|
||||||
<div class="mf"><span class="mf-label">Priorite</span><span class="ops-badge" :class="priorityClass(doc.priority)">{{ doc.priority }}</span></div>
|
<InlineField :value="doc.status" field="status" doctype="Issue" :docname="docName"
|
||||||
|
type="select" :options="['Open', 'Replied', 'On Hold', 'Resolved', 'Closed']"
|
||||||
|
@saved="v => doc.status = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="mf"><span class="mf-label">Priorite</span>
|
||||||
|
<InlineField :value="doc.priority" field="priority" doctype="Issue" :docname="docName"
|
||||||
|
type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
|
||||||
|
@saved="v => doc.priority = v.value" />
|
||||||
|
</div>
|
||||||
<div class="mf"><span class="mf-label">Ouvert le</span>{{ formatDate(doc.opening_date) }}</div>
|
<div class="mf"><span class="mf-label">Ouvert le</span>{{ formatDate(doc.opening_date) }}</div>
|
||||||
<div class="mf" v-if="doc.resolution_time"><span class="mf-label">Resolu le</span>{{ doc.resolution_time }}</div>
|
<div class="mf" v-if="doc.resolution_time"><span class="mf-label">Resolu le</span>{{ doc.resolution_time }}</div>
|
||||||
<div class="mf" v-if="doc.issue_type"><span class="mf-label">Type</span>{{ doc.issue_type }}</div>
|
<div class="mf"><span class="mf-label">Type</span>
|
||||||
|
<InlineField :value="doc.issue_type" field="issue_type" doctype="Issue" :docname="docName"
|
||||||
|
type="select" :options="['Support', 'Installation', 'Déménagement', 'Facturation', 'Réseau', 'Autre']"
|
||||||
|
placeholder="Type" @saved="v => doc.issue_type = v.value" />
|
||||||
|
</div>
|
||||||
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
||||||
<div class="mf" v-if="doc.raised_by"><span class="mf-label">Soumis par</span>{{ doc.raised_by }}</div>
|
<div class="mf" v-if="doc.raised_by"><span class="mf-label">Soumis par</span>{{ doc.raised_by }}</div>
|
||||||
<div class="mf" v-if="doc.owner"><span class="mf-label">Proprietaire</span>{{ doc.owner }}</div>
|
<div class="mf" v-if="doc.owner"><span class="mf-label">Proprietaire</span>{{ doc.owner }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Editable subject -->
|
||||||
|
<div class="q-mt-sm">
|
||||||
|
<div class="info-block-title">Sujet</div>
|
||||||
|
<InlineField :value="doc.subject" field="subject" doctype="Issue" :docname="docName"
|
||||||
|
placeholder="Sujet du ticket" @saved="v => doc.subject = v.value" />
|
||||||
|
</div>
|
||||||
<div v-if="doc.issue_split_from" class="q-mt-md">
|
<div v-if="doc.issue_split_from" class="q-mt-md">
|
||||||
<div class="info-block-title">Ticket parent</div>
|
<div class="info-block-title">Ticket parent</div>
|
||||||
<div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
|
<div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
|
||||||
|
|
@ -142,6 +161,19 @@
|
||||||
<div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg">
|
<div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg">
|
||||||
Aucun contenu pour ce ticket
|
Aucun contenu pour ce ticket
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply input -->
|
||||||
|
<div class="q-mt-md reply-box">
|
||||||
|
<div class="info-block-title">Repondre</div>
|
||||||
|
<q-input v-model="replyContent" dense outlined type="textarea" autogrow
|
||||||
|
placeholder="Ecrire une reponse..."
|
||||||
|
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
|
||||||
|
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
|
||||||
|
<div class="row justify-end q-mt-xs">
|
||||||
|
<q-btn unelevated dense size="sm" label="Envoyer" color="indigo-6" icon="send"
|
||||||
|
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ═══ Payment Entry ═══ -->
|
<!-- ═══ Payment Entry ═══ -->
|
||||||
|
|
@ -211,16 +243,46 @@
|
||||||
<!-- ═══ Service Equipment ═══ -->
|
<!-- ═══ Service Equipment ═══ -->
|
||||||
<template v-else-if="doctype === 'Service Equipment'">
|
<template v-else-if="doctype === 'Service Equipment'">
|
||||||
<div class="modal-field-grid">
|
<div class="modal-field-grid">
|
||||||
<div class="mf"><span class="mf-label">Type</span>{{ doc.equipment_type }}</div>
|
<div class="mf"><span class="mf-label">Type</span>
|
||||||
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="eqStatusClass(doc.status)">{{ doc.status }}</span></div>
|
<InlineField :value="doc.equipment_type" field="equipment_type" doctype="Service Equipment" :docname="docName"
|
||||||
<div class="mf"><span class="mf-label">Marque</span>{{ doc.brand || '---' }}</div>
|
type="select" :options="['ONT', 'Router', 'Switch', 'AP', 'OLT', 'Décodeur', 'Modem', 'Autre']"
|
||||||
<div class="mf"><span class="mf-label">Modele</span>{{ doc.model || '---' }}</div>
|
@saved="v => doc.equipment_type = v.value" />
|
||||||
<div class="mf"><span class="mf-label">N serie</span><code>{{ doc.serial_number }}</code></div>
|
</div>
|
||||||
<div class="mf" v-if="doc.mac_address"><span class="mf-label">MAC</span><code>{{ doc.mac_address }}</code></div>
|
<div class="mf"><span class="mf-label">Statut</span>
|
||||||
|
<InlineField :value="doc.status" field="status" doctype="Service Equipment" :docname="docName"
|
||||||
|
type="select" :options="['Active', 'Inactive', 'En stock', 'Défectueux', 'Retourné']"
|
||||||
|
@saved="v => doc.status = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="mf"><span class="mf-label">Marque</span>
|
||||||
|
<InlineField :value="doc.brand" field="brand" doctype="Service Equipment" :docname="docName"
|
||||||
|
placeholder="—" @saved="v => doc.brand = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="mf"><span class="mf-label">Modele</span>
|
||||||
|
<InlineField :value="doc.model" field="model" doctype="Service Equipment" :docname="docName"
|
||||||
|
placeholder="—" @saved="v => doc.model = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="mf"><span class="mf-label">N serie</span>
|
||||||
|
<InlineField :value="doc.serial_number" field="serial_number" doctype="Service Equipment" :docname="docName"
|
||||||
|
placeholder="—" @saved="v => doc.serial_number = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="mf"><span class="mf-label">MAC</span>
|
||||||
|
<InlineField :value="doc.mac_address" field="mac_address" doctype="Service Equipment" :docname="docName"
|
||||||
|
placeholder="—" @saved="v => doc.mac_address = v.value" />
|
||||||
|
</div>
|
||||||
<div class="mf" v-if="doc.barcode"><span class="mf-label">Code-barres</span><code>{{ doc.barcode }}</code></div>
|
<div class="mf" v-if="doc.barcode"><span class="mf-label">Code-barres</span><code>{{ doc.barcode }}</code></div>
|
||||||
<div class="mf" v-if="doc.ip_address"><span class="mf-label">IP</span><code>{{ doc.ip_address }}</code></div>
|
<div class="mf"><span class="mf-label">IP</span>
|
||||||
<div class="mf" v-if="doc.firmware_version"><span class="mf-label">Firmware</span>{{ doc.firmware_version }}</div>
|
<InlineField :value="doc.ip_address" field="ip_address" doctype="Service Equipment" :docname="docName"
|
||||||
<div class="mf"><span class="mf-label">Propriete</span>{{ doc.ownership || '---' }}</div>
|
placeholder="—" @saved="v => doc.ip_address = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="mf"><span class="mf-label">Firmware</span>
|
||||||
|
<InlineField :value="doc.firmware_version" field="firmware_version" doctype="Service Equipment" :docname="docName"
|
||||||
|
placeholder="—" @saved="v => doc.firmware_version = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="mf"><span class="mf-label">Propriete</span>
|
||||||
|
<InlineField :value="doc.ownership" field="ownership" doctype="Service Equipment" :docname="docName"
|
||||||
|
type="select" :options="['Client', 'Compagnie', 'Location']"
|
||||||
|
placeholder="—" @saved="v => doc.ownership = v.value" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="doc.olt_name" class="q-mt-md">
|
<div v-if="doc.olt_name" class="q-mt-md">
|
||||||
<div class="info-block-title">Information OLT</div>
|
<div class="info-block-title">Information OLT</div>
|
||||||
|
|
@ -235,13 +297,17 @@
|
||||||
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
||||||
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
|
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="doc.login_user" class="q-mt-md">
|
<div class="q-mt-md">
|
||||||
<div class="info-block-title">Acces distant</div>
|
<div class="info-block-title">Acces distant</div>
|
||||||
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span><code>{{ doc.login_user }}</code></div>
|
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span>
|
||||||
|
<InlineField :value="doc.login_user" field="login_user" doctype="Service Equipment" :docname="docName"
|
||||||
|
placeholder="—" @saved="v => doc.login_user = v.value" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="doc.notes" class="q-mt-md">
|
</div>
|
||||||
|
<div class="q-mt-md">
|
||||||
<div class="info-block-title">Notes</div>
|
<div class="info-block-title">Notes</div>
|
||||||
<div class="modal-desc">{{ doc.notes }}</div>
|
<InlineField :value="doc.notes" field="notes" doctype="Service Equipment" :docname="docName"
|
||||||
|
type="textarea" placeholder="Ajouter des notes..." @saved="v => doc.notes = v.value" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="doc.move_log?.length" class="q-mt-md">
|
<div v-if="doc.move_log?.length" class="q-mt-md">
|
||||||
<div class="info-block-title">Historique ({{ doc.move_log.length }})</div>
|
<div class="info-block-title">Historique ({{ doc.move_log.length }})</div>
|
||||||
|
|
@ -270,7 +336,8 @@
|
||||||
import { formatDate, formatDateShort, formatMoney, erpFileUrl } from 'src/composables/useFormatters'
|
import { formatDate, formatDateShort, formatMoney, erpFileUrl } from 'src/composables/useFormatters'
|
||||||
import { invStatusClass, ticketStatusClass, priorityClass, subStatusClass, eqStatusClass } from 'src/composables/useStatusClasses'
|
import { invStatusClass, ticketStatusClass, priorityClass, subStatusClass, eqStatusClass } from 'src/composables/useStatusClasses'
|
||||||
import { erpLink } from 'src/composables/useFormatters'
|
import { erpLink } from 'src/composables/useFormatters'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: Boolean,
|
open: Boolean,
|
||||||
|
|
@ -285,7 +352,7 @@ const props = defineProps({
|
||||||
docFields: { type: Object, default: () => ({}) },
|
docFields: { type: Object, default: () => ({}) },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring'])
|
const emit = defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring', 'reply-sent'])
|
||||||
|
|
||||||
const erpLinkUrl = computed(() => erpLink(props.doctype, props.docName))
|
const erpLinkUrl = computed(() => erpLink(props.doctype, props.docName))
|
||||||
|
|
||||||
|
|
@ -314,6 +381,38 @@ function formatDateTime (dt) {
|
||||||
function openExternal (url) {
|
function openExternal (url) {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Reply to ticket ───
|
||||||
|
import { createDoc } from 'src/api/erp'
|
||||||
|
|
||||||
|
const replyContent = ref('')
|
||||||
|
const sendingReply = ref(false)
|
||||||
|
|
||||||
|
async function sendReply () {
|
||||||
|
if (!replyContent.value?.trim() || sendingReply.value) return
|
||||||
|
sendingReply.value = true
|
||||||
|
try {
|
||||||
|
await createDoc('Communication', {
|
||||||
|
communication_type: 'Communication',
|
||||||
|
communication_medium: 'Other',
|
||||||
|
sent_or_received: 'Sent',
|
||||||
|
subject: props.title || props.docName,
|
||||||
|
content: replyContent.value.trim(),
|
||||||
|
reference_doctype: 'Issue',
|
||||||
|
reference_name: props.docName,
|
||||||
|
})
|
||||||
|
replyContent.value = ''
|
||||||
|
emit('reply-sent', props.docName)
|
||||||
|
const { Notify } = await import('quasar')
|
||||||
|
Notify?.create?.({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send reply:', e)
|
||||||
|
const { Notify } = await import('quasar')
|
||||||
|
Notify?.create?.({ type: 'negative', message: 'Erreur: reponse non envoyee', timeout: 3000 })
|
||||||
|
} finally {
|
||||||
|
sendingReply.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -415,4 +514,9 @@ function openExternal (url) {
|
||||||
.thread-body :deep(br + br) {
|
.thread-body :deep(br + br) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reply-box {
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
241
apps/ops/src/components/shared/InlineField.vue
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-field"
|
||||||
|
:class="{
|
||||||
|
'inline-field--editing': editing,
|
||||||
|
'inline-field--saving': saving,
|
||||||
|
'inline-field--readonly': readonly,
|
||||||
|
}"
|
||||||
|
@dblclick.stop="startEdit"
|
||||||
|
>
|
||||||
|
<!-- Display mode -->
|
||||||
|
<template v-if="!editing">
|
||||||
|
<slot name="display" :value="value" :display-value="displayValue" :start-edit="startEdit">
|
||||||
|
<span class="inline-field__value" :class="{ 'inline-field__placeholder': !displayValue }">
|
||||||
|
{{ displayValue || placeholder }}
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
<q-spinner v-if="saving" size="12px" color="indigo-6" class="q-ml-xs" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Edit mode: text / number -->
|
||||||
|
<q-input
|
||||||
|
v-else-if="type === 'text' || type === 'number'"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="editValue"
|
||||||
|
:type="type === 'number' ? 'number' : 'text'"
|
||||||
|
dense
|
||||||
|
borderless
|
||||||
|
:input-class="'inline-field__input'"
|
||||||
|
:autofocus="true"
|
||||||
|
@keydown.enter.prevent="commitEdit"
|
||||||
|
@keydown.esc.prevent="cancelEdit"
|
||||||
|
@blur="onBlur"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit mode: textarea -->
|
||||||
|
<q-input
|
||||||
|
v-else-if="type === 'textarea'"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="editValue"
|
||||||
|
type="textarea"
|
||||||
|
dense
|
||||||
|
borderless
|
||||||
|
autogrow
|
||||||
|
:input-class="'inline-field__input'"
|
||||||
|
:autofocus="true"
|
||||||
|
@keydown.esc.prevent="cancelEdit"
|
||||||
|
@keydown.ctrl.enter="commitEdit"
|
||||||
|
@keydown.meta.enter="commitEdit"
|
||||||
|
@blur="onBlur"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit mode: select -->
|
||||||
|
<q-select
|
||||||
|
v-else-if="type === 'select'"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="editValue"
|
||||||
|
:options="options"
|
||||||
|
dense
|
||||||
|
borderless
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
:input-class="'inline-field__input'"
|
||||||
|
:autofocus="true"
|
||||||
|
@update:model-value="commitEdit"
|
||||||
|
@keydown.esc.prevent="cancelEdit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edit mode: date -->
|
||||||
|
<q-input
|
||||||
|
v-else-if="type === 'date'"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="editValue"
|
||||||
|
type="date"
|
||||||
|
dense
|
||||||
|
borderless
|
||||||
|
:input-class="'inline-field__input'"
|
||||||
|
:autofocus="true"
|
||||||
|
@keydown.enter.prevent="commitEdit"
|
||||||
|
@keydown.esc.prevent="cancelEdit"
|
||||||
|
@blur="onBlur"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
import { useInlineEdit } from 'src/composables/useInlineEdit'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Current field value */
|
||||||
|
value: { type: [String, Number, null], default: '' },
|
||||||
|
/** Field name in the doctype */
|
||||||
|
field: { type: String, required: true },
|
||||||
|
/** ERPNext doctype */
|
||||||
|
doctype: { type: String, required: true },
|
||||||
|
/** ERPNext document name */
|
||||||
|
docname: { type: String, required: true },
|
||||||
|
/** Input type: text, number, textarea, select, date */
|
||||||
|
type: { type: String, default: 'text' },
|
||||||
|
/** Options for select type — array of strings or { label, value } */
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
/** Placeholder when value is empty */
|
||||||
|
placeholder: { type: String, default: '—' },
|
||||||
|
/** If true, disable editing */
|
||||||
|
readonly: { type: Boolean, default: false },
|
||||||
|
/** Custom display formatter */
|
||||||
|
formatter: { type: Function, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved', 'editing'])
|
||||||
|
|
||||||
|
const editing = ref(false)
|
||||||
|
const editValue = ref('')
|
||||||
|
const inputRef = ref(null)
|
||||||
|
const committing = ref(false) // prevents double-fire from blur + enter/select
|
||||||
|
const { save, saving } = useInlineEdit()
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
if (props.formatter) return props.formatter(props.value)
|
||||||
|
if (props.type === 'select' && props.options.length) {
|
||||||
|
const opt = props.options.find(o => (typeof o === 'object' ? o.value : o) === props.value)
|
||||||
|
return opt ? (typeof opt === 'object' ? opt.label : opt) : props.value
|
||||||
|
}
|
||||||
|
return props.value != null && props.value !== '' ? String(props.value) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function startEdit () {
|
||||||
|
if (props.readonly || editing.value) return
|
||||||
|
editValue.value = props.value ?? ''
|
||||||
|
editing.value = true
|
||||||
|
committing.value = false
|
||||||
|
emit('editing', true)
|
||||||
|
nextTick(() => {
|
||||||
|
const el = inputRef.value
|
||||||
|
if (el) {
|
||||||
|
if (typeof el.focus === 'function') el.focus()
|
||||||
|
if (props.type === 'select' && typeof el.showPopup === 'function') el.showPopup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur handler with small delay to let Enter/select fire first
|
||||||
|
function onBlur () {
|
||||||
|
// Delay so Enter keydown or select @update fires first and sets committing
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!committing.value && editing.value) commitEdit()
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitEdit () {
|
||||||
|
if (!editing.value || committing.value) return
|
||||||
|
committing.value = true
|
||||||
|
const newVal = props.type === 'number' ? Number(editValue.value) : editValue.value
|
||||||
|
editing.value = false
|
||||||
|
emit('editing', false)
|
||||||
|
|
||||||
|
// Skip save if value unchanged
|
||||||
|
if (newVal === props.value) {
|
||||||
|
committing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await save(props.doctype, props.docname, props.field, newVal)
|
||||||
|
if (ok) {
|
||||||
|
emit('saved', { field: props.field, value: newVal, doctype: props.doctype, docname: props.docname })
|
||||||
|
}
|
||||||
|
committing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit () {
|
||||||
|
committing.value = true // prevent blur from firing after cancel
|
||||||
|
editing.value = false
|
||||||
|
emit('editing', false)
|
||||||
|
setTimeout(() => { committing.value = false }, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allow parent to trigger edit programmatically */
|
||||||
|
defineExpose({ startEdit })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.inline-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:not(&--readonly):not(&--editing) {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--editing {
|
||||||
|
background: #e0f2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--saving {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
padding: 1px 4px;
|
||||||
|
min-height: 1.4em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__placeholder {
|
||||||
|
color: #9e9e9e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
font-size: inherit !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
padding: 1px 4px !important;
|
||||||
|
min-height: 1.4em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override Quasar input padding when inline
|
||||||
|
.q-field__control {
|
||||||
|
min-height: unset !important;
|
||||||
|
height: auto !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-field__native {
|
||||||
|
padding: 0 !important;
|
||||||
|
min-height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-field--dense .q-field__control {
|
||||||
|
min-height: unset !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
apps/ops/src/composables/useInlineEdit.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { updateDoc } from 'src/api/erp'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for inline field editing with optimistic UI.
|
||||||
|
* Usage: const { save, saving, error } = useInlineEdit()
|
||||||
|
*/
|
||||||
|
export function useInlineEdit () {
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a single field to ERPNext.
|
||||||
|
* @param {string} doctype - e.g. 'Service Location'
|
||||||
|
* @param {string} docname - e.g. 'SL-00123'
|
||||||
|
* @param {string} field - e.g. 'city'
|
||||||
|
* @param {*} value - new value
|
||||||
|
* @returns {Promise<boolean>} true if saved successfully
|
||||||
|
*/
|
||||||
|
async function save (doctype, docname, field, value) {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await updateDoc(doctype, docname, { [field]: value ?? '' })
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || 'Erreur de sauvegarde'
|
||||||
|
console.error(`[InlineEdit] Failed to save ${doctype}/${docname}.${field}:`, e)
|
||||||
|
// Show notification
|
||||||
|
try {
|
||||||
|
const { Notify } = await import('quasar')
|
||||||
|
Notify?.create?.({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Erreur: ${field} non sauvegardé`,
|
||||||
|
caption: e.message,
|
||||||
|
timeout: 3000,
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { save, saving, error }
|
||||||
|
}
|
||||||
|
|
@ -49,13 +49,23 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="text-subtitle1 text-weight-bold" :class="{ 'text-grey-6': !locHasSubs(loc.name) }">
|
<div class="text-subtitle1 text-weight-bold" :class="{ 'text-grey-6': !locHasSubs(loc.name) }">
|
||||||
{{ loc.address_line }}
|
<InlineField :value="loc.address_line" field="address_line" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Adresse" @saved="v => loc.address_line = v.value" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey-6">
|
<div class="text-caption text-grey-6">
|
||||||
{{ loc.city }}<template v-if="loc.postal_code">, {{ loc.postal_code }}</template>
|
<InlineField :value="loc.city" field="city" doctype="Service Location" :docname="loc.name"
|
||||||
<template v-if="loc.location_name"> — {{ loc.location_name }}</template>
|
placeholder="Ville" @saved="v => loc.city = v.value" /><template v-if="loc.postal_code || true">,
|
||||||
<template v-if="loc.contact_name"> · Contact: {{ loc.contact_name }}</template>
|
<InlineField :value="loc.postal_code" field="postal_code" doctype="Service Location" :docname="loc.name"
|
||||||
<template v-if="loc.contact_phone"> {{ loc.contact_phone }}</template>
|
placeholder="Code postal" @saved="v => loc.postal_code = v.value" /></template>
|
||||||
|
<template v-if="loc.location_name || true"> —
|
||||||
|
<InlineField :value="loc.location_name" field="location_name" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Nom du lieu" @saved="v => loc.location_name = v.value" /></template>
|
||||||
|
<template v-if="loc.contact_name || true"> · Contact:
|
||||||
|
<InlineField :value="loc.contact_name" field="contact_name" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Contact" @saved="v => loc.contact_name = v.value" /></template>
|
||||||
|
<template v-if="loc.contact_phone || true">
|
||||||
|
<InlineField :value="loc.contact_phone" field="contact_phone" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Téléphone" @saved="v => loc.contact_phone = v.value" /></template>
|
||||||
<template v-if="!locHasSubs(loc.name)"> · <em>Aucun abonnement</em></template>
|
<template v-if="!locHasSubs(loc.name)"> · <em>Aucun abonnement</em></template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,10 +85,15 @@
|
||||||
|
|
||||||
<!-- Network info + Device strip (inline) -->
|
<!-- Network info + Device strip (inline) -->
|
||||||
<div v-if="locEquip(loc.name).length || loc.connection_type" class="row items-center q-mt-sm q-mb-xs q-gutter-x-md">
|
<div v-if="locEquip(loc.name).length || loc.connection_type" class="row items-center q-mt-sm q-mb-xs q-gutter-x-md">
|
||||||
<div v-if="loc.connection_type" class="text-caption text-grey-6">
|
<div class="text-caption text-grey-6">
|
||||||
<q-icon name="cable" size="14px" class="q-mr-xs" />{{ loc.connection_type }}
|
<q-icon name="cable" size="14px" class="q-mr-xs" />
|
||||||
<template v-if="loc.olt_port"> · OLT: {{ loc.olt_port }}</template>
|
<InlineField :value="loc.connection_type" field="connection_type" doctype="Service Location" :docname="loc.name"
|
||||||
<template v-if="loc.network_id"> · {{ loc.network_id }}</template>
|
type="select" :options="['Fibre', 'Coax', 'DSL', 'Wireless', 'LTE']"
|
||||||
|
placeholder="Type" @saved="v => loc.connection_type = v.value" />
|
||||||
|
· OLT: <InlineField :value="loc.olt_port" field="olt_port" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Port OLT" @saved="v => loc.olt_port = v.value" />
|
||||||
|
· <InlineField :value="loc.network_id" field="network_id" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Network ID" @saved="v => loc.network_id = v.value" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="locEquip(loc.name).length" class="device-strip">
|
<div v-if="locEquip(loc.name).length" class="device-strip">
|
||||||
<div v-for="eq in locEquip(loc.name)" :key="eq.name" class="device-icon-chip" :class="deviceColorClass(eq.status)"
|
<div v-for="eq in locEquip(loc.name)" :key="eq.name" class="device-icon-chip" :class="deviceColorClass(eq.status)"
|
||||||
|
|
@ -522,6 +537,7 @@ import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
|
||||||
import ContactCard from 'src/components/customer/ContactCard.vue'
|
import ContactCard from 'src/components/customer/ContactCard.vue'
|
||||||
import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
|
import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
|
||||||
import BillingKPIs from 'src/components/customer/BillingKPIs.vue'
|
import BillingKPIs from 'src/components/customer/BillingKPIs.vue'
|
||||||
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
|
|
||||||
const props = defineProps({ id: String })
|
const props = defineProps({ id: String })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="row items-center q-mb-md q-col-gutter-sm">
|
<div class="row items-center q-mb-md q-col-gutter-sm">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="search" dense outlined placeholder="Nom, téléphone, adresse..."
|
v-model="search" dense outlined placeholder="Nom, ID legacy, numéro de compte..."
|
||||||
class="ops-search" @update:model-value="onSearchInput" @keyup.enter="doSearch" autofocus
|
class="ops-search" @update:model-value="onSearchInput" @keyup.enter="doSearch" autofocus
|
||||||
>
|
>
|
||||||
<template #prepend><q-icon name="search" /></template>
|
<template #prepend><q-icon name="search" /></template>
|
||||||
|
|
@ -48,8 +48,28 @@
|
||||||
</template>
|
</template>
|
||||||
<template #body-cell-customer_name="props">
|
<template #body-cell-customer_name="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<div class="text-weight-medium">{{ props.row.customer_name }}</div>
|
<div class="text-weight-medium" @dblclick.stop>
|
||||||
<div class="text-caption text-grey-6">{{ props.row.name }}</div>
|
<InlineField :value="props.row.customer_name" field="customer_name" doctype="Customer" :docname="props.row.name"
|
||||||
|
placeholder="Nom" @saved="v => props.row.customer_name = v.value" />
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-6">
|
||||||
|
{{ props.row.name }}
|
||||||
|
<template v-if="props.row.legacy_customer_id"> · {{ props.row.legacy_customer_id }}</template>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-customer_type="props">
|
||||||
|
<q-td :props="props" @dblclick.stop>
|
||||||
|
<InlineField :value="props.row.customer_type" field="customer_type" doctype="Customer" :docname="props.row.name"
|
||||||
|
type="select" :options="['Individual', 'Company']"
|
||||||
|
@saved="v => props.row.customer_type = v.value" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-customer_group="props">
|
||||||
|
<q-td :props="props" @dblclick.stop>
|
||||||
|
<InlineField :value="props.row.customer_group" field="customer_group" doctype="Customer" :docname="props.row.name"
|
||||||
|
type="select" :options="customerGroups"
|
||||||
|
@saved="v => props.row.customer_group = v.value" />
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
|
|
@ -60,8 +80,10 @@
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { listDocs, countDocs } from 'src/api/erp'
|
import { listDocs, countDocs } from 'src/api/erp'
|
||||||
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const customerGroups = ['Commercial', 'Individual', 'Government', 'Non Profit']
|
||||||
const search = ref(route.query.q || '')
|
const search = ref(route.query.q || '')
|
||||||
const statusFilter = ref('active')
|
const statusFilter = ref('active')
|
||||||
const clients = ref([])
|
const clients = ref([])
|
||||||
|
|
@ -72,6 +94,7 @@ let searchTimer = null
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
|
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
|
||||||
|
{ name: 'legacy_customer_id', label: 'ID Legacy', field: 'legacy_customer_id', align: 'left' },
|
||||||
{ name: 'customer_type', label: 'Type', field: 'customer_type', align: 'left' },
|
{ name: 'customer_type', label: 'Type', field: 'customer_type', align: 'left' },
|
||||||
{ name: 'customer_group', label: 'Groupe', field: 'customer_group', align: 'left' },
|
{ name: 'customer_group', label: 'Groupe', field: 'customer_group', align: 'left' },
|
||||||
{ name: 'territory', label: 'Territoire', field: 'territory', align: 'left' },
|
{ name: 'territory', label: 'Territoire', field: 'territory', align: 'left' },
|
||||||
|
|
@ -89,23 +112,30 @@ function onSearchInput () {
|
||||||
async function doSearch () {
|
async function doSearch () {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const filters = {}
|
const filters = {}
|
||||||
|
let or_filters
|
||||||
|
|
||||||
if (statusFilter.value === 'active') filters.disabled = 0
|
if (statusFilter.value === 'active') filters.disabled = 0
|
||||||
else if (statusFilter.value === 'disabled') filters.disabled = 1
|
else if (statusFilter.value === 'disabled') filters.disabled = 1
|
||||||
|
|
||||||
if (search.value.trim()) {
|
if (search.value.trim()) {
|
||||||
filters.customer_name = ['like', '%' + search.value.trim() + '%']
|
const q = '%' + search.value.trim() + '%'
|
||||||
|
or_filters = [
|
||||||
|
['customer_name', 'like', q],
|
||||||
|
['name', 'like', q],
|
||||||
|
['legacy_customer_id', 'like', q],
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, count] = await Promise.all([
|
const [data, count] = await Promise.all([
|
||||||
listDocs('Customer', {
|
listDocs('Customer', {
|
||||||
filters,
|
filters,
|
||||||
fields: ['name', 'customer_name', 'customer_type', 'customer_group', 'territory', 'disabled'],
|
or_filters,
|
||||||
|
fields: ['name', 'customer_name', 'customer_type', 'customer_group', 'territory', 'disabled', 'legacy_customer_id'],
|
||||||
limit: pagination.value.rowsPerPage,
|
limit: pagination.value.rowsPerPage,
|
||||||
offset: (pagination.value.page - 1) * pagination.value.rowsPerPage,
|
offset: (pagination.value.page - 1) * pagination.value.rowsPerPage,
|
||||||
orderBy: 'customer_name asc',
|
orderBy: 'customer_name asc',
|
||||||
}),
|
}),
|
||||||
countDocs('Customer', filters),
|
countDocs('Customer', filters, or_filters),
|
||||||
])
|
])
|
||||||
|
|
||||||
clients.value = data
|
clients.value = data
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,24 @@
|
||||||
</template>
|
</template>
|
||||||
<template #body-cell-status="props">
|
<template #body-cell-status="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
|
<InlineField :value="props.row.status" field="status" doctype="Issue" :docname="props.row.name"
|
||||||
|
type="select" :options="['Open', 'Replied', 'On Hold', 'Resolved', 'Closed']"
|
||||||
|
@saved="v => props.row.status = v.value">
|
||||||
|
<template #display>
|
||||||
<span class="ops-badge" :class="statusClass(props.row.status)">{{ props.row.status }}</span>
|
<span class="ops-badge" :class="statusClass(props.row.status)">{{ props.row.status }}</span>
|
||||||
|
</template>
|
||||||
|
</InlineField>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
<template #body-cell-priority="props">
|
<template #body-cell-priority="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
|
<InlineField :value="props.row.priority" field="priority" doctype="Issue" :docname="props.row.name"
|
||||||
|
type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
|
||||||
|
@saved="v => props.row.priority = v.value">
|
||||||
|
<template #display>
|
||||||
<span class="ops-badge" :class="priorityClass(props.row.priority)">{{ props.row.priority }}</span>
|
<span class="ops-badge" :class="priorityClass(props.row.priority)">{{ props.row.priority }}</span>
|
||||||
|
</template>
|
||||||
|
</InlineField>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
<template #body-cell-issue_type="props">
|
<template #body-cell-issue_type="props">
|
||||||
|
|
@ -136,6 +148,7 @@ import { formatDate } from 'src/composables/useFormatters'
|
||||||
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
|
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
import { useDetailModal } from 'src/composables/useDetailModal'
|
||||||
import DetailModal from 'src/components/shared/DetailModal.vue'
|
import DetailModal from 'src/components/shared/DetailModal.vue'
|
||||||
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
|
|
|
||||||
49
apps/portal/deploy-portal.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Deploy client.gigafibre.ca portal route to Traefik
|
||||||
|
#
|
||||||
|
# Usage: bash deploy-portal.sh
|
||||||
|
# Requires: SSH access to 96.125.196.67
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SERVER="root@96.125.196.67"
|
||||||
|
SSH_KEY="$HOME/.ssh/proxmox_vm"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== Deploying client.gigafibre.ca portal route ==="
|
||||||
|
|
||||||
|
# 1. Copy Traefik dynamic route
|
||||||
|
echo " → Copying Traefik config..."
|
||||||
|
scp -i "$SSH_KEY" "$SCRIPT_DIR/traefik-client-portal.yml" \
|
||||||
|
"$SERVER:/opt/traefik/dynamic/client-portal.yml"
|
||||||
|
|
||||||
|
# 2. Verify Traefik picks it up (check logs for new route)
|
||||||
|
echo " → Checking Traefik logs for route registration..."
|
||||||
|
ssh -i "$SSH_KEY" "$SERVER" 'sleep 2 && docker logs --tail 20 traefik 2>&1 | grep -i "client\|portal\|error" || echo " (no portal-specific logs yet — normal on first load)"'
|
||||||
|
|
||||||
|
# 3. Verify TLS cert provisioning
|
||||||
|
echo " → Checking TLS cert (Let's Encrypt will provision on first request)..."
|
||||||
|
echo " → Try: curl -sI https://client.gigafibre.ca/login | head -5"
|
||||||
|
|
||||||
|
# 4. Quick connectivity test
|
||||||
|
echo ""
|
||||||
|
echo " → Testing connectivity..."
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "https://client.gigafibre.ca/login" 2>/dev/null || echo "000")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo " ✓ client.gigafibre.ca is live! (HTTP $STATUS)"
|
||||||
|
elif [ "$STATUS" = "000" ]; then
|
||||||
|
echo " ⏳ TLS cert not ready yet — Let's Encrypt needs a moment"
|
||||||
|
echo " Wait 30s and try: curl -sI https://client.gigafibre.ca/login"
|
||||||
|
else
|
||||||
|
echo " → HTTP $STATUS — check Traefik logs: docker logs --tail 50 traefik"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done ==="
|
||||||
|
echo ""
|
||||||
|
echo "Portal URL: https://client.gigafibre.ca"
|
||||||
|
echo "Login: https://client.gigafibre.ca/login"
|
||||||
|
echo "Invoices: https://client.gigafibre.ca/invoices"
|
||||||
|
echo "Profile: https://client.gigafibre.ca/me"
|
||||||
|
echo ""
|
||||||
|
echo "Test user: etl@exprotransit.com / TestPortal2026!"
|
||||||
53
apps/portal/traefik-client-portal.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Traefik dynamic route: client.gigafibre.ca → ERPNext (no Authentik)
|
||||||
|
#
|
||||||
|
# Purpose: Customer portal accessible without SSO.
|
||||||
|
# Customers log in via ERPNext's built-in /login page.
|
||||||
|
#
|
||||||
|
# Deploy: copy to /opt/traefik/dynamic/ on 96.125.196.67
|
||||||
|
# scp traefik-client-portal.yml root@96.125.196.67:/opt/traefik/dynamic/
|
||||||
|
# (Traefik auto-reloads dynamic config — no restart needed)
|
||||||
|
#
|
||||||
|
# DNS: *.gigafibre.ca wildcard already resolves to 96.125.196.67
|
||||||
|
# TLS: Let's Encrypt auto-provisions cert for client.gigafibre.ca
|
||||||
|
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
# Main portal router — NO authentik middleware
|
||||||
|
client-portal:
|
||||||
|
rule: "Host(`client.gigafibre.ca`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
- websecure
|
||||||
|
service: client-portal-svc
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
# Explicitly NO middlewares — customers auth via ERPNext /login
|
||||||
|
|
||||||
|
# Block /desk access for portal users
|
||||||
|
client-portal-block-desk:
|
||||||
|
rule: "Host(`client.gigafibre.ca`) && PathPrefix(`/desk`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
- websecure
|
||||||
|
service: client-portal-svc
|
||||||
|
middlewares:
|
||||||
|
- portal-redirect-home
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
priority: 200
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
# Redirect /desk attempts to portal home
|
||||||
|
portal-redirect-home:
|
||||||
|
redirectRegex:
|
||||||
|
regex: ".*"
|
||||||
|
replacement: "https://client.gigafibre.ca/me"
|
||||||
|
permanent: false
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Same ERPNext frontend container, unique service name to avoid
|
||||||
|
# conflicts with Docker-label-defined services
|
||||||
|
client-portal-svc:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://erpnext-frontend-1:8080"
|
||||||
|
|
@ -1,6 +1,82 @@
|
||||||
# Changelog — Migration Legacy → ERPNext
|
# Changelog
|
||||||
|
|
||||||
## 2026-03-28 — Phase 1 : Données maîtres
|
## 2026-03-31 — Ops App: Inline Editing, Notifications, Search
|
||||||
|
|
||||||
|
### Inline Editing (Odoo-style)
|
||||||
|
- **InlineField component** (`apps/ops/src/components/shared/InlineField.vue`) — universal dblclick-to-edit supporting text, number, textarea, select, date types
|
||||||
|
- **useInlineEdit composable** (`apps/ops/src/composables/useInlineEdit.js`) — save logic wrapping `updateDoc()` with error handling + Quasar Notify
|
||||||
|
- **ClientDetailPage** — all Service Location fields now inline-editable (address, city, postal code, contact, OLT port, network ID, connection type)
|
||||||
|
- **CustomerHeader** — customer_name editable via InlineField
|
||||||
|
- **DetailModal** — Issue (status, priority, type, subject), Equipment (all fields), Invoice (remarks) — all inline-editable
|
||||||
|
- **TicketsPage** — status and priority columns editable from table with badge display slot
|
||||||
|
- **ClientsPage** — customer name, type, group editable directly from the list table
|
||||||
|
|
||||||
|
### Search Enhancement
|
||||||
|
- Client search now matches `customer_name`, `name` (account ID like CUST-4), and `legacy_customer_id` (like LPB4) using `or_filters`
|
||||||
|
- Legacy ID column added to clients table
|
||||||
|
- `listDocs` and `countDocs` API helpers updated to support `or_filters` parameter
|
||||||
|
|
||||||
|
### SMS/Email Notifications
|
||||||
|
- **ContactCard** — expanding notification panel with SMS/Email toggle, recipient selector, subject (email), message body, character count
|
||||||
|
- **Server Scripts** — `send_sms_notification` and `send_email_notification` API endpoints (normalize phone, route via n8n, log Comment on Customer)
|
||||||
|
- **sms.js** — new API wrapper for SMS endpoint
|
||||||
|
- Routes through n8n webhooks: `/webhook/sms-send` (Twilio) and `/webhook/email-send` (Mailjet)
|
||||||
|
|
||||||
|
### Ticket Replies
|
||||||
|
- Reply textarea + send button in DetailModal Issue view
|
||||||
|
- Creates `Communication` doc in ERPNext with proper linking
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fixed blur race condition in InlineField (80ms debounce + `committing` flag prevents double API calls)
|
||||||
|
- Fixed CustomerHeader double-save (InlineField saves directly, removed redundant parent emit)
|
||||||
|
- Fixed deploy script — was rsync'ing locally instead of SSH to production server
|
||||||
|
|
||||||
|
### Authentik Federation
|
||||||
|
- OAuth2 Source created on id.gigafibre.ca linking to auth.targo.ca as OIDC provider
|
||||||
|
- `email_link` user matching mode — staff from auth.targo.ca can log in to id.gigafibre.ca
|
||||||
|
- Customers register directly on id.gigafibre.ca (not added to auth.targo.ca)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-30 — Ops App V2, Field Tech App, OCR
|
||||||
|
|
||||||
|
### Ops App
|
||||||
|
- **ClientDetailPage** — full customer view with locations, subscriptions, equipment, tickets, invoices, payments, notes
|
||||||
|
- **Dispatch module** — integrated into ops app at `/dispatch` route (replaces standalone dispatch app)
|
||||||
|
- **Equipment page** — searchable list of all Service Equipment
|
||||||
|
- **OCR page** — Ollama Vision integration for invoice scanning
|
||||||
|
|
||||||
|
### Field Tech App (`apps/field/`)
|
||||||
|
- Barcode scanner (camera API), task list, diagnostics, offline support
|
||||||
|
- PWA with Workbox for field use
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- Ops app served at `erp.gigafibre.ca/ops/` via nginx proxy with API token injection
|
||||||
|
- Ollama Vision routed through ops-frontend nginx proxy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-29 — Migration Phases 5-7
|
||||||
|
|
||||||
|
### Phase 5: Opening Balance + AR Analysis
|
||||||
|
- Outstanding invoice analysis and reconciliation
|
||||||
|
|
||||||
|
### Phase 6: Tickets
|
||||||
|
- **242,618 tickets** migrated as ERPNext Issues with parent/child hierarchy
|
||||||
|
- Issue types mapped: Reparation Fibre, Installation Fibre, Telephonie, Television, etc.
|
||||||
|
- Assigned staff and opened_by_staff mapped from legacy numeric IDs to ERPNext User emails
|
||||||
|
- **784,290 ticket messages** imported as Communications
|
||||||
|
|
||||||
|
### Phase 7: Staff + Memos
|
||||||
|
- **45 ERPNext Users** created from legacy staff table
|
||||||
|
- **29,000 customer memos** imported as Comments with real creation dates
|
||||||
|
- **99,000 payments** imported with invoice references and mode mapping (PPA, cheque, credit card)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-03-28 — Migration Phases 1-4
|
||||||
|
|
||||||
|
### Phase 1 : Données maîtres
|
||||||
|
|
||||||
### Infrastructure
|
### Infrastructure
|
||||||
- **Frappe Assistant Core v2.3.3** installé sur ERPNext — MCP connecté à Claude Code
|
- **Frappe Assistant Core v2.3.3** installé sur ERPNext — MCP connecté à Claude Code
|
||||||
|
|
@ -99,7 +175,7 @@ Hiérarchie créée sous 3 parents :
|
||||||
|
|
||||||
| Système | Accès | Méthode |
|
| Système | Accès | Méthode |
|
||||||
|---------|-------|---------|
|
|---------|-------|---------|
|
||||||
| ERPNext API | `token b273a666c86d2d0:06120709db5e414` | REST API |
|
| ERPNext API | `token $ERP_SERVICE_TOKEN` (see server .env) | REST API |
|
||||||
| ERPNext MCP | Frappe Assistant Core | StreamableHTTP |
|
| ERPNext MCP | Frappe Assistant Core | StreamableHTTP |
|
||||||
| Legacy MariaDB | `facturation@10.100.80.100` | pymysql depuis container ERPNext |
|
| Legacy MariaDB | `facturation@10.100.80.100` | pymysql depuis container ERPNext |
|
||||||
| Legacy SSH | `root@96.125.192.252` (clé SSH copiée) | SSH |
|
| Legacy SSH | `root@96.125.192.252` (clé SSH copiée) | SSH |
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,59 @@
|
||||||
# Gigafibre FSM — Roadmap
|
# Gigafibre FSM — Roadmap
|
||||||
|
|
||||||
## Phase 1 — Foundation (Done)
|
## Phase 1 — Foundation (Done)
|
||||||
- [x] Dispatch PWA with timeline, drag-drop, map
|
- [x] ERPNext v16 setup with PostgreSQL
|
||||||
|
- [x] Custom FSM doctypes (Service Location, Equipment, Subscription)
|
||||||
|
- [x] Dispatch doctypes (Job, Technician, Tag with skill levels)
|
||||||
|
- [x] Dispatch PWA with timeline, drag-drop, Mapbox map
|
||||||
- [x] GPS tracking (Traccar hybrid REST + WebSocket)
|
- [x] GPS tracking (Traccar hybrid REST + WebSocket)
|
||||||
- [x] Tech CRUD in GPS modal
|
|
||||||
- [x] Authentik SSO (forwardAuth) for all apps
|
- [x] Authentik SSO (forwardAuth) for all apps
|
||||||
- [x] ERPNext API proxy (same-origin)
|
- [x] ERPNext API proxy (same-origin via nginx)
|
||||||
- [x] FSM doctypes: Service Location, Equipment, Subscription
|
- [x] Legacy data migration (6,667 customers, 21K subscriptions, 115K invoices, 242K tickets)
|
||||||
|
- [x] Gitea repo at git.targo.ca
|
||||||
|
|
||||||
## Phase 2 — PWA Integration
|
## Phase 2 — Ops App (Done)
|
||||||
- [ ] Customer/Location picker in job creation modal
|
- [x] Unified ops PWA at erp.gigafibre.ca/ops/
|
||||||
- [ ] Equipment scanner (barcode via camera API)
|
- [x] Client list with search (name, account ID, legacy ID)
|
||||||
- [ ] Checklist UI on job detail panel
|
- [x] Client detail page (contact, billing KPIs, locations, subscriptions, equipment, tickets, invoices, payments, notes)
|
||||||
- [ ] Photo capture with annotations
|
- [x] Inline editing on all fields (Odoo-style dblclick, InlineField component)
|
||||||
- [ ] Customer signature pad (HTML Canvas)
|
- [x] Dispatch module integrated into ops app
|
||||||
- [ ] Time tracking (start/pause/stop on job)
|
- [x] Ticket management with inline status/priority editing
|
||||||
- [ ] Offline-first with IndexedDB sync
|
- [x] Equipment tracking with detail modal
|
||||||
|
- [x] SMS/Email notifications via n8n webhooks (Twilio + Mailjet)
|
||||||
|
- [x] Ticket reply thread (Communication docs)
|
||||||
|
- [x] Invoice OCR (Ollama Vision)
|
||||||
|
- [x] Field tech mobile app (barcode, diagnostics, offline)
|
||||||
|
- [x] Authentik federation (auth.targo.ca staff -> id.gigafibre.ca)
|
||||||
|
|
||||||
## Phase 3 — Workflows & Automation
|
## Phase 3 — Workflows & Automation (In Progress)
|
||||||
- [ ] Issue → Dispatch Job (server script)
|
- [ ] Tag technicians with skills (assign Fibre/TV/Telephonie tags + levels to 46 techs)
|
||||||
- [ ] Job completion → equipment status update
|
- [ ] Wire auto-dispatch logic (cost-optimization matching in useAutoDispatch.js)
|
||||||
- [ ] Job completion → close helpdesk ticket
|
- [ ] Issue -> Dispatch Job creation (button in ticket detail)
|
||||||
- [ ] Equipment swap → inventory move log
|
- [ ] Job completion -> equipment status update
|
||||||
- [ ] Twilio SMS notifications (tech + customer)
|
- [ ] Job completion -> close helpdesk ticket
|
||||||
|
- [ ] Equipment swap -> inventory move log
|
||||||
- [ ] n8n workflows for escalation rules
|
- [ ] n8n workflows for escalation rules
|
||||||
|
- [ ] Activate n8n SMS/email workflows via UI
|
||||||
|
- [ ] Twilio upgrade to production (10DLC or Toll-Free)
|
||||||
- [ ] SLA tracking on subscriptions
|
- [ ] SLA tracking on subscriptions
|
||||||
|
|
||||||
## Phase 4 — Customer Portal
|
## Phase 4 — Customer Portal
|
||||||
- [ ] Customer-facing web app (service status)
|
- [ ] Customer-facing web app (service status, invoices)
|
||||||
|
- [ ] Stripe payment integration
|
||||||
- [ ] Online appointment booking
|
- [ ] Online appointment booking
|
||||||
- [ ] Real-time tech tracking ("On my way" SMS)
|
- [ ] Real-time tech tracking ("On my way" SMS)
|
||||||
- [ ] Invoice/payment history
|
|
||||||
- [ ] Equipment list per location
|
- [ ] Equipment list per location
|
||||||
- [ ] Service request submission
|
- [ ] Service request submission
|
||||||
|
- [ ] Legacy password migration (MD5 -> PBKDF2 via auth bridge)
|
||||||
|
|
||||||
## Phase 5 — Advanced Features
|
## Phase 5 — Advanced Features
|
||||||
- [ ] Van stock inventory per technician
|
- [ ] Van stock inventory per technician
|
||||||
- [ ] Part usage → auto-reorder
|
- [ ] Part usage -> auto-reorder
|
||||||
- [ ] Multi-day project tracking (fiber builds)
|
- [ ] Multi-day project tracking (fiber builds)
|
||||||
- [ ] Tech performance dashboards
|
- [ ] Tech performance dashboards
|
||||||
- [ ] Revenue analytics (MRR, churn, ARPU)
|
- [ ] Revenue analytics (MRR, churn, ARPU)
|
||||||
- [ ] Preventive maintenance scheduling
|
- [ ] Preventive maintenance scheduling
|
||||||
- [ ] White-label mobile app for techs
|
- [ ] Customer/location picker in job creation modal
|
||||||
|
- [ ] Photo capture with annotations
|
||||||
|
- [ ] Customer signature pad
|
||||||
|
- [ ] Time tracking (start/pause/stop on job)
|
||||||
|
|
|
||||||
187
scripts/bulk_submit.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Bulk submit migration data in ERPNext:
|
||||||
|
1. Enable all disabled Items (so invoices can be submitted)
|
||||||
|
2. Submit all draft Sales Invoices
|
||||||
|
3. Submit all draft Payment Entries (which already have invoice references for reconciliation)
|
||||||
|
|
||||||
|
SAFETY: No email accounts are configured in ERPNext, so no emails will be sent.
|
||||||
|
Additionally, we pass flags.mute_emails=1 on every submit call as extra safety.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 bulk_submit.py # dry-run (count only)
|
||||||
|
python3 bulk_submit.py --run # execute all steps
|
||||||
|
python3 bulk_submit.py --run --step items # only enable items
|
||||||
|
python3 bulk_submit.py --run --step inv # only submit invoices
|
||||||
|
python3 bulk_submit.py --run --step pay # only submit payments
|
||||||
|
python3 bulk_submit.py --run --customer CUST-4f09e799bd # one customer only (test)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
BASE = "https://erp.gigafibre.ca"
|
||||||
|
TOKEN = "b273a666c86d2d0:06120709db5e414"
|
||||||
|
HEADERS = {
|
||||||
|
"Authorization": f"token {TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
BATCH_SIZE = 100 # documents per batch
|
||||||
|
PAUSE = 0.3 # seconds between batches
|
||||||
|
|
||||||
|
|
||||||
|
def api_get(path, params=None):
|
||||||
|
r = requests.get(BASE + path, headers=HEADERS, params=params, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def api_put(path, data):
|
||||||
|
r = requests.put(BASE + path, headers=HEADERS, json=data, timeout=60)
|
||||||
|
if not r.ok:
|
||||||
|
return False, r.text[:300]
|
||||||
|
return True, r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Step 1: Enable all disabled items ──────────────────────────────
|
||||||
|
def enable_items(dry_run=False):
|
||||||
|
print("\n═══ Step 1: Enable disabled Items ═══")
|
||||||
|
data = api_get("/api/resource/Item", {
|
||||||
|
"filters": json.dumps({"disabled": 1}),
|
||||||
|
"fields": json.dumps(["name"]),
|
||||||
|
"limit_page_length": 0,
|
||||||
|
})
|
||||||
|
items = data.get("data", [])
|
||||||
|
print(f" Found {len(items)} disabled items")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
|
||||||
|
ok, fail = 0, 0
|
||||||
|
for item in items:
|
||||||
|
name = item["name"]
|
||||||
|
success, resp = api_put(f"/api/resource/Item/{quote(name, safe='')}", {"disabled": 0})
|
||||||
|
if success:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
print(f" FAIL enable {name}: {resp}")
|
||||||
|
print(f" Enabled: {ok}, Failed: {fail}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Generic bulk submit ───────────────────────────────────────────
|
||||||
|
def bulk_submit(doctype, label, filter_key, dry_run=False, customer=None):
|
||||||
|
print(f"\n═══ {label} ═══")
|
||||||
|
filters = {"docstatus": 0}
|
||||||
|
if customer:
|
||||||
|
filters[filter_key] = customer
|
||||||
|
|
||||||
|
# Count
|
||||||
|
count_data = api_get("/api/method/frappe.client.get_count", {
|
||||||
|
"doctype": doctype,
|
||||||
|
"filters": json.dumps(filters),
|
||||||
|
})
|
||||||
|
total = count_data.get("message", 0)
|
||||||
|
print(f" Total draft: {total}")
|
||||||
|
|
||||||
|
if dry_run or total == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
submitted, failed = 0, 0
|
||||||
|
errors = []
|
||||||
|
seen_failed = set() # track permanently failed names to avoid infinite loop
|
||||||
|
stall_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = api_get(f"/api/resource/{quote(doctype, safe='')}", {
|
||||||
|
"filters": json.dumps(filters),
|
||||||
|
"fields": json.dumps(["name"]),
|
||||||
|
"limit_page_length": BATCH_SIZE,
|
||||||
|
"limit_start": 0,
|
||||||
|
"order_by": "posting_date asc",
|
||||||
|
})
|
||||||
|
batch = data.get("data", [])
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
# If every item in this batch already failed, we're stuck
|
||||||
|
new_in_batch = [b for b in batch if b["name"] not in seen_failed]
|
||||||
|
if not new_in_batch:
|
||||||
|
print(f"\n All remaining {len(batch)} documents have errors — stopping.")
|
||||||
|
break
|
||||||
|
|
||||||
|
batch_submitted = 0
|
||||||
|
for doc in batch:
|
||||||
|
name = doc["name"]
|
||||||
|
if name in seen_failed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Submit with mute_emails flag
|
||||||
|
# For Sales Invoice: set_posting_time=1 to keep original posting_date
|
||||||
|
# (otherwise ERPNext resets to today, which breaks due_date validation)
|
||||||
|
submit_data = {"docstatus": 1, "flags": {"mute_emails": 1, "ignore_notifications": 1}}
|
||||||
|
if doctype == "Sales Invoice":
|
||||||
|
submit_data["set_posting_time"] = 1
|
||||||
|
success, resp = api_put(
|
||||||
|
f"/api/resource/{quote(doctype, safe='')}/{quote(name, safe='')}",
|
||||||
|
submit_data
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
submitted += 1
|
||||||
|
batch_submitted += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
seen_failed.add(name)
|
||||||
|
err_msg = resp[:200] if isinstance(resp, str) else str(resp)[:200]
|
||||||
|
if len(errors) < 30:
|
||||||
|
errors.append(f"{name}: {err_msg}")
|
||||||
|
|
||||||
|
done = submitted + failed
|
||||||
|
pct = int(done / total * 100) if total else 0
|
||||||
|
print(f" Progress: {done}/{total} ({pct}%) — ok={submitted} fail={failed} ", end="\r")
|
||||||
|
|
||||||
|
if batch_submitted == 0:
|
||||||
|
stall_count += 1
|
||||||
|
if stall_count > 3:
|
||||||
|
print(f"\n Stalled after {stall_count} batches with no progress — stopping.")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
stall_count = 0
|
||||||
|
|
||||||
|
time.sleep(PAUSE)
|
||||||
|
|
||||||
|
print(f"\n Done: submitted={submitted}, failed={failed}")
|
||||||
|
if errors:
|
||||||
|
print(f" Errors (first {len(errors)}):")
|
||||||
|
for e in errors:
|
||||||
|
print(f" {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Bulk submit ERPNext migration data")
|
||||||
|
parser.add_argument("--run", action="store_true", help="Actually execute (default is dry-run)")
|
||||||
|
parser.add_argument("--step", choices=["items", "inv", "pay"], help="Run only one step")
|
||||||
|
parser.add_argument("--customer", help="Limit to one customer (for testing)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
dry_run = not args.run
|
||||||
|
if dry_run:
|
||||||
|
print("DRY RUN — pass --run to execute\n")
|
||||||
|
|
||||||
|
steps = args.step or "all"
|
||||||
|
|
||||||
|
if steps in ("all", "items"):
|
||||||
|
enable_items(dry_run)
|
||||||
|
|
||||||
|
if steps in ("all", "inv"):
|
||||||
|
bulk_submit("Sales Invoice", "Step 2: Submit Sales Invoices", "customer", dry_run, args.customer)
|
||||||
|
|
||||||
|
if steps in ("all", "pay"):
|
||||||
|
bulk_submit("Payment Entry", "Step 3: Submit Payment Entries", "party", dry_run, args.customer)
|
||||||
|
|
||||||
|
print("\nDone!")
|
||||||
66
scripts/fix_ple_groupby.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fix the PostgreSQL GROUP BY bug in ERPNext's payment_ledger_entry.py
|
||||||
|
on the server via SSH.
|
||||||
|
|
||||||
|
The bug: update_voucher_outstanding() selects 'account', 'party_type', 'party'
|
||||||
|
but doesn't include them in GROUP BY — PostgreSQL requires this, MariaDB doesn't.
|
||||||
|
|
||||||
|
This script SSH into the server and patches the file in-place.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SERVER = "root@96.125.196.67"
|
||||||
|
# Path inside the erpnext docker container
|
||||||
|
CONTAINER = "erpnext-backend-1"
|
||||||
|
FILE_PATH = "apps/erpnext/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py"
|
||||||
|
|
||||||
|
# First, let's read the current file to find the exact code to patch
|
||||||
|
print("Step 1: Reading current file from server...")
|
||||||
|
result = subprocess.run(
|
||||||
|
["ssh", SERVER, f"docker exec {CONTAINER} cat /home/frappe/frappe-bench/{FILE_PATH}"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"ERROR reading file: {result.stderr}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
content = result.stdout
|
||||||
|
print(f" File size: {len(content)} bytes")
|
||||||
|
|
||||||
|
# Find the problematic groupby
|
||||||
|
# Look for the pattern where groupby has voucher_type and voucher_no but NOT account
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Find the update_voucher_outstanding function and its groupby
|
||||||
|
lines = content.split('\n')
|
||||||
|
found = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if 'def update_voucher_outstanding' in line:
|
||||||
|
print(f" Found function at line {i+1}")
|
||||||
|
if '.groupby(' in line and 'voucher_type' in line and 'voucher_no' in line:
|
||||||
|
# Check surrounding lines for account in groupby
|
||||||
|
context = '\n'.join(lines[max(0,i-3):i+5])
|
||||||
|
if 'ple.account' not in context or '.groupby' in line:
|
||||||
|
print(f" Found groupby at line {i+1}: {line.strip()}")
|
||||||
|
found = True
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
print(" Could not find the problematic groupby pattern")
|
||||||
|
print(" Dumping function for manual inspection...")
|
||||||
|
in_func = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if 'def update_voucher_outstanding' in line:
|
||||||
|
in_func = True
|
||||||
|
if in_func:
|
||||||
|
print(f" {i+1}: {line}")
|
||||||
|
if in_func and line.strip() and not line.startswith('\t') and not line.startswith(' ') and i > 0:
|
||||||
|
if 'def ' in line and 'update_voucher_outstanding' not in line:
|
||||||
|
break
|
||||||
|
if in_func and i > 250: # safety limit
|
||||||
|
break
|
||||||
|
|
||||||
|
print("\n--- Full file dumped for inspection ---")
|
||||||
|
print("Use this output to craft the sed command manually")
|
||||||
85
scripts/fix_ple_postgres.sh
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Fix PostgreSQL GROUP BY bug in ERPNext's payment_ledger_entry.py
|
||||||
|
#
|
||||||
|
# Run on the server:
|
||||||
|
# ssh root@96.125.196.67
|
||||||
|
# bash fix_ple_postgres.sh
|
||||||
|
#
|
||||||
|
# The bug: update_voucher_outstanding() selects 'account', 'party_type', 'party'
|
||||||
|
# columns but doesn't include them in GROUP BY. PostgreSQL requires all non-aggregated
|
||||||
|
# columns to be in GROUP BY.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONTAINER="erpnext-backend-1"
|
||||||
|
FILE="apps/erpnext/erpnext/accounts/doctype/payment_ledger_entry/payment_ledger_entry.py"
|
||||||
|
BENCH_DIR="/home/frappe/frappe-bench"
|
||||||
|
|
||||||
|
echo "=== Fix PLE PostgreSQL GROUP BY bug ==="
|
||||||
|
|
||||||
|
# Show current groupby lines
|
||||||
|
echo "Current groupby patterns:"
|
||||||
|
docker exec $CONTAINER grep -n "groupby" $BENCH_DIR/$FILE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Applying fix..."
|
||||||
|
|
||||||
|
# The fix: find .groupby lines that have voucher_type and voucher_no
|
||||||
|
# and add account, party_type, party
|
||||||
|
# We use a Python script inside the container for reliable patching
|
||||||
|
docker exec $CONTAINER python3 -c "
|
||||||
|
import re
|
||||||
|
|
||||||
|
filepath = '$BENCH_DIR/$FILE'
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Pattern: .groupby(ple.voucher_type, ple.voucher_no) without account
|
||||||
|
# We need to handle both single-line and multi-line groupby
|
||||||
|
original = content
|
||||||
|
|
||||||
|
# Fix 1: Single-line groupby
|
||||||
|
content = re.sub(
|
||||||
|
r'\.groupby\(\s*ple\.voucher_type\s*,\s*ple\.voucher_no\s*\)',
|
||||||
|
'.groupby(ple.voucher_type, ple.voucher_no, ple.account, ple.party_type, ple.party)',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
if content != original:
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('PATCHED: Added account, party_type, party to GROUP BY')
|
||||||
|
else:
|
||||||
|
# Try multi-line pattern
|
||||||
|
content = re.sub(
|
||||||
|
r'(\.groupby\([^)]*ple\.voucher_type[^)]*ple\.voucher_no)(\s*\))',
|
||||||
|
r'\1, ple.account, ple.party_type, ple.party\2',
|
||||||
|
original
|
||||||
|
)
|
||||||
|
if content != original:
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('PATCHED (multi-line): Added account, party_type, party to GROUP BY')
|
||||||
|
else:
|
||||||
|
print('WARNING: Could not find pattern to patch. Check manually:')
|
||||||
|
# Show the function for manual inspection
|
||||||
|
import ast
|
||||||
|
lines = original.split('\n')
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if 'groupby' in line.lower():
|
||||||
|
start = max(0, i-2)
|
||||||
|
end = min(len(lines), i+3)
|
||||||
|
for j in range(start, end):
|
||||||
|
print(f' {j+1}: {lines[j]}')
|
||||||
|
print()
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "After fix:"
|
||||||
|
docker exec $CONTAINER grep -n "groupby" $BENCH_DIR/$FILE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Restarting workers..."
|
||||||
|
docker restart $CONTAINER
|
||||||
|
echo ""
|
||||||
|
echo "=== Done! Wait 30s for container to start, then run bulk_submit ==="
|
||||||
649
scripts/migration/MIGRATION_MAP.md
Normal file
|
|
@ -0,0 +1,649 @@
|
||||||
|
# Legacy → ERPNext Migration Map
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Migration from legacy PHP/MariaDB billing system (`gestionclient`) to ERPNext v16 on PostgreSQL.
|
||||||
|
- **Source**: MariaDB at `10.100.80.100`, database `gestionclient`
|
||||||
|
- **Target**: ERPNext at `erp.gigafibre.ca`, company **TARGO**, currency **CAD**
|
||||||
|
- **Scope**: All historical data (no date cutoff)
|
||||||
|
- **Method**: Bulk SQL INSERT (bypasses Frappe ORM for speed — 4 min vs ~120 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Accounting Problems Solved
|
||||||
|
|
||||||
|
The legacy system had several non-standard practices that broke standard accounting. Here is what was fixed during migration:
|
||||||
|
|
||||||
|
| # | Legacy Problem | Impact | ERPNext Solution |
|
||||||
|
|---|---------------|--------|------------------|
|
||||||
|
| 1 | **Credit notes not linked to original invoices** — negative invoices created with no reference back to the invoice they cancel | No audit trail; credits appear as free-floating | 3 matching mechanisms reconstruct the links (16,830 credit notes linked via `return_against`) |
|
||||||
|
| 2 | **Fake "reversement" payments** — internal bookkeeping entries recorded as real payments when cancelling invoices | $955K phantom overpayment | Excluded from import; replaced by proper credit note allocation |
|
||||||
|
| 3 | **Duplicate payments from portal** — slow legacy backend causes same credit card charge to be submitted twice (same Stripe reference) | Invoices appear double-paid; bank balance overstated | Deduplicated by `(account_id, reference)` — 178,730 duplicates removed |
|
||||||
|
| 4 | **Invoices both paid and reversed** — customer pays, then invoice also gets a credit note reversal | Invoice shows negative outstanding (overpaid) | Extra reversal entries delinked; invoice marked as settled |
|
||||||
|
| 5 | **Tax-inclusive totals** — legacy stores total with tax included, no separate net amount | ERPNext needs both net and gross | Tax amounts back-calculated from `invoice_tax` table |
|
||||||
|
| 6 | **No due dates** — most invoices have no due date | Cannot determine overdue status | `posting_date` used as fallback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Glossary — Internal Prefixes
|
||||||
|
|
||||||
|
These prefixes are used in ERPNext document names to identify records created during migration:
|
||||||
|
|
||||||
|
| Prefix | Stands for | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `SINV-` | **S**ales **INV**oice | Invoice document (e.g., `SINV-638567`) |
|
||||||
|
| `PE-` | **P**ayment **E**ntry | Payment received from customer |
|
||||||
|
| `PER-` | **P**ayment **E**ntry **R**eference | Allocation of a payment to a specific invoice |
|
||||||
|
| `SII-` | **S**ales **I**nvoice **I**tem | Line item on an invoice |
|
||||||
|
| `stc-tps-` | **S**ales **T**ax **C**harge — TPS | GST tax row (5%) |
|
||||||
|
| `stc-tvq-` | **S**ales **T**ax **C**harge — TVQ | QST tax row (9.975%) |
|
||||||
|
| `ple-` | **P**ayment **L**edger **E**ntry | Tracks what's owed per invoice (ERPNext outstanding system) |
|
||||||
|
| `plc-` | **PL**E — **C**redit allocation | Credit note reducing a target invoice's balance |
|
||||||
|
| `plr-` | **PL**E — **R**eversal allocation | Reversal (from invoice notes) reducing a target invoice's balance |
|
||||||
|
| `gir-` | **G**L — **I**nvoice **R**eceivable | GL entry: debit to Accounts Receivable |
|
||||||
|
| `gii-` | **G**L — **I**nvoice **I**ncome | GL entry: credit to Revenue |
|
||||||
|
| `glt-` | **G**L — **T**PS | GL entry: credit to TPS (GST) liability |
|
||||||
|
| `glq-` | **G**L — TV**Q** | GL entry: credit to TVQ (QST) liability |
|
||||||
|
| `gpb-` | **G**L — **P**ayment **B**ank | GL entry: debit to Bank |
|
||||||
|
| `gpr-` | **G**L — **P**ayment **R**eceivable | GL entry: credit to Accounts Receivable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ LEGACY (MariaDB) │
|
||||||
|
│ │
|
||||||
|
│ account ──────────────────────────────────┐ │
|
||||||
|
│ invoice ──┬── invoice_item │ │
|
||||||
|
│ └── invoice_tax (TPS/TVQ rows) │ │
|
||||||
|
│ payment ──┬── payment_item (allocations) │ │
|
||||||
|
│ └── type='credit' (memo→#NNN) │ │
|
||||||
|
└────────────────────────┬───────────────────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ERPNext (PostgreSQL) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ Customer │◄───│ Sales Invoice │───►│ GL Entry (4 per inv) │ │
|
||||||
|
│ │ │ │ ├─ SI Item │ │ gir- receivable │ │
|
||||||
|
│ │ │ │ ├─ SI Tax(TPS) │ │ gii- income │ │
|
||||||
|
│ │ │ │ └─ SI Tax(TVQ) │ │ glt- TPS │ │
|
||||||
|
│ │ │ │ │ │ glq- TVQ │ │
|
||||||
|
│ │ │ │ │───►│ PLE (1 per invoice) │ │
|
||||||
|
│ │ │ │ │ │ ple-SINV- │ │
|
||||||
|
│ │ │ └─────────────────┘ └──────────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ │◄───│ Payment Entry │───►│ GL Entry (2 per pmt) │ │
|
||||||
|
│ │ │ │ └─ PE Ref │ │ gpb- bank │ │
|
||||||
|
│ │ │ │ (allocations)│ │ gpr- receivable │ │
|
||||||
|
│ │ │ │ │───►│ PLE (1 per alloc) │ │
|
||||||
|
│ │ │ │ │ │ ple-PER- │ │
|
||||||
|
│ └──────────┘ └─────────────────┘ └──────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Credit Allocations ──────────────────► PLE (plc-) │
|
||||||
|
│ (return inv → target inv) against_voucher = target │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Mapping
|
||||||
|
|
||||||
|
### Customer
|
||||||
|
|
||||||
|
```
|
||||||
|
Legacy (account) ERPNext (Customer)
|
||||||
|
───────────────── ──────────────────
|
||||||
|
id → legacy_account_id
|
||||||
|
first_name + last_name → customer_name
|
||||||
|
email → (Contact)
|
||||||
|
name = CUST-{hash}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sales Invoice
|
||||||
|
|
||||||
|
```
|
||||||
|
Legacy (invoice) ERPNext (Sales Invoice)
|
||||||
|
───────────────── ──────────────────────
|
||||||
|
id → legacy_invoice_id
|
||||||
|
name = SINV-{legacy_id}
|
||||||
|
Example: 638567 → SINV-638567
|
||||||
|
account_id → customer (via account→Customer map)
|
||||||
|
total_amt → grand_total (TAX-INCLUSIVE)
|
||||||
|
total_amt - tps - tvq → net_total
|
||||||
|
date_orig (unix ts) → posting_date (YYYY-MM-DD)
|
||||||
|
total_amt < 0 → is_return = 1
|
||||||
|
billed_amt → (not stored — derived from PLE)
|
||||||
|
outstanding_amount = SUM(PLE against this inv)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key**: Legacy `total_amt` is TAX-INCLUSIVE. ERPNext stores both `grand_total` (with tax) and `net_total` (without tax).
|
||||||
|
|
||||||
|
### Invoice Items
|
||||||
|
|
||||||
|
```
|
||||||
|
Legacy (invoice_item) ERPNext (Sales Invoice Item)
|
||||||
|
───────────────────── ──────────────────────────
|
||||||
|
invoice_id → parent = SINV name
|
||||||
|
product_name → item_name, description
|
||||||
|
quantity → qty
|
||||||
|
unitary_price → rate
|
||||||
|
quantity × unitary_price → amount
|
||||||
|
name = SII-{legacy_id}-{idx}
|
||||||
|
item_code = 'SVC'
|
||||||
|
income_account = "Autres produits d'exploitation - T"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invoice Taxes
|
||||||
|
|
||||||
|
```
|
||||||
|
Legacy (invoice_tax) ERPNext (Sales Taxes and Charges)
|
||||||
|
──────────────────── ─────────────────────────────────
|
||||||
|
invoice_id → parent = SINV name
|
||||||
|
tax_name = 'TPS' → description = 'TPS à payer - T'
|
||||||
|
amount → tax_amount (5% GST, #834975559RT0001)
|
||||||
|
tax_rate = 0.05 → rate = 5.0
|
||||||
|
tax_name = 'TVQ' → description = 'TVQ à payer - T'
|
||||||
|
amount → tax_amount (9.975% QST, #1213765929TQ0001)
|
||||||
|
tax_rate = 0.09975 → rate = 9.975
|
||||||
|
name = stc-tps-{legacy_id} / stc-tvq-{legacy_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Legacy stores TPS and TVQ as **separate rows** per invoice. ERPNext also uses separate rows (idx=1 for TPS, idx=2 for TVQ).
|
||||||
|
|
||||||
|
### Payment Entry
|
||||||
|
|
||||||
|
```
|
||||||
|
Legacy (payment) ERPNext (Payment Entry)
|
||||||
|
──────────────── ──────────────────────
|
||||||
|
id → legacy_payment_id
|
||||||
|
name = PE-{legacy_id}
|
||||||
|
account_id → party (via account→Customer map)
|
||||||
|
amount → paid_amount = received_amount
|
||||||
|
date_orig (unix ts) → posting_date
|
||||||
|
memo → remarks
|
||||||
|
type != 'credit' → (only non-credit payments imported as PE)
|
||||||
|
payment_type = 'Receive'
|
||||||
|
paid_from = 'Comptes clients - T'
|
||||||
|
paid_to = 'Banque - T'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Allocations
|
||||||
|
|
||||||
|
```
|
||||||
|
Legacy (payment_item) ERPNext (Payment Entry Reference)
|
||||||
|
───────────────────── ───────────────────────────────
|
||||||
|
payment_id → parent = PE name
|
||||||
|
invoice_id → reference_name = SINV name
|
||||||
|
amount → allocated_amount
|
||||||
|
name = PER-{legacy_pmt_id}-{idx}
|
||||||
|
reference_doctype = 'Sales Invoice'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credit Notes (Return Invoices)
|
||||||
|
|
||||||
|
```
|
||||||
|
Legacy — Three linking mechanisms:
|
||||||
|
|
||||||
|
1. Credit payment:
|
||||||
|
payment (type='credit', memo='credit created by invoice #NNN')
|
||||||
|
→ payment_item.invoice_id = target invoice
|
||||||
|
|
||||||
|
2. Invoice notes:
|
||||||
|
invoice.notes = 'Renversement de la facture #NNN'
|
||||||
|
→ #NNN = target legacy invoice ID
|
||||||
|
|
||||||
|
3. Reversement payment memo:
|
||||||
|
payment (type='reversement', memo='create by invoice #CREDIT for invoice #TARGET')
|
||||||
|
→ payment_item.invoice_id = target invoice
|
||||||
|
|
||||||
|
ERPNext:
|
||||||
|
Sales Invoice (is_return=1, return_against=target SINV)
|
||||||
|
→ PLE (plc-{id}) — from credit payments (mechanism 1)
|
||||||
|
→ PLE (plr-{id}) — from reversal notes + reversement memos (mechanisms 2+3)
|
||||||
|
voucher_no = source return SINV
|
||||||
|
against_voucher_no = target SINV
|
||||||
|
amount = credit invoice's grand_total (negative)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accounting Entries (Double-Entry)
|
||||||
|
|
||||||
|
### Per Sales Invoice (4 GL entries)
|
||||||
|
|
||||||
|
```
|
||||||
|
Debit Credit
|
||||||
|
───── ──────
|
||||||
|
Comptes clients - T (AR) grand_total
|
||||||
|
Autres produits d'expl. (Rev) net_total
|
||||||
|
TPS à payer - T (Tax) tps_amount
|
||||||
|
TVQ à payer - T (Tax) tvq_amount
|
||||||
|
───────── ─────────
|
||||||
|
grand_total = grand_total ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
For **return invoices** (negative amounts), debit/credit are swapped.
|
||||||
|
|
||||||
|
### Per Payment Entry (2 GL entries)
|
||||||
|
|
||||||
|
```
|
||||||
|
Debit Credit
|
||||||
|
───── ──────
|
||||||
|
Banque - T (Bank) paid_amount
|
||||||
|
Comptes clients - T (AR) paid_amount
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Ledger Entry (PLE) — Outstanding Tracking
|
||||||
|
|
||||||
|
```
|
||||||
|
Type amount against_voucher
|
||||||
|
──── ────── ───────────────
|
||||||
|
Invoice posting +grand_total self (SINV)
|
||||||
|
Payment allocation -allocated_amount target SINV
|
||||||
|
Credit allocation -credit_amount target SINV
|
||||||
|
Unallocated payment -paid_amount self (PE)
|
||||||
|
|
||||||
|
Outstanding = SUM(PLE.amount WHERE against_voucher = this invoice)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ERPNext Chart of Accounts
|
||||||
|
|
||||||
|
```
|
||||||
|
TARGO (Company, abbr: T)
|
||||||
|
├── Comptes clients - T Receivable (debit_to for all invoices)
|
||||||
|
├── Banque - T Bank (paid_to for all payments)
|
||||||
|
├── Autres produits d'exploitation - T Income (all invoice revenue)
|
||||||
|
├── TPS à payer - T Liability/Tax (5% GST)
|
||||||
|
└── TVQ à payer - T Liability/Tax (9.975% QST)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
### Migrated Data (Legacy IDs)
|
||||||
|
|
||||||
|
| Entity | Pattern | Example |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| Sales Invoice | `SINV-{legacy_id}` | `SINV-638567` |
|
||||||
|
| SI Item | `SII-{legacy_id}-{idx}` | `SII-638567-0` |
|
||||||
|
| SI Tax (TPS) | `stc-tps-{legacy_id}` | `stc-tps-638567` |
|
||||||
|
| SI Tax (TVQ) | `stc-tvq-{legacy_id}` | `stc-tvq-638567` |
|
||||||
|
| Payment Entry | `PE-{legacy_id}` | `PE-76531` |
|
||||||
|
| PE Reference | `PER-{legacy_id}-{idx}` | `PER-76531-0` |
|
||||||
|
| GL (inv receivable) | `gir-SINV-{id}` | `gir-SINV-638567` |
|
||||||
|
| GL (inv income) | `gii-SINV-{id}` | `gii-SINV-638567` |
|
||||||
|
| GL (inv TPS) | `glt-SINV-{id}` | `glt-SINV-638567` |
|
||||||
|
| GL (inv TVQ) | `glq-SINV-{id}` | `glq-SINV-638567` |
|
||||||
|
| GL (pmt bank) | `gpb-PE-{id}` | `gpb-PE-76531` |
|
||||||
|
| GL (pmt receivable) | `gpr-PE-{id}` | `gpr-PE-76531` |
|
||||||
|
| PLE (invoice) | `ple-SINV-{id}` | `ple-SINV-638567` |
|
||||||
|
| PLE (pmt alloc) | `ple-PER-{id}-{idx}` | `ple-PER-76531-0` |
|
||||||
|
| PLE (unallocated) | `ple-PE-{id}` | `ple-PE-76531` |
|
||||||
|
| PLE (credit alloc) | `plc-{serial}` | `plc-1234` |
|
||||||
|
| PLE (reversal alloc) | `plr-{serial}` | `plr-567` |
|
||||||
|
|
||||||
|
### Post-Migration (New Documents)
|
||||||
|
|
||||||
|
| Entity | Pattern | Example |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| Sales Invoice | `SINV-YYYY-NNNNN` | `SINV-2026-700001` |
|
||||||
|
| Payment Entry | ERPNext autoname | `PE-2026-00001` |
|
||||||
|
|
||||||
|
The different naming patterns between migrated and new documents ensure no collision if a reimport is needed after new documents have been created.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy Database Schema
|
||||||
|
|
||||||
|
### `invoice`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | bigint PK | → `legacy_invoice_id` in ERPNext |
|
||||||
|
| `date_orig` | bigint | UNIX timestamp |
|
||||||
|
| `account_id` | bigint FK | → `account.id` |
|
||||||
|
| `total_amt` | double(20,2) | **TAX-INCLUSIVE** |
|
||||||
|
| `billed_amt` | double(20,2) | Amount paid |
|
||||||
|
| `due_date` | bigint | UNIX timestamp |
|
||||||
|
| `correction` | tinyint | 1 = correction invoice |
|
||||||
|
| `notes` | mediumtext | |
|
||||||
|
|
||||||
|
### `invoice_item`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | bigint PK | |
|
||||||
|
| `invoice_id` | bigint FK | → `invoice.id` |
|
||||||
|
| `product_name` | varchar(512) | NOT `description` |
|
||||||
|
| `quantity` | double | NOT `qty` |
|
||||||
|
| `unitary_price` | double | NOT `price` |
|
||||||
|
| `sku` | varchar(128) | |
|
||||||
|
|
||||||
|
### `invoice_tax`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | bigint PK | |
|
||||||
|
| `invoice_id` | bigint FK | → `invoice.id` |
|
||||||
|
| `tax_name` | varchar(128) | `'TPS'` or `'TVQ'` — **separate rows, not columns** |
|
||||||
|
| `tax_rate` | double | 0.05 or 0.09975 |
|
||||||
|
| `amount` | double(20,2) | Tax amount for this type |
|
||||||
|
|
||||||
|
### `payment`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | bigint PK | |
|
||||||
|
| `account_id` | bigint FK | → `account.id` |
|
||||||
|
| `date_orig` | bigint | UNIX timestamp |
|
||||||
|
| `amount` | double | |
|
||||||
|
| `type` | varchar(25) | `'payment'`, `'credit'`, etc. |
|
||||||
|
| `memo` | varchar(512) | For credit: `"credit created by invoice #NNN"` |
|
||||||
|
| `reference` | varchar(128) | Stripe/processor transaction ID (e.g., `pi_3Sad...`) |
|
||||||
|
|
||||||
|
### `payment_item`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | bigint PK | |
|
||||||
|
| `payment_id` | bigint FK | → `payment.id` |
|
||||||
|
| `invoice_id` | bigint FK | → `invoice.id` |
|
||||||
|
| `amount` | double | Allocated to this invoice |
|
||||||
|
|
||||||
|
### `account` (customer)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | bigint PK | → `legacy_account_id` in Customer |
|
||||||
|
| `first_name` | varchar | |
|
||||||
|
| `last_name` | varchar | |
|
||||||
|
| `email` | varchar | |
|
||||||
|
| `company_name` | varchar | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fiscal Year
|
||||||
|
|
||||||
|
Canadian fiscal year: **July 1 – June 30**
|
||||||
|
|
||||||
|
```
|
||||||
|
posting_date month >= 7 → fiscal_year = "YYYY-(YYYY+1)" e.g. "2025-2026"
|
||||||
|
posting_date month < 7 → fiscal_year = "(YYYY-1)-YYYY" e.g. "2024-2025"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy Non-Compliance Changelog
|
||||||
|
|
||||||
|
The legacy PHP/MariaDB system uses several non-standard accounting practices that were corrected during migration to ERPNext.
|
||||||
|
|
||||||
|
### 1. Reversal Invoices — No Credit Link
|
||||||
|
|
||||||
|
**Legacy behavior**: To cancel an invoice, the system creates a negative invoice (same amount, opposite sign) on the same account. It sets `billed_amt = total_amt` on both invoices to mark them as "settled," but **does not create a credit payment or any explicit reference between them**. There is no `return_against`, no `credit_of`, and no payment linking the two.
|
||||||
|
|
||||||
|
**Problem**: With no link, there's no audit trail showing which invoice was cancelled by which credit note. The negative invoices appear as unallocated credits, creating phantom overpayment.
|
||||||
|
|
||||||
|
**ERPNext fix**: Three matching mechanisms were developed to reconstruct the links:
|
||||||
|
|
||||||
|
1. **Credit payment allocations** (15,694 matches) — `payment.type='credit'` with `memo='credit created by invoice #NNN'` → `payment_item.invoice_id` points to target. Zero overlap with mechanism 2.
|
||||||
|
2. **Invoice notes field** (5,095 matches) — `invoice.notes` contains `'Renversement de la facture #NNN'` referencing the target legacy invoice ID.
|
||||||
|
3. **Reversement payment memos** (143 unique matches) — `payment.type='reversement'` with `memo='create by invoice #CREDIT for invoice #TARGET'` → `payment_item.invoice_id` = target. Only applied to invoices not already matched by mechanisms 1-2.
|
||||||
|
|
||||||
|
Total: **16,830 credit notes linked** via `return_against` + PLE allocation entries.
|
||||||
|
|
||||||
|
### 2. Fake "Reversement" Payments
|
||||||
|
|
||||||
|
**Legacy behavior**: When a reversal invoice is created, the system also generates a `payment.type = 'reversement'` record allocated to the original invoice. This is **not a real customer payment** — it's an internal bookkeeping entry that marks the original invoice as "paid."
|
||||||
|
|
||||||
|
**Problem**: If imported as a real Payment Entry in ERPNext, the original invoice appears double-settled (once by the fake payment, once by the credit note PLE), creating ~$955K in phantom overpayment.
|
||||||
|
|
||||||
|
**ERPNext fix**: Excluded all ~5,000 "reversement" payments from import. These are not real financial transactions — the credit note relationship (mechanism 1/2/3 above) replaces them. The reversement memos are still parsed for matching purposes (mechanism 3).
|
||||||
|
|
||||||
|
### 3. Payment Types in Legacy
|
||||||
|
|
||||||
|
| Legacy `payment.type` | Real Payment? | ERPNext Treatment |
|
||||||
|
|------------------------|---------------|-------------------|
|
||||||
|
| `paiement direct` | Yes | Payment Entry |
|
||||||
|
| `carte credit` | Yes | Payment Entry |
|
||||||
|
| `ppa` | Yes (pre-authorized) | Payment Entry |
|
||||||
|
| `credit` | No — credit note allocation | PLE (plc-) only, memo parsed for matching |
|
||||||
|
| `cheque` | Yes | Payment Entry |
|
||||||
|
| `reversement` | **No — fake reversal payment** | **Excluded from PE**, memo parsed for matching |
|
||||||
|
| `comptant` | Yes | Payment Entry |
|
||||||
|
| `credit targo` | No — internal credit | **Excluded** |
|
||||||
|
|
||||||
|
### 4. Duplicate Payments (Double Form Submission)
|
||||||
|
|
||||||
|
**Legacy behavior**: The customer portal sometimes processes the same credit card charge twice when the legacy PHP backend is slow to respond. The submit handler fires again, creating a duplicate `payment` record. Both payments have the **same `reference`** field (Stripe transaction ID, e.g., `pi_3SadKtAU3HUVhnM10KjBOebK`) but different `payment.id` values.
|
||||||
|
|
||||||
|
**Problem**: Both payments are allocated to the same invoice via `payment_item`, causing the invoice to appear overpaid. The GL bank balance is also overstated.
|
||||||
|
|
||||||
|
**ERPNext fix**: Deduplicate payments by `(account_id, reference)` during import — keep only the first payment per unique reference. **178,730 duplicate payments** removed (from 522K raw to 343K unique).
|
||||||
|
|
||||||
|
### 5. Tax-Inclusive Totals
|
||||||
|
|
||||||
|
**Legacy behavior**: `invoice.total_amt` is **tax-inclusive** (includes TPS 5% + TVQ 9.975%). Individual tax amounts are stored in separate `invoice_tax` rows, not as columns on the invoice.
|
||||||
|
|
||||||
|
**ERPNext**: Stores both `grand_total` (with tax) and `net_total` (without tax). Tax amounts are back-calculated from the `invoice_tax` table. When no tax record exists, taxes are estimated at 14.975% of total.
|
||||||
|
|
||||||
|
### 6. No Due Date Tracking
|
||||||
|
|
||||||
|
**Legacy behavior**: Many invoices have `due_date = 0` (UNIX epoch) or NULL. The system doesn't enforce payment terms.
|
||||||
|
|
||||||
|
**ERPNext fix**: Uses `posting_date` as fallback when `due_date` is missing or invalid (before 2000).
|
||||||
|
|
||||||
|
### 7. Credit Notes with Both Payment and Reversal PLE
|
||||||
|
|
||||||
|
**Legacy behavior**: Some credit invoices have BOTH a `credit` payment allocation (mechanism 1) AND a separate reversal invoice reference (mechanism 2 or 3). If both are processed, the target invoice gets double-credited.
|
||||||
|
|
||||||
|
**Problem**: Creates phantom overpayment (negative outstanding) on target invoices. Originally caused $975K in overpaid balances.
|
||||||
|
|
||||||
|
**ERPNext fix**: Apply matching mechanisms in priority order. Track `already_linked` set — if a credit invoice was matched by credit payment (mechanism 1), skip it for reversal notes (mechanism 2) and reversement memos (mechanism 3). This prevents double-counting.
|
||||||
|
|
||||||
|
### 8. Orphaned Invoices (No Customer Account)
|
||||||
|
|
||||||
|
**Legacy behavior**: 9 invoices reference `account_id` values that don't exist in the `account` table (deleted customers or test data).
|
||||||
|
|
||||||
|
**ERPNext fix**: Skipped during import. Logged as unmapped: IDs 2712, 5627, 15119, 15234, 195096, 216370, 272051, 277963, 308988.
|
||||||
|
|
||||||
|
### 9. Invoice Naming for CRA Compliance
|
||||||
|
|
||||||
|
**Legacy behavior**: Invoices have integer IDs with no prefix. No formal naming convention.
|
||||||
|
|
||||||
|
**Problem**: CRA requires sequential invoice numbering for audit trail. Hex-encoded IDs (e.g., `SINV-000009BE67`) are not human-readable and can cause hash collisions on child tables.
|
||||||
|
|
||||||
|
**ERPNext fix**: Use `SINV-{legacy_id}` format (e.g., `SINV-638567`). Legacy IDs are already sequential integers. Post-migration invoices will use `SINV-YYYY-NNNNN` format (starting at `SINV-2026-700001`) to avoid collision with legacy IDs on reimport.
|
||||||
|
|
||||||
|
### 10. Customer Portal Credentials
|
||||||
|
|
||||||
|
**Legacy behavior**: 15,305 customer accounts with username/password (hashed, likely MD5/SHA1). Password reset tokens in `client_pwd` table (9,687 tokens). Stripe customer IDs stored in `account.stripe_id`.
|
||||||
|
|
||||||
|
**ERPNext fix**: Legacy password hashes are incompatible with Frappe's bcrypt auth. After migration, ERPNext Website Users will be created with customer emails, and bulk password reset emails will be sent. Stripe IDs will be linked to ERPNext's payment integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Results (2026-03-29, full historical import)
|
||||||
|
|
||||||
|
**Migration time: ~16 minutes** (966 seconds) for full reimport including cleanup, data load from legacy MariaDB, bulk SQL inserts, GL entries, PLE entries, and outstanding recalculation. This compares to an estimated ~120 hours if using Frappe ORM.
|
||||||
|
|
||||||
|
| Metric | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| Sales Invoices | 629,935 |
|
||||||
|
| Payment Entries | 343,684 (excl. reversement + credit + credit targo) |
|
||||||
|
| Payment References | 426,018 |
|
||||||
|
| GL Entries | 3,135,184 |
|
||||||
|
| PLE Entries | 1,060,041 |
|
||||||
|
| **GL Balance** | **$130,120,226.76 = $130,120,226.76 (diff $0.00)** |
|
||||||
|
|
||||||
|
### Credit Note Matching
|
||||||
|
| Mechanism | Matches |
|
||||||
|
|-----------|---------|
|
||||||
|
| Credit payment allocations (plc-) | 15,508 |
|
||||||
|
| Reversal notes + reversement memos (plr-) | 5,238 |
|
||||||
|
| **Total linked** | **20,746** |
|
||||||
|
|
||||||
|
### Invoice Status Breakdown
|
||||||
|
| Status | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| Paid | 415,861 |
|
||||||
|
| Overdue | 197,190 |
|
||||||
|
| Return | 16,868 |
|
||||||
|
| Credit Note Issued | 15 |
|
||||||
|
| Unpaid | 1 |
|
||||||
|
|
||||||
|
### Outstanding
|
||||||
|
- **Owed: $20,500,562.62**
|
||||||
|
- **Overpaid: -$3,695.77** (15 invoices — unmatched credit notes with no reference to original in any of the 3 mechanisms)
|
||||||
|
|
||||||
|
### Payment Deduplication
|
||||||
|
- Raw payments from legacy: 522,416
|
||||||
|
- After dedup by `(account_id, reference)`: 343,686
|
||||||
|
- Duplicates removed: **178,730**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Re-run
|
||||||
|
|
||||||
|
The migration is **idempotent** — the script deletes all existing data and reimports from scratch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From facturation.targo.ca:
|
||||||
|
ssh root@96.125.196.67 'docker cp /path/to/clean_reimport.py erpnext-backend-1:/home/frappe/frappe-bench/clean_reimport.py'
|
||||||
|
ssh root@96.125.196.67 'docker exec erpnext-backend-1 /home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/clean_reimport.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Update the password in the script before running (redacted as `*******` in git).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Migration Phases & Script Inventory
|
||||||
|
|
||||||
|
### Execution Order
|
||||||
|
|
||||||
|
The migration is split into phases. Each phase can be re-run independently (most scripts are idempotent or nuke-and-reimport).
|
||||||
|
|
||||||
|
| Phase | Script | Description | Status |
|
||||||
|
|-------|--------|-------------|--------|
|
||||||
|
| **0** | `nuke_data.py` | Delete all migrated data except Users, Items, Plans | Run before reimport |
|
||||||
|
| **1a** | `clean_reimport.py` | **Master accounting import**: 630K invoices, 344K payments, 3.1M GL, 1M PLE | **DONE** |
|
||||||
|
| **1b** | `migrate_direct.py` | Legacy account → Customer (15,303) | **DONE** (via migrate_all.py) |
|
||||||
|
| **1c** | `import_items.py` | Legacy product → Item (833) + Item Groups (41) | **DONE** |
|
||||||
|
| **2a** | `migrate_phase3.py` | Subscription Plans + Subscriptions from active services (cats 4,9,17,21,32,33) | **DONE** |
|
||||||
|
| **2b** | `import_missing_services.py` | Services from excluded categories (non-standard) | **DONE** |
|
||||||
|
| **2c** | `fix_subscription_details.py` | Populate actual_price, custom_description, item_code, item_group, billing_frequency from hijack data | **DONE** |
|
||||||
|
| **2d** | `fix_annual_billing_dates.py` | Fix annual billing dates from legacy date_next_invoice | **DONE** |
|
||||||
|
| **2e** | `fix_sub_address.py` | Link Subscription → Service Location via delivery_id | **DONE** |
|
||||||
|
| **3a** | `migrate_locations.py` | Legacy delivery → Service Location + device → Service Equipment | **DONE** |
|
||||||
|
| **3b** | `import_devices_and_enrich.py` | Import missing equipment + enrich locations with fibre OLT data | **DONE** |
|
||||||
|
| **3c** | `import_fibre_sql.py` (/tmp on server) | Direct SQL: Add OLT fields to Service Equipment (4,930 records from fibre table) | **DONE** |
|
||||||
|
| **4a** | `migrate_tickets.py` | Legacy ticket → Issue (98,524) + ticket_msg → Communication | **DONE** |
|
||||||
|
| **4b** | `fix_issue_cust2.py` | Link Issue → Customer via legacy account_id | **DONE** |
|
||||||
|
| **4c** | `fix_issue_owners.py` | Fix Issue owner/assignee from legacy staff_id | **DONE** |
|
||||||
|
| **4d** | `import_memos.py` | Legacy account_memo → Comments on Customer | **DONE** |
|
||||||
|
| **5a** | `import_customer_details.py` | Add 12+ custom fields to Customer (billing, contact, commercial flags) | **DONE** |
|
||||||
|
| **5b** | `import_services_and_enrich_customers.py` | Enrich Customer with phone, email, stripe_id, PPA, notes | **DONE** |
|
||||||
|
| **5c** | `cleanup_customer_status.py` | Disable Customers with no active subscriptions | **DONE** |
|
||||||
|
| **5d** | `import_terminated.py` | Import terminated customers (status 3,4,5) | **DONE** |
|
||||||
|
| **6a** | `import_employees.py` | Legacy staff → Employee (155), maps group_ad → Department | **DONE** |
|
||||||
|
| **6b** | `import_technicians.py` | Link Employee → Dispatch Technician | **DONE** |
|
||||||
|
| **6c** | `add_office_extension.py` | Add office_extension field to Employee from legacy staff.ext | **DONE** |
|
||||||
|
| **7a** | `setup_user_roles.py` | Create Role Profiles + assign Users (admin, tech, support, etc.) | **DONE** |
|
||||||
|
| **7b** | `setup_scheduler_toggle.py` | API endpoints for scheduler control (scheduler_status, toggle) | **DONE** |
|
||||||
|
| **8a** | `fix_customer_links.py` | Fix customer references in SINV, Subscription, Issue (name → CUST-xxx) | **DONE** |
|
||||||
|
| **8b** | `fix_invoice_outstanding.py` | Correct outstanding_amount from legacy billing_status | **DONE** |
|
||||||
|
| **8c** | `fix_reversals.py` | Link credit invoices → originals via return_against + PLE | **DONE** |
|
||||||
|
| **8d** | `fix_reversement.py` | Delete incorrectly imported reversement Payment Entries | **DONE** |
|
||||||
|
| **8e** | `fix_dates.py` | Fix creation/modified timestamps from legacy unix timestamps | **DONE** |
|
||||||
|
| **8f** | `fix_and_invoices.py` | Fix Subscription.party + import recent invoices | **DONE** |
|
||||||
|
| **8g** | `fix_no_rebate_discounts.py` | Restore catalog prices on deliveries (rebate handling) | **DONE** |
|
||||||
|
| **9a** | `rename_to_readable_ids.py` | Rename hex IDs → human-readable (CUST-xxx, LOC-addr, EQ-dev) | **DONE** |
|
||||||
|
| **9b** | `geocode_locations.py` | Geocode Service Locations via rqa_addresses (Quebec address DB) | **DONE** |
|
||||||
|
| **9c** | `update_item_descriptions.py` | Update Item descriptions from legacy French translations | **DONE** |
|
||||||
|
|
||||||
|
### Analysis/Exploration Scripts (read-only)
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `analyze_pricing_cleanup.py` | Pricing analysis: catalog vs hijack, rebate absorption |
|
||||||
|
| `check_missing_cat26.py` | Identify missing services in non-imported categories |
|
||||||
|
| `explore_expro_payments.py` | Compare legacy vs ERPNext payments for Expro Transit |
|
||||||
|
| `explore_expro_services.py` | Show active services for Expro Transit with pricing |
|
||||||
|
| `simulate_payment_import.py` | DRY RUN for Expro payments: timeline of invoice vs balance |
|
||||||
|
| `import_expro_payments.py` | Import missing Expro Transit payments (account 3673) |
|
||||||
|
|
||||||
|
### Helper Scripts (in parent /scripts/)
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `bulk_submit.py` | Bulk submit drafted Sales Invoices (docstatus 0 → 1) |
|
||||||
|
| `fix_ple_groupby.py` | Fix PostgreSQL GROUP BY errors in PLE queries |
|
||||||
|
| `fix_ple_postgres.sh` | Shell script to apply PostgreSQL patches |
|
||||||
|
| `server_bulk_submit.py` | Server-side bulk submit with progress tracking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy Tables → ERPNext Mapping (Complete)
|
||||||
|
|
||||||
|
### Core Data
|
||||||
|
|
||||||
|
| Legacy Table | ERPNext DocType | Script | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `account` | Customer | migrate_direct.py | 15,303 records |
|
||||||
|
| `account` (contact data) | Contact | import_services_and_enrich_customers.py | Phone, email, cell |
|
||||||
|
| `delivery` | Service Location | migrate_locations.py | Delivery addresses |
|
||||||
|
| `service` | Subscription | migrate_phase3.py + import_missing_services.py | Active services |
|
||||||
|
| `product` | Item | import_items.py | 833 items, 41 groups |
|
||||||
|
| `product` (plans) | Subscription Plan | migrate_phase3.py | Pricing plans |
|
||||||
|
| `device` | Service Equipment | migrate_locations.py + import_devices_and_enrich.py | ~7,241 devices |
|
||||||
|
| `fibre` + `fibre_olt` | Service Equipment (OLT fields) | import_fibre_sql.py | 4,930 with OLT data |
|
||||||
|
|
||||||
|
### Accounting
|
||||||
|
|
||||||
|
| Legacy Table | ERPNext DocType | Script | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `invoice` | Sales Invoice | clean_reimport.py | 629,935 records |
|
||||||
|
| `invoice_item` | Sales Invoice Item | clean_reimport.py | Line items |
|
||||||
|
| `invoice_tax` | Sales Taxes and Charges | clean_reimport.py | TPS/TVQ rows |
|
||||||
|
| `payment` | Payment Entry | clean_reimport.py | 343,684 (deduped) |
|
||||||
|
| `payment_item` | Payment Entry Reference | clean_reimport.py | 426,018 allocations |
|
||||||
|
| — | GL Entry | clean_reimport.py | 3,135,184 generated |
|
||||||
|
| — | Payment Ledger Entry | clean_reimport.py | 1,060,041 generated |
|
||||||
|
|
||||||
|
### Support & HR
|
||||||
|
|
||||||
|
| Legacy Table | ERPNext DocType | Script | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ticket` | Issue | migrate_tickets.py | 98,524 tickets |
|
||||||
|
| `ticket_msg` | Communication | migrate_tickets.py | Ticket replies |
|
||||||
|
| `ticket_dept` | Issue Type | migrate_tickets.py | Department → type |
|
||||||
|
| `account_memo` | Comment | import_memos.py | Internal notes |
|
||||||
|
| `staff` | Employee | import_employees.py | 155 employees |
|
||||||
|
| `staff` | User | migrate_users.py | Active staff → ERPNext users |
|
||||||
|
|
||||||
|
### Service Enrichment Fields
|
||||||
|
|
||||||
|
| Legacy Source | ERPNext Field | Script |
|
||||||
|
|---|---|---|
|
||||||
|
| `service.hijack_price` | Subscription.actual_price | fix_subscription_details.py |
|
||||||
|
| `service.hijack_desc` | Subscription.custom_description | fix_subscription_details.py |
|
||||||
|
| `service.product_id` → product.sku | Subscription.item_code | fix_subscription_details.py |
|
||||||
|
| `service.date_next_invoice` | Subscription.current_invoice_start | fix_annual_billing_dates.py |
|
||||||
|
| `service.billing_frequency` | Subscription.billing_frequency (M/A) | fix_subscription_details.py |
|
||||||
|
| `fibre.sn` | Service Equipment.serial_number | import_fibre_sql.py |
|
||||||
|
| `fibre.info_connect` → `fibre_olt` | Service Equipment.olt_name/ip/slot/port | import_fibre_sql.py |
|
||||||
|
| `account.stripe_id` | Customer.stripe_id | import_services_and_enrich_customers.py |
|
||||||
|
| `account.password` | Customer.legacy_password_hash | Not yet migrated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Migration Tasks
|
||||||
|
|
||||||
|
| Task | Priority | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Migrate legacy password hashes (`account.password`) | P1 | Needed for customer portal auth bridge (see project_portal_auth.md) |
|
||||||
|
| Investigate 15 overpaid invoices ($3,695.77) | P2 | Unmatched credit notes |
|
||||||
|
| Customer portal users (Website User creation) | P1 | 15,305 accounts with email — send password reset |
|
||||||
|
| QR code on invoice PDFs | P2 | Stripe payment link for customer portal |
|
||||||
|
| Scheduler reactivation | P0 | **PAUSED** — need Louis-Paul approval before enabling |
|
||||||
137
scripts/migration/add_office_extension.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""
|
||||||
|
Add office_extension custom field to Employee and populate from legacy staff.ext.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/add_office_extension.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Add office_extension custom field
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: ADD CUSTOM FIELD")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
existing = frappe.db.sql("""
|
||||||
|
SELECT fieldname FROM "tabCustom Field"
|
||||||
|
WHERE dt = 'Employee' AND fieldname = 'office_extension'
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Get max idx for contact_details tab fields
|
||||||
|
max_idx = frappe.db.sql("""
|
||||||
|
SELECT COALESCE(MAX(idx), 0) FROM "tabCustom Field"
|
||||||
|
WHERE dt = 'Employee'
|
||||||
|
""")[0][0]
|
||||||
|
|
||||||
|
cf_name = "Employee-office_extension"
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabCustom Field" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
dt, fieldname, label, fieldtype, insert_after, reqd, read_only, hidden
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, %(idx)s,
|
||||||
|
'Employee', 'office_extension', 'Office Extension', 'Data',
|
||||||
|
'cell_number', 0, 0, 0
|
||||||
|
)
|
||||||
|
""", {"name": cf_name, "now": now_str, "idx": max_idx + 1})
|
||||||
|
|
||||||
|
# Add column to table
|
||||||
|
try:
|
||||||
|
frappe.db.sql("""
|
||||||
|
ALTER TABLE "tabEmployee" ADD COLUMN office_extension varchar(20)
|
||||||
|
""")
|
||||||
|
except Exception as e:
|
||||||
|
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
|
||||||
|
print("Column 'office_extension' already exists")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Added 'office_extension' custom field to Employee")
|
||||||
|
else:
|
||||||
|
print("'office_extension' field already exists")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Populate from legacy staff.ext
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: POPULATE FROM LEGACY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, ext, email FROM staff
|
||||||
|
WHERE ext IS NOT NULL AND ext != '' AND ext != '0'
|
||||||
|
""")
|
||||||
|
staff_ext = cur.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Staff with extensions: {}".format(len(staff_ext)))
|
||||||
|
|
||||||
|
# Map legacy staff email → employee
|
||||||
|
emp_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, company_email, employee_number FROM "tabEmployee"
|
||||||
|
WHERE company_email IS NOT NULL
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
if r["company_email"]:
|
||||||
|
emp_map[r["company_email"].strip().lower()] = r["name"]
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for s in staff_ext:
|
||||||
|
email = (s["email"] or "").strip().lower()
|
||||||
|
emp_name = emp_map.get(email)
|
||||||
|
if emp_name:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabEmployee" SET office_extension = %s WHERE name = %s
|
||||||
|
""", (str(s["ext"]).strip(), emp_name))
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} employees with office extension".format(updated))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
with_ext = frappe.db.sql("""
|
||||||
|
SELECT name, employee_name, office_extension, cell_number, company_email
|
||||||
|
FROM "tabEmployee"
|
||||||
|
WHERE office_extension IS NOT NULL AND office_extension != ''
|
||||||
|
ORDER BY name
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
print("Employees with extension: {}".format(len(with_ext)))
|
||||||
|
for e in with_ext:
|
||||||
|
print(" {} → ext={} phone={} email={}".format(
|
||||||
|
e["employee_name"], e["office_extension"],
|
||||||
|
e["cell_number"] or "-", e["company_email"] or "-"))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nDone — cache cleared")
|
||||||
263
scripts/migration/analyze_pricing_cleanup.py
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"""
|
||||||
|
Analyze pricing cleanup: show catalog price for services, keep rebates as-is,
|
||||||
|
adjust biggest rebate to absorb the difference when hijack < catalog.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Positive products: show MAX(hijack_price, base_price) — catalog or higher
|
||||||
|
2. Negative products (rebates): show hijack_price as-is
|
||||||
|
3. If hijack_price < base_price on a positive product, the difference
|
||||||
|
gets added to the biggest rebate at that delivery address
|
||||||
|
4. Total per delivery must stay the same
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/analyze_pricing_cleanup.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# LOAD LEGACY DATA
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("Loading legacy data...")
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# All active services with product info
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.delivery_id, s.product_id, s.hijack, s.hijack_price, s.hijack_desc,
|
||||||
|
p.sku, p.price as base_price, p.category,
|
||||||
|
d.account_id
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
JOIN delivery d ON d.id = s.delivery_id
|
||||||
|
WHERE s.status = 1
|
||||||
|
ORDER BY s.delivery_id, p.price DESC
|
||||||
|
""")
|
||||||
|
all_services = cur.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("Active services loaded: {}".format(len(all_services)))
|
||||||
|
|
||||||
|
# Group by delivery_id
|
||||||
|
deliveries = {}
|
||||||
|
for s in all_services:
|
||||||
|
did = s["delivery_id"]
|
||||||
|
if did not in deliveries:
|
||||||
|
deliveries[did] = []
|
||||||
|
|
||||||
|
base = float(s["base_price"] or 0)
|
||||||
|
actual = float(s["hijack_price"]) if s["hijack"] else base
|
||||||
|
is_rebate = base < 0 # Product is inherently a rebate (SKU like RAB24M, RAB2X)
|
||||||
|
|
||||||
|
deliveries[did].append({
|
||||||
|
"svc_id": s["id"],
|
||||||
|
"sku": s["sku"],
|
||||||
|
"base_price": base,
|
||||||
|
"actual_price": actual,
|
||||||
|
"is_rebate": is_rebate,
|
||||||
|
"hijack": s["hijack"],
|
||||||
|
"hijack_desc": s["hijack_desc"] or "",
|
||||||
|
"account_id": s["account_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Deliveries with active services: {}".format(len(deliveries)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# APPLY PRICING RULES
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("PRICING ANALYSIS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
total_deliveries_affected = 0
|
||||||
|
total_services_adjusted = 0
|
||||||
|
adjustments = [] # [(delivery_id, account_id, old_total, new_total, services)]
|
||||||
|
|
||||||
|
for did, services in deliveries.items():
|
||||||
|
old_total = sum(s["actual_price"] for s in services)
|
||||||
|
|
||||||
|
# Step 1: Determine new display prices
|
||||||
|
discount_to_absorb = 0.0
|
||||||
|
for s in services:
|
||||||
|
if s["is_rebate"]:
|
||||||
|
# Rebate product: keep actual price
|
||||||
|
s["new_price"] = s["actual_price"]
|
||||||
|
else:
|
||||||
|
# Positive product
|
||||||
|
if s["actual_price"] < s["base_price"]:
|
||||||
|
# Hijack made it cheaper → show catalog, track discount
|
||||||
|
discount_to_absorb += (s["base_price"] - s["actual_price"])
|
||||||
|
s["new_price"] = s["base_price"]
|
||||||
|
s["adjusted"] = True
|
||||||
|
else:
|
||||||
|
# Hijack same or higher → keep actual (custom service)
|
||||||
|
s["new_price"] = s["actual_price"]
|
||||||
|
s["adjusted"] = False
|
||||||
|
|
||||||
|
# Step 2: Find biggest rebate and add discount_to_absorb to it
|
||||||
|
rebate_adjusted = False
|
||||||
|
if discount_to_absorb > 0.005:
|
||||||
|
# Find the rebate with the most negative price
|
||||||
|
rebates = [s for s in services if s["is_rebate"]]
|
||||||
|
if rebates:
|
||||||
|
biggest_rebate = min(rebates, key=lambda s: s["new_price"])
|
||||||
|
biggest_rebate["new_price"] -= discount_to_absorb
|
||||||
|
biggest_rebate["absorbed"] = discount_to_absorb
|
||||||
|
rebate_adjusted = True
|
||||||
|
total_deliveries_affected += 1
|
||||||
|
else:
|
||||||
|
# No rebate exists — need to create one? Or leave as-is
|
||||||
|
# For now, mark as needing attention
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_total = sum(s["new_price"] for s in services)
|
||||||
|
|
||||||
|
# Verify totals match
|
||||||
|
if abs(old_total - new_total) > 0.02:
|
||||||
|
adjustments.append((did, services[0]["account_id"], old_total, new_total, services, "MISMATCH"))
|
||||||
|
elif rebate_adjusted:
|
||||||
|
adjustments.append((did, services[0]["account_id"], old_total, new_total, services, "OK"))
|
||||||
|
|
||||||
|
for s in services:
|
||||||
|
if s.get("adjusted"):
|
||||||
|
total_services_adjusted += 1
|
||||||
|
|
||||||
|
print("\nDeliveries affected (rebate adjusted): {}".format(total_deliveries_affected))
|
||||||
|
print("Individual services price-restored to catalog: {}".format(total_services_adjusted))
|
||||||
|
|
||||||
|
# Count mismatches
|
||||||
|
mismatches = [a for a in adjustments if a[5] == "MISMATCH"]
|
||||||
|
ok_adjustments = [a for a in adjustments if a[5] == "OK"]
|
||||||
|
print("Clean adjustments (total preserved): {}".format(len(ok_adjustments)))
|
||||||
|
print("MISMATCHES (total changed): {}".format(len(mismatches)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# SHOW DETAILED EXAMPLES
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Find Expro Transit (account 3673) deliveries
|
||||||
|
expro = [a for a in adjustments if a[1] == 3673]
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("EXPRO TRANSIT (account 3673) — {} deliveries affected".format(len(expro)))
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
for did, acct_id, old_total, new_total, services, status in expro:
|
||||||
|
print("\n Delivery {} — {} [total: {:.2f}]".format(did, status, old_total))
|
||||||
|
print(" {:<14} {:>10} {:>10} {:>10} {}".format(
|
||||||
|
"SKU", "BASE", "ACTUAL", "NEW", "NOTE"))
|
||||||
|
print(" " + "-" * 66)
|
||||||
|
for s in sorted(services, key=lambda x: (x["is_rebate"], -x["new_price"])):
|
||||||
|
note = ""
|
||||||
|
if s.get("adjusted"):
|
||||||
|
note = "← restored to catalog (+{:.2f} to rebate)".format(s["base_price"] - s["actual_price"])
|
||||||
|
if s.get("absorbed"):
|
||||||
|
note = "← absorbed {:.2f} discount".format(s["absorbed"])
|
||||||
|
|
||||||
|
marker = " " if not s["is_rebate"] else " "
|
||||||
|
print(" {}{:<12} {:>10.2f} {:>10.2f} {:>10.2f} {}".format(
|
||||||
|
marker, s["sku"], s["base_price"], s["actual_price"], s["new_price"], note))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# SHOW OTHER SAMPLE CUSTOMERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("SAMPLE OTHER CUSTOMERS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Get customer names for sample accounts
|
||||||
|
sample_accts = set()
|
||||||
|
for a in ok_adjustments[:20]:
|
||||||
|
sample_accts.add(a[1])
|
||||||
|
|
||||||
|
if sample_accts:
|
||||||
|
acct_list = list(sample_accts)[:5]
|
||||||
|
for acct_id in acct_list:
|
||||||
|
cust = frappe.db.sql("""
|
||||||
|
SELECT name, customer_name FROM "tabCustomer"
|
||||||
|
WHERE legacy_account_id = %s LIMIT 1
|
||||||
|
""", (acct_id,), as_dict=True)
|
||||||
|
cust_name = cust[0]["customer_name"] if cust else "account {}".format(acct_id)
|
||||||
|
|
||||||
|
acct_adjustments = [a for a in adjustments if a[1] == acct_id]
|
||||||
|
print("\n {} (account {}) — {} deliveries".format(cust_name, acct_id, len(acct_adjustments)))
|
||||||
|
|
||||||
|
for did, _, old_total, new_total, services, status in acct_adjustments[:2]:
|
||||||
|
print(" Delivery {} [total: {:.2f}] {}".format(did, old_total, status))
|
||||||
|
for s in sorted(services, key=lambda x: (x["is_rebate"], -x["new_price"])):
|
||||||
|
if s.get("adjusted") or s.get("absorbed"):
|
||||||
|
marker = " " if not s["is_rebate"] else " "
|
||||||
|
note = ""
|
||||||
|
if s.get("adjusted"):
|
||||||
|
note = "catalog:{:.2f} actual:{:.2f} → NEW:{:.2f}".format(
|
||||||
|
s["base_price"], s["actual_price"], s["new_price"])
|
||||||
|
if s.get("absorbed"):
|
||||||
|
note = "absorbed {:.2f} → NEW:{:.2f}".format(s["absorbed"], s["new_price"])
|
||||||
|
print(" {}{:<12} {}".format(marker, s["sku"], note))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# DELIVERIES WITHOUT REBATES (discount but no rebate to absorb)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("PROBLEM CASES: discount but NO rebate to absorb it")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
no_rebate_discount = 0
|
||||||
|
for did, services in deliveries.items():
|
||||||
|
has_discount = False
|
||||||
|
has_rebate = False
|
||||||
|
for s in services:
|
||||||
|
if not s["is_rebate"] and s["actual_price"] < s["base_price"] - 0.01:
|
||||||
|
has_discount = True
|
||||||
|
if s["is_rebate"]:
|
||||||
|
has_rebate = True
|
||||||
|
if has_discount and not has_rebate:
|
||||||
|
no_rebate_discount += 1
|
||||||
|
if no_rebate_discount <= 5:
|
||||||
|
print(" Delivery {}: services with discount but no rebate product".format(did))
|
||||||
|
for s in services:
|
||||||
|
if s["actual_price"] < s["base_price"] - 0.01:
|
||||||
|
print(" {} base={:.2f} actual={:.2f} diff={:.2f}".format(
|
||||||
|
s["sku"], s["base_price"], s["actual_price"],
|
||||||
|
s["base_price"] - s["actual_price"]))
|
||||||
|
|
||||||
|
print("\nTotal deliveries with discount but no rebate: {}".format(no_rebate_discount))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# GLOBAL STATS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("GLOBAL SUMMARY")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
total_svc = len(all_services)
|
||||||
|
hijacked = sum(1 for s in all_services if s["hijack"])
|
||||||
|
hijacked_lower = sum(1 for did, svcs in deliveries.items() for s in svcs
|
||||||
|
if not s["is_rebate"] and s["actual_price"] < s["base_price"] - 0.01)
|
||||||
|
hijacked_higher = sum(1 for did, svcs in deliveries.items() for s in svcs
|
||||||
|
if not s["is_rebate"] and s["actual_price"] > s["base_price"] + 0.01 and s["hijack"])
|
||||||
|
|
||||||
|
print("Total active services: {:,}".format(total_svc))
|
||||||
|
print("Hijacked (custom price): {:,}".format(hijacked))
|
||||||
|
print(" ↳ cheaper than catalog: {:,} (restore to catalog + absorb in rebate)".format(hijacked_lower))
|
||||||
|
print(" ↳ more expensive than catalog: {:,} (keep actual — custom service)".format(hijacked_higher))
|
||||||
|
print("Deliveries needing rebate adjustment: {:,}".format(total_deliveries_affected))
|
||||||
|
print("Deliveries with no rebate to absorb: {:,}".format(no_rebate_discount))
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("ANALYSIS COMPLETE — no changes made")
|
||||||
|
print("=" * 70)
|
||||||
31
scripts/migration/check_missing_cat26.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import pymysql
|
||||||
|
conn = pymysql.connect(host='10.100.80.100', user='facturation', password='VD67owoj',
|
||||||
|
database='gestionclient', cursorclass=pymysql.cursors.DictCursor)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.category, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT p.sku) as skus
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
WHERE s.status = 1
|
||||||
|
AND p.category NOT IN (4,9,17,21,32,33)
|
||||||
|
GROUP BY p.category
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
""")
|
||||||
|
total = 0
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print("cat={}: {} services — SKUs: {}".format(r["category"], r["cnt"], r["skus"]))
|
||||||
|
total += r["cnt"]
|
||||||
|
print("\nTotal missing active services: {}".format(total))
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.sku, COUNT(*) as cnt, p.price
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
WHERE s.status = 1 AND p.category = 26
|
||||||
|
GROUP BY p.sku, p.price
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
""")
|
||||||
|
print("\nCategory 26 breakdown:")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(" {} x{} @ {:.2f}".format(r["sku"], r["cnt"], float(r["price"])))
|
||||||
|
conn.close()
|
||||||
1284
scripts/migration/clean_reimport.py
Normal file
92
scripts/migration/cleanup_customer_status.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""
|
||||||
|
Disable customers with no active Service Subscriptions.
|
||||||
|
These are legacy accounts (moved, cancelled, etc.) that were imported as enabled.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/cleanup_customer_status.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
T_TOTAL = time.time()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Identify customers to disable
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: IDENTIFY INACTIVE CUSTOMERS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Customers that are active but have NO active subscriptions
|
||||||
|
to_disable = frappe.db.sql("""
|
||||||
|
SELECT c.name FROM "tabCustomer" c
|
||||||
|
WHERE c.disabled = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "tabService Subscription" ss
|
||||||
|
WHERE ss.customer = c.name AND ss.status = %s
|
||||||
|
)
|
||||||
|
""", ("Actif",))
|
||||||
|
|
||||||
|
to_disable_names = [r[0] for r in to_disable]
|
||||||
|
print("Customers to disable (no active subscriptions): {}".format(len(to_disable_names)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Disable them
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: DISABLE CUSTOMERS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
if to_disable_names:
|
||||||
|
# Batch update in chunks of 1000
|
||||||
|
for i in range(0, len(to_disable_names), 1000):
|
||||||
|
batch = to_disable_names[i:i+1000]
|
||||||
|
placeholders = ", ".join(["%s"] * len(batch))
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabCustomer" SET disabled = 1
|
||||||
|
WHERE name IN ({})
|
||||||
|
""".format(placeholders), tuple(batch))
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Disabled batch {}-{}".format(i+1, min(i+1000, len(to_disable_names))))
|
||||||
|
|
||||||
|
print("Disabled {} customers".format(len(to_disable_names)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
by_status = frappe.db.sql("""
|
||||||
|
SELECT disabled, COUNT(*) as cnt FROM "tabCustomer"
|
||||||
|
GROUP BY disabled ORDER BY disabled
|
||||||
|
""", as_dict=True)
|
||||||
|
print("Customer status after cleanup:")
|
||||||
|
for r in by_status:
|
||||||
|
label = "Active (Abonné)" if r["disabled"] == 0 else "Disabled (Inactif)"
|
||||||
|
print(" {}: {}".format(label, r["cnt"]))
|
||||||
|
|
||||||
|
# Cross-check: all active customers should have at least one active sub
|
||||||
|
active_no_sub = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) FROM "tabCustomer" c
|
||||||
|
WHERE c.disabled = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "tabService Subscription" ss
|
||||||
|
WHERE ss.customer = c.name AND ss.status = %s
|
||||||
|
)
|
||||||
|
""", ("Actif",))[0][0]
|
||||||
|
print("\nActive customers with no active subscription: {} (should be 0)".format(active_no_sub))
|
||||||
|
|
||||||
|
elapsed = time.time() - T_TOTAL
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("DONE in {:.1f}s".format(elapsed))
|
||||||
|
print("="*60)
|
||||||
193
scripts/migration/create_portal_users.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""
|
||||||
|
Create Website Users for customer portal + store legacy MD5 password hashes.
|
||||||
|
Bridge auth: on first login, verify MD5 → convert to pbkdf2 → clear legacy hash.
|
||||||
|
|
||||||
|
Requires custom field 'legacy_password_md5' (Data) on User doctype.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/create_portal_users.py
|
||||||
|
"""
|
||||||
|
import os, sys, time
|
||||||
|
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
import frappe
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
# Disable rate limiting for bulk user creation
|
||||||
|
frappe.flags.in_migrate = True
|
||||||
|
frappe.flags.in_install = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
DRY_RUN = False # SET TO False WHEN READY
|
||||||
|
|
||||||
|
# ── Step 1: Ensure custom field exists ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Step 1: Ensure legacy_password_md5 custom field on User")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not frappe.db.exists('Custom Field', {'dt': 'User', 'fieldname': 'legacy_password_md5'}):
|
||||||
|
if not DRY_RUN:
|
||||||
|
cf = frappe.get_doc({
|
||||||
|
'doctype': 'Custom Field',
|
||||||
|
'dt': 'User',
|
||||||
|
'fieldname': 'legacy_password_md5',
|
||||||
|
'label': 'Legacy Password (MD5)',
|
||||||
|
'fieldtype': 'Data',
|
||||||
|
'hidden': 1,
|
||||||
|
'no_copy': 1,
|
||||||
|
'print_hide': 1,
|
||||||
|
'insert_after': 'last_password_reset_date',
|
||||||
|
})
|
||||||
|
cf.insert(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Created custom field")
|
||||||
|
else:
|
||||||
|
print(" [DRY RUN] Would create custom field")
|
||||||
|
else:
|
||||||
|
print(" Custom field already exists")
|
||||||
|
|
||||||
|
# ── Step 2: Fetch legacy accounts with email + password ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Step 2: Fetch legacy accounts")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
legacy = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="VD67owoj",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with legacy.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, email, password, first_name, last_name,
|
||||||
|
status, customer_id
|
||||||
|
FROM account
|
||||||
|
WHERE email IS NOT NULL AND email != '' AND TRIM(email) != ''
|
||||||
|
AND password IS NOT NULL AND password != ''
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
legacy_accounts = cur.fetchall()
|
||||||
|
legacy.close()
|
||||||
|
|
||||||
|
print(f" Legacy accounts with email + password: {len(legacy_accounts)}")
|
||||||
|
|
||||||
|
# ── Step 3: Build legacy_account_id → Customer name map ──
|
||||||
|
acct_to_cust = {}
|
||||||
|
rows = frappe.db.sql('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id IS NOT NULL', as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
acct_to_cust[int(r['legacy_account_id'])] = r['name']
|
||||||
|
print(f" Customers with legacy_account_id: {len(acct_to_cust)}")
|
||||||
|
|
||||||
|
# ── Step 4: Check existing users ──
|
||||||
|
existing_users = set()
|
||||||
|
rows = frappe.db.sql('SELECT LOWER(name) as name FROM "tabUser"', as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
existing_users.add(r['name'])
|
||||||
|
|
||||||
|
print(f" Existing ERPNext users: {len(existing_users)}")
|
||||||
|
|
||||||
|
# ── Step 5: Create Website Users ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Step 3: Create Website Users")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
created = 0
|
||||||
|
skipped_existing = 0
|
||||||
|
skipped_no_customer = 0
|
||||||
|
skipped_bad_email = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for acc in legacy_accounts:
|
||||||
|
email = acc['email'].strip().lower()
|
||||||
|
|
||||||
|
# Basic email validation
|
||||||
|
if '@' not in email or '.' not in email.split('@')[-1]:
|
||||||
|
skipped_bad_email += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Already exists?
|
||||||
|
if email in existing_users:
|
||||||
|
# Just update the legacy hash if not already set
|
||||||
|
if not DRY_RUN and acc['password']:
|
||||||
|
try:
|
||||||
|
frappe.db.sql(
|
||||||
|
"""UPDATE "tabUser" SET legacy_password_md5 = %s
|
||||||
|
WHERE LOWER(name) = %s AND (legacy_password_md5 IS NULL OR legacy_password_md5 = '')""",
|
||||||
|
(acc['password'], email)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Field might not exist yet in dry run
|
||||||
|
skipped_existing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the ERPNext customer via legacy_account_id
|
||||||
|
cust_name = acct_to_cust.get(int(acc['id']))
|
||||||
|
|
||||||
|
if not cust_name:
|
||||||
|
skipped_no_customer += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_name = (acc.get('first_name') or '').strip() or 'Client'
|
||||||
|
last_name = (acc.get('last_name') or '').strip() or ''
|
||||||
|
full_name = f"{first_name} {last_name}".strip()
|
||||||
|
|
||||||
|
if not DRY_RUN:
|
||||||
|
try:
|
||||||
|
user = frappe.get_doc({
|
||||||
|
'doctype': 'User',
|
||||||
|
'email': email,
|
||||||
|
'first_name': first_name,
|
||||||
|
'last_name': last_name,
|
||||||
|
'full_name': full_name,
|
||||||
|
'enabled': 1,
|
||||||
|
'user_type': 'Website User',
|
||||||
|
'legacy_password_md5': acc['password'] or '',
|
||||||
|
'roles': [{'role': 'Customer'}],
|
||||||
|
})
|
||||||
|
user.flags.no_welcome_email = True # Don't send email yet
|
||||||
|
user.flags.ignore_permissions = True
|
||||||
|
user.flags.in_import = True
|
||||||
|
user.insert(ignore_permissions=True, ignore_if_duplicate=True)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
# Link user to customer as portal user
|
||||||
|
customer_doc = frappe.get_doc('Customer', cust_name)
|
||||||
|
customer_doc.append('portal_users', {'user': email})
|
||||||
|
customer_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
except frappe.DuplicateEntryError:
|
||||||
|
skipped_existing += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 10:
|
||||||
|
print(f" ERR {email}: {e}")
|
||||||
|
else:
|
||||||
|
created += 1 # Count as would-create
|
||||||
|
|
||||||
|
if (created + skipped_existing) % 1000 == 0:
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.commit()
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
total = created + skipped_existing + skipped_no_customer + skipped_bad_email + errors
|
||||||
|
print(f" Progress: {total}/{len(legacy_accounts)} created={created} existing={skipped_existing} [{elapsed:.0f}s]")
|
||||||
|
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(f"\n Created: {created}")
|
||||||
|
print(f" Already existed: {skipped_existing}")
|
||||||
|
print(f" No matching customer: {skipped_no_customer}")
|
||||||
|
print(f" Bad email: {skipped_bad_email}")
|
||||||
|
print(f" Errors: {errors}")
|
||||||
|
print(f" Time: {elapsed:.0f}s")
|
||||||
|
|
||||||
|
if DRY_RUN:
|
||||||
|
print("\n ** DRY RUN — no changes made **")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("DONE")
|
||||||
|
print("=" * 60)
|
||||||
131
scripts/migration/explore_expro_payments.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""Explore legacy payments for Expro Transit (account 3673) vs ERPNext."""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_ID = 3673
|
||||||
|
CUSTOMER = "CUST-cbf03814b9"
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Get all payments for this account
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id, p.date_orig, p.amount, p.applied_amt, p.type, p.memo, p.reference, p.excedent, p.correction
|
||||||
|
FROM payment p
|
||||||
|
WHERE p.account_id = %s
|
||||||
|
ORDER BY p.date_orig DESC
|
||||||
|
""", (ACCOUNT_ID,))
|
||||||
|
payments = cur.fetchall()
|
||||||
|
|
||||||
|
print("\n=== ALL LEGACY PAYMENTS for account {} ===".format(ACCOUNT_ID))
|
||||||
|
print("Total: {} payments".format(len(payments)))
|
||||||
|
total_paid = 0
|
||||||
|
for r in payments:
|
||||||
|
dt = datetime.fromtimestamp(r["date_orig"]).strftime("%Y-%m-%d") if r["date_orig"] else "NULL"
|
||||||
|
total_paid += float(r["amount"] or 0)
|
||||||
|
print(" PE-{:<8} date={} amount={:>10.2f} applied={:>10.2f} type={:<12} ref={}".format(
|
||||||
|
r["id"], dt, float(r["amount"] or 0), float(r["applied_amt"] or 0),
|
||||||
|
r["type"] or "", r["reference"] or ""))
|
||||||
|
print(" TOTAL PAID: {:,.2f}".format(total_paid))
|
||||||
|
|
||||||
|
# Get all payment_items
|
||||||
|
cur.execute("""
|
||||||
|
SELECT pi.payment_id, pi.invoice_id, pi.amount, p.date_orig
|
||||||
|
FROM payment_item pi
|
||||||
|
JOIN payment p ON p.id = pi.payment_id
|
||||||
|
WHERE p.account_id = %s
|
||||||
|
ORDER BY p.date_orig DESC
|
||||||
|
""", (ACCOUNT_ID,))
|
||||||
|
items = cur.fetchall()
|
||||||
|
|
||||||
|
print("\n=== PAYMENT-INVOICE ALLOCATIONS ===")
|
||||||
|
print("Total allocations: {}".format(len(items)))
|
||||||
|
for r in items[:30]:
|
||||||
|
dt = datetime.fromtimestamp(r["date_orig"]).strftime("%Y-%m-%d") if r["date_orig"] else "NULL"
|
||||||
|
print(" payment PE-{} -> SINV-{} amount={:.2f} date={}".format(
|
||||||
|
r["payment_id"], r["invoice_id"], float(r["amount"] or 0), dt))
|
||||||
|
if len(items) > 30:
|
||||||
|
print(" ... ({} more)".format(len(items) - 30))
|
||||||
|
|
||||||
|
# Get all invoices for this account
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, total_amt, billed_amt, billing_status, date_orig
|
||||||
|
FROM invoice
|
||||||
|
WHERE account_id = %s
|
||||||
|
ORDER BY date_orig DESC
|
||||||
|
""", (ACCOUNT_ID,))
|
||||||
|
invoices = cur.fetchall()
|
||||||
|
|
||||||
|
print("\n=== LEGACY INVOICES ===")
|
||||||
|
print("Total: {} invoices".format(len(invoices)))
|
||||||
|
total_invoiced = 0
|
||||||
|
total_outstanding = 0
|
||||||
|
for inv in invoices:
|
||||||
|
total_amt = float(inv["total_amt"] or 0)
|
||||||
|
billed_amt = float(inv["billed_amt"] or 0)
|
||||||
|
total_invoiced += total_amt
|
||||||
|
montant_du = max(total_amt - billed_amt, 0)
|
||||||
|
total_outstanding += montant_du
|
||||||
|
print(" Total invoiced: {:,.2f}".format(total_invoiced))
|
||||||
|
print(" Total paid: {:,.2f}".format(total_paid))
|
||||||
|
print(" Total outstanding: {:,.2f}".format(total_outstanding))
|
||||||
|
|
||||||
|
# Now check ERPNext state
|
||||||
|
print("\n=== ERPNEXT STATE ===")
|
||||||
|
erp_inv = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) as cnt, COALESCE(SUM(grand_total), 0) as total,
|
||||||
|
COALESCE(SUM(outstanding_amount), 0) as outstanding
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE customer = %s AND docstatus = 1 AND grand_total > 0
|
||||||
|
""", (CUSTOMER,), as_dict=True)[0]
|
||||||
|
|
||||||
|
erp_pe = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) as cnt, COALESCE(SUM(paid_amount), 0) as total
|
||||||
|
FROM "tabPayment Entry"
|
||||||
|
WHERE party = %s AND docstatus = 1
|
||||||
|
""", (CUSTOMER,), as_dict=True)[0]
|
||||||
|
|
||||||
|
print(" Invoices: {} for {:,.2f} (outstanding: {:,.2f})".format(
|
||||||
|
erp_inv["cnt"], float(erp_inv["total"]), float(erp_inv["outstanding"])))
|
||||||
|
print(" Payments: {} for {:,.2f}".format(erp_pe["cnt"], float(erp_pe["total"])))
|
||||||
|
|
||||||
|
# Which PE ids exist in ERPNext?
|
||||||
|
erp_pes = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabPayment Entry" WHERE party = %s AND docstatus = 1 ORDER BY name
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
erp_pe_ids = set()
|
||||||
|
for pe in erp_pes:
|
||||||
|
try:
|
||||||
|
erp_pe_ids.add(int(pe["name"].split("-")[1]))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
legacy_pe_ids = set(r["id"] for r in payments)
|
||||||
|
missing = legacy_pe_ids - erp_pe_ids
|
||||||
|
existing = legacy_pe_ids & erp_pe_ids
|
||||||
|
|
||||||
|
print("\n Legacy payment IDs: {}".format(len(legacy_pe_ids)))
|
||||||
|
print(" In ERPNext: {}".format(len(existing)))
|
||||||
|
print(" MISSING: {}".format(len(missing)))
|
||||||
|
|
||||||
|
# Summary of missing payments
|
||||||
|
missing_total = 0
|
||||||
|
for r in payments:
|
||||||
|
if r["id"] in missing:
|
||||||
|
missing_total += float(r["amount"] or 0)
|
||||||
|
print(" Missing total: {:,.2f}".format(missing_total))
|
||||||
|
|
||||||
|
conn.close()
|
||||||
93
scripts/migration/explore_expro_services.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Explore legacy services + product descriptions for Expro Transit."""
|
||||||
|
import pymysql
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation",
|
||||||
|
password="*******", database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_ID = 3673
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Get delivery IDs for this account
|
||||||
|
cur.execute("SELECT id FROM delivery WHERE account_id = %s", (ACCOUNT_ID,))
|
||||||
|
delivery_ids = [r["id"] for r in cur.fetchall()]
|
||||||
|
print("Delivery IDs for account {}: {}".format(ACCOUNT_ID, delivery_ids))
|
||||||
|
|
||||||
|
if not delivery_ids:
|
||||||
|
print("No deliveries found!")
|
||||||
|
exit()
|
||||||
|
|
||||||
|
placeholders = ",".join(["%s"] * len(delivery_ids))
|
||||||
|
|
||||||
|
# French product translations
|
||||||
|
cur.execute("""
|
||||||
|
SELECT pt.product_id, pt.name as pname, pt.description_short, p.sku, p.price
|
||||||
|
FROM product_translate pt
|
||||||
|
JOIN product p ON p.id = pt.product_id
|
||||||
|
WHERE pt.language_id = 'fr'
|
||||||
|
AND p.sku IN ('FTTB1000I','CSERV','FTTH_LOCMOD','FTT_HFAR','HVIPFIXE','RAB24M','TELEPMENS','RAB2X')
|
||||||
|
""")
|
||||||
|
print("\n=== French product names ===")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(" sku={:<14} price={:>8} name={}".format(r["sku"], r["price"], r["pname"]))
|
||||||
|
|
||||||
|
# All active services at these delivery addresses
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.delivery_id, s.product_id, s.hijack, s.hijack_price, s.hijack_desc,
|
||||||
|
s.comment, s.radius_user, s.radius_pwd,
|
||||||
|
p.sku, p.price as base_price, p.category, p.type,
|
||||||
|
pt.name as prod_name, pt.description_short
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
LEFT JOIN product_translate pt ON pt.product_id = p.id AND pt.language_id = 'fr'
|
||||||
|
WHERE s.delivery_id IN ({}) AND s.status = 1
|
||||||
|
ORDER BY s.delivery_id, p.category, p.sku
|
||||||
|
""".format(placeholders), delivery_ids)
|
||||||
|
services = cur.fetchall()
|
||||||
|
|
||||||
|
print("\n=== Active services for Expro ({} total) ===".format(len(services)))
|
||||||
|
current_delivery = None
|
||||||
|
subtotal = 0
|
||||||
|
grand_total = 0
|
||||||
|
for s in services:
|
||||||
|
if s["delivery_id"] != current_delivery:
|
||||||
|
if current_delivery is not None:
|
||||||
|
print(" {:>58} ──────".format(""))
|
||||||
|
print(" {:>58} {:>8.2f}$".format("Sous-total:", subtotal))
|
||||||
|
grand_total += subtotal
|
||||||
|
subtotal = 0
|
||||||
|
current_delivery = s["delivery_id"]
|
||||||
|
print("\n ── Delivery {} ──".format(s["delivery_id"]))
|
||||||
|
|
||||||
|
price = s["hijack_price"] if s["hijack"] else s["base_price"]
|
||||||
|
desc = s["hijack_desc"] if s["hijack"] and s["hijack_desc"] else s["prod_name"]
|
||||||
|
subtotal += float(price or 0)
|
||||||
|
|
||||||
|
is_rebate = float(price or 0) < 0
|
||||||
|
indent = " " if is_rebate else " "
|
||||||
|
print(" {}svc={:<6} sku={:<14} {:>8.2f}$ {} {}".format(
|
||||||
|
indent, s["id"], s["sku"], float(price or 0),
|
||||||
|
desc or "", "({})".format(s["comment"]) if s["comment"] else ""))
|
||||||
|
if s["radius_user"]:
|
||||||
|
print(" {} PPPoE: {}".format(indent, s["radius_user"]))
|
||||||
|
|
||||||
|
if current_delivery is not None:
|
||||||
|
print(" {:>58} ──────".format(""))
|
||||||
|
print(" {:>58} {:>8.2f}$".format("Sous-total:", subtotal))
|
||||||
|
grand_total += subtotal
|
||||||
|
|
||||||
|
print("\n GRAND TOTAL: {:,.2f}$".format(grand_total))
|
||||||
|
|
||||||
|
# Also get all product categories
|
||||||
|
cur.execute("DESCRIBE product_cat")
|
||||||
|
print("\n=== product_cat table ===")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(" {} {}".format(r["Field"], r["Type"]))
|
||||||
|
|
||||||
|
cur.execute("SELECT * FROM product_cat ORDER BY id")
|
||||||
|
print("\n=== Product categories ===")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
print(" id={} {}".format(r["id"], r))
|
||||||
|
|
||||||
|
conn.close()
|
||||||
272
scripts/migration/fix_annual_billing_dates.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""
|
||||||
|
Fix annual subscription billing dates.
|
||||||
|
|
||||||
|
For each annual subscription (billing_frequency='A'):
|
||||||
|
1. Create an annual copy of its subscription plan (billing_interval=Year)
|
||||||
|
2. Re-link the subscription to the annual plan
|
||||||
|
3. Set current_invoice_start/end from legacy date_next_invoice
|
||||||
|
4. ERPNext scheduler uses plan's billing_interval to determine period length
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_annual_billing_dates.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
DRY_RUN = False
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="VD67owoj",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# LOAD LEGACY DATA
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("Loading legacy annual services...")
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.date_next_invoice, s.date_orig, s.payment_recurrence,
|
||||||
|
s.hijack_price, s.hijack, p.price as base_price, p.sku
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
WHERE s.status = 1 AND s.payment_recurrence = 0
|
||||||
|
""")
|
||||||
|
annual_services = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
print("Annual services in legacy: {}".format(len(annual_services)))
|
||||||
|
|
||||||
|
legacy_annual = {s["id"]: s for s in annual_services}
|
||||||
|
|
||||||
|
def ts_to_date(ts):
|
||||||
|
if not ts or ts <= 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# LOAD ANNUAL SUBSCRIPTIONS FROM ERPNEXT
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\nLoading annual subscriptions from ERPNext...")
|
||||||
|
annual_subs = frappe.db.sql("""
|
||||||
|
SELECT s.name, s.legacy_service_id, s.party, s.status,
|
||||||
|
s.current_invoice_start, s.current_invoice_end,
|
||||||
|
s.billing_frequency, s.start_date,
|
||||||
|
spd.plan, spd.name as spd_name
|
||||||
|
FROM "tabSubscription" s
|
||||||
|
LEFT JOIN "tabSubscription Plan Detail" spd ON spd.parent = s.name
|
||||||
|
WHERE s.billing_frequency = 'A'
|
||||||
|
ORDER BY s.name
|
||||||
|
""", as_dict=True)
|
||||||
|
print("Annual subscriptions: {}".format(len(annual_subs)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# LOAD EXISTING PLANS — need to create annual copies
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
plan_names = set(s["plan"] for s in annual_subs if s["plan"])
|
||||||
|
print("Distinct plans used by annual subs: {}".format(len(plan_names)))
|
||||||
|
|
||||||
|
existing_plans = {}
|
||||||
|
if plan_names:
|
||||||
|
placeholders = ",".join(["%s"] * len(plan_names))
|
||||||
|
plans = frappe.db.sql("""
|
||||||
|
SELECT name, plan_name, billing_interval, billing_interval_count,
|
||||||
|
cost, currency, item, price_determination, price_list
|
||||||
|
FROM "tabSubscription Plan"
|
||||||
|
WHERE plan_name IN ({})
|
||||||
|
""".format(placeholders), list(plan_names), as_dict=True)
|
||||||
|
existing_plans = {p["plan_name"]: p for p in plans}
|
||||||
|
for p in plans:
|
||||||
|
print(" {} — {}, cost={}, interval={} x{}".format(
|
||||||
|
p["plan_name"], p["item"], p["cost"], p["billing_interval"], p["billing_interval_count"]))
|
||||||
|
|
||||||
|
# Check which annual plan copies already exist
|
||||||
|
all_annual_plans = frappe.db.sql("""
|
||||||
|
SELECT name, plan_name FROM "tabSubscription Plan"
|
||||||
|
WHERE billing_interval = 'Year'
|
||||||
|
""", as_dict=True)
|
||||||
|
existing_annual_names = {p["plan_name"] for p in all_annual_plans}
|
||||||
|
print("\nExisting annual plans: {}".format(len(all_annual_plans)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# BUILD UPDATES
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("BUILDING UPDATES")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
issues = []
|
||||||
|
plans_to_create = {} # monthly_plan_name → annual_plan_name
|
||||||
|
|
||||||
|
for sub in annual_subs:
|
||||||
|
sid = sub["legacy_service_id"]
|
||||||
|
legacy = legacy_annual.get(sid)
|
||||||
|
|
||||||
|
if not legacy:
|
||||||
|
issues.append((sub["name"], sid, "No legacy service found"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine billing period from legacy
|
||||||
|
next_inv_date = ts_to_date(legacy["date_next_invoice"])
|
||||||
|
start_date = ts_to_date(legacy["date_orig"])
|
||||||
|
|
||||||
|
if next_inv_date:
|
||||||
|
inv_start_dt = datetime.strptime(next_inv_date, "%Y-%m-%d")
|
||||||
|
today_dt = datetime.now()
|
||||||
|
# Roll forward past dates to next cycle
|
||||||
|
while inv_start_dt < today_dt - timedelta(days=365):
|
||||||
|
inv_start_dt = inv_start_dt.replace(year=inv_start_dt.year + 1)
|
||||||
|
inv_start = inv_start_dt.strftime("%Y-%m-%d")
|
||||||
|
inv_end = (inv_start_dt.replace(year=inv_start_dt.year + 1) - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
elif start_date:
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
today_dt = datetime.now()
|
||||||
|
candidate = start_dt.replace(year=today_dt.year)
|
||||||
|
if candidate < today_dt:
|
||||||
|
candidate = candidate.replace(year=today_dt.year + 1)
|
||||||
|
inv_start = candidate.strftime("%Y-%m-%d")
|
||||||
|
inv_end = (candidate.replace(year=candidate.year + 1) - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
issues.append((sub["name"], sid, "No dates in legacy"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Figure out the annual plan name: PLAN-AN-{SKU}
|
||||||
|
monthly_plan = sub["plan"]
|
||||||
|
if monthly_plan:
|
||||||
|
# Extract SKU from plan name (PLAN-FTTH80I → FTTH80I)
|
||||||
|
sku_part = monthly_plan.replace("PLAN-", "")
|
||||||
|
annual_plan = "PLAN-AN-" + sku_part
|
||||||
|
if monthly_plan not in plans_to_create and annual_plan not in existing_annual_names:
|
||||||
|
plans_to_create[monthly_plan] = annual_plan
|
||||||
|
else:
|
||||||
|
annual_plan = None
|
||||||
|
|
||||||
|
updates.append({
|
||||||
|
"sub_name": sub["name"],
|
||||||
|
"spd_name": sub["spd_name"],
|
||||||
|
"legacy_id": sid,
|
||||||
|
"party": sub["party"],
|
||||||
|
"monthly_plan": monthly_plan,
|
||||||
|
"annual_plan": annual_plan,
|
||||||
|
"inv_start": inv_start,
|
||||||
|
"inv_end": inv_end,
|
||||||
|
"sku": legacy.get("sku", "?"),
|
||||||
|
"price": float(legacy["hijack_price"]) if legacy["hijack"] else float(legacy["base_price"] or 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("\nUpdates to apply: {}".format(len(updates)))
|
||||||
|
print("Annual plans to create: {}".format(len(plans_to_create)))
|
||||||
|
print("Issues: {}".format(len(issues)))
|
||||||
|
|
||||||
|
# Show plans to create
|
||||||
|
for monthly, annual in plans_to_create.items():
|
||||||
|
orig = existing_plans.get(monthly, {})
|
||||||
|
print(" {} → {} (item: {}, cost: {})".format(
|
||||||
|
monthly, annual, orig.get("item", "?"), orig.get("cost", "?")))
|
||||||
|
|
||||||
|
# Show sample updates
|
||||||
|
print("\nSample updates:")
|
||||||
|
for u in updates[:15]:
|
||||||
|
print(" {} svc#{} {} — {} → period {}/{} plan:{} (${:.2f})".format(
|
||||||
|
u["sub_name"], u["legacy_id"], u["sku"], u["party"],
|
||||||
|
u["inv_start"], u["inv_end"], u["annual_plan"] or "NONE", u["price"]))
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
print("\nIssues:")
|
||||||
|
for name, sid, reason in issues[:10]:
|
||||||
|
print(" {} svc#{}: {}".format(name, sid, reason))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# APPLY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
if DRY_RUN:
|
||||||
|
print("\n*** DRY RUN — no changes made ***")
|
||||||
|
print("Set DRY_RUN = False to apply {} updates + {} new plans".format(
|
||||||
|
len(updates), len(plans_to_create)))
|
||||||
|
else:
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("APPLYING CHANGES")
|
||||||
|
print("=" * 70)
|
||||||
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# Step 0: Fix existing plan names — migration stored SP-xxx but autoname expects plan_name
|
||||||
|
# The SPD plan field already stores plan_name, so name must match for Frappe ORM to work
|
||||||
|
mismatched = frappe.db.sql("""
|
||||||
|
SELECT name, plan_name FROM "tabSubscription Plan"
|
||||||
|
WHERE name != plan_name
|
||||||
|
""", as_dict=True)
|
||||||
|
if mismatched:
|
||||||
|
print("Fixing {} plan names (SP-xxx → plan_name)...".format(len(mismatched)))
|
||||||
|
for p in mismatched:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription Plan" SET name = %s WHERE name = %s
|
||||||
|
""", (p["plan_name"], p["name"]))
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Done — plan names now match plan_name field")
|
||||||
|
|
||||||
|
# Step 1: Create annual plan copies
|
||||||
|
for monthly_name, annual_name in plans_to_create.items():
|
||||||
|
orig = existing_plans.get(monthly_name)
|
||||||
|
if not orig:
|
||||||
|
print(" SKIP {} — original plan not found".format(monthly_name))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = frappe.get_doc({
|
||||||
|
"doctype": "Subscription Plan",
|
||||||
|
"plan_name": annual_name,
|
||||||
|
"item": orig["item"],
|
||||||
|
"price_determination": orig.get("price_determination") or "Fixed Rate",
|
||||||
|
"cost": orig["cost"],
|
||||||
|
"currency": orig.get("currency") or "CAD",
|
||||||
|
"billing_interval": "Year",
|
||||||
|
"billing_interval_count": 1,
|
||||||
|
})
|
||||||
|
plan.insert(ignore_permissions=True)
|
||||||
|
print(" Created annual plan: {} (item: {}, cost: {})".format(
|
||||||
|
annual_name, orig["item"], orig["cost"]))
|
||||||
|
except Exception as e:
|
||||||
|
print(" ERR creating plan {}: {}".format(annual_name, str(e)[:100]))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Step 2: Update subscriptions — billing dates + re-link to annual plan
|
||||||
|
updated = 0
|
||||||
|
plan_switched = 0
|
||||||
|
for u in updates:
|
||||||
|
# Set billing dates
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET current_invoice_start = %s,
|
||||||
|
current_invoice_end = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (u["inv_start"], u["inv_end"], u["sub_name"]))
|
||||||
|
|
||||||
|
# Switch plan in Subscription Plan Detail
|
||||||
|
if u["annual_plan"] and u["spd_name"]:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription Plan Detail"
|
||||||
|
SET plan = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (u["annual_plan"], u["spd_name"]))
|
||||||
|
plan_switched += 1
|
||||||
|
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("\nUpdated {} subscriptions with billing dates".format(updated))
|
||||||
|
print("Switched {} subscriptions to annual plans".format(plan_switched))
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("DONE")
|
||||||
|
print("=" * 70)
|
||||||
336
scripts/migration/fix_customer_links.py
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fix broken customer links: replace customer_name with CUST-xxx in:
|
||||||
|
- Sales Invoice (customer field)
|
||||||
|
- Subscription (party field)
|
||||||
|
- Issue (customer field)
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
nohup python3 /tmp/fix_customer_links.py > /tmp/fix_customer_links.log 2>&1 &
|
||||||
|
|
||||||
|
Safe: only updates rows where the field does NOT already start with 'CUST-'.
|
||||||
|
Handles duplicate customer names by skipping them (logged as warnings).
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print("[{}] {}".format(datetime.now(timezone.utc).strftime("%H:%M:%S"), msg), flush=True)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log("=== Fix Customer Links ===")
|
||||||
|
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pgc = pg.cursor()
|
||||||
|
|
||||||
|
# 1. Build customer_name → CUST-xxx mapping
|
||||||
|
log("Building customer_name → CUST-xxx mapping...")
|
||||||
|
pgc.execute('SELECT name, customer_name FROM "tabCustomer"')
|
||||||
|
rows = pgc.fetchall()
|
||||||
|
|
||||||
|
# Detect duplicates: if two customers share the same customer_name, we can't
|
||||||
|
# reliably fix by name alone. We'll use legacy_account_id as fallback.
|
||||||
|
name_to_cust = {} # customer_name → CUST-xxx (only if unique)
|
||||||
|
name_dupes = set()
|
||||||
|
|
||||||
|
for cust_id, cust_name in rows:
|
||||||
|
if cust_name in name_to_cust:
|
||||||
|
name_dupes.add(cust_name)
|
||||||
|
else:
|
||||||
|
name_to_cust[cust_name] = cust_id
|
||||||
|
|
||||||
|
# Remove duplicates from the mapping
|
||||||
|
for dupe in name_dupes:
|
||||||
|
del name_to_cust[dupe]
|
||||||
|
|
||||||
|
log(" {} customers total, {} unique names, {} duplicates excluded".format(
|
||||||
|
len(rows), len(name_to_cust), len(name_dupes)))
|
||||||
|
|
||||||
|
if name_dupes:
|
||||||
|
# Show first 20 dupes
|
||||||
|
for d in sorted(name_dupes)[:20]:
|
||||||
|
log(" DUPE: '{}'".format(d))
|
||||||
|
if len(name_dupes) > 20:
|
||||||
|
log(" ... and {} more duplicates".format(len(name_dupes) - 20))
|
||||||
|
|
||||||
|
# For duplicates, build a secondary mapping using legacy_account_id
|
||||||
|
# We'll try to resolve them via the document's legacy fields
|
||||||
|
pgc.execute('SELECT name, customer_name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id > 0')
|
||||||
|
legacy_map = {} # legacy_account_id → CUST-xxx
|
||||||
|
cust_to_legacy = {} # customer_name → [legacy_account_id, ...] (for dupes)
|
||||||
|
for cust_id, cust_name, legacy_id in pgc.fetchall():
|
||||||
|
legacy_map[legacy_id] = cust_id
|
||||||
|
if cust_name in name_dupes:
|
||||||
|
cust_to_legacy.setdefault(cust_name, []).append((legacy_id, cust_id))
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# 2. Fix Sales Invoices
|
||||||
|
# =====================
|
||||||
|
log("")
|
||||||
|
log("--- Fixing Sales Invoices ---")
|
||||||
|
|
||||||
|
# Get broken invoices (customer field is NOT a CUST-xxx ID)
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, customer, legacy_invoice_id
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE customer NOT LIKE 'CUST-%%'
|
||||||
|
""")
|
||||||
|
broken_inv = pgc.fetchall()
|
||||||
|
log(" {} broken invoices to fix".format(len(broken_inv)))
|
||||||
|
|
||||||
|
# For invoices, we can also try to resolve via legacy_invoice_id → account_id
|
||||||
|
# But first try the simple name mapping
|
||||||
|
inv_fixed = inv_skip = inv_dupe_fixed = 0
|
||||||
|
|
||||||
|
for sinv_name, current_customer, legacy_inv_id in broken_inv:
|
||||||
|
cust_id = name_to_cust.get(current_customer)
|
||||||
|
|
||||||
|
if not cust_id and current_customer in name_dupes and legacy_inv_id:
|
||||||
|
# Try to resolve duplicate via legacy invoice → account mapping
|
||||||
|
# We'd need legacy data for this, so skip for now and count
|
||||||
|
inv_skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not cust_id:
|
||||||
|
inv_skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabSales Invoice"
|
||||||
|
SET customer = %s, modified = NOW()
|
||||||
|
WHERE name = %s
|
||||||
|
""", (cust_id, sinv_name))
|
||||||
|
inv_fixed += 1
|
||||||
|
|
||||||
|
if inv_fixed % 5000 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" {} fixed, {} skipped...".format(inv_fixed, inv_skip))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" DONE: {} fixed, {} skipped (dupes/unmapped)".format(inv_fixed, inv_skip))
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# 3. Fix Subscriptions
|
||||||
|
# =====================
|
||||||
|
log("")
|
||||||
|
log("--- Fixing Subscriptions ---")
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, party
|
||||||
|
FROM "tabSubscription"
|
||||||
|
WHERE party_type = 'Customer' AND party NOT LIKE 'CUST-%%'
|
||||||
|
""")
|
||||||
|
broken_sub = pgc.fetchall()
|
||||||
|
log(" {} broken subscriptions to fix".format(len(broken_sub)))
|
||||||
|
|
||||||
|
sub_fixed = sub_skip = 0
|
||||||
|
|
||||||
|
for sub_name, current_party in broken_sub:
|
||||||
|
cust_id = name_to_cust.get(current_party)
|
||||||
|
if not cust_id:
|
||||||
|
sub_skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET party = %s, modified = NOW()
|
||||||
|
WHERE name = %s
|
||||||
|
""", (cust_id, sub_name))
|
||||||
|
sub_fixed += 1
|
||||||
|
|
||||||
|
if sub_fixed % 5000 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" {} fixed, {} skipped...".format(sub_fixed, sub_skip))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" DONE: {} fixed, {} skipped (dupes/unmapped)".format(sub_fixed, sub_skip))
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# 4. Fix Issues
|
||||||
|
# =====================
|
||||||
|
log("")
|
||||||
|
log("--- Fixing Issues ---")
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, customer
|
||||||
|
FROM "tabIssue"
|
||||||
|
WHERE customer IS NOT NULL
|
||||||
|
AND customer != ''
|
||||||
|
AND customer NOT LIKE 'CUST-%%'
|
||||||
|
""")
|
||||||
|
broken_iss = pgc.fetchall()
|
||||||
|
log(" {} broken issues to fix".format(len(broken_iss)))
|
||||||
|
|
||||||
|
iss_fixed = iss_skip = 0
|
||||||
|
|
||||||
|
for issue_name, current_customer in broken_iss:
|
||||||
|
cust_id = name_to_cust.get(current_customer)
|
||||||
|
if not cust_id:
|
||||||
|
iss_skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabIssue"
|
||||||
|
SET customer = %s, modified = NOW()
|
||||||
|
WHERE name = %s
|
||||||
|
""", (cust_id, issue_name))
|
||||||
|
iss_fixed += 1
|
||||||
|
|
||||||
|
if iss_fixed % 5000 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" {} fixed, {} skipped...".format(iss_fixed, iss_skip))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# 5. Fix duplicate names via legacy MariaDB lookup
|
||||||
|
# =====================
|
||||||
|
total_skipped = inv_skip + sub_skip + iss_skip
|
||||||
|
if total_skipped > 0 and name_dupes:
|
||||||
|
log("")
|
||||||
|
log("--- Phase 2: Resolving duplicates via legacy DB ---")
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
|
||||||
|
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 300}
|
||||||
|
mc = pymysql.connect(**LEGACY)
|
||||||
|
mcur = mc.cursor(pymysql.cursors.DictCursor)
|
||||||
|
|
||||||
|
# Build invoice_id → account_id mapping for broken invoices
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, customer, legacy_invoice_id
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE customer NOT LIKE 'CUST-%%' AND legacy_invoice_id > 0
|
||||||
|
""")
|
||||||
|
still_broken_inv = pgc.fetchall()
|
||||||
|
|
||||||
|
if still_broken_inv:
|
||||||
|
log(" Resolving {} invoices via legacy invoice→account mapping...".format(len(still_broken_inv)))
|
||||||
|
legacy_inv_ids = [r[2] for r in still_broken_inv]
|
||||||
|
|
||||||
|
# Batch lookup
|
||||||
|
inv_to_acct = {}
|
||||||
|
chunk = 10000
|
||||||
|
for s in range(0, len(legacy_inv_ids), chunk):
|
||||||
|
batch = legacy_inv_ids[s:s+chunk]
|
||||||
|
mcur.execute("SELECT id, account_id FROM invoice WHERE id IN ({})".format(
|
||||||
|
",".join(["%s"] * len(batch))), batch)
|
||||||
|
for r in mcur.fetchall():
|
||||||
|
inv_to_acct[r["id"]] = r["account_id"]
|
||||||
|
|
||||||
|
inv2_fixed = 0
|
||||||
|
for sinv_name, current_customer, legacy_inv_id in still_broken_inv:
|
||||||
|
acct_id = inv_to_acct.get(legacy_inv_id)
|
||||||
|
if acct_id and acct_id in legacy_map:
|
||||||
|
cust_id = legacy_map[acct_id]
|
||||||
|
pgc.execute('UPDATE "tabSales Invoice" SET customer = %s, modified = NOW() WHERE name = %s',
|
||||||
|
(cust_id, sinv_name))
|
||||||
|
inv2_fixed += 1
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" {} additional invoices fixed via legacy lookup".format(inv2_fixed))
|
||||||
|
inv_fixed += inv2_fixed
|
||||||
|
|
||||||
|
# Resolve subscriptions via legacy_service_id → delivery → account
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, party, legacy_service_id
|
||||||
|
FROM "tabSubscription"
|
||||||
|
WHERE party_type = 'Customer' AND party NOT LIKE 'CUST-%%' AND legacy_service_id > 0
|
||||||
|
""")
|
||||||
|
still_broken_sub = pgc.fetchall()
|
||||||
|
|
||||||
|
if still_broken_sub:
|
||||||
|
log(" Resolving {} subscriptions via legacy service→account mapping...".format(len(still_broken_sub)))
|
||||||
|
legacy_svc_ids = [r[2] for r in still_broken_sub]
|
||||||
|
|
||||||
|
svc_to_acct = {}
|
||||||
|
for s in range(0, len(legacy_svc_ids), chunk):
|
||||||
|
batch = legacy_svc_ids[s:s+chunk]
|
||||||
|
mcur.execute("""
|
||||||
|
SELECT s.id, d.account_id
|
||||||
|
FROM service s JOIN delivery d ON s.delivery_id = d.id
|
||||||
|
WHERE s.id IN ({})
|
||||||
|
""".format(",".join(["%s"] * len(batch))), batch)
|
||||||
|
for r in mcur.fetchall():
|
||||||
|
svc_to_acct[r["id"]] = r["account_id"]
|
||||||
|
|
||||||
|
sub2_fixed = 0
|
||||||
|
for sub_name, current_party, legacy_svc_id in still_broken_sub:
|
||||||
|
acct_id = svc_to_acct.get(legacy_svc_id)
|
||||||
|
if acct_id and acct_id in legacy_map:
|
||||||
|
cust_id = legacy_map[acct_id]
|
||||||
|
pgc.execute('UPDATE "tabSubscription" SET party = %s, modified = NOW() WHERE name = %s',
|
||||||
|
(cust_id, sub_name))
|
||||||
|
sub2_fixed += 1
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" {} additional subscriptions fixed via legacy lookup".format(sub2_fixed))
|
||||||
|
sub_fixed += sub2_fixed
|
||||||
|
|
||||||
|
# Resolve issues via legacy_ticket_id → ticket.account_id
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, customer, legacy_ticket_id
|
||||||
|
FROM "tabIssue"
|
||||||
|
WHERE customer IS NOT NULL AND customer != ''
|
||||||
|
AND customer NOT LIKE 'CUST-%%' AND legacy_ticket_id > 0
|
||||||
|
""")
|
||||||
|
still_broken_iss = pgc.fetchall()
|
||||||
|
|
||||||
|
if still_broken_iss:
|
||||||
|
log(" Resolving {} issues via legacy ticket→account mapping...".format(len(still_broken_iss)))
|
||||||
|
legacy_tkt_ids = [r[2] for r in still_broken_iss]
|
||||||
|
|
||||||
|
tkt_to_acct = {}
|
||||||
|
for s in range(0, len(legacy_tkt_ids), chunk):
|
||||||
|
batch = legacy_tkt_ids[s:s+chunk]
|
||||||
|
mcur.execute("SELECT id, account_id FROM ticket WHERE id IN ({})".format(
|
||||||
|
",".join(["%s"] * len(batch))), batch)
|
||||||
|
for r in mcur.fetchall():
|
||||||
|
tkt_to_acct[r["id"]] = r["account_id"]
|
||||||
|
|
||||||
|
iss2_fixed = 0
|
||||||
|
for issue_name, current_customer, legacy_tkt_id in still_broken_iss:
|
||||||
|
acct_id = tkt_to_acct.get(legacy_tkt_id)
|
||||||
|
if acct_id and acct_id in legacy_map:
|
||||||
|
cust_id = legacy_map[acct_id]
|
||||||
|
pgc.execute('UPDATE "tabIssue" SET customer = %s, modified = NOW() WHERE name = %s',
|
||||||
|
(cust_id, issue_name))
|
||||||
|
iss2_fixed += 1
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" {} additional issues fixed via legacy lookup".format(iss2_fixed))
|
||||||
|
iss_fixed += iss2_fixed
|
||||||
|
|
||||||
|
mc.close()
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
log(" pymysql not available — skipping legacy lookup phase")
|
||||||
|
except Exception as e:
|
||||||
|
log(" Legacy lookup error: {}".format(str(e)[:200]))
|
||||||
|
|
||||||
|
iss_log = " DONE: {} fixed, {} skipped".format(iss_fixed, iss_skip)
|
||||||
|
log(iss_log)
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Summary
|
||||||
|
# =====================
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("FIX CUSTOMER LINKS — SUMMARY")
|
||||||
|
log("=" * 60)
|
||||||
|
log(" Sales Invoices: {} fixed".format(inv_fixed))
|
||||||
|
log(" Subscriptions: {} fixed".format(sub_fixed))
|
||||||
|
log(" Issues: {} fixed".format(iss_fixed))
|
||||||
|
log("")
|
||||||
|
log(" Duplicate names excluded from simple mapping: {}".format(len(name_dupes)))
|
||||||
|
log("=" * 60)
|
||||||
|
log("")
|
||||||
|
log("Next: bench --site erp.gigafibre.ca clear-cache")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
190
scripts/migration/fix_invoice_customer_names.py
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"""
|
||||||
|
Fix customer_name on all Sales Invoices + Payment Entries.
|
||||||
|
During migration, customer_name was set to CUST-xxx instead of the actual name.
|
||||||
|
This script updates it from the Customer doctype.
|
||||||
|
|
||||||
|
Also imports legacy invoice.notes as Comments on Sales Invoice
|
||||||
|
(e.g. "Renversement de la facture #635893 - Sera facturé dans fiche personnelle")
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_invoice_customer_names.py
|
||||||
|
"""
|
||||||
|
import os, sys, time
|
||||||
|
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
import frappe
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
DRY_RUN = False
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Fix customer_name on Sales Invoices
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("PHASE 1: Fix customer_name on Sales Invoices")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
# Build customer name map
|
||||||
|
cust_map = {}
|
||||||
|
rows = frappe.db.sql('SELECT name, customer_name FROM "tabCustomer"', as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
cust_map[r['name']] = r['customer_name']
|
||||||
|
print(f" Loaded {len(cust_map)} customer names")
|
||||||
|
|
||||||
|
# Count broken invoices
|
||||||
|
broken = frappe.db.sql(
|
||||||
|
"""SELECT COUNT(*) FROM "tabSales Invoice" WHERE customer_name LIKE 'CUST-%%'"""
|
||||||
|
)[0][0]
|
||||||
|
print(f" Invoices with CUST-xxx name: {broken}")
|
||||||
|
|
||||||
|
if not DRY_RUN and broken > 0:
|
||||||
|
# Bulk update using a single UPDATE ... FROM
|
||||||
|
updated = frappe.db.sql("""
|
||||||
|
UPDATE "tabSales Invoice" si
|
||||||
|
SET customer_name = c.customer_name
|
||||||
|
FROM "tabCustomer" c
|
||||||
|
WHERE si.customer = c.name
|
||||||
|
AND si.customer_name LIKE 'CUST-%%'
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Updated Sales Invoices [{time.time()-t0:.0f}s]")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Fix customer_name on Payment Entries
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("PHASE 2: Fix customer_name on Payment Entries")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
broken_pe = frappe.db.sql(
|
||||||
|
"""SELECT COUNT(*) FROM "tabPayment Entry" WHERE party_name LIKE 'CUST-%%'"""
|
||||||
|
)[0][0]
|
||||||
|
print(f" Payment Entries with CUST-xxx name: {broken_pe}")
|
||||||
|
|
||||||
|
if not DRY_RUN and broken_pe > 0:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabPayment Entry" pe
|
||||||
|
SET party_name = c.customer_name
|
||||||
|
FROM "tabCustomer" c
|
||||||
|
WHERE pe.party = c.name
|
||||||
|
AND pe.party_name LIKE 'CUST-%%'
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Updated Payment Entries [{time.time()-t0:.0f}s]")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: Import legacy invoice notes as Comments
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("PHASE 3: Import invoice notes as Comments")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
legacy = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="VD67owoj",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all invoices with notes
|
||||||
|
with legacy.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, notes, account_id, FROM_UNIXTIME(date_orig) as date_created
|
||||||
|
FROM invoice
|
||||||
|
WHERE notes IS NOT NULL AND notes != '' AND TRIM(notes) != ''
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
noted_invoices = cur.fetchall()
|
||||||
|
|
||||||
|
print(f" Legacy invoices with notes: {len(noted_invoices)}")
|
||||||
|
|
||||||
|
# Check which SINV exist in ERPNext
|
||||||
|
existing_sinv = set()
|
||||||
|
rows = frappe.db.sql('SELECT name FROM "tabSales Invoice"')
|
||||||
|
for r in rows:
|
||||||
|
existing_sinv.add(r[0])
|
||||||
|
print(f" Existing SINVs in ERPNext: {len(existing_sinv)}")
|
||||||
|
|
||||||
|
# Check existing comments to avoid duplicates
|
||||||
|
existing_comments = set()
|
||||||
|
rows = frappe.db.sql(
|
||||||
|
"""SELECT reference_name FROM "tabComment"
|
||||||
|
WHERE reference_doctype = 'Sales Invoice' AND comment_type = 'Comment'"""
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
existing_comments.add(r[0])
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
batch = []
|
||||||
|
now = frappe.utils.now()
|
||||||
|
|
||||||
|
for inv in noted_invoices:
|
||||||
|
sinv_name = f"SINV-{inv['id']}"
|
||||||
|
if sinv_name not in existing_sinv:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if sinv_name in existing_comments:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
notes = inv['notes'].strip()
|
||||||
|
if not notes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
creation = str(inv['date_created']) if inv['date_created'] else now
|
||||||
|
|
||||||
|
batch.append({
|
||||||
|
'name': f"inv-note-{inv['id']}",
|
||||||
|
'comment_type': 'Comment',
|
||||||
|
'reference_doctype': 'Sales Invoice',
|
||||||
|
'reference_name': sinv_name,
|
||||||
|
'content': notes,
|
||||||
|
'owner': 'Administrator',
|
||||||
|
'comment_by': 'Système legacy',
|
||||||
|
'creation': creation,
|
||||||
|
'modified': creation,
|
||||||
|
'modified_by': 'Administrator',
|
||||||
|
})
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
print(f" Notes to import: {imported}, skipped: {skipped}")
|
||||||
|
|
||||||
|
if not DRY_RUN and batch:
|
||||||
|
# Insert row by row using frappe.db.sql (PostgreSQL compatible)
|
||||||
|
CHUNK = 5000
|
||||||
|
cols = list(batch[0].keys())
|
||||||
|
col_names = ", ".join([f'"{c}"' for c in cols])
|
||||||
|
placeholders = ", ".join(["%s"] * len(cols))
|
||||||
|
sql = f'INSERT INTO "tabComment" ({col_names}) VALUES ({placeholders}) ON CONFLICT ("name") DO NOTHING'
|
||||||
|
for i, row in enumerate(batch):
|
||||||
|
vals = tuple(row[c] for c in cols)
|
||||||
|
frappe.db.sql(sql, vals, as_dict=False)
|
||||||
|
if (i + 1) % CHUNK == 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
rate = (i + 1) / elapsed
|
||||||
|
print(f" Progress: {i+1}/{len(batch)} ({rate:.0f}/s) [{elapsed:.0f}s]")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Imported {imported} invoice notes [{time.time()-t0:.0f}s]")
|
||||||
|
|
||||||
|
legacy.close()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# SUMMARY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("DONE")
|
||||||
|
print("=" * 60)
|
||||||
|
if DRY_RUN:
|
||||||
|
print(" ** DRY RUN — no changes made **")
|
||||||
|
print(f" Sales Invoices customer_name fixed: {broken}")
|
||||||
|
print(f" Payment Entries party_name fixed: {broken_pe}")
|
||||||
|
print(f" Invoice notes imported as Comments: {imported}")
|
||||||
275
scripts/migration/fix_invoice_outstanding.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""
|
||||||
|
Fix outstanding_amount on Sales Invoices using legacy system as source of truth.
|
||||||
|
|
||||||
|
Legacy invoice table:
|
||||||
|
- billing_status: 1 = paid, 0 = unpaid
|
||||||
|
- total_amt: invoice total
|
||||||
|
- billed_amt: amount that has been paid
|
||||||
|
- montant_du (computed) = total_amt - billed_amt
|
||||||
|
|
||||||
|
ERPNext migration created wrong payment allocations, causing phantom outstanding.
|
||||||
|
This script reads every legacy invoice's real status and corrects ERPNext.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_invoice_outstanding.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
T_TOTAL = time.time()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Load legacy invoice statuses
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: LOAD LEGACY INVOICE DATA")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, total_amt, billed_amt, billing_status
|
||||||
|
FROM invoice
|
||||||
|
""")
|
||||||
|
legacy = {}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
total = float(row["total_amt"] or 0)
|
||||||
|
billed = float(row["billed_amt"] or 0)
|
||||||
|
montant_du = round(total - billed, 2)
|
||||||
|
if montant_du < 0:
|
||||||
|
montant_du = 0.0
|
||||||
|
legacy[row["id"]] = {
|
||||||
|
"montant_du": montant_du,
|
||||||
|
"billing_status": row["billing_status"],
|
||||||
|
"total_amt": total,
|
||||||
|
"billed_amt": billed,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Legacy invoices loaded: {:,}".format(len(legacy)))
|
||||||
|
|
||||||
|
status_dist = {}
|
||||||
|
for v in legacy.values():
|
||||||
|
s = v["billing_status"]
|
||||||
|
status_dist[s] = status_dist.get(s, 0) + 1
|
||||||
|
print(" billing_status=0 (unpaid): {:,}".format(status_dist.get(0, 0)))
|
||||||
|
print(" billing_status=1 (paid): {:,}".format(status_dist.get(1, 0)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Load ERPNext invoices and find mismatches
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: FIND MISMATCHES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
erp_invoices = frappe.db.sql("""
|
||||||
|
SELECT name, outstanding_amount, status, grand_total
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE docstatus = 1
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
print("ERPNext submitted invoices: {:,}".format(len(erp_invoices)))
|
||||||
|
|
||||||
|
mismatches = []
|
||||||
|
matched = 0
|
||||||
|
no_legacy = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for inv in erp_invoices:
|
||||||
|
# Extract legacy ID from SINV-{id}
|
||||||
|
try:
|
||||||
|
legacy_id = int(inv["name"].split("-")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
errors.append(inv["name"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
leg = legacy.get(legacy_id)
|
||||||
|
if not leg:
|
||||||
|
no_legacy += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
erp_out = round(float(inv["outstanding_amount"] or 0), 2)
|
||||||
|
legacy_out = leg["montant_du"]
|
||||||
|
|
||||||
|
if abs(erp_out - legacy_out) > 0.005:
|
||||||
|
mismatches.append({
|
||||||
|
"name": inv["name"],
|
||||||
|
"legacy_id": legacy_id,
|
||||||
|
"erp_outstanding": erp_out,
|
||||||
|
"legacy_outstanding": legacy_out,
|
||||||
|
"erp_status": inv["status"],
|
||||||
|
"grand_total": float(inv["grand_total"] or 0),
|
||||||
|
"billing_status": leg["billing_status"],
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
matched += 1
|
||||||
|
|
||||||
|
print("Correct (matched): {:,}".format(matched))
|
||||||
|
print("MISMATCHED: {:,}".format(len(mismatches)))
|
||||||
|
print("No legacy record: {:,}".format(no_legacy))
|
||||||
|
print("Parse errors: {:,}".format(len(errors)))
|
||||||
|
|
||||||
|
# Breakdown
|
||||||
|
should_be_zero = [m for m in mismatches if m["legacy_outstanding"] == 0]
|
||||||
|
should_be_nonzero = [m for m in mismatches if m["legacy_outstanding"] > 0]
|
||||||
|
print("\n Legacy says PAID (should be 0): {:,} invoices".format(len(should_be_zero)))
|
||||||
|
print(" Legacy says UNPAID (real balance): {:,} invoices".format(len(should_be_nonzero)))
|
||||||
|
|
||||||
|
phantom_total = sum(m["erp_outstanding"] for m in should_be_zero)
|
||||||
|
print(" Phantom outstanding to clear: ${:,.2f}".format(phantom_total))
|
||||||
|
|
||||||
|
# Sample
|
||||||
|
print("\nSample mismatches:")
|
||||||
|
for m in mismatches[:10]:
|
||||||
|
print(" {} | ERPNext={:.2f} → Legacy={:.2f} | billing_status={}".format(
|
||||||
|
m["name"], m["erp_outstanding"], m["legacy_outstanding"], m["billing_status"]))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: Triple-check before fixing
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: TRIPLE-CHECK")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Verify with specific known-good cases
|
||||||
|
# Invoice 634020 for our problem customer — legacy says paid, ERPNext says paid
|
||||||
|
test_634020 = legacy.get(634020)
|
||||||
|
print("Invoice 634020 (should be paid):")
|
||||||
|
print(" Legacy: montant_du={}, billing_status={}".format(
|
||||||
|
test_634020["montant_du"] if test_634020 else "MISSING",
|
||||||
|
test_634020["billing_status"] if test_634020 else "MISSING"))
|
||||||
|
erp_634020 = frappe.db.sql('SELECT outstanding_amount, status FROM "tabSales Invoice" WHERE name=%s',
|
||||||
|
("SINV-634020",), as_dict=True)
|
||||||
|
if erp_634020:
|
||||||
|
print(" ERPNext: outstanding={}, status={}".format(erp_634020[0]["outstanding_amount"], erp_634020[0]["status"]))
|
||||||
|
|
||||||
|
# Invoice 607832 — legacy says paid (billing_status=1, billed_amt=14.00), ERPNext says Overdue
|
||||||
|
test_607832 = legacy.get(607832)
|
||||||
|
print("\nInvoice 607832 (phantom overdue):")
|
||||||
|
print(" Legacy: montant_du={}, billing_status={}".format(
|
||||||
|
test_607832["montant_du"] if test_607832 else "MISSING",
|
||||||
|
test_607832["billing_status"] if test_607832 else "MISSING"))
|
||||||
|
erp_607832 = frappe.db.sql('SELECT outstanding_amount, status FROM "tabSales Invoice" WHERE name=%s',
|
||||||
|
("SINV-607832",), as_dict=True)
|
||||||
|
if erp_607832:
|
||||||
|
print(" ERPNext: outstanding={}, status={}".format(erp_607832[0]["outstanding_amount"], erp_607832[0]["status"]))
|
||||||
|
print(" WILL FIX → outstanding=0, status=Paid")
|
||||||
|
|
||||||
|
# Verify an invoice that IS genuinely unpaid in legacy (billing_status=0)
|
||||||
|
unpaid_sample = [m for m in mismatches if m["billing_status"] == 0][:3]
|
||||||
|
if unpaid_sample:
|
||||||
|
print("\nSample genuinely unpaid invoices:")
|
||||||
|
for m in unpaid_sample:
|
||||||
|
print(" {} | legacy_outstanding={:.2f} (genuinely owed)".format(m["name"], m["legacy_outstanding"]))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 4: APPLY FIXES
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 4: APPLY FIXES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
batch_size = 2000
|
||||||
|
|
||||||
|
for i in range(0, len(mismatches), batch_size):
|
||||||
|
batch = mismatches[i:i+batch_size]
|
||||||
|
for m in batch:
|
||||||
|
new_outstanding = m["legacy_outstanding"]
|
||||||
|
|
||||||
|
# Determine correct status
|
||||||
|
if new_outstanding <= 0:
|
||||||
|
new_status = "Paid"
|
||||||
|
elif new_outstanding >= m["grand_total"] - 0.01:
|
||||||
|
new_status = "Overdue" # Fully unpaid and past due
|
||||||
|
else:
|
||||||
|
new_status = "Overdue" # Partially paid, treat as overdue
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSales Invoice"
|
||||||
|
SET outstanding_amount = %s, status = %s
|
||||||
|
WHERE name = %s AND docstatus = 1
|
||||||
|
""", (new_outstanding, new_status, m["name"]))
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Fixed {:,}/{:,}...".format(min(i+batch_size, len(mismatches)), len(mismatches)))
|
||||||
|
|
||||||
|
print("Fixed {:,} invoices".format(fixed))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 5: VERIFY AFTER FIX
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 5: VERIFY AFTER FIX")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Problem customer
|
||||||
|
problem_cust = "CUST-993e9763ce"
|
||||||
|
after = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) as cnt, COALESCE(SUM(outstanding_amount), 0) as total
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE customer = %s AND docstatus = 1 AND outstanding_amount > 0
|
||||||
|
""", (problem_cust,), as_dict=True)
|
||||||
|
print("Problem customer ({}) AFTER fix:".format(problem_cust))
|
||||||
|
print(" Invoices with outstanding > 0: {}".format(after[0]["cnt"]))
|
||||||
|
print(" Total outstanding: ${:.2f}".format(float(after[0]["total"])))
|
||||||
|
|
||||||
|
# Invoice 607832 specifically
|
||||||
|
after_607832 = frappe.db.sql('SELECT outstanding_amount, status FROM "tabSales Invoice" WHERE name=%s',
|
||||||
|
("SINV-607832",), as_dict=True)
|
||||||
|
if after_607832:
|
||||||
|
print(" SINV-607832: outstanding={}, status={}".format(
|
||||||
|
after_607832[0]["outstanding_amount"], after_607832[0]["status"]))
|
||||||
|
|
||||||
|
# Global stats
|
||||||
|
global_before_paid = frappe.db.sql("""
|
||||||
|
SELECT status, COUNT(*) as cnt, COALESCE(SUM(outstanding_amount), 0) as total
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE docstatus = 1
|
||||||
|
GROUP BY status ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\nGlobal invoice status distribution AFTER fix:")
|
||||||
|
for r in global_before_paid:
|
||||||
|
print(" {}: {:,} invoices, outstanding ${:,.2f}".format(
|
||||||
|
r["status"], r["cnt"], float(r["total"])))
|
||||||
|
|
||||||
|
# Double-check: re-scan for remaining mismatches
|
||||||
|
print("\nRe-scanning for remaining mismatches...")
|
||||||
|
remaining_mismatches = 0
|
||||||
|
erp_after = frappe.db.sql("""
|
||||||
|
SELECT name, outstanding_amount FROM "tabSales Invoice" WHERE docstatus = 1
|
||||||
|
""", as_dict=True)
|
||||||
|
for inv in erp_after:
|
||||||
|
try:
|
||||||
|
legacy_id = int(inv["name"].split("-")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
continue
|
||||||
|
leg = legacy.get(legacy_id)
|
||||||
|
if not leg:
|
||||||
|
continue
|
||||||
|
if abs(float(inv["outstanding_amount"] or 0) - leg["montant_du"]) > 0.005:
|
||||||
|
remaining_mismatches += 1
|
||||||
|
|
||||||
|
print("Remaining mismatches after fix: {}".format(remaining_mismatches))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
elapsed = time.time() - T_TOTAL
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("DONE in {:.1f}s — cache cleared".format(elapsed))
|
||||||
|
print("="*60)
|
||||||
461
scripts/migration/fix_no_rebate_discounts.py
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
"""
|
||||||
|
Fix deliveries: restore catalog prices + create RAB-PROMO for discount absorption.
|
||||||
|
|
||||||
|
Handles BOTH:
|
||||||
|
A) Deliveries with existing rebates (catalog restore + adjust rebate)
|
||||||
|
B) Deliveries with NO rebate (catalog restore + create RAB-PROMO)
|
||||||
|
|
||||||
|
TEST MODE: Only runs on specific test accounts.
|
||||||
|
Set TEST_ACCOUNTS = None to run on ALL accounts.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_no_rebate_discounts.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DRY_RUN = False # Set False to actually write
|
||||||
|
|
||||||
|
# Test on specific accounts only — set to None for all
|
||||||
|
# 3673 = Expro Transit, others from the 310 no-rebate list
|
||||||
|
TEST_ACCOUNTS = {3673, 263, 343, 264, 166}
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
print("DRY_RUN:", DRY_RUN)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="VD67owoj",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1: Load legacy data
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 1: LOAD LEGACY DATA")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.delivery_id, s.product_id, s.hijack, s.hijack_price, s.hijack_desc,
|
||||||
|
p.sku, p.price as base_price, d.account_id
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
JOIN delivery d ON d.id = s.delivery_id
|
||||||
|
WHERE s.status = 1
|
||||||
|
ORDER BY s.delivery_id, p.price DESC
|
||||||
|
""")
|
||||||
|
all_services = cur.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Group by delivery
|
||||||
|
deliveries = {}
|
||||||
|
for s in all_services:
|
||||||
|
did = s["delivery_id"]
|
||||||
|
if did not in deliveries:
|
||||||
|
deliveries[did] = []
|
||||||
|
base = float(s["base_price"] or 0)
|
||||||
|
actual = float(s["hijack_price"]) if s["hijack"] else base
|
||||||
|
is_rebate = base < 0
|
||||||
|
deliveries[did].append({
|
||||||
|
"svc_id": s["id"], "sku": s["sku"], "base_price": base,
|
||||||
|
"actual_price": actual, "is_rebate": is_rebate,
|
||||||
|
"hijack": s["hijack"],
|
||||||
|
"hijack_desc": (s["hijack_desc"] or "").strip(),
|
||||||
|
"account_id": s["account_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Classify deliveries
|
||||||
|
no_rebate_cases = []
|
||||||
|
has_rebate_cases = []
|
||||||
|
for did, services in deliveries.items():
|
||||||
|
acct_id = services[0]["account_id"]
|
||||||
|
if TEST_ACCOUNTS and acct_id not in TEST_ACCOUNTS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
has_discount = has_rebate = False
|
||||||
|
discount_total = 0.0
|
||||||
|
discount_services = []
|
||||||
|
for s in services:
|
||||||
|
if not s["is_rebate"] and s["actual_price"] < s["base_price"] - 0.01:
|
||||||
|
has_discount = True
|
||||||
|
discount_total += s["base_price"] - s["actual_price"]
|
||||||
|
discount_services.append(s)
|
||||||
|
if s["is_rebate"]:
|
||||||
|
has_rebate = True
|
||||||
|
|
||||||
|
if not has_discount:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"delivery_id": did, "account_id": acct_id,
|
||||||
|
"discount_total": round(discount_total, 2),
|
||||||
|
"discount_services": discount_services, "all_services": services,
|
||||||
|
}
|
||||||
|
if has_rebate:
|
||||||
|
has_rebate_cases.append(entry)
|
||||||
|
else:
|
||||||
|
no_rebate_cases.append(entry)
|
||||||
|
|
||||||
|
print("Test accounts: {}".format(TEST_ACCOUNTS or "ALL"))
|
||||||
|
print("Deliveries with existing rebate to adjust: {}".format(len(has_rebate_cases)))
|
||||||
|
print("Deliveries needing RAB-PROMO creation: {}".format(len(no_rebate_cases)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2: Ensure RAB-PROMO Item exists
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: ENSURE RAB-PROMO ITEM")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
rab_exists = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabItem" WHERE name = 'RAB-PROMO'
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not rab_exists:
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabItem" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus,
|
||||||
|
item_code, item_name, item_group, description,
|
||||||
|
is_stock_item, has_variants, disabled
|
||||||
|
) VALUES (
|
||||||
|
'RAB-PROMO', NOW(), NOW(), 'Administrator', 'Administrator', 0,
|
||||||
|
'RAB-PROMO', 'Rabais promotionnel', 'Rabais', 'Rabais promotionnel — créé automatiquement pour les services sans rabais existant',
|
||||||
|
0, 0, 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Created RAB-PROMO item")
|
||||||
|
else:
|
||||||
|
print(" [DRY RUN] Would create RAB-PROMO item")
|
||||||
|
else:
|
||||||
|
print(" RAB-PROMO already exists")
|
||||||
|
|
||||||
|
# Also ensure Subscription Plan exists
|
||||||
|
plan_exists = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabSubscription Plan" WHERE name = 'PLAN-RAB-PROMO'
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not plan_exists:
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabSubscription Plan" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus,
|
||||||
|
plan_name, item, cost, billing_interval, billing_interval_count,
|
||||||
|
currency, price_determination
|
||||||
|
) VALUES (
|
||||||
|
'PLAN-RAB-PROMO', NOW(), NOW(), 'Administrator', 'Administrator', 0,
|
||||||
|
'PLAN-RAB-PROMO', 'RAB-PROMO', 0, 'Month', 1,
|
||||||
|
'CAD', 'Fixed Rate'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Created PLAN-RAB-PROMO subscription plan")
|
||||||
|
else:
|
||||||
|
print(" [DRY RUN] Would create PLAN-RAB-PROMO subscription plan")
|
||||||
|
else:
|
||||||
|
print(" PLAN-RAB-PROMO already exists")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3: Map delivery_id → ERPNext Subscription + Customer
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 3: MAP LEGACY → ERPNEXT")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Get all subscriptions with legacy_service_id
|
||||||
|
subs = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_service_id, party, service_location, actual_price, item_code, status
|
||||||
|
FROM "tabSubscription"
|
||||||
|
WHERE legacy_service_id IS NOT NULL
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
sub_by_legacy = {}
|
||||||
|
for s in subs:
|
||||||
|
lid = s.get("legacy_service_id")
|
||||||
|
if lid:
|
||||||
|
sub_by_legacy[lid] = s
|
||||||
|
|
||||||
|
print("Mapped ERPNext subscriptions: {}".format(len(sub_by_legacy)))
|
||||||
|
|
||||||
|
# Map account_id → customer
|
||||||
|
customers = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_account_id FROM "tabCustomer"
|
||||||
|
WHERE legacy_account_id IS NOT NULL
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
cust_by_acct = {}
|
||||||
|
for c in customers:
|
||||||
|
aid = c.get("legacy_account_id")
|
||||||
|
if aid:
|
||||||
|
cust_by_acct[int(aid)] = c["name"]
|
||||||
|
|
||||||
|
print("Mapped customers: {}".format(len(cust_by_acct)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 4: Process each delivery
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 4: CREATE RAB-PROMO SUBSCRIPTIONS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
skipped_no_customer = 0
|
||||||
|
skipped_no_sub = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for case in no_rebate_cases:
|
||||||
|
did = case["delivery_id"]
|
||||||
|
acct_id = case["account_id"]
|
||||||
|
discount = case["discount_total"]
|
||||||
|
|
||||||
|
# Find customer + service_location from existing subscriptions (not from legacy_account_id)
|
||||||
|
service_location = None
|
||||||
|
customer = None
|
||||||
|
any_sub = None
|
||||||
|
for s in case["all_services"]:
|
||||||
|
erp_sub = sub_by_legacy.get(s["svc_id"])
|
||||||
|
if erp_sub:
|
||||||
|
service_location = erp_sub.get("service_location")
|
||||||
|
customer = erp_sub.get("party")
|
||||||
|
any_sub = erp_sub
|
||||||
|
break
|
||||||
|
|
||||||
|
if not customer:
|
||||||
|
# Fallback to legacy_account_id mapping
|
||||||
|
customer = cust_by_acct.get(acct_id)
|
||||||
|
|
||||||
|
if not customer:
|
||||||
|
skipped_no_customer += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not any_sub:
|
||||||
|
skipped_no_sub += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build description from hijack_desc of discount services
|
||||||
|
descs = []
|
||||||
|
for s in case["discount_services"]:
|
||||||
|
if s["hijack_desc"]:
|
||||||
|
descs.append(s["hijack_desc"])
|
||||||
|
description = "; ".join(descs) if descs else "Rabais loyauté"
|
||||||
|
|
||||||
|
# Step 4a: Update positive products to catalog price
|
||||||
|
for s in case["discount_services"]:
|
||||||
|
erp_sub = sub_by_legacy.get(s["svc_id"])
|
||||||
|
if erp_sub:
|
||||||
|
# If it's a "fake rebate" (positive product with negative actual), restore to 0
|
||||||
|
# If it's a discounted positive, restore to base_price
|
||||||
|
if s["actual_price"] < 0:
|
||||||
|
new_price = 0 # Was used as a discount line, zero it out
|
||||||
|
else:
|
||||||
|
new_price = s["base_price"]
|
||||||
|
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET actual_price = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (new_price, erp_sub["name"]))
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
# Step 4b: Create RAB-PROMO subscription
|
||||||
|
rabais_name = "SUB-RAB-{}-{}".format(did, acct_id)
|
||||||
|
|
||||||
|
# Check if already exists
|
||||||
|
existing = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabSubscription" WHERE name = %s
|
||||||
|
""", (rabais_name,))
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not DRY_RUN:
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Insert subscription
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabSubscription" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus,
|
||||||
|
party_type, party, service_location, status,
|
||||||
|
actual_price, custom_description, item_code, item_group,
|
||||||
|
item_name, billing_frequency,
|
||||||
|
start_date
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, 'Administrator', 'Administrator', 0,
|
||||||
|
'Customer', %s, %s, 'Active',
|
||||||
|
%s, %s, 'RAB-PROMO', 'Rabais',
|
||||||
|
'Rabais promotionnel', 'M',
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""", (
|
||||||
|
rabais_name, now, now,
|
||||||
|
customer, service_location,
|
||||||
|
-discount, description,
|
||||||
|
today,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Insert Subscription Plan Detail child
|
||||||
|
spd_name = "{}-plan".format(rabais_name)
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabSubscription Plan Detail" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus,
|
||||||
|
parent, parentfield, parenttype, idx,
|
||||||
|
plan, qty
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, 'Administrator', 'Administrator', 0,
|
||||||
|
%s, 'plans', 'Subscription', 1,
|
||||||
|
'PLAN-RAB-PROMO', 1
|
||||||
|
)
|
||||||
|
""", (spd_name, now, now, rabais_name))
|
||||||
|
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if created % 50 == 0 and created > 0 and not DRY_RUN:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Processed {}/{}...".format(created, len(no_rebate_cases)))
|
||||||
|
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
print("\nRAB-PROMO subscriptions created: {}".format(created))
|
||||||
|
print("Positive products price-restored: {}".format(updated))
|
||||||
|
print("Skipped (no customer in ERP): {}".format(skipped_no_customer))
|
||||||
|
print("Skipped (no subscription in ERP): {}".format(skipped_no_sub))
|
||||||
|
print("Errors: {}".format(errors))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 5: FIX DELIVERIES WITH EXISTING REBATES
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 5: FIX DELIVERIES WITH EXISTING REBATES")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
clean_adjusted = 0
|
||||||
|
rebates_adjusted = 0
|
||||||
|
|
||||||
|
for case in has_rebate_cases:
|
||||||
|
services = case["all_services"]
|
||||||
|
discount_to_absorb = case["discount_total"]
|
||||||
|
|
||||||
|
biggest_rebate = min(
|
||||||
|
[s for s in services if s["is_rebate"]],
|
||||||
|
key=lambda s: s["actual_price"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update positive products to catalog price
|
||||||
|
for s in case["discount_services"]:
|
||||||
|
erp_sub = sub_by_legacy.get(s["svc_id"])
|
||||||
|
if erp_sub:
|
||||||
|
new_price = s["base_price"] if s["actual_price"] >= 0 else 0
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET actual_price = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (new_price, erp_sub["name"]))
|
||||||
|
clean_adjusted += 1
|
||||||
|
|
||||||
|
# Update biggest rebate to absorb difference
|
||||||
|
rebate_sub = sub_by_legacy.get(biggest_rebate["svc_id"])
|
||||||
|
if rebate_sub:
|
||||||
|
new_rebate_price = biggest_rebate["actual_price"] - discount_to_absorb
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET actual_price = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (round(new_rebate_price, 2), rebate_sub["name"]))
|
||||||
|
rebates_adjusted += 1
|
||||||
|
|
||||||
|
if not DRY_RUN:
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
print("Positive products restored to catalog: {}".format(clean_adjusted))
|
||||||
|
print("Rebates adjusted to absorb discount: {}".format(rebates_adjusted))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 6: VERIFY ALL TEST ACCOUNTS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 6: VERIFY TEST ACCOUNTS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if TEST_ACCOUNTS:
|
||||||
|
for acct_id in sorted(TEST_ACCOUNTS):
|
||||||
|
# Find customer from subscriptions (more reliable than legacy_account_id)
|
||||||
|
customer = None
|
||||||
|
for did_check, svcs_check in deliveries.items():
|
||||||
|
if svcs_check[0]["account_id"] == acct_id:
|
||||||
|
for sc in svcs_check:
|
||||||
|
erp_sc = sub_by_legacy.get(sc["svc_id"])
|
||||||
|
if erp_sc:
|
||||||
|
customer = erp_sc["party"]
|
||||||
|
break
|
||||||
|
if customer:
|
||||||
|
break
|
||||||
|
if not customer:
|
||||||
|
customer = cust_by_acct.get(acct_id)
|
||||||
|
if not customer:
|
||||||
|
print("\n Account {} — no customer in ERP".format(acct_id))
|
||||||
|
continue
|
||||||
|
|
||||||
|
cust_info = frappe.db.sql("""
|
||||||
|
SELECT customer_name FROM "tabCustomer" WHERE name = %s
|
||||||
|
""", (customer,), as_dict=True)
|
||||||
|
cust_name = cust_info[0]["customer_name"] if cust_info else customer
|
||||||
|
|
||||||
|
subs_list = frappe.db.sql("""
|
||||||
|
SELECT name, item_code, item_name, actual_price, custom_description,
|
||||||
|
service_location, status
|
||||||
|
FROM "tabSubscription"
|
||||||
|
WHERE party = %s
|
||||||
|
ORDER BY service_location, actual_price DESC
|
||||||
|
""", (customer,), as_dict=True)
|
||||||
|
|
||||||
|
print("\n {} (account {}) — {} subs".format(cust_name, acct_id, len(subs_list)))
|
||||||
|
current_loc = None
|
||||||
|
loc_total = 0
|
||||||
|
grand_total = 0
|
||||||
|
for s in subs_list:
|
||||||
|
loc = s.get("service_location") or "?"
|
||||||
|
if loc != current_loc:
|
||||||
|
if current_loc:
|
||||||
|
print(" SUBTOTAL: ${:.2f}".format(loc_total))
|
||||||
|
current_loc = loc
|
||||||
|
loc_total = 0
|
||||||
|
print(" [{}]".format(loc[:60]))
|
||||||
|
price = float(s["actual_price"] or 0)
|
||||||
|
loc_total += price
|
||||||
|
grand_total += price
|
||||||
|
is_rab = price < 0
|
||||||
|
indent = " " if is_rab else " "
|
||||||
|
desc = s.get("custom_description") or ""
|
||||||
|
print(" {}{:<14} {:>8.2f} {}{}".format(
|
||||||
|
indent, (s["item_code"] or "")[:14], price,
|
||||||
|
(s["item_name"] or "")[:40],
|
||||||
|
" [{}]".format(desc[:40]) if desc else ""))
|
||||||
|
if current_loc:
|
||||||
|
print(" SUBTOTAL: ${:.2f}".format(loc_total))
|
||||||
|
print(" GRAND TOTAL: ${:.2f}".format(grand_total))
|
||||||
|
|
||||||
|
# Global stats
|
||||||
|
total_subs = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription"')[0][0]
|
||||||
|
rab_promo_count = frappe.db.sql(
|
||||||
|
'SELECT COUNT(*) FROM "tabSubscription" WHERE item_code = %s', ('RAB-PROMO',)
|
||||||
|
)[0][0]
|
||||||
|
print("\nTotal subscriptions: {}".format(total_subs))
|
||||||
|
print("RAB-PROMO subscriptions: {}".format(rab_promo_count))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nDone — cache cleared")
|
||||||
211
scripts/migration/fix_reversals.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""
|
||||||
|
Fix reversal invoices: link credit invoices to their original via return_against + PLE.
|
||||||
|
These are cancellation invoices created in legacy with no credit payment — just billed_amt = total_amt.
|
||||||
|
"""
|
||||||
|
import frappe, os, pymysql, time
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
legacy = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="*******",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all unlinked settled return invoices
|
||||||
|
unlinked = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_invoice_id, grand_total, customer, posting_date
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE docstatus = 1 AND is_return = 1
|
||||||
|
AND (return_against IS NULL OR return_against = '')
|
||||||
|
AND outstanding_amount < -0.005
|
||||||
|
ORDER BY outstanding_amount ASC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("Unlinked settled returns: {}".format(len(unlinked)))
|
||||||
|
|
||||||
|
# Build legacy_invoice_id → SINV name map
|
||||||
|
inv_map = {}
|
||||||
|
map_rows = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_invoice_id FROM "tabSales Invoice"
|
||||||
|
WHERE legacy_invoice_id IS NOT NULL AND docstatus = 1 AND is_return != 1
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in map_rows:
|
||||||
|
inv_map[str(r['legacy_invoice_id'])] = r['name']
|
||||||
|
print("Invoice map: {} entries".format(len(inv_map)))
|
||||||
|
|
||||||
|
# Match each reversal to its original
|
||||||
|
matches = [] # (credit_sinv, target_sinv, amount)
|
||||||
|
unmatched = 0
|
||||||
|
|
||||||
|
with legacy.cursor() as cur:
|
||||||
|
for ret in unlinked:
|
||||||
|
leg_id = ret['legacy_invoice_id']
|
||||||
|
amount = float(ret['grand_total'])
|
||||||
|
target_amount = -amount
|
||||||
|
|
||||||
|
cur.execute("SELECT account_id, date_orig FROM invoice WHERE id = %s", (leg_id,))
|
||||||
|
credit_inv = cur.fetchone()
|
||||||
|
if not credit_inv:
|
||||||
|
unmatched += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM invoice
|
||||||
|
WHERE account_id = %s
|
||||||
|
AND ROUND(total_amt, 2) = ROUND(%s, 2)
|
||||||
|
AND total_amt > 0
|
||||||
|
AND date_orig <= %s
|
||||||
|
AND date_orig >= UNIX_TIMESTAMP('2024-04-08')
|
||||||
|
ORDER BY date_orig DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", (credit_inv['account_id'], target_amount, credit_inv['date_orig']))
|
||||||
|
match = cur.fetchone()
|
||||||
|
|
||||||
|
if match:
|
||||||
|
target_sinv = inv_map.get(str(match['id']))
|
||||||
|
if target_sinv:
|
||||||
|
matches.append((ret['name'], target_sinv, amount))
|
||||||
|
else:
|
||||||
|
unmatched += 1
|
||||||
|
else:
|
||||||
|
unmatched += 1
|
||||||
|
|
||||||
|
legacy.close()
|
||||||
|
print("Matched: {} | Unmatched: {}".format(len(matches), unmatched))
|
||||||
|
|
||||||
|
# Load into temp table
|
||||||
|
frappe.db.sql("DROP TABLE IF EXISTS _tmp_reversal_match")
|
||||||
|
frappe.db.commit()
|
||||||
|
frappe.db.sql("""
|
||||||
|
CREATE TABLE _tmp_reversal_match (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
credit_sinv VARCHAR(140),
|
||||||
|
target_sinv VARCHAR(140),
|
||||||
|
amount DOUBLE PRECISION
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
for i in range(0, len(matches), 5000):
|
||||||
|
batch = matches[i:i+5000]
|
||||||
|
values = ",".join(["('{}', '{}', {})".format(c, t, a) for c, t, a in batch])
|
||||||
|
frappe.db.sql("INSERT INTO _tmp_reversal_match (credit_sinv, target_sinv, amount) VALUES {}".format(values))
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Loaded {} matches into temp table".format(len(matches)))
|
||||||
|
|
||||||
|
# Set return_against
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSales Invoice" si
|
||||||
|
SET return_against = rm.target_sinv
|
||||||
|
FROM _tmp_reversal_match rm
|
||||||
|
WHERE si.name = rm.credit_sinv
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Set return_against on {} credit invoices".format(len(matches)))
|
||||||
|
|
||||||
|
# Delete self-referencing PLE for these credit invoices
|
||||||
|
frappe.db.sql("""
|
||||||
|
DELETE FROM "tabPayment Ledger Entry"
|
||||||
|
WHERE name IN (
|
||||||
|
SELECT 'ple-' || rm.credit_sinv FROM _tmp_reversal_match rm
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Insert PLE: credit allocation against target invoice
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabPayment Ledger Entry" (
|
||||||
|
name, owner, creation, modified, modified_by, docstatus,
|
||||||
|
posting_date, company,
|
||||||
|
account_type, account, account_currency,
|
||||||
|
party_type, party,
|
||||||
|
due_date,
|
||||||
|
voucher_type, voucher_no,
|
||||||
|
against_voucher_type, against_voucher_no,
|
||||||
|
amount, amount_in_account_currency,
|
||||||
|
delinked, remarks
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'plr-' || rm.id, 'Administrator', NOW(), NOW(), 'Administrator', 1,
|
||||||
|
si.posting_date, 'TARGO',
|
||||||
|
'Receivable', 'Comptes clients - T', 'CAD',
|
||||||
|
'Customer', si.customer,
|
||||||
|
COALESCE(si.due_date, si.posting_date),
|
||||||
|
'Sales Invoice', rm.credit_sinv,
|
||||||
|
'Sales Invoice', rm.target_sinv,
|
||||||
|
rm.amount, rm.amount,
|
||||||
|
0, 'Reversal allocation'
|
||||||
|
FROM _tmp_reversal_match rm
|
||||||
|
JOIN "tabSales Invoice" si ON si.name = rm.credit_sinv
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
new_ple = frappe.db.sql("SELECT COUNT(*) FROM \"tabPayment Ledger Entry\" WHERE name LIKE 'plr-%%'")[0][0]
|
||||||
|
print("Created {} reversal PLE entries".format(new_ple))
|
||||||
|
|
||||||
|
# Set outstanding = 0 on linked credit invoices
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSales Invoice"
|
||||||
|
SET outstanding_amount = 0, status = 'Return'
|
||||||
|
WHERE name IN (SELECT credit_sinv FROM _tmp_reversal_match)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Recalculate outstanding on target invoices (original invoices being cancelled)
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSales Invoice" si
|
||||||
|
SET outstanding_amount = COALESCE((
|
||||||
|
SELECT SUM(ple.amount)
|
||||||
|
FROM "tabPayment Ledger Entry" ple
|
||||||
|
WHERE ple.against_voucher_type = 'Sales Invoice'
|
||||||
|
AND ple.against_voucher_no = si.name
|
||||||
|
AND ple.delinked = 0
|
||||||
|
), si.grand_total)
|
||||||
|
WHERE si.name IN (SELECT target_sinv FROM _tmp_reversal_match)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Update statuses for affected target invoices
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Paid'
|
||||||
|
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
|
||||||
|
AND is_return != 1 AND ROUND(outstanding_amount::numeric, 2) = 0""")
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Overdue'
|
||||||
|
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
|
||||||
|
AND is_return != 1 AND outstanding_amount > 0.005
|
||||||
|
AND COALESCE(due_date, posting_date) < CURRENT_DATE""")
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Unpaid'
|
||||||
|
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
|
||||||
|
AND is_return != 1 AND outstanding_amount > 0.005
|
||||||
|
AND COALESCE(due_date, posting_date) >= CURRENT_DATE""")
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Credit Note Issued'
|
||||||
|
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
|
||||||
|
AND is_return != 1 AND outstanding_amount < -0.005""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
frappe.db.sql("DROP TABLE IF EXISTS _tmp_reversal_match")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Verification
|
||||||
|
print("\n=== Verification ===")
|
||||||
|
outstanding = frappe.db.sql("""
|
||||||
|
SELECT
|
||||||
|
ROUND(SUM(CASE WHEN outstanding_amount > 0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as owed,
|
||||||
|
ROUND(SUM(CASE WHEN outstanding_amount < -0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as overpaid
|
||||||
|
FROM "tabSales Invoice" WHERE docstatus = 1
|
||||||
|
""", as_dict=True)[0]
|
||||||
|
print(" Outstanding: ${} owed | ${} overpaid".format(outstanding['owed'], outstanding['overpaid']))
|
||||||
|
|
||||||
|
statuses = frappe.db.sql("""
|
||||||
|
SELECT status, COUNT(*) as cnt
|
||||||
|
FROM "tabSales Invoice" WHERE docstatus = 1
|
||||||
|
GROUP BY status ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\n Status breakdown:")
|
||||||
|
for s in statuses:
|
||||||
|
print(" {}: {}".format(s['status'], s['cnt']))
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print("\nDone in {:.0f}s".format(elapsed))
|
||||||
168
scripts/migration/fix_reversement.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""
|
||||||
|
Fix: Remove 'reversement' Payment Entries that were incorrectly imported.
|
||||||
|
These are system-generated reversal payments in legacy — NOT real customer payments.
|
||||||
|
They double-count with the credit note PLE entries we created in fix_reversals.py.
|
||||||
|
"""
|
||||||
|
import frappe, os, time
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
# Find all Payment Entries that came from 'reversement' type payments
|
||||||
|
# These have legacy_payment_id set, and we can identify them via legacy DB
|
||||||
|
# But simpler: check which PEs are allocated to invoices that are targets of our reversal matches
|
||||||
|
|
||||||
|
# First, let's find reversement PEs by checking legacy
|
||||||
|
import pymysql
|
||||||
|
legacy = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="*******",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with legacy.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM payment
|
||||||
|
WHERE type = 'reversement'
|
||||||
|
AND date_orig >= UNIX_TIMESTAMP('2024-04-08')
|
||||||
|
""")
|
||||||
|
rev_ids = [str(r['id']) for r in cur.fetchall()]
|
||||||
|
legacy.close()
|
||||||
|
print("Legacy reversement payments: {}".format(len(rev_ids)))
|
||||||
|
|
||||||
|
# Map to ERPNext PE names (PE-{hex10})
|
||||||
|
pe_names = []
|
||||||
|
for rid in rev_ids:
|
||||||
|
pe_names.append("PE-{:010x}".format(int(rid)))
|
||||||
|
|
||||||
|
# Verify these exist
|
||||||
|
existing = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabPayment Entry"
|
||||||
|
WHERE name IN ({})
|
||||||
|
""".format(",".join(["'{}'".format(n) for n in pe_names])))
|
||||||
|
existing_names = [r[0] for r in existing]
|
||||||
|
print("Existing reversement PEs in ERPNext: {}".format(len(existing_names)))
|
||||||
|
|
||||||
|
if not existing_names:
|
||||||
|
print("Nothing to delete.")
|
||||||
|
exit()
|
||||||
|
|
||||||
|
# Load into temp table for efficient joins
|
||||||
|
frappe.db.sql("DROP TABLE IF EXISTS _tmp_rev_pe")
|
||||||
|
frappe.db.commit()
|
||||||
|
frappe.db.sql("""
|
||||||
|
CREATE TABLE _tmp_rev_pe (
|
||||||
|
pe_name VARCHAR(140) PRIMARY KEY
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
for i in range(0, len(existing_names), 5000):
|
||||||
|
batch = existing_names[i:i+5000]
|
||||||
|
values = ",".join(["('{}')".format(n) for n in batch])
|
||||||
|
frappe.db.sql("INSERT INTO _tmp_rev_pe (pe_name) VALUES {}".format(values))
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Delete PLE entries for these payment entries
|
||||||
|
deleted_ple = frappe.db.sql("""
|
||||||
|
DELETE FROM "tabPayment Ledger Entry"
|
||||||
|
WHERE voucher_type = 'Payment Entry'
|
||||||
|
AND voucher_no IN (SELECT pe_name FROM _tmp_rev_pe)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
ple_count = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) FROM "tabPayment Ledger Entry"
|
||||||
|
WHERE voucher_type = 'Payment Entry'
|
||||||
|
AND voucher_no IN (SELECT pe_name FROM _tmp_rev_pe)
|
||||||
|
""")[0][0]
|
||||||
|
print("Remaining PLE for reversement PEs: {} (should be 0)".format(ple_count))
|
||||||
|
|
||||||
|
# Delete GL entries for these payment entries
|
||||||
|
frappe.db.sql("""
|
||||||
|
DELETE FROM "tabGL Entry"
|
||||||
|
WHERE voucher_type = 'Payment Entry'
|
||||||
|
AND voucher_no IN (SELECT pe_name FROM _tmp_rev_pe)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Delete Payment Entry References
|
||||||
|
frappe.db.sql("""
|
||||||
|
DELETE FROM "tabPayment Entry Reference"
|
||||||
|
WHERE parent IN (SELECT pe_name FROM _tmp_rev_pe)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Delete Payment Entries themselves
|
||||||
|
frappe.db.sql("""
|
||||||
|
DELETE FROM "tabPayment Entry"
|
||||||
|
WHERE name IN (SELECT pe_name FROM _tmp_rev_pe)
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Deleted {} reversement Payment Entries and their GL/PLE".format(len(existing_names)))
|
||||||
|
|
||||||
|
# Recalculate outstanding on ALL invoices that were targets of reversals
|
||||||
|
# (These are the invoices that had both a payment AND a credit PLE)
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSales Invoice" si
|
||||||
|
SET outstanding_amount = COALESCE((
|
||||||
|
SELECT SUM(ple.amount)
|
||||||
|
FROM "tabPayment Ledger Entry" ple
|
||||||
|
WHERE ple.against_voucher_type = 'Sales Invoice'
|
||||||
|
AND ple.against_voucher_no = si.name
|
||||||
|
AND ple.delinked = 0
|
||||||
|
), si.grand_total)
|
||||||
|
WHERE si.docstatus = 1 AND si.is_return != 1
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Update statuses
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Paid'
|
||||||
|
WHERE docstatus = 1 AND is_return != 1 AND ROUND(outstanding_amount::numeric, 2) = 0""")
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Overdue'
|
||||||
|
WHERE docstatus = 1 AND is_return != 1 AND outstanding_amount > 0.005
|
||||||
|
AND COALESCE(due_date, posting_date) < CURRENT_DATE""")
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Unpaid'
|
||||||
|
WHERE docstatus = 1 AND is_return != 1 AND outstanding_amount > 0.005
|
||||||
|
AND COALESCE(due_date, posting_date) >= CURRENT_DATE""")
|
||||||
|
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Credit Note Issued'
|
||||||
|
WHERE docstatus = 1 AND is_return != 1 AND outstanding_amount < -0.005""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
frappe.db.sql("DROP TABLE IF EXISTS _tmp_rev_pe")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Verification
|
||||||
|
print("\n=== Verification ===")
|
||||||
|
outstanding = frappe.db.sql("""
|
||||||
|
SELECT
|
||||||
|
ROUND(SUM(CASE WHEN outstanding_amount > 0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as owed,
|
||||||
|
ROUND(SUM(CASE WHEN outstanding_amount < -0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as overpaid
|
||||||
|
FROM "tabSales Invoice" WHERE docstatus = 1
|
||||||
|
""", as_dict=True)[0]
|
||||||
|
print(" Outstanding: ${} owed | ${} overpaid".format(outstanding['owed'], outstanding['overpaid']))
|
||||||
|
|
||||||
|
statuses = frappe.db.sql("""
|
||||||
|
SELECT status, COUNT(*) as cnt
|
||||||
|
FROM "tabSales Invoice" WHERE docstatus = 1
|
||||||
|
GROUP BY status ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\n Status breakdown:")
|
||||||
|
for s in statuses:
|
||||||
|
print(" {}: {}".format(s['status'], s['cnt']))
|
||||||
|
|
||||||
|
# GL balance check
|
||||||
|
gl = frappe.db.sql("""
|
||||||
|
SELECT
|
||||||
|
ROUND(SUM(debit)::numeric, 2) as total_debit,
|
||||||
|
ROUND(SUM(credit)::numeric, 2) as total_credit
|
||||||
|
FROM "tabGL Entry"
|
||||||
|
""", as_dict=True)[0]
|
||||||
|
print("\n GL Balance: debit={} credit={} diff={}".format(
|
||||||
|
gl['total_debit'], gl['total_credit'],
|
||||||
|
round(float(gl['total_debit'] or 0) - float(gl['total_credit'] or 0), 2)))
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print("\nDone in {:.0f}s".format(elapsed))
|
||||||
183
scripts/migration/fix_subscription_details.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""
|
||||||
|
Fix subscription details: add actual_price, custom_description from legacy hijack data.
|
||||||
|
Also populate item_code and item_group for display.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_subscription_details.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import html
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1: Add custom fields if they don't exist
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 1: ADD CUSTOM FIELDS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
fields_to_add = [
|
||||||
|
("actual_price", "Decimal", "Actual Price"),
|
||||||
|
("custom_description", "Small Text", "Custom Description"),
|
||||||
|
("item_code", "Data", "Item Code"),
|
||||||
|
("item_group", "Data", "Item Group"),
|
||||||
|
("billing_frequency", "Data", "Billing Frequency"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for fieldname, fieldtype, label in fields_to_add:
|
||||||
|
existing = frappe.db.sql("""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tabSubscription' AND column_name = %s
|
||||||
|
""", (fieldname,))
|
||||||
|
if not existing:
|
||||||
|
# Add column directly
|
||||||
|
if fieldtype == "Decimal":
|
||||||
|
frappe.db.sql('ALTER TABLE "tabSubscription" ADD COLUMN {} DECIMAL(18,6) DEFAULT 0'.format(fieldname))
|
||||||
|
else:
|
||||||
|
frappe.db.sql('ALTER TABLE "tabSubscription" ADD COLUMN {} VARCHAR(512)'.format(fieldname))
|
||||||
|
print(" Added column: {}".format(fieldname))
|
||||||
|
else:
|
||||||
|
print(" Column exists: {}".format(fieldname))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2: Load legacy service data (hijack prices + descriptions)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: LOAD LEGACY SERVICE DATA")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.hijack, s.hijack_price, s.hijack_desc,
|
||||||
|
p.sku, p.price as base_price, p.category,
|
||||||
|
pc.name as cat_name
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
LEFT JOIN product_cat pc ON pc.id = p.category
|
||||||
|
WHERE s.id > 0
|
||||||
|
""")
|
||||||
|
legacy_services = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
actual_price = float(r["hijack_price"]) if r["hijack"] else float(r["base_price"] or 0)
|
||||||
|
desc = r["hijack_desc"] if r["hijack"] and r["hijack_desc"] else ""
|
||||||
|
cat = html.unescape(r["cat_name"]) if r["cat_name"] else ""
|
||||||
|
legacy_services[r["id"]] = {
|
||||||
|
"actual_price": actual_price,
|
||||||
|
"description": desc.strip(),
|
||||||
|
"sku": r["sku"],
|
||||||
|
"category": cat,
|
||||||
|
}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("Legacy services loaded: {}".format(len(legacy_services)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3: Load ERPNext Item info
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
items = frappe.db.sql("""
|
||||||
|
SELECT name, item_name, item_group FROM "tabItem"
|
||||||
|
""", as_dict=True)
|
||||||
|
item_map = {i["name"]: i for i in items}
|
||||||
|
print("Items loaded: {}".format(len(item_map)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 4: Update subscriptions
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 3: UPDATE SUBSCRIPTIONS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Get all subscriptions with their plan details
|
||||||
|
subs = frappe.db.sql("""
|
||||||
|
SELECT s.name, s.legacy_service_id,
|
||||||
|
spd.plan, sp.item, sp.cost, sp.billing_interval
|
||||||
|
FROM "tabSubscription" s
|
||||||
|
LEFT JOIN "tabSubscription Plan Detail" spd ON spd.parent = s.name
|
||||||
|
LEFT JOIN "tabSubscription Plan" sp ON sp.plan_name = spd.plan
|
||||||
|
ORDER BY s.name
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
print("Total subscription rows: {}".format(len(subs)))
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
batch_size = 2000
|
||||||
|
for i, sub in enumerate(subs):
|
||||||
|
legacy_id = sub.get("legacy_service_id")
|
||||||
|
item_code = sub.get("item") or ""
|
||||||
|
plan_cost = float(sub.get("cost") or 0)
|
||||||
|
|
||||||
|
# Get actual price from legacy
|
||||||
|
leg = legacy_services.get(legacy_id) if legacy_id else None
|
||||||
|
if leg:
|
||||||
|
actual_price = leg["actual_price"]
|
||||||
|
custom_desc = leg["description"]
|
||||||
|
item_code = leg["sku"] or item_code
|
||||||
|
item_group = leg["category"]
|
||||||
|
else:
|
||||||
|
actual_price = plan_cost
|
||||||
|
custom_desc = ""
|
||||||
|
item_group = item_map.get(item_code, {}).get("item_group", "") if item_code else ""
|
||||||
|
|
||||||
|
# Billing frequency
|
||||||
|
billing_freq = sub.get("billing_interval") or "Month"
|
||||||
|
freq_label = "M" if billing_freq == "Month" else "A" if billing_freq == "Year" else billing_freq[:1]
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET actual_price = %s, custom_description = %s, item_code = %s, item_group = %s, billing_frequency = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (actual_price, custom_desc, item_code, item_group, freq_label, sub["name"]))
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated % batch_size == 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Updated {}/{}...".format(updated, len(subs)))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated: {} subscriptions".format(updated))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 4: VERIFY with Expro
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 4: VERIFY (Expro Transit)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
expro = frappe.db.sql("""
|
||||||
|
SELECT name, item_code, actual_price, custom_description, item_group, billing_frequency,
|
||||||
|
service_location, radius_user, status
|
||||||
|
FROM "tabSubscription"
|
||||||
|
WHERE party = 'CUST-cbf03814b9'
|
||||||
|
ORDER BY service_location, actual_price DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
for s in expro:
|
||||||
|
is_rebate = float(s["actual_price"] or 0) < 0
|
||||||
|
indent = " " if is_rebate else " "
|
||||||
|
desc = s["custom_description"] or ""
|
||||||
|
print("{}{} {:>8.2f} {} {} {}".format(
|
||||||
|
indent, (s["item_code"] or "")[:14].ljust(14),
|
||||||
|
float(s["actual_price"] or 0),
|
||||||
|
(s["billing_frequency"] or "M"),
|
||||||
|
s["status"],
|
||||||
|
desc[:50]))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nDone — cache cleared")
|
||||||
151
scripts/migration/geocode_locations.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Geocode Service Locations using the rqa_addresses (Adresses Québec) table.
|
||||||
|
Matches by extracting numero from address_line, then fuzzy-matching
|
||||||
|
against rqa_addresses using city + street similarity.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
nohup python3 /tmp/geocode_locations.py > /tmp/geocode_locations.log 2>&1 &
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
# Common city name normalizations for matching
|
||||||
|
CITY_NORMALIZE = {
|
||||||
|
"st-": "saint-",
|
||||||
|
"ste-": "sainte-",
|
||||||
|
"st ": "saint-",
|
||||||
|
"ste ": "sainte-",
|
||||||
|
"st.": "saint-",
|
||||||
|
"ste.": "sainte-",
|
||||||
|
}
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print("[{}] {}".format(datetime.now(timezone.utc).strftime("%H:%M:%S"), msg), flush=True)
|
||||||
|
|
||||||
|
def normalize_city(city):
|
||||||
|
"""Normalize city name for matching."""
|
||||||
|
c = city.lower().strip()
|
||||||
|
for old, new in CITY_NORMALIZE.items():
|
||||||
|
if c.startswith(old):
|
||||||
|
c = new + c[len(old):]
|
||||||
|
# Remove accents would be ideal but keep it simple
|
||||||
|
# Remove " de " variants
|
||||||
|
c = re.sub(r'\s+de\s+', '-', c)
|
||||||
|
c = re.sub(r'\s+', '-', c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
def extract_numero(address):
|
||||||
|
"""Extract civic number from address string."""
|
||||||
|
addr = address.strip()
|
||||||
|
# Try to find number at start: "1185 Route 133" → "1185"
|
||||||
|
m = re.match(r'^(\d+[A-Za-z]?)\s*[,\s]', addr)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
# Just digits at start
|
||||||
|
m = re.match(r'^(\d+)', addr)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log("=== Geocode Service Locations via AQ ===")
|
||||||
|
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pg.autocommit = False
|
||||||
|
pgc = pg.cursor()
|
||||||
|
|
||||||
|
# Get locations needing GPS
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, address_line, city, postal_code
|
||||||
|
FROM "tabService Location"
|
||||||
|
WHERE (latitude = 0 OR latitude IS NULL)
|
||||||
|
AND address_line NOT IN ('N/A', '', 'xxx')
|
||||||
|
AND city NOT IN ('N/A', '')
|
||||||
|
""")
|
||||||
|
locations = pgc.fetchall()
|
||||||
|
log(" {} locations to geocode".format(len(locations)))
|
||||||
|
|
||||||
|
matched = missed = 0
|
||||||
|
|
||||||
|
for i, (loc_name, addr, city, postal) in enumerate(locations):
|
||||||
|
lat = lon = None
|
||||||
|
|
||||||
|
# Strategy 1: Match by postal code + numero (most precise)
|
||||||
|
numero = extract_numero(addr)
|
||||||
|
if postal and len(postal) >= 6:
|
||||||
|
postal_clean = postal.strip().upper().replace(" ", "")
|
||||||
|
if numero:
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT latitude, longitude FROM rqa_addresses
|
||||||
|
WHERE REPLACE(UPPER(code_postal), ' ', '') = %s AND numero = %s
|
||||||
|
LIMIT 1
|
||||||
|
""", (postal_clean, numero))
|
||||||
|
row = pgc.fetchone()
|
||||||
|
if row:
|
||||||
|
lat, lon = row
|
||||||
|
|
||||||
|
# Strategy 2: Match by numero + city + fuzzy street
|
||||||
|
if not lat and numero and city:
|
||||||
|
city_norm = normalize_city(city)
|
||||||
|
# Build search string for trigram matching
|
||||||
|
search = "{} {}".format(numero, addr.lower())
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT latitude, longitude,
|
||||||
|
similarity(search_text, %s) as sim
|
||||||
|
FROM rqa_addresses
|
||||||
|
WHERE numero = %s
|
||||||
|
AND LOWER(ville) %% %s
|
||||||
|
ORDER BY similarity(search_text, %s) DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", (search, numero, city_norm, search))
|
||||||
|
row = pgc.fetchone()
|
||||||
|
if row and row[2] > 0.15:
|
||||||
|
lat, lon = row[0], row[1]
|
||||||
|
|
||||||
|
# Strategy 3: Full address fuzzy match against address_full
|
||||||
|
if not lat and city:
|
||||||
|
full_addr = "{}, {}".format(addr, city).lower()
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT latitude, longitude,
|
||||||
|
similarity(address_full, %s) as sim
|
||||||
|
FROM rqa_addresses
|
||||||
|
WHERE address_full %% %s
|
||||||
|
ORDER BY similarity(address_full, %s) DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", (full_addr, full_addr, full_addr))
|
||||||
|
row = pgc.fetchone()
|
||||||
|
if row and row[2] > 0.25:
|
||||||
|
lat, lon = row[0], row[1]
|
||||||
|
|
||||||
|
if lat and lon:
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabService Location"
|
||||||
|
SET latitude = %s, longitude = %s, modified = NOW()
|
||||||
|
WHERE name = %s
|
||||||
|
""", (lat, lon, loc_name))
|
||||||
|
matched += 1
|
||||||
|
else:
|
||||||
|
missed += 1
|
||||||
|
|
||||||
|
if (matched + missed) % 500 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" [{}/{}] matched={} missed={}".format(i+1, len(locations), matched, missed))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("GEOCODE COMPLETE")
|
||||||
|
log(" Matched: {} ({:.1f}%)".format(matched, 100*matched/len(locations) if locations else 0))
|
||||||
|
log(" Missed: {}".format(missed))
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
202
scripts/migration/import_customer_details.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""
|
||||||
|
Import additional customer details from legacy DB into ERPNext.
|
||||||
|
|
||||||
|
Adds custom fields and populates:
|
||||||
|
- invoice_delivery_method: Email/Paper/Both
|
||||||
|
- is_commercial: Commercial account flag
|
||||||
|
- is_bad_payer: Mauvais payeur flag
|
||||||
|
- tax_category_legacy: Tax group
|
||||||
|
- contact_name_legacy: Contact person
|
||||||
|
- tel_home/tel_office/cell: Phone numbers
|
||||||
|
- mandataire: Authorized representative
|
||||||
|
- exclude_fees: Frais exclusion flag
|
||||||
|
- notes_internal: Internal notes (misc)
|
||||||
|
- date_created_legacy: Account creation date
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_customer_details.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1: Create custom fields on Customer
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("Creating custom fields on Customer...")
|
||||||
|
|
||||||
|
CUSTOM_FIELDS = [
|
||||||
|
{"fieldname": "billing_section", "label": "Facturation", "fieldtype": "Section Break", "insert_after": "legacy_section"},
|
||||||
|
{"fieldname": "invoice_delivery_method", "label": "Envoi facture", "fieldtype": "Select", "options": "\nEmail\nPapier\nEmail + Papier", "insert_after": "billing_section"},
|
||||||
|
{"fieldname": "is_commercial", "label": "Compte commercial", "fieldtype": "Check", "insert_after": "invoice_delivery_method"},
|
||||||
|
{"fieldname": "is_bad_payer", "label": "Mauvais payeur", "fieldtype": "Check", "insert_after": "is_commercial"},
|
||||||
|
{"fieldname": "exclude_fees", "label": "Exclure frais", "fieldtype": "Check", "insert_after": "is_bad_payer"},
|
||||||
|
{"fieldname": "billing_col_break", "label": "", "fieldtype": "Column Break", "insert_after": "exclude_fees"},
|
||||||
|
{"fieldname": "tax_category_legacy", "label": "Groupe taxe", "fieldtype": "Select", "options": "\nFederal + Provincial (9.5%)\nFederal seulement\nExempté", "insert_after": "billing_col_break"},
|
||||||
|
{"fieldname": "contact_section", "label": "Contact détaillé", "fieldtype": "Section Break", "insert_after": "tax_category_legacy"},
|
||||||
|
{"fieldname": "contact_name_legacy", "label": "Contact", "fieldtype": "Data", "insert_after": "contact_section"},
|
||||||
|
{"fieldname": "mandataire", "label": "Mandataire", "fieldtype": "Data", "insert_after": "contact_name_legacy"},
|
||||||
|
{"fieldname": "tel_home", "label": "Téléphone maison", "fieldtype": "Data", "insert_after": "mandataire"},
|
||||||
|
{"fieldname": "contact_col_break", "label": "", "fieldtype": "Column Break", "insert_after": "tel_home"},
|
||||||
|
{"fieldname": "tel_office", "label": "Téléphone bureau", "fieldtype": "Data", "insert_after": "contact_col_break"},
|
||||||
|
{"fieldname": "cell_phone", "label": "Cellulaire", "fieldtype": "Data", "insert_after": "tel_office"},
|
||||||
|
{"fieldname": "fax", "label": "Fax", "fieldtype": "Data", "insert_after": "cell_phone"},
|
||||||
|
{"fieldname": "notes_section", "label": "Notes", "fieldtype": "Section Break", "insert_after": "fax"},
|
||||||
|
{"fieldname": "notes_internal", "label": "Notes internes", "fieldtype": "Small Text", "insert_after": "notes_section"},
|
||||||
|
{"fieldname": "email_billing", "label": "Email facturation", "fieldtype": "Data", "insert_after": "notes_internal"},
|
||||||
|
{"fieldname": "email_publipostage", "label": "Email publipostage", "fieldtype": "Data", "insert_after": "email_billing"},
|
||||||
|
{"fieldname": "date_created_legacy", "label": "Date création (legacy)", "fieldtype": "Date", "insert_after": "email_publipostage"},
|
||||||
|
]
|
||||||
|
|
||||||
|
for cf in CUSTOM_FIELDS:
|
||||||
|
existing = frappe.db.exists("Custom Field", {"dt": "Customer", "fieldname": cf["fieldname"]})
|
||||||
|
if existing:
|
||||||
|
print(" {} — already exists".format(cf["fieldname"]))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Custom Field",
|
||||||
|
"dt": "Customer",
|
||||||
|
**cf,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
print(" {} — created".format(cf["fieldname"]))
|
||||||
|
except Exception as e:
|
||||||
|
print(" {} — ERR: {}".format(cf["fieldname"], str(e)[:80]))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Custom fields done.")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2: Load legacy data
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\nLoading legacy data...")
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="VD67owoj",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, customer_id, invoice_delivery, commercial, mauvais_payeur,
|
||||||
|
tax_group, contact, mandataire, tel_home, tel_office, cell, fax,
|
||||||
|
misc, email, email_autre, date_orig, frais, ppa, notes_client,
|
||||||
|
address1, address2, city, state, zip
|
||||||
|
FROM account
|
||||||
|
WHERE status = 1
|
||||||
|
""")
|
||||||
|
accounts = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
print("Active legacy accounts: {}".format(len(accounts)))
|
||||||
|
|
||||||
|
# Customer mapping
|
||||||
|
cust_map = {}
|
||||||
|
custs = frappe.db.sql('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0', as_dict=True)
|
||||||
|
for c in custs:
|
||||||
|
cust_map[c["legacy_account_id"]] = c["name"]
|
||||||
|
print("Customer mapping: {}".format(len(cust_map)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3: Update customers
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
INVOICE_DELIVERY = {1: "Email", 2: "Papier", 3: "Email + Papier"}
|
||||||
|
TAX_GROUP = {1: "Federal + Provincial (9.5%)", 2: "Federal seulement", 3: "Exempté"}
|
||||||
|
|
||||||
|
def ts_to_date(ts):
|
||||||
|
if not ts or ts <= 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
print("\nUpdating customers...")
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for a in accounts:
|
||||||
|
acct_id = a["id"]
|
||||||
|
cust_name = cust_map.get(acct_id)
|
||||||
|
if not cust_name:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
if a["invoice_delivery"]:
|
||||||
|
updates["invoice_delivery_method"] = INVOICE_DELIVERY.get(a["invoice_delivery"], "")
|
||||||
|
if a["commercial"]:
|
||||||
|
updates["is_commercial"] = 1
|
||||||
|
if a["mauvais_payeur"]:
|
||||||
|
updates["is_bad_payer"] = 1
|
||||||
|
if a["frais"]:
|
||||||
|
updates["exclude_fees"] = 1
|
||||||
|
if a["tax_group"]:
|
||||||
|
updates["tax_category_legacy"] = TAX_GROUP.get(a["tax_group"], "")
|
||||||
|
if a["contact"]:
|
||||||
|
updates["contact_name_legacy"] = a["contact"]
|
||||||
|
if a["mandataire"]:
|
||||||
|
updates["mandataire"] = a["mandataire"]
|
||||||
|
if a["tel_home"]:
|
||||||
|
updates["tel_home"] = a["tel_home"]
|
||||||
|
if a["tel_office"]:
|
||||||
|
updates["tel_office"] = a["tel_office"]
|
||||||
|
if a["cell"]:
|
||||||
|
updates["cell_phone"] = a["cell"]
|
||||||
|
if a["fax"]:
|
||||||
|
updates["fax"] = a["fax"]
|
||||||
|
if a["misc"]:
|
||||||
|
updates["notes_internal"] = a["misc"]
|
||||||
|
if a["email"]:
|
||||||
|
updates["email_billing"] = a["email"]
|
||||||
|
if a["email_autre"]:
|
||||||
|
updates["email_publipostage"] = a["email_autre"]
|
||||||
|
created = ts_to_date(a["date_orig"])
|
||||||
|
if created:
|
||||||
|
updates["date_created_legacy"] = created
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Truncate long values for Data fields (varchar 140)
|
||||||
|
for field in ["contact_name_legacy", "mandataire", "tel_home", "tel_office",
|
||||||
|
"cell_phone", "fax", "email_billing", "email_publipostage"]:
|
||||||
|
if field in updates and updates[field] and len(str(updates[field])) > 140:
|
||||||
|
updates[field] = str(updates[field])[:140]
|
||||||
|
|
||||||
|
# Build SET clause
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for field, val in updates.items():
|
||||||
|
set_parts.append('"{}" = %s'.format(field))
|
||||||
|
values.append(val)
|
||||||
|
values.append(cust_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
frappe.db.sql(
|
||||||
|
'UPDATE "tabCustomer" SET {} WHERE name = %s'.format(", ".join(set_parts)),
|
||||||
|
values
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 5:
|
||||||
|
print(" ERR {}: {}".format(cust_name, str(e)[:100]))
|
||||||
|
frappe.db.rollback()
|
||||||
|
|
||||||
|
if updated % 1000 == 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Progress: {}/{}".format(updated, len(accounts)))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("\nUpdated: {} customers".format(updated))
|
||||||
|
print("Skipped (no mapping): {}".format(skipped))
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("DONE")
|
||||||
|
print("=" * 70)
|
||||||
351
scripts/migration/import_devices_and_enrich.py
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
"""
|
||||||
|
Import missing devices into Service Equipment and enrich Service Locations
|
||||||
|
with fibre data (connection_type, OLT port, VLANs).
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_devices_and_enrich.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
T_TOTAL = time.time()
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# LEGACY DB CONNECTION
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Build lookup maps
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: BUILD LOOKUP MAPS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Map legacy delivery_id → ERPNext Service Location name
|
||||||
|
loc_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_delivery_id FROM "tabService Location"
|
||||||
|
WHERE legacy_delivery_id IS NOT NULL AND legacy_delivery_id > 0
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
loc_map[r["legacy_delivery_id"]] = r["name"]
|
||||||
|
print("Location map: {} entries".format(len(loc_map)))
|
||||||
|
|
||||||
|
# Map legacy account_id → ERPNext Customer name
|
||||||
|
cust_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_account_id FROM "tabCustomer"
|
||||||
|
WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
cust_map[r["legacy_account_id"]] = r["name"]
|
||||||
|
print("Customer map: {} entries".format(len(cust_map)))
|
||||||
|
|
||||||
|
# Get already-imported device IDs
|
||||||
|
existing_devices = set()
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT legacy_device_id FROM "tabService Equipment"
|
||||||
|
WHERE legacy_device_id IS NOT NULL AND legacy_device_id > 0
|
||||||
|
""")
|
||||||
|
for r in rows:
|
||||||
|
existing_devices.add(r[0])
|
||||||
|
print("Already imported devices: {}".format(len(existing_devices)))
|
||||||
|
|
||||||
|
# Map delivery_id → account_id from legacy
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, account_id FROM delivery")
|
||||||
|
delivery_account = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
delivery_account[r["id"]] = r["account_id"]
|
||||||
|
print("Delivery→account map: {} entries".format(len(delivery_account)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Import missing devices
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: IMPORT MISSING DEVICES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Category mapping: legacy category → ERPNext equipment_type
|
||||||
|
CATEGORY_MAP = {
|
||||||
|
"onu": "ONT",
|
||||||
|
"tplink_tplg": "ONT",
|
||||||
|
"tplink_device2": "ONT",
|
||||||
|
"raisecom_rcmg": "ONT",
|
||||||
|
"stb": "Decodeur TV",
|
||||||
|
"stb_ministra": "Decodeur TV",
|
||||||
|
"airosm": "AP WiFi",
|
||||||
|
"airos_ac": "AP WiFi",
|
||||||
|
"cambium": "AP WiFi",
|
||||||
|
"ht803g1ge": "Telephone IP",
|
||||||
|
"custom": "Autre",
|
||||||
|
}
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, delivery_id, category, name, manufacturier, model,
|
||||||
|
sn, mac, manage, port, protocol, manage_cli, port_cli,
|
||||||
|
protocol_cli, user, pass, parent
|
||||||
|
FROM device ORDER BY id
|
||||||
|
""")
|
||||||
|
devices = cur.fetchall()
|
||||||
|
|
||||||
|
print("Total legacy devices: {}".format(len(devices)))
|
||||||
|
|
||||||
|
# Build set of existing serial numbers to avoid unique constraint violations
|
||||||
|
existing_serials = frappe.db.sql("""
|
||||||
|
SELECT serial_number FROM "tabService Equipment" WHERE serial_number IS NOT NULL
|
||||||
|
""")
|
||||||
|
seen_serials = set(r[0] for r in existing_serials)
|
||||||
|
print("Existing serial numbers: {}".format(len(seen_serials)))
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
for dev in devices:
|
||||||
|
if dev["id"] in existing_devices:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
equipment_type = CATEGORY_MAP.get(dev["category"], "Autre")
|
||||||
|
serial_number = dev["sn"] or "NO-SN-{}".format(dev["id"])
|
||||||
|
# Ensure uniqueness — append device ID if serial already seen
|
||||||
|
if serial_number in seen_serials:
|
||||||
|
serial_number = "{}-D{}".format(serial_number, dev["id"])
|
||||||
|
seen_serials.add(serial_number)
|
||||||
|
mac = dev["mac"] or None
|
||||||
|
brand = dev["manufacturier"] or None
|
||||||
|
model = dev["model"] or None
|
||||||
|
|
||||||
|
# Management IP — prefer manage_cli (clean IP), fallback to manage (may be URL)
|
||||||
|
ip_address = None
|
||||||
|
if dev["manage_cli"] and dev["manage_cli"].strip():
|
||||||
|
ip_address = dev["manage_cli"].strip()
|
||||||
|
elif dev["manage"] and dev["manage"].strip():
|
||||||
|
mgmt = dev["manage"].strip()
|
||||||
|
# If it's just an IP (not a full URL), use it
|
||||||
|
if not mgmt.startswith("http") and not "/" in mgmt:
|
||||||
|
ip_address = mgmt
|
||||||
|
|
||||||
|
login_user = dev["user"] if dev["user"] and dev["user"].strip() else None
|
||||||
|
login_pass = dev["pass"] if dev["pass"] and dev["pass"].strip() else None
|
||||||
|
|
||||||
|
# Link to Service Location via delivery_id
|
||||||
|
service_location = loc_map.get(dev["delivery_id"]) if dev["delivery_id"] else None
|
||||||
|
|
||||||
|
# Link to Customer via delivery → account
|
||||||
|
customer = None
|
||||||
|
if dev["delivery_id"] and dev["delivery_id"] in delivery_account:
|
||||||
|
account_id = delivery_account[dev["delivery_id"]]
|
||||||
|
customer = cust_map.get(account_id)
|
||||||
|
|
||||||
|
# Generate unique name
|
||||||
|
eq_name = "EQ-{}".format(hashlib.md5(str(dev["id"]).encode()).hexdigest()[:10])
|
||||||
|
|
||||||
|
batch.append({
|
||||||
|
"name": eq_name,
|
||||||
|
"now": now_str,
|
||||||
|
"equipment_type": equipment_type,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"mac_address": mac,
|
||||||
|
"brand": brand,
|
||||||
|
"model": model,
|
||||||
|
"ip_address": ip_address,
|
||||||
|
"login_user": login_user,
|
||||||
|
"login_password": login_pass,
|
||||||
|
"customer": customer,
|
||||||
|
"service_location": service_location,
|
||||||
|
"legacy_device_id": dev["id"],
|
||||||
|
"status": "Actif",
|
||||||
|
"ownership": "Gigafibre",
|
||||||
|
})
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
# Insert in batches of 500
|
||||||
|
if len(batch) >= 500:
|
||||||
|
for eq in batch:
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabService Equipment" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
equipment_type, serial_number, mac_address, brand, model,
|
||||||
|
ip_address, login_user, login_password,
|
||||||
|
customer, service_location, legacy_device_id,
|
||||||
|
status, ownership
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
||||||
|
%(equipment_type)s, %(serial_number)s, %(mac_address)s, %(brand)s, %(model)s,
|
||||||
|
%(ip_address)s, %(login_user)s, %(login_password)s,
|
||||||
|
%(customer)s, %(service_location)s, %(legacy_device_id)s,
|
||||||
|
%(status)s, %(ownership)s
|
||||||
|
)
|
||||||
|
""", eq)
|
||||||
|
frappe.db.commit()
|
||||||
|
batch = []
|
||||||
|
print(" Inserted {}...".format(inserted))
|
||||||
|
|
||||||
|
# Final batch
|
||||||
|
if batch:
|
||||||
|
for eq in batch:
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabService Equipment" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
equipment_type, serial_number, mac_address, brand, model,
|
||||||
|
ip_address, login_user, login_password,
|
||||||
|
customer, service_location, legacy_device_id,
|
||||||
|
status, ownership
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
||||||
|
%(equipment_type)s, %(serial_number)s, %(mac_address)s, %(brand)s, %(model)s,
|
||||||
|
%(ip_address)s, %(login_user)s, %(login_password)s,
|
||||||
|
%(customer)s, %(service_location)s, %(legacy_device_id)s,
|
||||||
|
%(status)s, %(ownership)s
|
||||||
|
)
|
||||||
|
""", eq)
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
print("Inserted {} new devices ({} already existed)".format(inserted, skipped))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: Enrich Service Locations with fibre data
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: ENRICH LOCATIONS WITH FIBRE DATA")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Get fibre data with service → delivery link
|
||||||
|
cur.execute("""
|
||||||
|
SELECT f.service_id, s.delivery_id,
|
||||||
|
f.frame, f.slot, f.port, f.ontid,
|
||||||
|
f.vlan_manage, f.vlan_internet, f.vlan_telephone, f.vlan_tele,
|
||||||
|
f.sn as ont_sn, f.tech as fibre_tech
|
||||||
|
FROM fibre f
|
||||||
|
LEFT JOIN service s ON f.service_id = s.id
|
||||||
|
WHERE s.delivery_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
fibre_data = cur.fetchall()
|
||||||
|
|
||||||
|
print("Fibre records with delivery link: {}".format(len(fibre_data)))
|
||||||
|
|
||||||
|
updated_locs = 0
|
||||||
|
for fb in fibre_data:
|
||||||
|
loc_name = loc_map.get(fb["delivery_id"])
|
||||||
|
if not loc_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
olt_port = "{}/{}/{}".format(fb["frame"], fb["slot"], fb["port"])
|
||||||
|
if fb["ontid"]:
|
||||||
|
olt_port += " ONT:{}".format(fb["ontid"])
|
||||||
|
|
||||||
|
vlans = []
|
||||||
|
if fb["vlan_internet"]:
|
||||||
|
vlans.append("inet:{}".format(fb["vlan_internet"]))
|
||||||
|
if fb["vlan_manage"]:
|
||||||
|
vlans.append("mgmt:{}".format(fb["vlan_manage"]))
|
||||||
|
if fb["vlan_telephone"]:
|
||||||
|
vlans.append("tel:{}".format(fb["vlan_telephone"]))
|
||||||
|
if fb["vlan_tele"]:
|
||||||
|
vlans.append("tv:{}".format(fb["vlan_tele"]))
|
||||||
|
network_id = " ".join(vlans) if vlans else None
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabService Location"
|
||||||
|
SET connection_type = 'Fibre FTTH',
|
||||||
|
olt_port = %(olt_port)s,
|
||||||
|
network_id = %(network_id)s
|
||||||
|
WHERE name = %(name)s
|
||||||
|
AND (connection_type IS NULL OR connection_type = '')
|
||||||
|
""", {"name": loc_name, "olt_port": olt_port, "network_id": network_id})
|
||||||
|
updated_locs += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Enriched {} locations with fibre data".format(updated_locs))
|
||||||
|
|
||||||
|
# Also set connection_type for locations with wireless devices
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT d.delivery_id
|
||||||
|
FROM device d
|
||||||
|
WHERE d.category IN ('airosm', 'airos_ac', 'cambium')
|
||||||
|
AND d.delivery_id > 0
|
||||||
|
""")
|
||||||
|
wireless_deliveries = [r["delivery_id"] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
wireless_updated = 0
|
||||||
|
for del_id in wireless_deliveries:
|
||||||
|
loc_name = loc_map.get(del_id)
|
||||||
|
if loc_name:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabService Location"
|
||||||
|
SET connection_type = 'Sans-fil'
|
||||||
|
WHERE name = %s
|
||||||
|
AND (connection_type IS NULL OR connection_type = '')
|
||||||
|
""", (loc_name,))
|
||||||
|
wireless_updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Set {} locations as Sans-fil (wireless)".format(wireless_updated))
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 4: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 4: VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
total_eq = frappe.db.sql('SELECT COUNT(*) FROM "tabService Equipment"')[0][0]
|
||||||
|
by_type = frappe.db.sql("""
|
||||||
|
SELECT equipment_type, COUNT(*) as cnt FROM "tabService Equipment"
|
||||||
|
GROUP BY equipment_type ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("Total Service Equipment: {}".format(total_eq))
|
||||||
|
for t in by_type:
|
||||||
|
print(" {}: {}".format(t["equipment_type"], t["cnt"]))
|
||||||
|
|
||||||
|
with_customer = frappe.db.sql('SELECT COUNT(*) FROM "tabService Equipment" WHERE customer IS NOT NULL')[0][0]
|
||||||
|
with_location = frappe.db.sql('SELECT COUNT(*) FROM "tabService Equipment" WHERE service_location IS NOT NULL')[0][0]
|
||||||
|
with_ip = frappe.db.sql("SELECT COUNT(*) FROM \"tabService Equipment\" WHERE ip_address IS NOT NULL")[0][0]
|
||||||
|
with_creds = frappe.db.sql("SELECT COUNT(*) FROM \"tabService Equipment\" WHERE login_user IS NOT NULL")[0][0]
|
||||||
|
print("\nWith customer link: {}".format(with_customer))
|
||||||
|
print("With service_location link: {}".format(with_location))
|
||||||
|
print("With IP address: {}".format(with_ip))
|
||||||
|
print("With credentials: {}".format(with_creds))
|
||||||
|
|
||||||
|
# Location enrichment stats
|
||||||
|
loc_by_conn = frappe.db.sql("""
|
||||||
|
SELECT connection_type, COUNT(*) as cnt FROM "tabService Location"
|
||||||
|
GROUP BY connection_type ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\nService Locations by connection type:")
|
||||||
|
for l in loc_by_conn:
|
||||||
|
print(" {}: {}".format(l["connection_type"] or "(not set)", l["cnt"]))
|
||||||
|
|
||||||
|
with_olt = frappe.db.sql("SELECT COUNT(*) FROM \"tabService Location\" WHERE olt_port IS NOT NULL")[0][0]
|
||||||
|
print("With OLT port: {}".format(with_olt))
|
||||||
|
|
||||||
|
elapsed = time.time() - T_TOTAL
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("DONE in {:.1f}s".format(elapsed))
|
||||||
|
print("="*60)
|
||||||
291
scripts/migration/import_employees.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""
|
||||||
|
Import employees from legacy staff table into ERPNext Employee doctype.
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_employees.py
|
||||||
|
|
||||||
|
Maps legacy group_ad → Department, status → Active/Inactive.
|
||||||
|
Idempotent: deletes existing employees before reimporting.
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
T_TOTAL = time.time()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# CONFIG
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
COMPANY = "TARGO"
|
||||||
|
|
||||||
|
# Legacy group_ad → ERPNext Department
|
||||||
|
DEPT_MAP = {
|
||||||
|
"admin": "Management - T",
|
||||||
|
"sysadmin": "Operations - T",
|
||||||
|
"tech": "Operations - T",
|
||||||
|
"support": "Customer Service - T",
|
||||||
|
"comptabilite": "Accounts - T",
|
||||||
|
"facturation": "Accounts - T",
|
||||||
|
"": None,
|
||||||
|
"none": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy group_ad → ERPNext Designation
|
||||||
|
DESIG_MAP = {
|
||||||
|
"admin": "Manager",
|
||||||
|
"sysadmin": "Engineer",
|
||||||
|
"tech": "Technician",
|
||||||
|
"support": "Customer Service Representative",
|
||||||
|
"comptabilite": "Accountant",
|
||||||
|
"facturation": "Accountant",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 0: Ensure prerequisite records exist
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 0: PREREQUISITES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Gender "Prefer not to say"
|
||||||
|
if not frappe.db.exists("Gender", "Prefer not to say"):
|
||||||
|
frappe.get_doc({"doctype": "Gender", "gender": "Prefer not to say"}).insert()
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Created Gender: Prefer not to say")
|
||||||
|
|
||||||
|
# Designation "Technician" — insert via SQL
|
||||||
|
for desig_name in ["Technician"]:
|
||||||
|
if not frappe.db.exists("Designation", desig_name):
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabDesignation" (name, creation, modified, modified_by, owner, docstatus, idx)
|
||||||
|
VALUES (%(n)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0)
|
||||||
|
""", {"n": desig_name, "now": now})
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Created Designation:", desig_name)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: CLEANUP existing employees
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: CLEANUP")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
existing = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee"')[0][0]
|
||||||
|
if existing > 0:
|
||||||
|
frappe.db.sql('DELETE FROM "tabEmployee"')
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Deleted {} existing employees".format(existing))
|
||||||
|
else:
|
||||||
|
print("No existing employees to delete")
|
||||||
|
|
||||||
|
# Reset naming series counter
|
||||||
|
frappe.db.sql("""
|
||||||
|
DELETE FROM "tabSeries" WHERE name = 'HR-EMP-'
|
||||||
|
""")
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: FETCH legacy staff
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: FETCH LEGACY STAFF")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, status, username, first_name, last_name, email, ext, cell,
|
||||||
|
group_ad, date_embauche, fete, matricule_desjardins, ldap_id
|
||||||
|
FROM staff ORDER BY id
|
||||||
|
""")
|
||||||
|
staff = cur.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Fetched {} staff records".format(len(staff)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: INSERT employees via bulk SQL
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: INSERT EMPLOYEES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
counter = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for s in staff:
|
||||||
|
# Skip system/bot accounts with no real name
|
||||||
|
if not s["first_name"] or s["first_name"].strip() == "":
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
counter += 1
|
||||||
|
emp_name = "HR-EMP-{}".format(counter)
|
||||||
|
first_name = (s["first_name"] or "").replace("'", "'").strip()
|
||||||
|
last_name = (s["last_name"] or "").replace("'", "'").strip()
|
||||||
|
full_name = "{} {}".format(first_name, last_name).strip()
|
||||||
|
|
||||||
|
# Status: legacy 1 = Active, -1 = Inactive/Left
|
||||||
|
status = "Active" if s["status"] == 1 else "Left"
|
||||||
|
|
||||||
|
# Department
|
||||||
|
group = (s["group_ad"] or "").strip().lower()
|
||||||
|
dept = DEPT_MAP.get(group)
|
||||||
|
|
||||||
|
# Designation
|
||||||
|
desig = DESIG_MAP.get(group)
|
||||||
|
|
||||||
|
# Date of joining from unix timestamp
|
||||||
|
doj = None
|
||||||
|
if s["date_embauche"]:
|
||||||
|
try:
|
||||||
|
ts = int(s["date_embauche"])
|
||||||
|
if ts > 0:
|
||||||
|
doj = datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
if not doj:
|
||||||
|
doj = "2020-01-01" # placeholder
|
||||||
|
|
||||||
|
# Date of birth from fete (DD|MM or MM|DD format)
|
||||||
|
dob = None
|
||||||
|
if s["fete"]:
|
||||||
|
parts = s["fete"].split("|")
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
day = int(parts[0])
|
||||||
|
month = int(parts[1])
|
||||||
|
# Format is DD|MM based on sample data (e.g. "06|05" = June 5th)
|
||||||
|
# But "30|12" = 30th of December — day|month
|
||||||
|
if day > 12:
|
||||||
|
# day is definitely the day
|
||||||
|
dob = "1990-{:02d}-{:02d}".format(month, day)
|
||||||
|
elif month > 12:
|
||||||
|
# month field is actually the day
|
||||||
|
dob = "1990-{:02d}-{:02d}".format(day, month)
|
||||||
|
else:
|
||||||
|
# Ambiguous — use DD|MM interpretation
|
||||||
|
dob = "1990-{:02d}-{:02d}".format(month, day)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
if not dob:
|
||||||
|
dob = "1990-01-01" # placeholder
|
||||||
|
|
||||||
|
# Email
|
||||||
|
email = (s["email"] or "").strip()
|
||||||
|
company_email = email if email.endswith("@targointernet.com") else None
|
||||||
|
|
||||||
|
# Cell phone
|
||||||
|
cell = (s["cell"] or "").strip()
|
||||||
|
|
||||||
|
# Employee number = legacy staff ID
|
||||||
|
emp_number = str(s["id"])
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabEmployee" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
naming_series, first_name, last_name, employee_name,
|
||||||
|
gender, date_of_birth, date_of_joining,
|
||||||
|
status, company, department, designation,
|
||||||
|
employee_number, cell_number, company_email, personal_email,
|
||||||
|
prefered_contact_email,
|
||||||
|
lft, rgt
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
||||||
|
'HR-EMP-', %(first_name)s, %(last_name)s, %(full_name)s,
|
||||||
|
'Prefer not to say', %(dob)s, %(doj)s,
|
||||||
|
%(status)s, %(company)s, %(dept)s, %(desig)s,
|
||||||
|
%(emp_number)s, %(cell)s, %(company_email)s, %(personal_email)s,
|
||||||
|
%(pref_email)s,
|
||||||
|
0, 0
|
||||||
|
)
|
||||||
|
""", {
|
||||||
|
"name": emp_name,
|
||||||
|
"now": now_str,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"full_name": full_name,
|
||||||
|
"dob": dob,
|
||||||
|
"doj": doj,
|
||||||
|
"status": status,
|
||||||
|
"company": COMPANY,
|
||||||
|
"dept": dept,
|
||||||
|
"desig": desig,
|
||||||
|
"emp_number": emp_number,
|
||||||
|
"cell": cell if cell else None,
|
||||||
|
"company_email": company_email,
|
||||||
|
"personal_email": email if email and not email.endswith("@targointernet.com") else None,
|
||||||
|
"pref_email": "Company Email" if company_email else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Inserted {} employees ({} skipped - no name)".format(counter, skipped))
|
||||||
|
|
||||||
|
# Set the naming series counter
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabSeries" (name, current) VALUES ('HR-EMP-', %(counter)s)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET current = %(counter)s
|
||||||
|
""", {"counter": counter})
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Rebuild tree (Employee is a tree doctype with lft/rgt)
|
||||||
|
try:
|
||||||
|
frappe.rebuild_tree("Employee", "reports_to")
|
||||||
|
print("Rebuilt employee tree")
|
||||||
|
except Exception as e:
|
||||||
|
print("Tree rebuild skipped:", e)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 4: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 4: VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
total = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee"')[0][0]
|
||||||
|
active = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee" WHERE status = %s', ("Active",))[0][0]
|
||||||
|
left = frappe.db.sql('SELECT COUNT(*) FROM "tabEmployee" WHERE status = %s', ("Left",))[0][0]
|
||||||
|
|
||||||
|
print("Total employees: {}".format(total))
|
||||||
|
print(" Active: {}".format(active))
|
||||||
|
print(" Left: {}".format(left))
|
||||||
|
|
||||||
|
by_dept = frappe.db.sql("""
|
||||||
|
SELECT department, COUNT(*) as cnt FROM "tabEmployee"
|
||||||
|
GROUP BY department ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\nBy department:")
|
||||||
|
for d in by_dept:
|
||||||
|
print(" {}: {}".format(d["department"] or "(none)", d["cnt"]))
|
||||||
|
|
||||||
|
# Sample
|
||||||
|
sample = frappe.db.sql("""
|
||||||
|
SELECT name, employee_name, status, department, designation, employee_number, date_of_joining
|
||||||
|
FROM "tabEmployee" ORDER BY name LIMIT 10
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\nSample:")
|
||||||
|
for e in sample:
|
||||||
|
print(" {} {} [{}] dept={} desig={} legacy_id={} joined={}".format(
|
||||||
|
e["name"], e["employee_name"], e["status"],
|
||||||
|
e["department"], e["designation"], e["employee_number"], e["date_of_joining"]))
|
||||||
|
|
||||||
|
elapsed = time.time() - T_TOTAL
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("DONE in {:.1f}s".format(elapsed))
|
||||||
|
print("="*60)
|
||||||
270
scripts/migration/import_expro_payments.py
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
"""
|
||||||
|
Import missing payments for Expro Transit Inc (account 3673).
|
||||||
|
Creates Payment Entry documents in ERPNext from legacy data.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_expro_payments.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_ID = 3673
|
||||||
|
CUSTOMER = "CUST-cbf03814b9"
|
||||||
|
COMPANY = "TARGO"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1: Load legacy data
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 1: LOAD LEGACY DATA")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id, p.date_orig, p.amount, p.type, p.reference
|
||||||
|
FROM payment p
|
||||||
|
WHERE p.account_id = %s
|
||||||
|
ORDER BY p.date_orig ASC
|
||||||
|
""", (ACCOUNT_ID,))
|
||||||
|
legacy_payments = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT pi.payment_id, pi.invoice_id, pi.amount
|
||||||
|
FROM payment_item pi
|
||||||
|
JOIN payment p ON p.id = pi.payment_id
|
||||||
|
WHERE p.account_id = %s
|
||||||
|
""", (ACCOUNT_ID,))
|
||||||
|
legacy_allocs = cur.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Build allocation map
|
||||||
|
alloc_map = {}
|
||||||
|
for a in legacy_allocs:
|
||||||
|
pid = a["payment_id"]
|
||||||
|
if pid not in alloc_map:
|
||||||
|
alloc_map[pid] = []
|
||||||
|
alloc_map[pid].append({
|
||||||
|
"invoice_id": a["invoice_id"],
|
||||||
|
"amount": float(a["amount"] or 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Legacy payments: {}".format(len(legacy_payments)))
|
||||||
|
print("Legacy allocations: {}".format(len(legacy_allocs)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2: Find which ones already exist
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: FIND MISSING PAYMENTS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
erp_pes = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabPayment Entry" WHERE party = %s
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
erp_pe_ids = set()
|
||||||
|
for pe in erp_pes:
|
||||||
|
try:
|
||||||
|
erp_pe_ids.add(int(pe["name"].split("-")[1]))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check which invoices exist
|
||||||
|
erp_invs = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabSales Invoice" WHERE customer = %s AND docstatus = 1
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
erp_inv_names = set(i["name"] for i in erp_invs)
|
||||||
|
|
||||||
|
to_create = []
|
||||||
|
skipped_exists = 0
|
||||||
|
skipped_no_inv = 0
|
||||||
|
|
||||||
|
for p in legacy_payments:
|
||||||
|
if p["id"] in erp_pe_ids:
|
||||||
|
skipped_exists += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
dt = datetime.fromtimestamp(p["date_orig"]).strftime("%Y-%m-%d") if p["date_orig"] else None
|
||||||
|
amount = float(p["amount"] or 0)
|
||||||
|
ptype = (p["type"] or "").strip()
|
||||||
|
ref = (p["reference"] or "").strip()
|
||||||
|
|
||||||
|
# Get allocations and verify invoices exist
|
||||||
|
allocations = alloc_map.get(p["id"], [])
|
||||||
|
valid_allocs = []
|
||||||
|
for a in allocations:
|
||||||
|
sinv_name = "SINV-{}".format(a["invoice_id"])
|
||||||
|
if sinv_name in erp_inv_names:
|
||||||
|
valid_allocs.append({
|
||||||
|
"reference_doctype": "Sales Invoice",
|
||||||
|
"reference_name": sinv_name,
|
||||||
|
"allocated_amount": a["amount"],
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
skipped_no_inv += 1
|
||||||
|
|
||||||
|
to_create.append({
|
||||||
|
"name": "PE-{}".format(p["id"]),
|
||||||
|
"date": dt,
|
||||||
|
"amount": amount,
|
||||||
|
"type": ptype,
|
||||||
|
"reference": ref,
|
||||||
|
"allocations": valid_allocs,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Already exists: {}".format(skipped_exists))
|
||||||
|
print("TO CREATE: {}".format(len(to_create)))
|
||||||
|
print("Allocs skipped (inv not found): {}".format(skipped_no_inv))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3: Get/create accounts
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 3: RESOLVE ACCOUNTS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Use the same accounts as existing Payment Entries for this customer
|
||||||
|
existing_pe = frappe.db.sql("""
|
||||||
|
SELECT paid_from, paid_to FROM "tabPayment Entry"
|
||||||
|
WHERE party = %s AND docstatus = 1 LIMIT 1
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
|
||||||
|
if existing_pe:
|
||||||
|
receivable = existing_pe[0]["paid_from"]
|
||||||
|
paid_to = existing_pe[0]["paid_to"]
|
||||||
|
else:
|
||||||
|
receivable = "Comptes clients - T"
|
||||||
|
paid_to = "Banque - T"
|
||||||
|
|
||||||
|
print("Receivable account (paid_from): {}".format(receivable))
|
||||||
|
print("Bank account (paid_to): {}".format(paid_to))
|
||||||
|
|
||||||
|
if not receivable or not paid_to:
|
||||||
|
print("ERROR: Missing accounts!")
|
||||||
|
exit()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 4: CREATE PAYMENT ENTRIES VIA DIRECT SQL
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 4: CREATE PAYMENT ENTRIES")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for p in to_create:
|
||||||
|
try:
|
||||||
|
pe_name = p["name"]
|
||||||
|
posting_date = p["date"] or "2012-01-01"
|
||||||
|
amount = p["amount"]
|
||||||
|
|
||||||
|
# Insert Payment Entry
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabPayment Entry" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus,
|
||||||
|
naming_series, payment_type, posting_date,
|
||||||
|
company, party_type, party, party_name,
|
||||||
|
paid_from, paid_to, paid_amount, received_amount,
|
||||||
|
target_exchange_rate, source_exchange_rate,
|
||||||
|
paid_from_account_currency, paid_to_account_currency,
|
||||||
|
reference_no, reference_date,
|
||||||
|
mode_of_payment, status
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, 'Administrator', 'Administrator', 1,
|
||||||
|
'ACC-PAY-.YYYY.-', 'Receive', %s,
|
||||||
|
%s, 'Customer', %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
1.0, 1.0,
|
||||||
|
'CAD', 'CAD',
|
||||||
|
%s, %s,
|
||||||
|
%s, 'Submitted'
|
||||||
|
)
|
||||||
|
""", (
|
||||||
|
pe_name, posting_date, posting_date, posting_date,
|
||||||
|
COMPANY, CUSTOMER, "Expro Transit Inc.",
|
||||||
|
receivable, paid_to, amount, amount,
|
||||||
|
p["reference"] or pe_name, posting_date,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Insert Payment Entry References (allocations)
|
||||||
|
for idx, alloc in enumerate(p["allocations"], 1):
|
||||||
|
ref_name = "{}-ref-{}".format(pe_name, idx)
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabPayment Entry Reference" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
parent, parentfield, parenttype,
|
||||||
|
reference_doctype, reference_name, allocated_amount,
|
||||||
|
total_amount, outstanding_amount, exchange_rate
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, 'Administrator', 'Administrator', 1, %s,
|
||||||
|
%s, 'references', 'Payment Entry',
|
||||||
|
%s, %s, %s,
|
||||||
|
0, 0, 1.0
|
||||||
|
)
|
||||||
|
""", (
|
||||||
|
ref_name, posting_date, posting_date, idx,
|
||||||
|
pe_name,
|
||||||
|
alloc["reference_doctype"], alloc["reference_name"], alloc["allocated_amount"],
|
||||||
|
))
|
||||||
|
|
||||||
|
created += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 5:
|
||||||
|
print(" ERROR on {}: {}".format(p["name"], str(e)[:100]))
|
||||||
|
|
||||||
|
if created % 50 == 0 and created > 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Created {}/{}...".format(created, len(to_create)))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("\nCreated: {}".format(created))
|
||||||
|
print("Errors: {}".format(errors))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 5: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 5: VERIFY")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Count payments now
|
||||||
|
pe_after = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) as cnt, COALESCE(SUM(paid_amount), 0) as total
|
||||||
|
FROM "tabPayment Entry"
|
||||||
|
WHERE party = %s AND docstatus = 1
|
||||||
|
""", (CUSTOMER,), as_dict=True)[0]
|
||||||
|
print("Payment Entries after import: {} for ${:,.2f}".format(pe_after["cnt"], float(pe_after["total"])))
|
||||||
|
|
||||||
|
# Check a few samples
|
||||||
|
samples = frappe.db.sql("""
|
||||||
|
SELECT pe.name, pe.posting_date, pe.paid_amount,
|
||||||
|
(SELECT COUNT(*) FROM "tabPayment Entry Reference" per WHERE per.parent = pe.name) as ref_count
|
||||||
|
FROM "tabPayment Entry" pe
|
||||||
|
WHERE pe.party = %s AND pe.docstatus = 1
|
||||||
|
ORDER BY pe.posting_date DESC LIMIT 10
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
print("\nRecent payments:")
|
||||||
|
for s in samples:
|
||||||
|
print(" {} date={} amount={:,.2f} refs={}".format(s["name"], s["posting_date"], float(s["paid_amount"]), s["ref_count"]))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nDone — cache cleared")
|
||||||
157
scripts/migration/import_invoice_notes.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""
|
||||||
|
Phase 3 standalone: Import legacy invoice.notes as Comments on Sales Invoice.
|
||||||
|
Runs from any host that can reach BOTH:
|
||||||
|
- Legacy MariaDB (10.100.80.100)
|
||||||
|
- ERPNext API (erp.gigafibre.ca)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python import_invoice_notes.py
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
|
||||||
|
|
||||||
|
# ── Config ──
|
||||||
|
LEGACY_HOST = "10.100.80.100"
|
||||||
|
LEGACY_USER = "facturation"
|
||||||
|
LEGACY_PASS = "VD67owoj"
|
||||||
|
LEGACY_DB = "gestionclient"
|
||||||
|
|
||||||
|
ERP_URL = "https://erp.gigafibre.ca"
|
||||||
|
ERP_KEY = os.environ.get("ERP_API_KEY", "")
|
||||||
|
ERP_SECRET = os.environ.get("ERP_API_SECRET", "")
|
||||||
|
|
||||||
|
DRY_RUN = False
|
||||||
|
|
||||||
|
# ── Connect to legacy DB ──
|
||||||
|
print("Connecting to legacy MariaDB...")
|
||||||
|
legacy = pymysql.connect(
|
||||||
|
host=LEGACY_HOST, user=LEGACY_USER, password=LEGACY_PASS,
|
||||||
|
database=LEGACY_DB, cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with legacy.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, notes, account_id, FROM_UNIXTIME(date_orig) as date_created
|
||||||
|
FROM invoice
|
||||||
|
WHERE notes IS NOT NULL AND notes != '' AND TRIM(notes) != ''
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
noted_invoices = cur.fetchall()
|
||||||
|
legacy.close()
|
||||||
|
print(f" Legacy invoices with notes: {len(noted_invoices)}")
|
||||||
|
|
||||||
|
# ── ERPNext session ──
|
||||||
|
sess = requests.Session()
|
||||||
|
if ERP_KEY and ERP_SECRET:
|
||||||
|
sess.headers['Authorization'] = f'token {ERP_KEY}:{ERP_SECRET}'
|
||||||
|
else:
|
||||||
|
# Try cookie auth — login
|
||||||
|
erp_user = os.environ.get("ERP_USER", "Administrator")
|
||||||
|
erp_pass = os.environ.get("ERP_PASS", "")
|
||||||
|
if not erp_pass:
|
||||||
|
print("ERROR: Set ERP_API_KEY+ERP_API_SECRET or ERP_USER+ERP_PASS env vars")
|
||||||
|
sys.exit(1)
|
||||||
|
r = sess.post(f"{ERP_URL}/api/method/login", data={"usr": erp_user, "pwd": erp_pass})
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"Login failed: {r.status_code} {r.text[:200]}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" Logged in as {erp_user}")
|
||||||
|
|
||||||
|
# ── Get existing SINVs ──
|
||||||
|
print(" Fetching existing Sales Invoices...")
|
||||||
|
existing_sinv = set()
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
r = sess.get(f"{ERP_URL}/api/resource/Sales Invoice", params={
|
||||||
|
'fields': '["name"]', 'limit_page_length': 10000, 'limit_start': offset,
|
||||||
|
})
|
||||||
|
data = r.json().get('data', [])
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for d in data:
|
||||||
|
existing_sinv.add(d['name'])
|
||||||
|
offset += len(data)
|
||||||
|
print(f" Existing SINVs: {len(existing_sinv)}")
|
||||||
|
|
||||||
|
# ── Get existing Comments on invoices ──
|
||||||
|
print(" Fetching existing Comments on Sales Invoices...")
|
||||||
|
existing_comments = set()
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
r = sess.get(f"{ERP_URL}/api/resource/Comment", params={
|
||||||
|
'fields': '["reference_name"]',
|
||||||
|
'filters': json.dumps({"reference_doctype": "Sales Invoice", "comment_type": "Comment"}),
|
||||||
|
'limit_page_length': 10000, 'limit_start': offset,
|
||||||
|
})
|
||||||
|
data = r.json().get('data', [])
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for d in data:
|
||||||
|
existing_comments.add(d['reference_name'])
|
||||||
|
offset += len(data)
|
||||||
|
print(f" Existing Comments on invoices: {len(existing_comments)}")
|
||||||
|
|
||||||
|
# ── Build batch ──
|
||||||
|
batch = []
|
||||||
|
skipped = 0
|
||||||
|
for inv in noted_invoices:
|
||||||
|
sinv_name = f"SINV-{inv['id']}"
|
||||||
|
if sinv_name not in existing_sinv:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if sinv_name in existing_comments:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
notes = inv['notes'].strip()
|
||||||
|
if not notes:
|
||||||
|
continue
|
||||||
|
batch.append({
|
||||||
|
'sinv': sinv_name,
|
||||||
|
'content': notes,
|
||||||
|
'creation': str(inv['date_created']) if inv['date_created'] else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" Notes to import: {len(batch)}, skipped: {skipped}")
|
||||||
|
|
||||||
|
if DRY_RUN:
|
||||||
|
print(" ** DRY RUN — no changes **")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# ── Import via API ──
|
||||||
|
t0 = time.time()
|
||||||
|
imported = 0
|
||||||
|
errors = 0
|
||||||
|
for i, note in enumerate(batch):
|
||||||
|
payload = {
|
||||||
|
'doctype': 'Comment',
|
||||||
|
'comment_type': 'Comment',
|
||||||
|
'reference_doctype': 'Sales Invoice',
|
||||||
|
'reference_name': note['sinv'],
|
||||||
|
'content': note['content'],
|
||||||
|
'comment_by': 'Système legacy',
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
r = sess.post(f"{ERP_URL}/api/resource/Comment", json=payload)
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
imported += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 5:
|
||||||
|
print(f" ERR {note['sinv']}: {r.status_code} {r.text[:100]}")
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 5:
|
||||||
|
print(f" EXCEPTION {note['sinv']}: {e}")
|
||||||
|
|
||||||
|
if (i + 1) % 500 == 0:
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
rate = (i + 1) / elapsed
|
||||||
|
print(f" Progress: {i+1}/{len(batch)} ({rate:.0f}/s) imported={imported} errors={errors}")
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(f"\n DONE: {imported} imported, {errors} errors [{elapsed:.0f}s]")
|
||||||
386
scripts/migration/import_missing_services.py
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
"""
|
||||||
|
Import missing services — categories excluded from the original migration.
|
||||||
|
|
||||||
|
Original migration (phase 3) only imported categories: 4,9,17,21,32,33
|
||||||
|
This imports ALL remaining active services from other categories.
|
||||||
|
|
||||||
|
For each missing service:
|
||||||
|
1. Ensure Item exists in ERPNext
|
||||||
|
2. Ensure Subscription Plan exists
|
||||||
|
3. Create Subscription linked to correct customer + service_location
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_missing_services.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
DRY_RUN = False
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100", user="facturation", password="VD67owoj",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# LOAD LEGACY DATA
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("Loading legacy data...")
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# All active services from excluded categories
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.delivery_id, s.product_id, s.status,
|
||||||
|
s.hijack, s.hijack_price, s.hijack_desc,
|
||||||
|
s.payment_recurrence, s.date_orig, s.date_next_invoice,
|
||||||
|
s.radius_user, s.radius_pwd,
|
||||||
|
p.sku, p.price as base_price, p.category,
|
||||||
|
d.account_id
|
||||||
|
FROM service s
|
||||||
|
JOIN product p ON p.id = s.product_id
|
||||||
|
JOIN delivery d ON d.id = s.delivery_id
|
||||||
|
WHERE s.status = 1
|
||||||
|
AND p.category NOT IN (4,9,17,21,32,33)
|
||||||
|
ORDER BY d.account_id, s.id
|
||||||
|
""")
|
||||||
|
missing_services = cur.fetchall()
|
||||||
|
|
||||||
|
# All products from these categories
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT p.id, p.sku, p.price, p.category
|
||||||
|
FROM product p
|
||||||
|
JOIN service s ON s.product_id = p.id
|
||||||
|
WHERE s.status = 1 AND p.category NOT IN (4,9,17,21,32,33)
|
||||||
|
""")
|
||||||
|
products = cur.fetchall()
|
||||||
|
|
||||||
|
# Category names (hardcoded from legacy)
|
||||||
|
categories = {
|
||||||
|
1: "Installation initiale", 7: "Location serveur", 8: "Location équipement",
|
||||||
|
11: "Nom de domaine", 13: "Location espace", 15: "Hébergement",
|
||||||
|
16: "Support", 23: "Hotspot camping", 26: "Installation et équipement fibre",
|
||||||
|
28: "Quotidien pro", 34: "Installation et équipement télé",
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("Missing active services: {}".format(len(missing_services)))
|
||||||
|
print("Distinct products: {}".format(len(products)))
|
||||||
|
|
||||||
|
# Show breakdown by category
|
||||||
|
cat_counts = {}
|
||||||
|
for s in missing_services:
|
||||||
|
cat = s["category"]
|
||||||
|
cat_name = categories.get(cat, "cat={}".format(cat))
|
||||||
|
if cat_name not in cat_counts:
|
||||||
|
cat_counts[cat_name] = 0
|
||||||
|
cat_counts[cat_name] += 1
|
||||||
|
for name, cnt in sorted(cat_counts.items(), key=lambda x: -x[1]):
|
||||||
|
print(" {}: {}".format(name, cnt))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# LOAD ERPNEXT DATA
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\nLoading ERPNext data...")
|
||||||
|
|
||||||
|
# Existing items
|
||||||
|
existing_items = set()
|
||||||
|
items = frappe.db.sql('SELECT name FROM "tabItem"', as_dict=True)
|
||||||
|
for i in items:
|
||||||
|
existing_items.add(i["name"])
|
||||||
|
print("Existing items: {}".format(len(existing_items)))
|
||||||
|
|
||||||
|
# Existing subscription plans
|
||||||
|
existing_plans = set()
|
||||||
|
plans = frappe.db.sql('SELECT plan_name FROM "tabSubscription Plan"', as_dict=True)
|
||||||
|
for p in plans:
|
||||||
|
existing_plans.add(p["plan_name"])
|
||||||
|
print("Existing plans: {}".format(len(existing_plans)))
|
||||||
|
|
||||||
|
# Item details: sku → {item_name, item_group}
|
||||||
|
item_details = {}
|
||||||
|
item_rows = frappe.db.sql('SELECT name, item_name, item_group FROM "tabItem"', as_dict=True)
|
||||||
|
for i in item_rows:
|
||||||
|
item_details[i["name"]] = {"item_name": i["item_name"], "item_group": i["item_group"]}
|
||||||
|
|
||||||
|
# Existing subscriptions by legacy_service_id
|
||||||
|
existing_subs = set()
|
||||||
|
subs = frappe.db.sql('SELECT legacy_service_id FROM "tabSubscription" WHERE legacy_service_id IS NOT NULL', as_dict=True)
|
||||||
|
for s in subs:
|
||||||
|
existing_subs.add(s["legacy_service_id"])
|
||||||
|
print("Existing subscriptions: {}".format(len(existing_subs)))
|
||||||
|
|
||||||
|
# Customer mapping: legacy_account_id → ERPNext customer name
|
||||||
|
cust_map = {}
|
||||||
|
custs = frappe.db.sql('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0', as_dict=True)
|
||||||
|
for c in custs:
|
||||||
|
cust_map[c["legacy_account_id"]] = c["name"]
|
||||||
|
print("Customer mapping: {}".format(len(cust_map)))
|
||||||
|
|
||||||
|
# Service location mapping: look up by customer + delivery address
|
||||||
|
# We'll find the service_location from existing subscriptions for the same delivery
|
||||||
|
loc_map = {} # (account_id, delivery_id) → service_location from existing subs
|
||||||
|
loc_subs = frappe.db.sql("""
|
||||||
|
SELECT s.legacy_service_id, s.service_location, s.party
|
||||||
|
FROM "tabSubscription" s
|
||||||
|
WHERE s.service_location IS NOT NULL AND s.legacy_service_id IS NOT NULL
|
||||||
|
""", as_dict=True)
|
||||||
|
# Build delivery → location map from legacy
|
||||||
|
with pymysql.connect(host="10.100.80.100", user="facturation", password="VD67owoj",
|
||||||
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor) as conn2:
|
||||||
|
with conn2.cursor() as cur2:
|
||||||
|
cur2.execute("""
|
||||||
|
SELECT s.id as service_id, s.delivery_id, d.account_id
|
||||||
|
FROM service s
|
||||||
|
JOIN delivery d ON d.id = s.delivery_id
|
||||||
|
WHERE s.status = 1
|
||||||
|
""")
|
||||||
|
svc_delivery = {r["service_id"]: (r["account_id"], r["delivery_id"]) for r in cur2.fetchall()}
|
||||||
|
|
||||||
|
# Map delivery_id → service_location from existing subscriptions
|
||||||
|
delivery_loc = {}
|
||||||
|
for ls in loc_subs:
|
||||||
|
sid = ls["legacy_service_id"]
|
||||||
|
if sid in svc_delivery:
|
||||||
|
acct, did = svc_delivery[sid]
|
||||||
|
if did not in delivery_loc and ls["service_location"]:
|
||||||
|
delivery_loc[did] = ls["service_location"]
|
||||||
|
print("Delivery→location mappings: {}".format(len(delivery_loc)))
|
||||||
|
|
||||||
|
# Category to item_group mapping
|
||||||
|
CATEGORY_GROUP = {
|
||||||
|
26: "Installation et équipement fibre",
|
||||||
|
34: "Installation et équipement télé",
|
||||||
|
8: "Installation et équipement fibre",
|
||||||
|
15: "Hébergement",
|
||||||
|
11: "Nom de domaine",
|
||||||
|
13: "Installation et équipement fibre",
|
||||||
|
1: "Installation et équipement fibre",
|
||||||
|
16: "Installation et équipement fibre",
|
||||||
|
28: "Installation et équipement fibre",
|
||||||
|
7: "Installation et équipement fibre",
|
||||||
|
23: "Installation et équipement fibre",
|
||||||
|
}
|
||||||
|
|
||||||
|
TAX_TEMPLATE = "Canada - Résidentiel - TC"
|
||||||
|
COMPANY = "Targo Communications"
|
||||||
|
|
||||||
|
def ts_to_date(ts):
|
||||||
|
if not ts or ts <= 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PREPARE: Items and Plans
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("ITEMS & PLANS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
items_to_create = []
|
||||||
|
plans_to_create = []
|
||||||
|
|
||||||
|
for p in products:
|
||||||
|
sku = p["sku"]
|
||||||
|
if sku not in existing_items:
|
||||||
|
items_to_create.append({
|
||||||
|
"sku": sku,
|
||||||
|
"price": float(p["price"]),
|
||||||
|
"category": p["category"],
|
||||||
|
"group": CATEGORY_GROUP.get(p["category"], "Installation et équipement fibre"),
|
||||||
|
})
|
||||||
|
|
||||||
|
plan_name = "PLAN-" + sku
|
||||||
|
if plan_name not in existing_plans:
|
||||||
|
plans_to_create.append({
|
||||||
|
"plan_name": plan_name,
|
||||||
|
"item": sku,
|
||||||
|
"cost": float(p["price"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Items to create: {}".format(len(items_to_create)))
|
||||||
|
for i in items_to_create:
|
||||||
|
print(" {} @ {:.2f} → {}".format(i["sku"], i["price"], i["group"]))
|
||||||
|
|
||||||
|
print("Plans to create: {}".format(len(plans_to_create)))
|
||||||
|
for p in plans_to_create:
|
||||||
|
print(" {} (item: {}, cost: {:.2f})".format(p["plan_name"], p["item"], p["cost"]))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PREPARE: Subscriptions
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("SUBSCRIPTIONS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
subs_to_create = []
|
||||||
|
skipped = {"no_customer": 0, "already_exists": 0, "no_location": 0}
|
||||||
|
|
||||||
|
for s in missing_services:
|
||||||
|
sid = s["id"]
|
||||||
|
if sid in existing_subs:
|
||||||
|
skipped["already_exists"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
acct_id = s["account_id"]
|
||||||
|
cust_name = cust_map.get(acct_id)
|
||||||
|
if not cust_name:
|
||||||
|
skipped["no_customer"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
sku = s["sku"]
|
||||||
|
plan_name = "PLAN-" + sku
|
||||||
|
delivery_id = s["delivery_id"]
|
||||||
|
service_location = delivery_loc.get(delivery_id)
|
||||||
|
|
||||||
|
if not service_location:
|
||||||
|
skipped["no_location"] += 1
|
||||||
|
# Still create — just without location
|
||||||
|
pass
|
||||||
|
|
||||||
|
price = float(s["hijack_price"]) if s["hijack"] else float(s["base_price"])
|
||||||
|
freq = "A" if s["payment_recurrence"] == 0 else "M"
|
||||||
|
|
||||||
|
start_date = ts_to_date(s["date_orig"]) or "2020-01-01"
|
||||||
|
|
||||||
|
idet = item_details.get(sku, {})
|
||||||
|
subs_to_create.append({
|
||||||
|
"legacy_id": sid,
|
||||||
|
"customer": cust_name,
|
||||||
|
"plan": plan_name,
|
||||||
|
"sku": sku,
|
||||||
|
"price": price,
|
||||||
|
"freq": freq,
|
||||||
|
"start_date": start_date,
|
||||||
|
"service_location": service_location,
|
||||||
|
"hijack_desc": s["hijack_desc"] or "",
|
||||||
|
"category": s["category"],
|
||||||
|
"item_name": idet.get("item_name", sku),
|
||||||
|
"item_group": idet.get("item_group", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Subscriptions to create: {}".format(len(subs_to_create)))
|
||||||
|
print("Skipped: {}".format(skipped))
|
||||||
|
|
||||||
|
# Show samples by category
|
||||||
|
for cat_name in sorted(cat_counts.keys(), key=lambda x: -cat_counts[x]):
|
||||||
|
samples = [s for s in subs_to_create if categories.get(s["category"]) == cat_name][:3]
|
||||||
|
if samples:
|
||||||
|
print("\n {} ({} total):".format(cat_name, sum(1 for s in subs_to_create if categories.get(s["category"]) == cat_name)))
|
||||||
|
for s in samples:
|
||||||
|
print(" svc#{} {} {} {:.2f} → {} at {}".format(
|
||||||
|
s["legacy_id"], s["sku"], s["freq"], s["price"],
|
||||||
|
s["customer"], s["service_location"] or "NO_LOC"))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# APPLY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
if DRY_RUN:
|
||||||
|
print("\n*** DRY RUN — no changes made ***")
|
||||||
|
print("Set DRY_RUN = False to create {} items, {} plans, {} subscriptions".format(
|
||||||
|
len(items_to_create), len(plans_to_create), len(subs_to_create)))
|
||||||
|
else:
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("APPLYING CHANGES")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Step 1: Create missing Items
|
||||||
|
for i in items_to_create:
|
||||||
|
try:
|
||||||
|
item_groups = frappe.db.sql('SELECT name FROM "tabItem Group" WHERE name = %s', (i["group"],))
|
||||||
|
if not item_groups:
|
||||||
|
i["group"] = "All Item Groups"
|
||||||
|
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Item",
|
||||||
|
"item_code": i["sku"],
|
||||||
|
"item_name": i["sku"],
|
||||||
|
"item_group": i["group"],
|
||||||
|
"stock_uom": "Nos",
|
||||||
|
"is_stock_item": 0,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
print(" Created item: {}".format(i["sku"]))
|
||||||
|
except Exception as e:
|
||||||
|
print(" ERR item {}: {}".format(i["sku"], str(e)[:100]))
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Step 2: Create missing Subscription Plans
|
||||||
|
for p in plans_to_create:
|
||||||
|
try:
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Subscription Plan",
|
||||||
|
"plan_name": p["plan_name"],
|
||||||
|
"item": p["item"],
|
||||||
|
"price_determination": "Fixed Rate",
|
||||||
|
"cost": p["cost"],
|
||||||
|
"currency": "CAD",
|
||||||
|
"billing_interval": "Month",
|
||||||
|
"billing_interval_count": 1,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
print(" Created plan: {}".format(p["plan_name"]))
|
||||||
|
except Exception as e:
|
||||||
|
print(" ERR plan {}: {}".format(p["plan_name"], str(e)[:100]))
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# Step 3: Create subscriptions
|
||||||
|
created = 0
|
||||||
|
errors = 0
|
||||||
|
for s in subs_to_create:
|
||||||
|
try:
|
||||||
|
sub = frappe.get_doc({
|
||||||
|
"doctype": "Subscription",
|
||||||
|
"party_type": "Customer",
|
||||||
|
"party": s["customer"],
|
||||||
|
"company": COMPANY,
|
||||||
|
"status": "Active",
|
||||||
|
"start_date": s["start_date"],
|
||||||
|
"generate_invoice_at": "Beginning of the current subscription period",
|
||||||
|
"days_until_due": 30,
|
||||||
|
"follow_calendar_months": 1,
|
||||||
|
"generate_new_invoices_past_due_date": 1,
|
||||||
|
"submit_invoice": 0,
|
||||||
|
"cancel_at_period_end": 0,
|
||||||
|
"legacy_service_id": s["legacy_id"],
|
||||||
|
"service_location": s["service_location"],
|
||||||
|
"actual_price": s["price"],
|
||||||
|
"custom_description": s["hijack_desc"] if s["hijack_desc"] else None,
|
||||||
|
"item_code": s["sku"],
|
||||||
|
"item_name": s["item_name"],
|
||||||
|
"item_group": s["item_group"],
|
||||||
|
"billing_frequency": s["freq"],
|
||||||
|
"plans": [{
|
||||||
|
"plan": s["plan"],
|
||||||
|
"qty": 1,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
sub.flags.ignore_validate = True
|
||||||
|
sub.flags.ignore_links = True
|
||||||
|
sub.insert(ignore_permissions=True)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if created % 500 == 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Progress: {}/{}".format(created, len(subs_to_create)))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 20:
|
||||||
|
print(" ERR svc#{}: {}".format(s["legacy_id"], str(e)[:150]))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("\nCreated: {} subscriptions".format(created))
|
||||||
|
print("Errors: {}".format(errors))
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("DONE")
|
||||||
|
print("=" * 70)
|
||||||
452
scripts/migration/import_services_and_enrich_customers.py
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
"""
|
||||||
|
Import active services as Service Subscriptions and enrich Customer records
|
||||||
|
with full account details (phone, email, stripe, PPA, notes).
|
||||||
|
|
||||||
|
Also adds custom fields to Service Subscription for RADIUS/legacy data.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_services_and_enrich_customers.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
T_TOTAL = time.time()
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Add custom fields to Service Subscription
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: ADD CUSTOM FIELDS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
custom_fields = [
|
||||||
|
("legacy_service_id", "Legacy Service ID", "Int", 30),
|
||||||
|
("radius_user", "RADIUS User", "Data", 31),
|
||||||
|
("radius_password", "RADIUS Password", "Data", 32),
|
||||||
|
("product_sku", "Product SKU", "Data", 33),
|
||||||
|
("device", "Device", "Link", 34), # options = Service Equipment
|
||||||
|
]
|
||||||
|
|
||||||
|
for fname, label, ftype, idx in custom_fields:
|
||||||
|
exists = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabDocField"
|
||||||
|
WHERE parent = 'Service Subscription' AND fieldname = %s
|
||||||
|
""", (fname,))
|
||||||
|
if not exists:
|
||||||
|
opts = "Service Equipment" if fname == "device" else None
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabDocField" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
parent, parentfield, parenttype,
|
||||||
|
fieldname, label, fieldtype, options, reqd, read_only, hidden
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, %(idx)s,
|
||||||
|
'Service Subscription', 'fields', 'DocType',
|
||||||
|
%(fname)s, %(label)s, %(ftype)s, %(opts)s, 0, 0, 0
|
||||||
|
)
|
||||||
|
""", {
|
||||||
|
"name": "ss-{}-{}".format(fname, int(time.time())),
|
||||||
|
"now": now_str, "idx": idx,
|
||||||
|
"fname": fname, "label": label, "ftype": ftype, "opts": opts,
|
||||||
|
})
|
||||||
|
# Add column to table
|
||||||
|
col_type = "bigint" if ftype == "Int" else "varchar(140)"
|
||||||
|
try:
|
||||||
|
frappe.db.sql('ALTER TABLE "tabService Subscription" ADD COLUMN {} {}'.format(fname, col_type))
|
||||||
|
except Exception as e:
|
||||||
|
if "already exists" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Added field: {}".format(fname))
|
||||||
|
else:
|
||||||
|
print(" Field exists: {}".format(fname))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Build lookup maps
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: BUILD LOOKUP MAPS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# delivery_id → Service Location name
|
||||||
|
loc_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_delivery_id FROM "tabService Location"
|
||||||
|
WHERE legacy_delivery_id IS NOT NULL AND legacy_delivery_id > 0
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
loc_map[r["legacy_delivery_id"]] = r["name"]
|
||||||
|
print("Location map: {} entries".format(len(loc_map)))
|
||||||
|
|
||||||
|
# account_id → Customer name
|
||||||
|
cust_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_account_id FROM "tabCustomer"
|
||||||
|
WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
cust_map[r["legacy_account_id"]] = r["name"]
|
||||||
|
print("Customer map: {} entries".format(len(cust_map)))
|
||||||
|
|
||||||
|
# device_id → Service Equipment name
|
||||||
|
dev_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_device_id FROM "tabService Equipment"
|
||||||
|
WHERE legacy_device_id IS NOT NULL AND legacy_device_id > 0
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
dev_map[r["legacy_device_id"]] = r["name"]
|
||||||
|
print("Device map: {} entries".format(len(dev_map)))
|
||||||
|
|
||||||
|
# delivery_id → account_id
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, account_id FROM delivery")
|
||||||
|
del_acct = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
del_acct[r["id"]] = r["account_id"]
|
||||||
|
print("Delivery→account map: {} entries".format(len(del_acct)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: Import services as Service Subscriptions
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: IMPORT SERVICE SUBSCRIPTIONS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Clear existing
|
||||||
|
existing_subs = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription"')[0][0]
|
||||||
|
if existing_subs > 0:
|
||||||
|
frappe.db.sql('DELETE FROM "tabService Subscription"')
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Deleted {} existing subscriptions".format(existing_subs))
|
||||||
|
|
||||||
|
# Product category → service_category mapping
|
||||||
|
# Legacy: Mensualités fibre, Installation fibre, Mensualités sans fil, Téléphonie,
|
||||||
|
# Mensualités télévision, Installation télé, Adresse IP Fixe, Hébergement, etc.
|
||||||
|
PROD_CAT_MAP = {
|
||||||
|
4: "Internet", # Mensualités sans fil
|
||||||
|
32: "Internet", # Mensualités fibre
|
||||||
|
8: "Internet", # Installation et équipement internet sans fil
|
||||||
|
26: "Internet", # Installation et équipement fibre
|
||||||
|
29: "Internet", # Equipement internet fibre
|
||||||
|
7: "Internet", # Equipement internet sans fil
|
||||||
|
23: "Internet", # Internet camping
|
||||||
|
17: "Internet", # Adresse IP Fixe
|
||||||
|
16: "Internet", # Téléchargement supplémentaire
|
||||||
|
21: "Internet", # Location point à point
|
||||||
|
33: "IPTV", # Mensualités télévision
|
||||||
|
34: "IPTV", # Installation et équipement télé
|
||||||
|
9: "VoIP", # Téléphonie
|
||||||
|
15: "Hébergement", # Hébergement
|
||||||
|
11: "Hébergement", # Nom de domaine
|
||||||
|
30: "Hébergement", # Location espace cloud
|
||||||
|
10: "Autre", # Site internet
|
||||||
|
13: "Autre", # Location d'espace
|
||||||
|
}
|
||||||
|
|
||||||
|
# payment_recurrence → billing_cycle
|
||||||
|
RECUR_MAP = {
|
||||||
|
1: "Mensuel",
|
||||||
|
2: "Mensuel",
|
||||||
|
3: "Trimestriel",
|
||||||
|
4: "Annuel",
|
||||||
|
}
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.id, s.delivery_id, s.device_id, s.product_id, s.status, s.comment,
|
||||||
|
s.payment_recurrence, s.hijack, s.hijack_price,
|
||||||
|
s.hijack_download_speed, s.hijack_upload_speed,
|
||||||
|
s.date_orig, s.date_suspended, s.date_next_invoice, s.date_end_contract,
|
||||||
|
s.forfait_internet, s.radius_user, s.radius_pwd,
|
||||||
|
p.sku, p.price, p.download_speed, p.upload_speed, p.category as prod_cat
|
||||||
|
FROM service s
|
||||||
|
LEFT JOIN product p ON s.product_id = p.id
|
||||||
|
WHERE s.status = 1
|
||||||
|
ORDER BY s.id
|
||||||
|
""")
|
||||||
|
services = cur.fetchall()
|
||||||
|
|
||||||
|
# Also get product names
|
||||||
|
cur.execute("""
|
||||||
|
SELECT pt.product_id, pt.name as prod_name
|
||||||
|
FROM product_translate pt
|
||||||
|
WHERE pt.language_id = 'francais'
|
||||||
|
""")
|
||||||
|
prod_names = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
prod_names[r["product_id"]] = r["prod_name"]
|
||||||
|
|
||||||
|
print("Active services to import: {}".format(len(services)))
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
no_location = 0
|
||||||
|
|
||||||
|
for svc in services:
|
||||||
|
# Resolve customer via delivery → account
|
||||||
|
customer = None
|
||||||
|
service_location = loc_map.get(svc["delivery_id"]) if svc["delivery_id"] else None
|
||||||
|
if not service_location:
|
||||||
|
no_location += 1
|
||||||
|
continue # Skip services without a service location (required field)
|
||||||
|
|
||||||
|
if svc["delivery_id"] and svc["delivery_id"] in del_acct:
|
||||||
|
customer = cust_map.get(del_acct[svc["delivery_id"]])
|
||||||
|
if not customer:
|
||||||
|
no_location += 1
|
||||||
|
continue # Skip services without a customer (required field)
|
||||||
|
|
||||||
|
# Service category
|
||||||
|
prod_cat = svc["prod_cat"] or 0
|
||||||
|
service_category = PROD_CAT_MAP.get(prod_cat, "Autre")
|
||||||
|
|
||||||
|
# Plan name
|
||||||
|
plan_name = prod_names.get(svc["product_id"], svc["sku"] or "Unknown")
|
||||||
|
|
||||||
|
# Speed (legacy stores in kbps, convert to Mbps)
|
||||||
|
speed_down = 0
|
||||||
|
speed_up = 0
|
||||||
|
if svc["hijack"] and svc["hijack_download_speed"]:
|
||||||
|
speed_down = int(svc["hijack_download_speed"]) // 1024
|
||||||
|
speed_up = int(svc["hijack_upload_speed"] or 0) // 1024
|
||||||
|
elif svc["download_speed"]:
|
||||||
|
speed_down = int(svc["download_speed"]) // 1024
|
||||||
|
speed_up = int(svc["upload_speed"] or 0) // 1024
|
||||||
|
|
||||||
|
# Price
|
||||||
|
price = float(svc["hijack_price"] or 0) if svc["hijack"] else float(svc["price"] or 0)
|
||||||
|
|
||||||
|
# Billing cycle
|
||||||
|
billing_cycle = RECUR_MAP.get(svc["payment_recurrence"], "Mensuel")
|
||||||
|
|
||||||
|
# Start date
|
||||||
|
start_date = None
|
||||||
|
if svc["date_orig"]:
|
||||||
|
try:
|
||||||
|
start_date = datetime.fromtimestamp(int(svc["date_orig"])).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
if not start_date:
|
||||||
|
start_date = "2020-01-01"
|
||||||
|
|
||||||
|
# End date (contract)
|
||||||
|
end_date = None
|
||||||
|
if svc["date_end_contract"]:
|
||||||
|
try:
|
||||||
|
end_date = datetime.fromtimestamp(int(svc["date_end_contract"])).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Device link
|
||||||
|
device = dev_map.get(svc["device_id"]) if svc["device_id"] else None
|
||||||
|
|
||||||
|
# Generate name
|
||||||
|
sub_name = "SUB-{}".format(svc["id"])
|
||||||
|
|
||||||
|
inserted += 1
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabService Subscription" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
customer, service_location, status, service_category,
|
||||||
|
plan_name, speed_down, speed_up,
|
||||||
|
monthly_price, billing_cycle,
|
||||||
|
start_date, end_date, notes,
|
||||||
|
legacy_service_id, radius_user, radius_password, product_sku, device
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
||||||
|
%(customer)s, %(service_location)s, 'Actif', %(service_category)s,
|
||||||
|
%(plan_name)s, %(speed_down)s, %(speed_up)s,
|
||||||
|
%(monthly_price)s, %(billing_cycle)s,
|
||||||
|
%(start_date)s, %(end_date)s, %(notes)s,
|
||||||
|
%(legacy_service_id)s, %(radius_user)s, %(radius_password)s, %(product_sku)s, %(device)s
|
||||||
|
)
|
||||||
|
""", {
|
||||||
|
"name": sub_name,
|
||||||
|
"now": now_str,
|
||||||
|
"customer": customer,
|
||||||
|
"service_location": service_location,
|
||||||
|
"service_category": service_category,
|
||||||
|
"plan_name": plan_name,
|
||||||
|
"speed_down": speed_down,
|
||||||
|
"speed_up": speed_up,
|
||||||
|
"monthly_price": price,
|
||||||
|
"billing_cycle": billing_cycle,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"notes": svc["comment"] if svc["comment"] else None,
|
||||||
|
"legacy_service_id": svc["id"],
|
||||||
|
"radius_user": svc["radius_user"],
|
||||||
|
"radius_password": svc["radius_pwd"],
|
||||||
|
"product_sku": svc["sku"],
|
||||||
|
"device": device,
|
||||||
|
})
|
||||||
|
|
||||||
|
if inserted % 5000 == 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Inserted {}...".format(inserted))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Inserted {} Service Subscriptions ({} skipped - no location/customer)".format(inserted, no_location))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 4: Enrich Customer records with account details
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 4: ENRICH CUSTOMER RECORDS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, customer_id, email, email_autre, tel_home, cell,
|
||||||
|
stripe_id, ppa, ppa_name, ppa_code, ppa_branch, ppa_account,
|
||||||
|
notes_client, language_id, commercial, vip, mauvais_payeur,
|
||||||
|
invoice_delivery, company, contact
|
||||||
|
FROM account
|
||||||
|
WHERE status = 1
|
||||||
|
""")
|
||||||
|
accounts = cur.fetchall()
|
||||||
|
|
||||||
|
print("Active accounts to enrich: {}".format(len(accounts)))
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for acct in accounts:
|
||||||
|
cust_name = cust_map.get(acct["id"])
|
||||||
|
if not cust_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build update fields
|
||||||
|
updates = {}
|
||||||
|
sets = []
|
||||||
|
|
||||||
|
# Email
|
||||||
|
if acct["email"] and acct["email"].strip():
|
||||||
|
updates["email_id"] = acct["email"].strip()
|
||||||
|
sets.append('email_id = %(email_id)s')
|
||||||
|
|
||||||
|
# Mobile
|
||||||
|
cell = (acct["cell"] or "").strip()
|
||||||
|
if not cell:
|
||||||
|
cell = (acct["tel_home"] or "").strip()
|
||||||
|
if cell:
|
||||||
|
updates["mobile_no"] = cell
|
||||||
|
sets.append('mobile_no = %(mobile_no)s')
|
||||||
|
|
||||||
|
# Stripe ID
|
||||||
|
if acct["stripe_id"] and acct["stripe_id"].strip():
|
||||||
|
updates["stripe_id"] = acct["stripe_id"].strip()
|
||||||
|
sets.append('stripe_id = %(stripe_id)s')
|
||||||
|
|
||||||
|
# PPA enabled
|
||||||
|
if acct["ppa"]:
|
||||||
|
updates["ppa_enabled"] = 1
|
||||||
|
sets.append('ppa_enabled = %(ppa_enabled)s')
|
||||||
|
|
||||||
|
# Language
|
||||||
|
lang = "fr" if acct["language_id"] == "francais" else "en"
|
||||||
|
updates["language"] = lang
|
||||||
|
sets.append('language = %(language)s')
|
||||||
|
|
||||||
|
# Customer details (notes + contact)
|
||||||
|
details_parts = []
|
||||||
|
if acct["notes_client"] and acct["notes_client"].strip():
|
||||||
|
details_parts.append(acct["notes_client"].strip())
|
||||||
|
if acct["contact"] and acct["contact"].strip():
|
||||||
|
details_parts.append("Contact: " + acct["contact"].strip())
|
||||||
|
if acct["vip"]:
|
||||||
|
details_parts.append("[VIP]")
|
||||||
|
if acct["mauvais_payeur"]:
|
||||||
|
details_parts.append("[MAUVAIS PAYEUR]")
|
||||||
|
if acct["commercial"]:
|
||||||
|
details_parts.append("[COMMERCIAL]")
|
||||||
|
if details_parts:
|
||||||
|
updates["customer_details"] = "\n".join(details_parts)
|
||||||
|
sets.append('customer_details = %(customer_details)s')
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
updates["cust_name"] = cust_name
|
||||||
|
frappe.db.sql(
|
||||||
|
'UPDATE "tabCustomer" SET {} WHERE name = %(cust_name)s'.format(", ".join(sets)),
|
||||||
|
updates
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated % 2000 == 0 and updated > 0:
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Updated {}...".format(updated))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} Customer records".format(updated))
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 5: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 5: VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
total_subs = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription"')[0][0]
|
||||||
|
by_cat = frappe.db.sql("""
|
||||||
|
SELECT service_category, COUNT(*) as cnt FROM "tabService Subscription"
|
||||||
|
GROUP BY service_category ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("Total Service Subscriptions: {}".format(total_subs))
|
||||||
|
for c in by_cat:
|
||||||
|
print(" {}: {}".format(c["service_category"], c["cnt"]))
|
||||||
|
|
||||||
|
with_device = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription" WHERE device IS NOT NULL')[0][0]
|
||||||
|
with_radius = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription" WHERE radius_user IS NOT NULL')[0][0]
|
||||||
|
print("\nWith device link: {}".format(with_device))
|
||||||
|
print("With RADIUS credentials: {}".format(with_radius))
|
||||||
|
|
||||||
|
# Customer enrichment
|
||||||
|
cust_with_email = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE email_id IS NOT NULL")[0][0]
|
||||||
|
cust_with_phone = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE mobile_no IS NOT NULL")[0][0]
|
||||||
|
cust_with_stripe = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE stripe_id IS NOT NULL")[0][0]
|
||||||
|
cust_with_ppa = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer" WHERE ppa_enabled = 1')[0][0]
|
||||||
|
print("\nCustomer enrichment:")
|
||||||
|
print(" With email: {}".format(cust_with_email))
|
||||||
|
print(" With phone: {}".format(cust_with_phone))
|
||||||
|
print(" With Stripe: {}".format(cust_with_stripe))
|
||||||
|
print(" With PPA: {}".format(cust_with_ppa))
|
||||||
|
|
||||||
|
# Sample subscriptions
|
||||||
|
samples = frappe.db.sql("""
|
||||||
|
SELECT name, customer, service_category, plan_name, speed_down, speed_up,
|
||||||
|
monthly_price, radius_user, device, legacy_service_id
|
||||||
|
FROM "tabService Subscription" LIMIT 10
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\nSample subscriptions:")
|
||||||
|
for s in samples:
|
||||||
|
print(" {} cat={} plan={} {}↓/{}↑ ${} radius={} dev={}".format(
|
||||||
|
s["name"], s["service_category"], (s["plan_name"] or "")[:30],
|
||||||
|
s["speed_down"], s["speed_up"], s["monthly_price"],
|
||||||
|
s["radius_user"] or "-", s["device"] or "-"))
|
||||||
|
|
||||||
|
elapsed = time.time() - T_TOTAL
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("DONE in {:.1f}s".format(elapsed))
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
145
scripts/migration/import_technicians.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""
|
||||||
|
Link Employee → Dispatch Technician and populate technicians from active staff.
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_technicians.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Add 'employee' Link field to Dispatch Technician
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: ADD EMPLOYEE LINK FIELD")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
existing = frappe.db.sql("""
|
||||||
|
SELECT fieldname FROM "tabDocField"
|
||||||
|
WHERE parent = 'Dispatch Technician' AND fieldname = 'employee'
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Get max idx
|
||||||
|
max_idx = frappe.db.sql("""
|
||||||
|
SELECT COALESCE(MAX(idx), 0) FROM "tabDocField"
|
||||||
|
WHERE parent = 'Dispatch Technician'
|
||||||
|
""")[0][0]
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabDocField" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
parent, parentfield, parenttype,
|
||||||
|
fieldname, label, fieldtype, options, reqd, read_only, hidden
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, %(idx)s,
|
||||||
|
'Dispatch Technician', 'fields', 'DocType',
|
||||||
|
'employee', 'Employee', 'Link', 'Employee', 0, 0, 0
|
||||||
|
)
|
||||||
|
""", {
|
||||||
|
"name": "dt-employee-link-{}".format(int(time.time())),
|
||||||
|
"now": now_str,
|
||||||
|
"idx": max_idx + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add the column to the actual table
|
||||||
|
try:
|
||||||
|
frappe.db.sql("""
|
||||||
|
ALTER TABLE "tabDispatch Technician" ADD COLUMN employee varchar(140)
|
||||||
|
""")
|
||||||
|
except Exception as e:
|
||||||
|
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
|
||||||
|
print("Column 'employee' already exists")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Added 'employee' Link field to Dispatch Technician")
|
||||||
|
else:
|
||||||
|
print("'employee' field already exists on Dispatch Technician")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Clear test technicians and populate from employees
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: POPULATE TECHNICIANS FROM EMPLOYEES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Delete existing test technicians
|
||||||
|
existing_techs = frappe.db.sql('SELECT COUNT(*) FROM "tabDispatch Technician"')[0][0]
|
||||||
|
if existing_techs > 0:
|
||||||
|
frappe.db.sql('DELETE FROM "tabDispatch Technician"')
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Deleted {} test technicians".format(existing_techs))
|
||||||
|
|
||||||
|
# Get active employees in tech/support/sysadmin roles (Operations + Customer Service departments)
|
||||||
|
# These are the staff who do field work or dispatch-related tasks
|
||||||
|
employees = frappe.db.sql("""
|
||||||
|
SELECT name, employee_name, employee_number, cell_number, company_email,
|
||||||
|
department, designation, status
|
||||||
|
FROM "tabEmployee"
|
||||||
|
WHERE status = 'Active'
|
||||||
|
AND department IN ('Operations - T', 'Customer Service - T', 'Management - T')
|
||||||
|
ORDER BY name
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
print("Active dispatch-eligible employees: {}".format(len(employees)))
|
||||||
|
|
||||||
|
counter = 0
|
||||||
|
for emp in employees:
|
||||||
|
counter += 1
|
||||||
|
tech_id = "TECH-{}".format(emp["employee_number"])
|
||||||
|
tech_name = tech_id # document name
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabDispatch Technician" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
technician_id, full_name, phone, email, status, employee
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
||||||
|
%(tech_id)s, %(full_name)s, %(phone)s, %(email)s, 'Disponible', %(employee)s
|
||||||
|
)
|
||||||
|
""", {
|
||||||
|
"name": tech_name,
|
||||||
|
"now": now_str,
|
||||||
|
"tech_id": tech_id,
|
||||||
|
"full_name": emp["employee_name"],
|
||||||
|
"phone": emp["cell_number"],
|
||||||
|
"email": emp["company_email"],
|
||||||
|
"employee": emp["name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Created {} Dispatch Technicians".format(counter))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
techs = frappe.db.sql("""
|
||||||
|
SELECT name, technician_id, full_name, phone, email, employee
|
||||||
|
FROM "tabDispatch Technician"
|
||||||
|
ORDER BY name
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
print("Total Dispatch Technicians: {}".format(len(techs)))
|
||||||
|
for t in techs:
|
||||||
|
print(" {} → {} phone={} email={} emp={}".format(
|
||||||
|
t["technician_id"], t["full_name"],
|
||||||
|
t["phone"] or "-", t["email"] or "-", t["employee"]))
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nCache cleared — Dispatch Technician doctype updated")
|
||||||
196
scripts/migration/import_ticket_msgs.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import legacy ticket_msg → ERPNext Comment on Issue.
|
||||||
|
|
||||||
|
Maps: ticket_msg.ticket_id → Issue (via legacy_ticket_id)
|
||||||
|
ticket_msg.staff_id → staff name for comment_by
|
||||||
|
|
||||||
|
Uses direct PostgreSQL INSERT for speed (784k+ messages).
|
||||||
|
Skips already-imported messages (checks by name pattern).
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_ticket_msgs.py
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
import psycopg2
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
|
||||||
|
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 600}
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
ADMIN = "Administrator"
|
||||||
|
BATCH_SIZE = 5000
|
||||||
|
|
||||||
|
def ts_to_dt(unix_ts):
|
||||||
|
if not unix_ts or unix_ts <= 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(int(unix_ts), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
log("=== Import ticket_msg → Comment on Issue ===")
|
||||||
|
|
||||||
|
# 1. Read legacy data
|
||||||
|
log("Reading legacy staff...")
|
||||||
|
mc = pymysql.connect(**LEGACY)
|
||||||
|
cur = mc.cursor(pymysql.cursors.DictCursor)
|
||||||
|
|
||||||
|
cur.execute("SELECT id, first_name, last_name, email FROM staff ORDER BY id")
|
||||||
|
staff_list = cur.fetchall()
|
||||||
|
staff_map = {}
|
||||||
|
for s in staff_list:
|
||||||
|
name = ((s.get("first_name") or "") + " " + (s.get("last_name") or "")).strip()
|
||||||
|
staff_map[s["id"]] = {"name": name or "Staff #" + str(s["id"]), "email": s.get("email", "")}
|
||||||
|
|
||||||
|
log(" {} staff loaded".format(len(staff_map)))
|
||||||
|
|
||||||
|
# 2. Connect ERPNext PG
|
||||||
|
log("Connecting to ERPNext PostgreSQL...")
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pgc = pg.cursor()
|
||||||
|
|
||||||
|
# Build issue lookup: legacy_ticket_id → issue name
|
||||||
|
pgc.execute('SELECT legacy_ticket_id, name FROM "tabIssue" WHERE legacy_ticket_id IS NOT NULL AND legacy_ticket_id > 0')
|
||||||
|
issue_map = {r[0]: r[1] for r in pgc.fetchall()}
|
||||||
|
log(" {} issues mapped".format(len(issue_map)))
|
||||||
|
|
||||||
|
# Check existing imported comments (by name pattern TMSG-)
|
||||||
|
pgc.execute("""SELECT name FROM "tabComment" WHERE name LIKE 'TMSG-%'""")
|
||||||
|
existing = set(r[0] for r in pgc.fetchall())
|
||||||
|
log(" {} existing TMSG comments (will skip)".format(len(existing)))
|
||||||
|
|
||||||
|
# 3. Read and import messages in batches
|
||||||
|
log("Reading ticket_msg from legacy (streaming)...")
|
||||||
|
cur_stream = mc.cursor(pymysql.cursors.SSDictCursor)
|
||||||
|
cur_stream.execute("""SELECT id, ticket_id, staff_id, msg, date_orig, public, important
|
||||||
|
FROM ticket_msg ORDER BY ticket_id, id""")
|
||||||
|
|
||||||
|
ok = skip_no_issue = skip_existing = skip_empty = err = 0
|
||||||
|
batch = []
|
||||||
|
total_read = 0
|
||||||
|
|
||||||
|
for row in cur_stream:
|
||||||
|
total_read += 1
|
||||||
|
tid = row["ticket_id"]
|
||||||
|
mid = row["id"]
|
||||||
|
msg_name = "TMSG-{}".format(mid)
|
||||||
|
|
||||||
|
# Skip if already imported
|
||||||
|
if msg_name in existing:
|
||||||
|
skip_existing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if no matching issue
|
||||||
|
issue_name = issue_map.get(tid)
|
||||||
|
if not issue_name:
|
||||||
|
skip_no_issue += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip empty messages
|
||||||
|
msg_text = row.get("msg") or ""
|
||||||
|
if not msg_text.strip():
|
||||||
|
skip_empty += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
staff = staff_map.get(row.get("staff_id"), {"name": "Système", "email": ""})
|
||||||
|
msg_date = ts_to_dt(row.get("date_orig")) or now
|
||||||
|
|
||||||
|
batch.append((
|
||||||
|
msg_name, # name
|
||||||
|
msg_date, # creation
|
||||||
|
msg_date, # modified
|
||||||
|
ADMIN, # modified_by
|
||||||
|
staff["email"] or ADMIN, # owner
|
||||||
|
0, # docstatus
|
||||||
|
0, # idx
|
||||||
|
"Comment", # comment_type
|
||||||
|
staff["email"], # comment_email
|
||||||
|
"", # subject
|
||||||
|
staff["name"], # comment_by
|
||||||
|
0, # published
|
||||||
|
1, # seen
|
||||||
|
"Issue", # reference_doctype
|
||||||
|
issue_name, # reference_name
|
||||||
|
ADMIN, # reference_owner
|
||||||
|
msg_text, # content
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(batch) >= BATCH_SIZE:
|
||||||
|
try:
|
||||||
|
_insert_batch(pgc, batch)
|
||||||
|
pg.commit()
|
||||||
|
ok += len(batch)
|
||||||
|
except Exception as e:
|
||||||
|
pg.rollback()
|
||||||
|
# Fallback: row by row
|
||||||
|
for b in batch:
|
||||||
|
try:
|
||||||
|
_insert_batch(pgc, [b])
|
||||||
|
pg.commit()
|
||||||
|
ok += 1
|
||||||
|
except Exception:
|
||||||
|
pg.rollback()
|
||||||
|
err += 1
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
if ok % 50000 == 0:
|
||||||
|
log(" read={} ok={} skip_issue={} skip_dup={} skip_empty={} err={}".format(
|
||||||
|
total_read, ok, skip_no_issue, skip_existing, skip_empty, err))
|
||||||
|
|
||||||
|
# Final batch
|
||||||
|
if batch:
|
||||||
|
try:
|
||||||
|
_insert_batch(pgc, batch)
|
||||||
|
pg.commit()
|
||||||
|
ok += len(batch)
|
||||||
|
except Exception:
|
||||||
|
pg.rollback()
|
||||||
|
for b in batch:
|
||||||
|
try:
|
||||||
|
_insert_batch(pgc, [b])
|
||||||
|
pg.commit()
|
||||||
|
ok += 1
|
||||||
|
except Exception:
|
||||||
|
pg.rollback()
|
||||||
|
err += 1
|
||||||
|
|
||||||
|
cur_stream.close()
|
||||||
|
mc.close()
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("Total read: {}".format(total_read))
|
||||||
|
log("Imported: {}".format(ok))
|
||||||
|
log("Skip (no issue): {}".format(skip_no_issue))
|
||||||
|
log("Skip (existing): {}".format(skip_existing))
|
||||||
|
log("Skip (empty): {}".format(skip_empty))
|
||||||
|
log("Errors: {}".format(err))
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_batch(pgc, rows):
|
||||||
|
"""Insert batch of Comment rows."""
|
||||||
|
args = ",".join(
|
||||||
|
pgc.mogrify("(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", r).decode()
|
||||||
|
for r in rows
|
||||||
|
)
|
||||||
|
pgc.execute("""
|
||||||
|
INSERT INTO "tabComment" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
comment_type, comment_email, subject, comment_by, published, seen,
|
||||||
|
reference_doctype, reference_name, reference_owner, content
|
||||||
|
) VALUES """ + args + """ ON CONFLICT (name) DO NOTHING""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1004
scripts/migration/migrate_all.py
Normal file
496
scripts/migration/migrate_locations.py
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate legacy delivery → Service Location, device → Service Equipment.
|
||||||
|
Then link existing Subscriptions and Issues to their Service Location.
|
||||||
|
|
||||||
|
Dependencies: migrate_all.py must have run first (Customers, Subscriptions, Issues exist).
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
nohup python3 /tmp/migrate_locations.py > /tmp/migrate_locations.log 2>&1 &
|
||||||
|
tail -f /tmp/migrate_locations.log
|
||||||
|
|
||||||
|
Phase 1: Add legacy_delivery_id custom field + column to Service Location
|
||||||
|
Phase 2: Import deliveries → Service Location
|
||||||
|
Phase 3: Import devices → Service Equipment
|
||||||
|
Phase 4: Link Subscriptions → Service Location (via legacy service.delivery_id)
|
||||||
|
Phase 5: Link Issues → Service Location (via legacy ticket.delivery_id)
|
||||||
|
"""
|
||||||
|
import pymysql
|
||||||
|
import psycopg2
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
|
||||||
|
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 600}
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
ADMIN = "Administrator"
|
||||||
|
|
||||||
|
# Legacy device category → ERPNext equipment_type
|
||||||
|
DEVICE_TYPE_MAP = {
|
||||||
|
"cpe": "ONT",
|
||||||
|
"ont": "ONT",
|
||||||
|
"onu": "ONT",
|
||||||
|
"modem": "Modem",
|
||||||
|
"routeur": "Routeur",
|
||||||
|
"router": "Routeur",
|
||||||
|
"switch": "Switch",
|
||||||
|
"ap": "AP WiFi",
|
||||||
|
"access point": "AP WiFi",
|
||||||
|
"decodeur": "Decodeur TV",
|
||||||
|
"stb": "Decodeur TV",
|
||||||
|
"telephone": "Telephone IP",
|
||||||
|
"ata": "Telephone IP",
|
||||||
|
"amplificateur": "Amplificateur",
|
||||||
|
}
|
||||||
|
|
||||||
|
def uid(prefix=""):
|
||||||
|
return prefix + uuid.uuid4().hex[:10]
|
||||||
|
|
||||||
|
def ts():
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
def clean(val):
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
return unescape(str(val)).strip()
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print("[{}] {}".format(datetime.now(timezone.utc).strftime("%H:%M:%S"), msg), flush=True)
|
||||||
|
|
||||||
|
def guess_device_type(category, name, model):
|
||||||
|
"""Map legacy device category/name to ERPNext equipment_type."""
|
||||||
|
cat = clean(category).lower()
|
||||||
|
nm = clean(name).lower()
|
||||||
|
mdl = clean(model).lower()
|
||||||
|
combined = "{} {} {}".format(cat, nm, mdl)
|
||||||
|
|
||||||
|
for key, val in DEVICE_TYPE_MAP.items():
|
||||||
|
if key in combined:
|
||||||
|
return val
|
||||||
|
|
||||||
|
# Fallback heuristics
|
||||||
|
if "fibre" in combined or "gpon" in combined:
|
||||||
|
return "ONT"
|
||||||
|
if "wifi" in combined or "wireless" in combined:
|
||||||
|
return "AP WiFi"
|
||||||
|
|
||||||
|
return "Autre"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log("=" * 60)
|
||||||
|
log("MIGRATE LOCATIONS + EQUIPMENT")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
mc = pymysql.connect(**LEGACY)
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pg.autocommit = False
|
||||||
|
pgc = pg.cursor()
|
||||||
|
now = ts()
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Phase 1: Ensure legacy_delivery_id column exists
|
||||||
|
# ============================
|
||||||
|
log("")
|
||||||
|
log("--- Phase 1: Ensure custom fields ---")
|
||||||
|
|
||||||
|
pgc.execute("""SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tabService Location' AND column_name = 'legacy_delivery_id'""")
|
||||||
|
if not pgc.fetchone():
|
||||||
|
pgc.execute('ALTER TABLE "tabService Location" ADD COLUMN legacy_delivery_id bigint')
|
||||||
|
# Also register as Custom Field so ERPNext knows about it
|
||||||
|
try:
|
||||||
|
pgc.execute("""
|
||||||
|
INSERT INTO "tabCustom Field" (name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
dt, label, fieldname, fieldtype, insert_after)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, 0, 0,
|
||||||
|
'Service Location', 'Legacy Delivery ID', 'legacy_delivery_id', 'Int', 'access_notes')
|
||||||
|
""", (uid("CF-"), now, now, ADMIN, ADMIN))
|
||||||
|
except:
|
||||||
|
pg.rollback()
|
||||||
|
pg.commit()
|
||||||
|
log(" Added legacy_delivery_id to Service Location")
|
||||||
|
else:
|
||||||
|
log(" legacy_delivery_id already exists")
|
||||||
|
|
||||||
|
# Ensure legacy_device_id on Service Equipment
|
||||||
|
pgc.execute("""SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tabService Equipment' AND column_name = 'legacy_device_id'""")
|
||||||
|
if not pgc.fetchone():
|
||||||
|
pgc.execute('ALTER TABLE "tabService Equipment" ADD COLUMN legacy_device_id bigint')
|
||||||
|
try:
|
||||||
|
pgc.execute("""
|
||||||
|
INSERT INTO "tabCustom Field" (name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
dt, label, fieldname, fieldtype, insert_after)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, 0, 0,
|
||||||
|
'Service Equipment', 'Legacy Device ID', 'legacy_device_id', 'Int', 'notes')
|
||||||
|
""", (uid("CF-"), now, now, ADMIN, ADMIN))
|
||||||
|
except:
|
||||||
|
pg.rollback()
|
||||||
|
pg.commit()
|
||||||
|
log(" Added legacy_device_id to Service Equipment")
|
||||||
|
else:
|
||||||
|
log(" legacy_device_id already exists")
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Phase 2: Import deliveries → Service Location
|
||||||
|
# ============================
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("Phase 2: Deliveries → Service Location")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
cur = mc.cursor(pymysql.cursors.DictCursor)
|
||||||
|
cur.execute("SELECT * FROM delivery ORDER BY id")
|
||||||
|
deliveries = cur.fetchall()
|
||||||
|
log(" {} deliveries loaded".format(len(deliveries)))
|
||||||
|
|
||||||
|
# Customer mapping
|
||||||
|
pgc.execute('SELECT legacy_account_id, name FROM "tabCustomer" WHERE legacy_account_id > 0')
|
||||||
|
cust_map = {r[0]: r[1] for r in pgc.fetchall()}
|
||||||
|
|
||||||
|
# Check existing
|
||||||
|
pgc.execute('SELECT legacy_delivery_id FROM "tabService Location" WHERE legacy_delivery_id > 0')
|
||||||
|
existing_loc = set(r[0] for r in pgc.fetchall())
|
||||||
|
log(" {} already imported".format(len(existing_loc)))
|
||||||
|
|
||||||
|
# delivery_id → Service Location name mapping (for phases 3-5)
|
||||||
|
del_map = {}
|
||||||
|
loc_ok = loc_skip = loc_err = 0
|
||||||
|
|
||||||
|
for i, d in enumerate(deliveries):
|
||||||
|
did = d["id"]
|
||||||
|
|
||||||
|
if did in existing_loc:
|
||||||
|
# Still need the mapping for later phases
|
||||||
|
loc_skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
cust_id = cust_map.get(d["account_id"])
|
||||||
|
if not cust_id:
|
||||||
|
loc_err += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
addr = clean(d.get("address1"))
|
||||||
|
city = clean(d.get("city"))
|
||||||
|
loc_name_display = clean(d.get("name")) or "{}, {}".format(addr, city) if addr else "Location-{}".format(did)
|
||||||
|
loc_id = uid("LOC-")
|
||||||
|
|
||||||
|
# Parse GPS
|
||||||
|
lat = 0
|
||||||
|
lon = 0
|
||||||
|
try:
|
||||||
|
if d.get("latitude"):
|
||||||
|
lat = float(d["latitude"])
|
||||||
|
if d.get("longitude"):
|
||||||
|
lon = float(d["longitude"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
pgc.execute("""
|
||||||
|
INSERT INTO "tabService Location" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
customer, location_name, status,
|
||||||
|
address_line, city, postal_code, province,
|
||||||
|
latitude, longitude,
|
||||||
|
contact_name, contact_phone,
|
||||||
|
legacy_delivery_id
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, 0, 0,
|
||||||
|
%s, %s, 'Active',
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s,
|
||||||
|
%s, %s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""", (loc_id, now, now, ADMIN, ADMIN,
|
||||||
|
cust_id, loc_name_display[:140],
|
||||||
|
addr or "N/A", city or "N/A",
|
||||||
|
clean(d.get("zip")) or None,
|
||||||
|
clean(d.get("state")) or "QC",
|
||||||
|
lat, lon,
|
||||||
|
clean(d.get("contact")) or None,
|
||||||
|
clean(d.get("tel_home")) or clean(d.get("cell")) or None,
|
||||||
|
did))
|
||||||
|
|
||||||
|
del_map[did] = loc_id
|
||||||
|
loc_ok += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
loc_err += 1
|
||||||
|
pg.rollback()
|
||||||
|
if loc_err <= 10:
|
||||||
|
log(" ERR del#{} -> {}".format(did, str(e)[:100]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if loc_ok % 1000 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" [{}/{}] ok={} skip={} err={}".format(i+1, len(deliveries), loc_ok, loc_skip, loc_err))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# Load mapping for skipped (already existing) locations
|
||||||
|
if loc_skip > 0:
|
||||||
|
pgc.execute('SELECT legacy_delivery_id, name FROM "tabService Location" WHERE legacy_delivery_id > 0')
|
||||||
|
for lid, lname in pgc.fetchall():
|
||||||
|
del_map[lid] = lname
|
||||||
|
|
||||||
|
log(" Service Locations: {} created | {} skipped | {} errors".format(loc_ok, loc_skip, loc_err))
|
||||||
|
log(" del_map has {} entries".format(len(del_map)))
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Phase 3: Import devices → Service Equipment
|
||||||
|
# ============================
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("Phase 3: Devices → Service Equipment")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
cur.execute("SELECT * FROM device ORDER BY id")
|
||||||
|
devices = cur.fetchall()
|
||||||
|
log(" {} devices loaded".format(len(devices)))
|
||||||
|
|
||||||
|
pgc.execute('SELECT legacy_device_id FROM "tabService Equipment" WHERE legacy_device_id > 0')
|
||||||
|
existing_dev = set(r[0] for r in pgc.fetchall())
|
||||||
|
|
||||||
|
# device_id → Equipment name mapping (for parent hierarchy)
|
||||||
|
dev_map = {}
|
||||||
|
dev_ok = dev_skip = dev_err = 0
|
||||||
|
|
||||||
|
for i, dv in enumerate(devices):
|
||||||
|
dvid = dv["id"]
|
||||||
|
|
||||||
|
if dvid in existing_dev:
|
||||||
|
dev_skip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
loc_id = del_map.get(dv.get("delivery_id"))
|
||||||
|
# Get customer from the location's customer, or from delivery → account
|
||||||
|
cust_id = None
|
||||||
|
if loc_id:
|
||||||
|
pgc.execute('SELECT customer FROM "tabService Location" WHERE name = %s', (loc_id,))
|
||||||
|
row = pgc.fetchone()
|
||||||
|
if row:
|
||||||
|
cust_id = row[0]
|
||||||
|
|
||||||
|
sn = (clean(dv.get("sn")) or "SN-{}".format(dvid))[:140]
|
||||||
|
mac = clean(dv.get("mac"))[:140] if dv.get("mac") else None
|
||||||
|
equip_type = guess_device_type(
|
||||||
|
dv.get("category"), dv.get("name"), dv.get("model"))
|
||||||
|
equip_id = uid("EQ-")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pgc.execute("""
|
||||||
|
INSERT INTO "tabService Equipment" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
equipment_type, brand, model, serial_number, mac_address,
|
||||||
|
customer, service_location, status, ownership,
|
||||||
|
ip_address, login_user, login_password,
|
||||||
|
legacy_device_id
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, 0, 0,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, 'Actif', 'Gigafibre',
|
||||||
|
%s, %s, %s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""", (equip_id, now, now, ADMIN, ADMIN,
|
||||||
|
equip_type,
|
||||||
|
clean(dv.get("manufacturier")) or None,
|
||||||
|
clean(dv.get("model")) or None,
|
||||||
|
sn[:140],
|
||||||
|
mac or None,
|
||||||
|
cust_id,
|
||||||
|
loc_id,
|
||||||
|
clean(dv.get("manage")) or None,
|
||||||
|
clean(dv.get("user")) or None,
|
||||||
|
clean(dv.get("pass")) or None,
|
||||||
|
dvid))
|
||||||
|
|
||||||
|
dev_map[dvid] = equip_id
|
||||||
|
dev_ok += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
pg.rollback()
|
||||||
|
# Retry with unique SN on duplicate key
|
||||||
|
if "unique constraint" in str(e).lower() and "serial_number" in str(e).lower():
|
||||||
|
sn = "{}-{}".format(sn[:130], dvid)
|
||||||
|
try:
|
||||||
|
pgc.execute("""
|
||||||
|
INSERT INTO "tabService Equipment" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
equipment_type, brand, model, serial_number, mac_address,
|
||||||
|
customer, service_location, status, ownership,
|
||||||
|
ip_address, login_user, login_password,
|
||||||
|
legacy_device_id
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, 0, 0,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, 'Actif', 'Gigafibre',
|
||||||
|
%s, %s, %s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""", (equip_id, now, now, ADMIN, ADMIN,
|
||||||
|
equip_type,
|
||||||
|
clean(dv.get("manufacturier")) or None,
|
||||||
|
clean(dv.get("model")) or None,
|
||||||
|
sn, mac,
|
||||||
|
cust_id, loc_id,
|
||||||
|
clean(dv.get("manage")) or None,
|
||||||
|
clean(dv.get("user")) or None,
|
||||||
|
clean(dv.get("pass")) or None,
|
||||||
|
dvid))
|
||||||
|
dev_map[dvid] = equip_id
|
||||||
|
dev_ok += 1
|
||||||
|
continue
|
||||||
|
except Exception as e2:
|
||||||
|
pg.rollback()
|
||||||
|
|
||||||
|
dev_err += 1
|
||||||
|
if dev_err <= 10:
|
||||||
|
log(" ERR dev#{} -> {}".format(dvid, str(e)[:100]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dev_ok % 1000 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" [{}/{}] ok={} skip={} err={}".format(i+1, len(devices), dev_ok, dev_skip, dev_err))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" Equipment: {} created | {} skipped | {} errors".format(dev_ok, dev_skip, dev_err))
|
||||||
|
|
||||||
|
# Phase 3b: Set parent equipment (device hierarchy)
|
||||||
|
log(" Setting device parent hierarchy...")
|
||||||
|
parent_set = 0
|
||||||
|
for dv in devices:
|
||||||
|
if dv.get("parent") and dv["parent"] > 0:
|
||||||
|
child_eq = dev_map.get(dv["id"])
|
||||||
|
parent_eq = dev_map.get(dv["parent"])
|
||||||
|
if child_eq and parent_eq:
|
||||||
|
# No native parent field on Service Equipment, store in notes for now
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabService Equipment"
|
||||||
|
SET notes = COALESCE(notes, '') || 'Parent: ' || %s || E'\n'
|
||||||
|
WHERE name = %s
|
||||||
|
""", (parent_eq, child_eq))
|
||||||
|
parent_set += 1
|
||||||
|
pg.commit()
|
||||||
|
log(" {} parent links set".format(parent_set))
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Phase 4: Link Subscriptions → Service Location
|
||||||
|
# ============================
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("Phase 4: Link Subscriptions → Service Location")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
# Get service → delivery mapping from legacy
|
||||||
|
cur.execute("SELECT id, delivery_id FROM service WHERE status = 1 AND delivery_id > 0")
|
||||||
|
svc_to_del = {r["id"]: r["delivery_id"] for r in cur.fetchall()}
|
||||||
|
log(" {} service→delivery mappings".format(len(svc_to_del)))
|
||||||
|
|
||||||
|
# Get subscriptions with legacy_service_id
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, legacy_service_id FROM "tabSubscription"
|
||||||
|
WHERE legacy_service_id > 0
|
||||||
|
AND (service_location IS NULL OR service_location = '')
|
||||||
|
""")
|
||||||
|
subs_to_link = pgc.fetchall()
|
||||||
|
log(" {} subscriptions to link".format(len(subs_to_link)))
|
||||||
|
|
||||||
|
sub_linked = sub_miss = 0
|
||||||
|
for sub_name, legacy_svc_id in subs_to_link:
|
||||||
|
del_id = svc_to_del.get(legacy_svc_id)
|
||||||
|
if not del_id:
|
||||||
|
sub_miss += 1
|
||||||
|
continue
|
||||||
|
loc_id = del_map.get(del_id)
|
||||||
|
if not loc_id:
|
||||||
|
sub_miss += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET service_location = %s, modified = NOW()
|
||||||
|
WHERE name = %s
|
||||||
|
""", (loc_id, sub_name))
|
||||||
|
sub_linked += 1
|
||||||
|
|
||||||
|
if sub_linked % 5000 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" {} linked...".format(sub_linked))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" Subscriptions linked: {} | missed: {}".format(sub_linked, sub_miss))
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Phase 5: Link Issues → Service Location
|
||||||
|
# ============================
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("Phase 5: Link Issues → Service Location")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
# Get ticket → delivery mapping from legacy
|
||||||
|
cur.execute("SELECT id, delivery_id FROM ticket WHERE delivery_id > 0")
|
||||||
|
tkt_to_del = {r["id"]: r["delivery_id"] for r in cur.fetchall()}
|
||||||
|
log(" {} ticket→delivery mappings".format(len(tkt_to_del)))
|
||||||
|
|
||||||
|
# Get issues with legacy_ticket_id that need linking
|
||||||
|
pgc.execute("""
|
||||||
|
SELECT name, legacy_ticket_id FROM "tabIssue"
|
||||||
|
WHERE legacy_ticket_id > 0
|
||||||
|
AND (service_location IS NULL OR service_location = '')
|
||||||
|
""")
|
||||||
|
issues_to_link = pgc.fetchall()
|
||||||
|
log(" {} issues to link".format(len(issues_to_link)))
|
||||||
|
|
||||||
|
iss_linked = iss_miss = 0
|
||||||
|
for issue_name, legacy_tkt_id in issues_to_link:
|
||||||
|
del_id = tkt_to_del.get(legacy_tkt_id)
|
||||||
|
if not del_id:
|
||||||
|
iss_miss += 1
|
||||||
|
continue
|
||||||
|
loc_id = del_map.get(del_id)
|
||||||
|
if not loc_id:
|
||||||
|
iss_miss += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabIssue"
|
||||||
|
SET service_location = %s, modified = NOW()
|
||||||
|
WHERE name = %s
|
||||||
|
""", (loc_id, issue_name))
|
||||||
|
iss_linked += 1
|
||||||
|
|
||||||
|
if iss_linked % 10000 == 0:
|
||||||
|
pg.commit()
|
||||||
|
log(" {} linked...".format(iss_linked))
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
log(" Issues linked: {} | missed: {}".format(iss_linked, iss_miss))
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Summary
|
||||||
|
# ============================
|
||||||
|
mc.close()
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("MIGRATION LOCATIONS + EQUIPMENT COMPLETE")
|
||||||
|
log("=" * 60)
|
||||||
|
log(" Service Locations: {} created".format(loc_ok))
|
||||||
|
log(" Service Equipment: {} created ({} parent links)".format(dev_ok, parent_set))
|
||||||
|
log(" Subscriptions → Location: {} linked".format(sub_linked))
|
||||||
|
log(" Issues → Location: {} linked".format(iss_linked))
|
||||||
|
log("=" * 60)
|
||||||
|
log("")
|
||||||
|
log("Next: bench --site erp.gigafibre.ca clear-cache")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -73,13 +73,15 @@ def main():
|
||||||
cur.execute("SELECT id, username, first_name, last_name, email FROM staff ORDER BY id")
|
cur.execute("SELECT id, username, first_name, last_name, email FROM staff ORDER BY id")
|
||||||
staff_list = cur.fetchall()
|
staff_list = cur.fetchall()
|
||||||
|
|
||||||
# ALL tickets (open, pending, closed)
|
# ALL tickets (open, pending, closed) — only needed columns (avoid wizard/wizard_fibre blobs)
|
||||||
cur.execute("""SELECT * FROM ticket ORDER BY id""")
|
cur.execute("""SELECT id, account_id, delivery_id, subject, status, priority,
|
||||||
|
dept_id, date_create, parent, open_by, assign_to, important
|
||||||
|
FROM ticket ORDER BY id""")
|
||||||
tickets = cur.fetchall()
|
tickets = cur.fetchall()
|
||||||
|
|
||||||
# Messages for open/pending tickets + last message for closed (for context)
|
# Messages for open/pending tickets + last message for closed (for context)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT m.* FROM ticket_msg m
|
SELECT m.id, m.ticket_id, m.staff_id, m.msg, m.date_orig FROM ticket_msg m
|
||||||
JOIN ticket t ON m.ticket_id = t.id
|
JOIN ticket t ON m.ticket_id = t.id
|
||||||
WHERE t.status IN ('open', 'pending')
|
WHERE t.status IN ('open', 'pending')
|
||||||
ORDER BY m.ticket_id, m.id
|
ORDER BY m.ticket_id, m.id
|
||||||
|
|
@ -186,6 +188,7 @@ def main():
|
||||||
priority = PRIORITY_MAP.get(t.get("priority", 2), "Medium")
|
priority = PRIORITY_MAP.get(t.get("priority", 2), "Medium")
|
||||||
dept_name = dept_map.get(t.get("dept_id"), None)
|
dept_name = dept_map.get(t.get("dept_id"), None)
|
||||||
cust_name = cust_map.get(t.get("account_id"))
|
cust_name = cust_map.get(t.get("account_id"))
|
||||||
|
is_important = 1 if t.get("important") else 0
|
||||||
|
|
||||||
opening_date = ts_to_dateonly(t.get("date_create"))
|
opening_date = ts_to_dateonly(t.get("date_create"))
|
||||||
opening_time = None
|
opening_time = None
|
||||||
|
|
@ -203,23 +206,25 @@ def main():
|
||||||
issue_name = uid("ISS-")
|
issue_name = uid("ISS-")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Savepoint so errors only rollback THIS ticket, not the whole batch
|
||||||
|
pgc.execute("SAVEPOINT sp_ticket")
|
||||||
pgc.execute("""
|
pgc.execute("""
|
||||||
INSERT INTO "tabIssue" (
|
INSERT INTO "tabIssue" (
|
||||||
name, creation, modified, modified_by, owner, docstatus, idx,
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
naming_series, subject, status, priority, issue_type,
|
naming_series, subject, status, priority, issue_type,
|
||||||
customer, company, opening_date, opening_time,
|
customer, company, opening_date, opening_time,
|
||||||
legacy_ticket_id, is_incident,
|
legacy_ticket_id, is_incident, is_important,
|
||||||
parent_incident, service_location
|
parent_incident, service_location
|
||||||
) VALUES (
|
) VALUES (
|
||||||
%s, %s, %s, %s, %s, 0, 0,
|
%s, %s, %s, %s, %s, 0, 0,
|
||||||
'ISS-.YYYY.-', %s, %s, %s, %s,
|
'ISS-.YYYY.-', %s, %s, %s, %s,
|
||||||
%s, %s, %s, %s,
|
%s, %s, %s, %s,
|
||||||
%s, %s, %s, %s
|
%s, %s, %s, %s, %s
|
||||||
)
|
)
|
||||||
""", (issue_name, ts, ts, ADMIN, ADMIN,
|
""", (issue_name, ts, ts, ADMIN, ADMIN,
|
||||||
subject[:255], status, priority, dept_name,
|
subject[:255], status, priority, dept_name,
|
||||||
cust_name, COMPANY, opening_date, opening_time,
|
cust_name, COMPANY, opening_date, opening_time,
|
||||||
tid, 0, None, None))
|
tid, 0, is_important, None, None))
|
||||||
|
|
||||||
ticket_to_issue[tid] = issue_name
|
ticket_to_issue[tid] = issue_name
|
||||||
i_ok += 1
|
i_ok += 1
|
||||||
|
|
@ -233,27 +238,32 @@ def main():
|
||||||
msg_date = ts_to_date(m.get("date_orig"))
|
msg_date = ts_to_date(m.get("date_orig"))
|
||||||
|
|
||||||
comm_name = uid("COM-")
|
comm_name = uid("COM-")
|
||||||
|
try:
|
||||||
pgc.execute("""
|
pgc.execute("""
|
||||||
INSERT INTO "tabCommunication" (
|
INSERT INTO "tabComment" (
|
||||||
name, creation, modified, modified_by, owner, docstatus, idx,
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
subject, content, communication_type, comment_type,
|
comment_type, comment_by, content,
|
||||||
reference_doctype, reference_name,
|
reference_doctype, reference_name
|
||||||
sender, communication_date, sent_or_received
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
%s, %s, %s, %s, %s, 0, 0,
|
%s, %s, %s, %s, %s, 0, 0,
|
||||||
%s, %s, 'Communication', 'Comment',
|
'Comment', %s, %s,
|
||||||
'Issue', %s,
|
'Issue', %s
|
||||||
%s, %s, 'Sent'
|
|
||||||
)
|
)
|
||||||
""", (comm_name, ts, ts, ADMIN, ADMIN,
|
""", (comm_name, msg_date or ts, msg_date or ts, ADMIN, ADMIN,
|
||||||
subject[:255], msg_text,
|
sender, msg_text,
|
||||||
issue_name,
|
issue_name))
|
||||||
sender, msg_date or ts))
|
|
||||||
comm_ok += 1
|
comm_ok += 1
|
||||||
|
except Exception:
|
||||||
|
pgc.execute("ROLLBACK TO SAVEPOINT sp_ticket")
|
||||||
|
pgc.execute("SAVEPOINT sp_ticket")
|
||||||
|
# Skip message but keep the Issue
|
||||||
|
|
||||||
|
pgc.execute("RELEASE SAVEPOINT sp_ticket")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
i_err += 1
|
i_err += 1
|
||||||
pg.rollback()
|
pgc.execute("ROLLBACK TO SAVEPOINT sp_ticket")
|
||||||
|
pgc.execute("RELEASE SAVEPOINT sp_ticket")
|
||||||
if i_err <= 20:
|
if i_err <= 20:
|
||||||
log(" ERR ticket#{} -> {}".format(tid, str(e)[:100]))
|
log(" ERR ticket#{} -> {}".format(tid, str(e)[:100]))
|
||||||
continue
|
continue
|
||||||
|
|
@ -295,10 +305,26 @@ def main():
|
||||||
""")
|
""")
|
||||||
|
|
||||||
pg.commit()
|
pg.commit()
|
||||||
pg.close()
|
|
||||||
|
|
||||||
log(" {} parent links set, {} incidents identified".format(parent_set, incident_set))
|
log(" {} parent links set, {} incidents identified".format(parent_set, incident_set))
|
||||||
|
|
||||||
|
# 7. Update is_important on ALL tickets (existing + new)
|
||||||
|
log("")
|
||||||
|
log("--- Updating is_important flag ---")
|
||||||
|
imp_map = {t["id"]: 1 for t in tickets if t.get("important")}
|
||||||
|
imp_updated = 0
|
||||||
|
if imp_map:
|
||||||
|
imp_ids = list(imp_map.keys())
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabIssue" SET is_important = 1
|
||||||
|
WHERE legacy_ticket_id = ANY(%s) AND (is_important IS NULL OR is_important = 0)
|
||||||
|
""", (imp_ids,))
|
||||||
|
imp_updated = pgc.rowcount
|
||||||
|
pg.commit()
|
||||||
|
log(" {} tickets marked as important".format(imp_updated))
|
||||||
|
|
||||||
|
pg.close()
|
||||||
|
|
||||||
log("")
|
log("")
|
||||||
log("=" * 60)
|
log("=" * 60)
|
||||||
log("Issue Types: {} created".format(types_created))
|
log("Issue Types: {} created".format(types_created))
|
||||||
|
|
|
||||||
126
scripts/migration/nuke_data.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Nuke all migrated data EXCEPT Users + Items + Item Groups + Subscription Plans.
|
||||||
|
Deletes: Customers, Contacts, Addresses, Dynamic Links, Sales Invoices,
|
||||||
|
Payment Entries, Subscriptions, Issues, Comments, Communications,
|
||||||
|
Journal Entries, and their child tables.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
python3 /tmp/nuke_data.py
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print("[{}] {}".format(datetime.now(timezone.utc).strftime("%H:%M:%S"), msg), flush=True)
|
||||||
|
|
||||||
|
def nuke(pgc, table, where=""):
|
||||||
|
sql = 'DELETE FROM "{}"'.format(table)
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + where
|
||||||
|
pgc.execute(sql)
|
||||||
|
count = pgc.rowcount
|
||||||
|
log(" {} — {} rows deleted".format(table, count))
|
||||||
|
return count
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log("=== NUKE migrated data (keep Users + Items) ===")
|
||||||
|
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pgc = pg.cursor()
|
||||||
|
|
||||||
|
# Order matters: child tables first, then parents
|
||||||
|
|
||||||
|
# 1. Journal Entry child + parent
|
||||||
|
log("")
|
||||||
|
log("--- Journal Entries ---")
|
||||||
|
nuke(pgc, "tabJournal Entry Account")
|
||||||
|
nuke(pgc, "tabJournal Entry")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 2. Payment Entry child + parent
|
||||||
|
log("")
|
||||||
|
log("--- Payment Entries ---")
|
||||||
|
nuke(pgc, "tabPayment Entry Reference")
|
||||||
|
nuke(pgc, "tabPayment Entry")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 3. Sales Invoice child + parent
|
||||||
|
log("")
|
||||||
|
log("--- Sales Invoices ---")
|
||||||
|
nuke(pgc, "tabSales Invoice Item")
|
||||||
|
nuke(pgc, "tabSales Invoice Payment")
|
||||||
|
nuke(pgc, "tabSales Invoice Timesheet")
|
||||||
|
nuke(pgc, "tabSales Taxes and Charges", "parent LIKE 'SINV-%' OR parenttype = 'Sales Invoice'")
|
||||||
|
nuke(pgc, "tabSales Invoice")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 4. Subscriptions child + parent
|
||||||
|
log("")
|
||||||
|
log("--- Subscriptions ---")
|
||||||
|
nuke(pgc, "tabSubscription Plan Detail")
|
||||||
|
nuke(pgc, "tabSubscription Invoice")
|
||||||
|
nuke(pgc, "tabSubscription")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 5. Issues + Communications
|
||||||
|
log("")
|
||||||
|
log("--- Issues + Communications ---")
|
||||||
|
nuke(pgc, "tabCommunication")
|
||||||
|
nuke(pgc, "tabCommunication Link")
|
||||||
|
nuke(pgc, "tabIssue")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 6. Comments (memos imported as comments on Customer)
|
||||||
|
log("")
|
||||||
|
log("--- Comments ---")
|
||||||
|
nuke(pgc, "tabComment", "comment_type = 'Comment' AND reference_doctype = 'Customer'")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 7. Contacts + child tables
|
||||||
|
log("")
|
||||||
|
log("--- Contacts ---")
|
||||||
|
nuke(pgc, "tabContact Phone")
|
||||||
|
nuke(pgc, "tabContact Email")
|
||||||
|
nuke(pgc, "tabContact")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 8. Addresses
|
||||||
|
log("")
|
||||||
|
log("--- Addresses ---")
|
||||||
|
nuke(pgc, "tabAddress")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 9. Dynamic Links (Contact→Customer, Address→Customer links)
|
||||||
|
log("")
|
||||||
|
log("--- Dynamic Links ---")
|
||||||
|
nuke(pgc, "tabDynamic Link", "link_doctype = 'Customer'")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 10. Customers
|
||||||
|
log("")
|
||||||
|
log("--- Customers ---")
|
||||||
|
nuke(pgc, "tabCustomer")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# 11. Issue Types + Issue Priorities (will be recreated)
|
||||||
|
log("")
|
||||||
|
log("--- Issue Types + Priorities ---")
|
||||||
|
nuke(pgc, "tabIssue Type")
|
||||||
|
nuke(pgc, "tabIssue Priority")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
log("")
|
||||||
|
log("=" * 60)
|
||||||
|
log("NUKE COMPLETE")
|
||||||
|
log("Kept: Users, Items, Item Groups, Subscription Plans, Accounts")
|
||||||
|
log("Next: run migrate_all.py")
|
||||||
|
log("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
272
scripts/migration/rename_to_readable_ids.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""
|
||||||
|
Rename hex-based document IDs to human-readable names:
|
||||||
|
- Customer: CUST-{hex} → CUST-{legacy_account_id}
|
||||||
|
- Service Location: LOC-{hex} → "{address_line}, {city}" or LOC-{legacy_delivery_id}
|
||||||
|
- Service Equipment: EQ-{hex} → EQ-{legacy_device_id}
|
||||||
|
|
||||||
|
Uses direct SQL for speed (frappe.rename_doc is too slow for 15k+ records).
|
||||||
|
Updates all foreign key references.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/rename_to_readable_ids.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
T_TOTAL = time.time()
|
||||||
|
|
||||||
|
def batch_rename(table, old_to_new, ref_tables, label):
|
||||||
|
"""Rename documents and update all foreign key references."""
|
||||||
|
if not old_to_new:
|
||||||
|
print(" Nothing to rename for {}".format(label))
|
||||||
|
return
|
||||||
|
|
||||||
|
print(" Renaming {} {} records...".format(len(old_to_new), label))
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
# Build temp mapping table for efficient bulk UPDATE
|
||||||
|
# Process in batches to avoid memory issues
|
||||||
|
batch_size = 2000
|
||||||
|
items = list(old_to_new.items())
|
||||||
|
|
||||||
|
for batch_start in range(0, len(items), batch_size):
|
||||||
|
batch = items[batch_start:batch_start + batch_size]
|
||||||
|
|
||||||
|
# Update main table name
|
||||||
|
for old_name, new_name in batch:
|
||||||
|
frappe.db.sql(
|
||||||
|
'UPDATE "{}" SET name = %s WHERE name = %s'.format(table),
|
||||||
|
(new_name, old_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update all foreign key references
|
||||||
|
for ref_table, ref_col in ref_tables:
|
||||||
|
for old_name, new_name in batch:
|
||||||
|
frappe.db.sql(
|
||||||
|
'UPDATE "{}" SET {} = %s WHERE {} = %s'.format(ref_table, ref_col, ref_col),
|
||||||
|
(new_name, old_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
done = min(batch_start + batch_size, len(items))
|
||||||
|
print(" {}/{}...".format(done, len(items)))
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
print(" Done {} in {:.1f}s".format(label, elapsed))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: RENAME CUSTOMERS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: RENAME CUSTOMERS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
customers = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_account_id FROM "tabCustomer"
|
||||||
|
WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0
|
||||||
|
ORDER BY legacy_account_id
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
cust_rename = {}
|
||||||
|
seen_cust = set()
|
||||||
|
for c in customers:
|
||||||
|
new_name = "CUST-{}".format(c["legacy_account_id"])
|
||||||
|
if new_name == c["name"]:
|
||||||
|
continue # already correct
|
||||||
|
if new_name in seen_cust:
|
||||||
|
new_name = "CUST-{}-b".format(c["legacy_account_id"])
|
||||||
|
seen_cust.add(new_name)
|
||||||
|
cust_rename[c["name"]] = new_name
|
||||||
|
|
||||||
|
print("Customers to rename: {} (of {})".format(len(cust_rename), len(customers)))
|
||||||
|
|
||||||
|
# All tables with a 'customer' Link field pointing to Customer
|
||||||
|
CUST_REFS = [
|
||||||
|
("tabService Location", "customer"),
|
||||||
|
("tabService Subscription", "customer"),
|
||||||
|
("tabService Equipment", "customer"),
|
||||||
|
("tabDispatch Job", "customer"),
|
||||||
|
("tabIssue", "customer"),
|
||||||
|
("tabSales Invoice", "customer"),
|
||||||
|
("tabSales Order", "customer"),
|
||||||
|
("tabDelivery Note", "customer"),
|
||||||
|
("tabSerial No", "customer"),
|
||||||
|
("tabProject", "customer"),
|
||||||
|
("tabWarranty Claim", "customer"),
|
||||||
|
("tabMaintenance Visit", "customer"),
|
||||||
|
("tabMaintenance Schedule", "customer"),
|
||||||
|
("tabLoyalty Point Entry", "customer"),
|
||||||
|
("tabPOS Invoice", "customer"),
|
||||||
|
("tabPOS Invoice Reference", "customer"),
|
||||||
|
("tabMaterial Request", "customer"),
|
||||||
|
("tabTimesheet", "customer"),
|
||||||
|
("tabBlanket Order", "customer"),
|
||||||
|
("tabDunning", "customer"),
|
||||||
|
("tabInstallation Note", "customer"),
|
||||||
|
("tabDelivery Stop", "customer"),
|
||||||
|
("tabPricing Rule", "customer"),
|
||||||
|
("tabTax Rule", "customer"),
|
||||||
|
("tabCall Log", "customer"),
|
||||||
|
("tabProcess Statement Of Accounts Customer", "customer"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Also update customer_name references in child tables where parent=customer name
|
||||||
|
CUST_PARENT_REFS = [
|
||||||
|
("tabHas Role", "parent"),
|
||||||
|
("tabDynamic Link", "link_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
all_cust_refs = CUST_REFS + CUST_PARENT_REFS
|
||||||
|
|
||||||
|
batch_rename("tabCustomer", cust_rename, all_cust_refs, "Customer")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: RENAME SERVICE LOCATIONS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: RENAME SERVICE LOCATIONS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
locations = frappe.db.sql("""
|
||||||
|
SELECT name, address_line, city, legacy_delivery_id
|
||||||
|
FROM "tabService Location"
|
||||||
|
WHERE legacy_delivery_id IS NOT NULL AND legacy_delivery_id > 0
|
||||||
|
ORDER BY legacy_delivery_id
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
loc_rename = {}
|
||||||
|
seen_loc = set()
|
||||||
|
for loc in locations:
|
||||||
|
addr = (loc["address_line"] or "").strip()
|
||||||
|
city = (loc["city"] or "").strip()
|
||||||
|
|
||||||
|
if addr and city:
|
||||||
|
# Clean up address for use as document name
|
||||||
|
new_name = "{}, {}".format(addr, city)
|
||||||
|
# Frappe name max is 140 chars, keep it reasonable
|
||||||
|
if len(new_name) > 120:
|
||||||
|
new_name = new_name[:120]
|
||||||
|
# Remove chars that cause issues in URLs
|
||||||
|
new_name = new_name.replace("/", "-").replace("\\", "-")
|
||||||
|
elif addr:
|
||||||
|
new_name = addr[:120]
|
||||||
|
else:
|
||||||
|
new_name = "LOC-{}".format(loc["legacy_delivery_id"])
|
||||||
|
|
||||||
|
# Handle duplicates (same address, different delivery)
|
||||||
|
if new_name in seen_loc:
|
||||||
|
new_name = "{} [{}]".format(new_name, loc["legacy_delivery_id"])
|
||||||
|
seen_loc.add(new_name)
|
||||||
|
|
||||||
|
if new_name != loc["name"]:
|
||||||
|
loc_rename[loc["name"]] = new_name
|
||||||
|
|
||||||
|
print("Locations to rename: {} (of {})".format(len(loc_rename), len(locations)))
|
||||||
|
|
||||||
|
LOC_REFS = [
|
||||||
|
("tabService Subscription", "service_location"),
|
||||||
|
("tabService Equipment", "service_location"),
|
||||||
|
("tabDispatch Job", "service_location"),
|
||||||
|
("tabIssue", "service_location"),
|
||||||
|
]
|
||||||
|
|
||||||
|
batch_rename("tabService Location", loc_rename, LOC_REFS, "Service Location")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: RENAME SERVICE EQUIPMENT
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: RENAME SERVICE EQUIPMENT")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
equipment = frappe.db.sql("""
|
||||||
|
SELECT name, legacy_device_id FROM "tabService Equipment"
|
||||||
|
WHERE legacy_device_id IS NOT NULL AND legacy_device_id > 0
|
||||||
|
ORDER BY legacy_device_id
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
eq_rename = {}
|
||||||
|
seen_eq = set()
|
||||||
|
for eq in equipment:
|
||||||
|
new_name = "EQ-{}".format(eq["legacy_device_id"])
|
||||||
|
if new_name == eq["name"]:
|
||||||
|
continue
|
||||||
|
if new_name in seen_eq:
|
||||||
|
new_name = "EQ-{}-b".format(eq["legacy_device_id"])
|
||||||
|
seen_eq.add(new_name)
|
||||||
|
eq_rename[eq["name"]] = new_name
|
||||||
|
|
||||||
|
print("Equipment to rename: {} (of {})".format(len(eq_rename), len(equipment)))
|
||||||
|
|
||||||
|
EQ_REFS = [
|
||||||
|
("tabService Subscription", "device"),
|
||||||
|
]
|
||||||
|
|
||||||
|
batch_rename("tabService Equipment", eq_rename, EQ_REFS, "Service Equipment")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 4: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 4: VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Sample customers
|
||||||
|
print("\nSample Customers:")
|
||||||
|
sample_c = frappe.db.sql("""
|
||||||
|
SELECT name, customer_name FROM "tabCustomer"
|
||||||
|
WHERE disabled = 0 ORDER BY name LIMIT 10
|
||||||
|
""", as_dict=True)
|
||||||
|
for c in sample_c:
|
||||||
|
print(" {} → {}".format(c["name"], c["customer_name"]))
|
||||||
|
|
||||||
|
# Sample locations
|
||||||
|
print("\nSample Service Locations:")
|
||||||
|
sample_l = frappe.db.sql("""
|
||||||
|
SELECT name, customer, city FROM "tabService Location"
|
||||||
|
WHERE status = 'Active' ORDER BY name LIMIT 10
|
||||||
|
""", as_dict=True)
|
||||||
|
for l in sample_l:
|
||||||
|
print(" {} → customer={}".format(l["name"], l["customer"]))
|
||||||
|
|
||||||
|
# Sample equipment
|
||||||
|
print("\nSample Service Equipment:")
|
||||||
|
sample_e = frappe.db.sql("""
|
||||||
|
SELECT name, equipment_type, serial_number, customer FROM "tabService Equipment"
|
||||||
|
ORDER BY name LIMIT 10
|
||||||
|
""", as_dict=True)
|
||||||
|
for e in sample_e:
|
||||||
|
print(" {} → {} sn={} customer={}".format(
|
||||||
|
e["name"], e["equipment_type"], e["serial_number"], e["customer"]))
|
||||||
|
|
||||||
|
# Check for orphaned references
|
||||||
|
print("\nOrphan check:")
|
||||||
|
orphan_sub = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) FROM "tabService Subscription" ss
|
||||||
|
WHERE ss.customer IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "tabCustomer" c WHERE c.name = ss.customer)
|
||||||
|
""")[0][0]
|
||||||
|
print(" Subscriptions with invalid customer ref: {}".format(orphan_sub))
|
||||||
|
|
||||||
|
orphan_eq = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) FROM "tabService Equipment" eq
|
||||||
|
WHERE eq.service_location IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "tabService Location" sl WHERE sl.name = eq.service_location)
|
||||||
|
""")[0][0]
|
||||||
|
print(" Equipment with invalid location ref: {}".format(orphan_eq))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
elapsed = time.time() - T_TOTAL
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("DONE in {:.1f}s — cache cleared".format(elapsed))
|
||||||
|
print("="*60)
|
||||||
357
scripts/migration/setup_invoice_print_format.py
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
"""
|
||||||
|
Create custom Print Format for Sales Invoice — Gigafibre/TARGO style.
|
||||||
|
Inspired by Cogeco layout: summary page 1, details page 2, envelope window address.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/setup_invoice_print_format.py
|
||||||
|
"""
|
||||||
|
import os, sys
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
import frappe
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
# ── Update Print Settings to Letter size ──
|
||||||
|
from frappe.installer import update_site_config
|
||||||
|
frappe.db.set_single_value("Print Settings", "pdf_page_size", "Letter")
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" PDF page size set to Letter")
|
||||||
|
|
||||||
|
# ── Register the logo file if not exists ──
|
||||||
|
if not frappe.db.exists("File", {"file_url": "/files/targo-logo-green.svg"}):
|
||||||
|
f = frappe.get_doc({
|
||||||
|
"doctype": "File",
|
||||||
|
"file_name": "targo-logo-green.svg",
|
||||||
|
"file_url": "/files/targo-logo-green.svg",
|
||||||
|
"is_private": 0,
|
||||||
|
})
|
||||||
|
f.insert(ignore_permissions=True)
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Registered logo file")
|
||||||
|
|
||||||
|
PRINT_FORMAT_NAME = "Facture TARGO"
|
||||||
|
|
||||||
|
html_template = r"""
|
||||||
|
{%- set company_name = "TARGO Communications" -%}
|
||||||
|
{%- set company_addr = "123 rue Principale" -%}
|
||||||
|
{%- set company_city = "Victoriaville QC G6P 1A1" -%}
|
||||||
|
{%- set company_tel = "(819) 758-1555" -%}
|
||||||
|
{%- set company_web = "gigafibre.ca" -%}
|
||||||
|
{%- set tps_no = "TPS: #819304698RT0001" -%}
|
||||||
|
{%- set tvq_no = "TVQ: #1215640113TQ0001" -%}
|
||||||
|
{%- set brand_green = "#019547" -%}
|
||||||
|
{%- set brand_light = "#e8f5ee" -%}
|
||||||
|
|
||||||
|
{%- set is_credit = doc.is_return == 1 -%}
|
||||||
|
|
||||||
|
{%- set mois_fr = {"January":"janvier","February":"février","March":"mars","April":"avril","May":"mai","June":"juin","July":"juillet","August":"août","September":"septembre","October":"octobre","November":"novembre","December":"décembre"} -%}
|
||||||
|
{%- macro date_fr(d) -%}
|
||||||
|
{%- if d -%}
|
||||||
|
{%- set dt = frappe.utils.getdate(d) -%}
|
||||||
|
{{ dt.day }} {{ mois_fr.get(dt.strftime("%B"), dt.strftime("%B")) }} {{ dt.year }}
|
||||||
|
{%- else -%}—{%- endif -%}
|
||||||
|
{%- endmacro -%}
|
||||||
|
{%- macro date_short(d) -%}
|
||||||
|
{%- if d -%}
|
||||||
|
{%- set dt = frappe.utils.getdate(d) -%}
|
||||||
|
{{ "%02d/%02d/%04d" | format(dt.day, dt.month, dt.year) }}
|
||||||
|
{%- else -%}—{%- endif -%}
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{# Decode HTML entities in item names #}
|
||||||
|
{%- macro clean(s) -%}{{ s | replace("'", "'") | replace("&", "&") | replace("<", "<") | replace(">", ">") | replace(""", '"') if s else "" }}{%- endmacro -%}
|
||||||
|
|
||||||
|
{# Get customer address from Service Location or address_display #}
|
||||||
|
{%- set cust_addr = doc.address_display or "" -%}
|
||||||
|
{%- if not cust_addr and doc.customer_address -%}
|
||||||
|
{%- set addr_doc = frappe.get_doc("Address", doc.customer_address) -%}
|
||||||
|
{%- set cust_addr = (addr_doc.address_line1 or "") + "\n" + (addr_doc.city or "") + " " + (addr_doc.state or "") + " " + (addr_doc.pincode or "") -%}
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@page { size: Letter; margin: 12mm 15mm 10mm 15mm; }
|
||||||
|
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 9pt; color: #333; line-height: 1.4; }
|
||||||
|
.inv { width: 100%; }
|
||||||
|
.hdr-table { width: 100%; margin-bottom: 6px; }
|
||||||
|
.hdr-table td { vertical-align: top; padding: 0; }
|
||||||
|
.logo img { height: 36px; }
|
||||||
|
.doc-title { font-size: 16pt; font-weight: 700; color: {{ brand_green }}; text-align: right; }
|
||||||
|
.info-table { width: 100%; margin-bottom: 10px; }
|
||||||
|
.info-table td { vertical-align: top; padding: 2px 0; }
|
||||||
|
.info-table .lbl { color: #888; font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||||
|
.info-table .val { font-weight: 600; }
|
||||||
|
.info-left { width: 50%; }
|
||||||
|
.info-right { width: 50%; text-align: right; }
|
||||||
|
.tax-nums { font-size: 7.5pt; color: #888; margin: 4px 0 8px; }
|
||||||
|
/* Total box — table-based for wkhtmltopdf */
|
||||||
|
.total-wrap { width: 100%; margin: 10px 0; }
|
||||||
|
.total-wrap td { padding: 10px 16px; color: white; vertical-align: middle; }
|
||||||
|
.total-bg { background: {{ brand_green }}; }
|
||||||
|
.total-bg.credit { background: #dc3545; }
|
||||||
|
.total-label { font-size: 11pt; }
|
||||||
|
.total-amount { font-size: 18pt; font-weight: 700; text-align: right; }
|
||||||
|
/* Summary */
|
||||||
|
.summary { border: 1.5px solid {{ brand_green }}; padding: 10px 14px; margin: 10px 0; }
|
||||||
|
.summary-title { font-size: 10pt; font-weight: 700; color: {{ brand_green }}; border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-bottom: 6px; }
|
||||||
|
.s-row { width: 100%; }
|
||||||
|
.s-row td { padding: 2px 0; font-size: 9pt; }
|
||||||
|
.s-row .r { text-align: right; }
|
||||||
|
.s-row .indent td { padding-left: 14px; color: #555; }
|
||||||
|
.s-row .subtot td { border-top: 1px solid #ddd; padding-top: 4px; font-weight: 600; }
|
||||||
|
/* Contact */
|
||||||
|
.contact-table { width: 100%; margin: 10px 0; font-size: 8pt; color: #666; }
|
||||||
|
.contact-table td { vertical-align: top; padding: 2px 0; }
|
||||||
|
/* QR */
|
||||||
|
.qr-wrap { background: {{ brand_light }}; padding: 8px 12px; margin: 8px 0; font-size: 8pt; }
|
||||||
|
.qr-wrap table td { vertical-align: middle; padding: 2px 8px; }
|
||||||
|
.qr-box { width: 55px; height: 55px; border: 1px solid #ccc; text-align: center; font-size: 7pt; color: #999; }
|
||||||
|
/* Coupon */
|
||||||
|
.coupon-line { border-top: 2px dashed #ccc; margin: 14px 0 4px; font-size: 7pt; color: #999; text-align: center; }
|
||||||
|
.coupon-table { width: 100%; }
|
||||||
|
.coupon-table td { text-align: center; font-size: 8pt; vertical-align: middle; padding: 4px; }
|
||||||
|
.coupon-table .c-lbl { font-size: 6.5pt; color: #888; text-transform: uppercase; }
|
||||||
|
.coupon-table .c-val { font-weight: 600; }
|
||||||
|
.coupon-table .c-logo img { height: 22px; }
|
||||||
|
/* Envelope address */
|
||||||
|
.env-addr { margin-top: 16px; padding-top: 6px; font-size: 10pt; line-height: 1.5; text-transform: uppercase; }
|
||||||
|
.footer-line { font-size: 7pt; color: #999; text-align: center; margin-top: 6px; }
|
||||||
|
/* Return notice */
|
||||||
|
.return-box { background: #fff3cd; border: 1px solid #ffc107; padding: 6px 12px; margin: 6px 0; font-size: 9pt; }
|
||||||
|
/* Page 2 — details */
|
||||||
|
.detail-title { font-size: 10pt; font-weight: 700; color: {{ brand_green }}; padding: 6px 0; border-bottom: 2px solid {{ brand_green }}; margin-bottom: 4px; }
|
||||||
|
.dtl { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
|
||||||
|
.dtl th { text-align: left; padding: 4px 6px; background: {{ brand_light }}; font-weight: 600; color: #555; font-size: 7.5pt; text-transform: uppercase; }
|
||||||
|
.dtl th.r, .dtl td.r { text-align: right; }
|
||||||
|
.dtl td { padding: 3px 6px; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.dtl tr.tax td { color: #888; font-size: 8pt; border-bottom: none; padding: 1px 6px; }
|
||||||
|
.dtl tr.stot td { font-weight: 600; border-top: 1px solid #ddd; border-bottom: none; padding-top: 4px; }
|
||||||
|
.page-break { page-break-before: always; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="inv">
|
||||||
|
<!-- ═══════ PAGE 1: SOMMAIRE ═══════ -->
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<table class="hdr-table"><tr>
|
||||||
|
<td class="logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
|
||||||
|
<td class="doc-title">{% if is_credit %}NOTE DE CRÉDIT{% else %}FACTURE{% endif %}</td>
|
||||||
|
</tr></table>
|
||||||
|
|
||||||
|
<!-- INFO CLIENT + FACTURE -->
|
||||||
|
<table class="info-table"><tr>
|
||||||
|
<td class="info-left">
|
||||||
|
<div class="lbl">Services fournis à</div>
|
||||||
|
<div class="val">{{ doc.customer_name or doc.customer }}</div>
|
||||||
|
{% if cust_addr %}
|
||||||
|
<div style="margin-top:2px">{{ cust_addr | striptags | replace("\n","<br>") }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="info-right">
|
||||||
|
<table style="float:right; text-align:right;">
|
||||||
|
<tr><td class="lbl">Nº de compte</td></tr>
|
||||||
|
<tr><td class="val">{{ doc.customer }}</td></tr>
|
||||||
|
<tr><td class="lbl" style="padding-top:6px">Nº de facture</td></tr>
|
||||||
|
<tr><td class="val">{{ doc.name }}</td></tr>
|
||||||
|
<tr><td class="lbl" style="padding-top:6px">Date de facturation</td></tr>
|
||||||
|
<tr><td class="val">{{ date_fr(doc.posting_date) }}</td></tr>
|
||||||
|
{% if doc.due_date %}
|
||||||
|
<tr><td class="lbl" style="padding-top:6px">Date d'échéance</td></tr>
|
||||||
|
<tr><td class="val">{{ date_fr(doc.due_date) }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr></table>
|
||||||
|
|
||||||
|
<!-- TAX NUMBERS -->
|
||||||
|
<div class="tax-nums">{{ tps_no }} | {{ tvq_no }}</div>
|
||||||
|
|
||||||
|
<!-- CREDIT NOTE -->
|
||||||
|
{% if is_credit %}
|
||||||
|
<div class="return-box">
|
||||||
|
<strong>Note de crédit</strong>
|
||||||
|
{% if doc.return_against %} — Renversement de la facture <strong>{{ doc.return_against }}</strong>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- MONTANT TOTAL -->
|
||||||
|
<table class="total-wrap"><tr class="total-bg {% if is_credit %}credit{% endif %}">
|
||||||
|
<td class="total-label">{% if is_credit %}MONTANT CRÉDITÉ{% else %}MONTANT TOTAL DÛ{% endif %}</td>
|
||||||
|
<td class="total-amount">{{ frappe.utils.fmt_money(doc.grand_total | abs, currency=doc.currency) }}</td>
|
||||||
|
</tr></table>
|
||||||
|
|
||||||
|
<!-- SOMMAIRE DU COMPTE -->
|
||||||
|
<div class="summary">
|
||||||
|
<div class="summary-title">SOMMAIRE DU COMPTE</div>
|
||||||
|
<table class="s-row">
|
||||||
|
{% if doc.outstanding_amount != doc.grand_total %}
|
||||||
|
<tr>
|
||||||
|
<td>Solde antérieur</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money((doc.outstanding_amount or 0) - (doc.grand_total or 0), currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr><td colspan="2" style="padding-top:6px"><strong>Frais du mois courant</strong></td></tr>
|
||||||
|
|
||||||
|
{%- set service_groups = {} -%}
|
||||||
|
{%- for item in doc.items -%}
|
||||||
|
{%- set group = item.item_group or "Services" -%}
|
||||||
|
{%- if group not in service_groups -%}
|
||||||
|
{%- set _ = service_groups.update({group: 0}) -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- set _ = service_groups.update({group: service_groups[group] + (item.amount or 0)}) -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{% for group, amount in service_groups.items() %}
|
||||||
|
<tr class="indent">
|
||||||
|
<td>{{ group }}</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(amount, currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<tr class="indent">
|
||||||
|
<td>Sous-total avant taxes</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(doc.net_total, currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% for tax in doc.taxes %}
|
||||||
|
<tr class="indent">
|
||||||
|
<td>{{ "TPS" if "TPS" in (tax.description or tax.account_head or "") else "TVQ" }} ({{ tax.rate }}%)</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(tax.tax_amount, currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="subtot">
|
||||||
|
<td>Total du mois courant</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTACT -->
|
||||||
|
<table class="contact-table"><tr>
|
||||||
|
<td>
|
||||||
|
<strong style="color:#333">Contactez-nous</strong><br>
|
||||||
|
{{ company_tel }}<br>
|
||||||
|
{{ company_web }}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
<strong style="color:#333">Service à la clientèle</strong><br>
|
||||||
|
Lun-Ven 8h-17h<br>
|
||||||
|
info@gigafibre.ca
|
||||||
|
</td>
|
||||||
|
</tr></table>
|
||||||
|
|
||||||
|
<!-- QR CODE -->
|
||||||
|
<div class="qr-wrap">
|
||||||
|
<table><tr>
|
||||||
|
<td><div class="qr-box"><br>QR</div></td>
|
||||||
|
<td><strong>Payez en ligne</strong><br>Scannez le code QR ou visitez<br><strong style="color:{{ brand_green }}">{{ company_web }}/payer</strong></td>
|
||||||
|
</tr></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COUPON DÉTACHABLE -->
|
||||||
|
<div class="coupon-line">✂ Prière d'expédier cette partie avec votre paiement</div>
|
||||||
|
<table class="coupon-table"><tr>
|
||||||
|
<td class="c-logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
|
||||||
|
<td><div class="c-lbl">Montant versé</div><div class="c-val">________</div></td>
|
||||||
|
<td><div class="c-lbl">Nº de compte</div><div class="c-val">{{ doc.customer }}</div></td>
|
||||||
|
<td><div class="c-lbl">Date d'échéance</div><div class="c-val">{{ date_short(doc.due_date) }}</div></td>
|
||||||
|
<td><div class="c-lbl">Montant à payer</div><div class="c-val">{{ frappe.utils.fmt_money(doc.grand_total | abs, currency=doc.currency) }}</div></td>
|
||||||
|
</tr></table>
|
||||||
|
|
||||||
|
<!-- ADRESSE FENÊTRE ENVELOPPE -->
|
||||||
|
<div class="env-addr">
|
||||||
|
<strong>{{ doc.customer_name or doc.customer }}</strong><br>
|
||||||
|
{% if cust_addr %}
|
||||||
|
{{ cust_addr | striptags | replace("\n","<br>") }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-line">{{ company_name }} • {{ company_addr }}, {{ company_city }} • {{ company_tel }}</div>
|
||||||
|
|
||||||
|
<!-- ═══════ PAGE 2: DÉTAILS ═══════ -->
|
||||||
|
<div class="page-break"></div>
|
||||||
|
|
||||||
|
<table class="hdr-table"><tr>
|
||||||
|
<td class="logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
|
||||||
|
<td style="text-align:right; font-size:8pt; color:#888">
|
||||||
|
Nº de facture: <strong>{{ doc.name }}</strong><br>
|
||||||
|
Nº de compte: <strong>{{ doc.customer }}</strong><br>
|
||||||
|
Date: {{ date_fr(doc.posting_date) }}
|
||||||
|
</td>
|
||||||
|
</tr></table>
|
||||||
|
|
||||||
|
<div class="detail-title">DÉTAILS DE LA FACTURE</div>
|
||||||
|
|
||||||
|
<table class="dtl">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:50%">Description</th>
|
||||||
|
<th class="r">Qté</th>
|
||||||
|
<th class="r">Prix unit.</th>
|
||||||
|
<th class="r">Montant</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in doc.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ clean(item.item_name or item.item_code) }}</td>
|
||||||
|
<td class="r">{{ item.qty | int if item.qty == (item.qty | int) else item.qty }}</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(item.rate, currency=doc.currency) }}</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(item.amount, currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="stot">
|
||||||
|
<td colspan="3">Sous-total avant taxes</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(doc.net_total, currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% for tax in doc.taxes %}
|
||||||
|
<tr class="tax">
|
||||||
|
<td colspan="3">{{ "TPS" if "TPS" in (tax.description or tax.account_head or "") else "TVQ" }} ({{ tax.rate }}%)</td>
|
||||||
|
<td class="r">{{ frappe.utils.fmt_money(tax.tax_amount, currency=doc.currency) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="stot">
|
||||||
|
<td colspan="3"><strong>TOTAL</strong></td>
|
||||||
|
<td class="r"><strong>{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- FOOTER PAGE 2 -->
|
||||||
|
<div style="margin-top:30px; text-align:center;">
|
||||||
|
<img src="/files/targo-logo-green.svg" alt="TARGO" style="height:24px; opacity:0.25">
|
||||||
|
<div class="footer-line">{{ company_name }} • {{ company_addr }}, {{ company_city }} • {{ company_tel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create or update the Print Format
|
||||||
|
if frappe.db.exists("Print Format", PRINT_FORMAT_NAME):
|
||||||
|
doc = frappe.get_doc("Print Format", PRINT_FORMAT_NAME)
|
||||||
|
doc.html = html_template
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
print(f" Updated Print Format: {PRINT_FORMAT_NAME}")
|
||||||
|
else:
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Print Format",
|
||||||
|
"name": PRINT_FORMAT_NAME,
|
||||||
|
"__newname": PRINT_FORMAT_NAME,
|
||||||
|
"doc_type": "Sales Invoice",
|
||||||
|
"module": "Accounts",
|
||||||
|
"print_format_type": "Jinja",
|
||||||
|
"standard": "No",
|
||||||
|
"custom_format": 1,
|
||||||
|
"html": html_template,
|
||||||
|
"default_print_language": "fr",
|
||||||
|
"disabled": 0,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
print(f" Created Print Format: {PRINT_FORMAT_NAME}")
|
||||||
|
|
||||||
|
# Set as default for Sales Invoice
|
||||||
|
frappe.db.set_value("Property Setter", None, "value", PRINT_FORMAT_NAME, {
|
||||||
|
"doctype_or_field": "DocType",
|
||||||
|
"doc_type": "Sales Invoice",
|
||||||
|
"property": "default_print_format",
|
||||||
|
})
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print(f" Done! Print Format '{PRINT_FORMAT_NAME}' ready.")
|
||||||
|
print(" Preview: ERPNext → Sales Invoice → Print → Select 'Facture TARGO'")
|
||||||
99
scripts/migration/setup_portal_auth_bridge.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""
|
||||||
|
Install the portal auth bridge as a Server Script in ERPNext.
|
||||||
|
This creates a whitelisted API endpoint: /api/method/portal_login
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. User POSTs email + password
|
||||||
|
2. If user has legacy_password_md5:
|
||||||
|
→ md5(password) matches? → update to pbkdf2, clear legacy hash, create session
|
||||||
|
→ no match? → error
|
||||||
|
3. If no legacy hash: standard frappe auth
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/setup_portal_auth_bridge.py
|
||||||
|
"""
|
||||||
|
import os, sys
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
import frappe
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
SCRIPT_NAME = "Portal Login Bridge"
|
||||||
|
|
||||||
|
script_code = '''
|
||||||
|
import hashlib
|
||||||
|
import frappe
|
||||||
|
from frappe.utils.password import update_password, check_password
|
||||||
|
|
||||||
|
email = frappe.form_dict.get("email", "").strip().lower()
|
||||||
|
password = frappe.form_dict.get("password", "")
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
frappe.throw("Email et mot de passe requis", frappe.AuthenticationError)
|
||||||
|
|
||||||
|
if not frappe.db.exists("User", email):
|
||||||
|
frappe.throw("Identifiants invalides", frappe.AuthenticationError)
|
||||||
|
|
||||||
|
user = frappe.get_doc("User", email)
|
||||||
|
|
||||||
|
if not user.enabled:
|
||||||
|
frappe.throw("Compte désactivé", frappe.AuthenticationError)
|
||||||
|
|
||||||
|
legacy_hash = (user.get("legacy_password_md5") or "").strip()
|
||||||
|
authenticated = False
|
||||||
|
|
||||||
|
if legacy_hash:
|
||||||
|
input_md5 = hashlib.md5(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
if input_md5 == legacy_hash:
|
||||||
|
update_password(email, password, logout_all_sessions=False)
|
||||||
|
frappe.db.set_value("User", email, "legacy_password_md5", "", update_modified=False)
|
||||||
|
frappe.db.commit()
|
||||||
|
frappe.logger().info(f"Portal auth bridge: migrated password for {email}")
|
||||||
|
authenticated = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
check_password(email, password)
|
||||||
|
frappe.db.set_value("User", email, "legacy_password_md5", "", update_modified=False)
|
||||||
|
frappe.db.commit()
|
||||||
|
authenticated = True
|
||||||
|
except frappe.AuthenticationError:
|
||||||
|
frappe.throw("Mot de passe incorrect", frappe.AuthenticationError)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
check_password(email, password)
|
||||||
|
authenticated = True
|
||||||
|
except frappe.AuthenticationError:
|
||||||
|
frappe.throw("Mot de passe incorrect", frappe.AuthenticationError)
|
||||||
|
|
||||||
|
if authenticated:
|
||||||
|
frappe.local.login_manager.login_as(email)
|
||||||
|
frappe.response["message"] = "OK"
|
||||||
|
frappe.response["user"] = email
|
||||||
|
frappe.response["full_name"] = user.full_name
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Create or update the Server Script
|
||||||
|
if frappe.db.exists("Server Script", SCRIPT_NAME):
|
||||||
|
doc = frappe.get_doc("Server Script", SCRIPT_NAME)
|
||||||
|
doc.script = script_code
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
print(f" Updated Server Script: {SCRIPT_NAME}")
|
||||||
|
else:
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Server Script",
|
||||||
|
"name": SCRIPT_NAME,
|
||||||
|
"__newname": SCRIPT_NAME,
|
||||||
|
"script_type": "API",
|
||||||
|
"api_method": "portal_login",
|
||||||
|
"allow_guest": 1,
|
||||||
|
"script": script_code,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
print(f" Created Server Script: {SCRIPT_NAME}")
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Done! Endpoint available at: POST /api/method/portal_login")
|
||||||
|
print(" Params: email, password")
|
||||||
|
print(" Returns: { message: 'OK', user: '...', full_name: '...' }")
|
||||||
155
scripts/migration/setup_scheduler_toggle.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""
|
||||||
|
Create a Server Script API to toggle the scheduler via HTTP.
|
||||||
|
Also set up a cron job to auto-enable on April 1st.
|
||||||
|
|
||||||
|
Usage after setup:
|
||||||
|
GET /api/method/scheduler_status → {"status": "enabled"/"disabled"}
|
||||||
|
POST /api/method/toggle_scheduler → toggles and returns new status
|
||||||
|
POST /api/method/set_scheduler?enable=1 → explicitly enable
|
||||||
|
POST /api/method/set_scheduler?enable=0 → explicitly disable
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/setup_scheduler_toggle.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Create Server Scripts for scheduler control
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
scripts = [
|
||||||
|
{
|
||||||
|
"name": "scheduler_status",
|
||||||
|
"script_type": "API",
|
||||||
|
"api_method": "scheduler_status",
|
||||||
|
"allow_guest": 0,
|
||||||
|
"script": """
|
||||||
|
import json
|
||||||
|
site_config_path = frappe.get_site_path("site_config.json")
|
||||||
|
with open(site_config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
paused = config.get("pause_scheduler", 0)
|
||||||
|
frappe.response["message"] = {
|
||||||
|
"status": "disabled" if paused else "enabled",
|
||||||
|
"pause_scheduler": paused
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "toggle_scheduler",
|
||||||
|
"script_type": "API",
|
||||||
|
"api_method": "toggle_scheduler",
|
||||||
|
"allow_guest": 0,
|
||||||
|
"script": """
|
||||||
|
import json
|
||||||
|
site_config_path = frappe.get_site_path("site_config.json")
|
||||||
|
with open(site_config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
currently_paused = config.get("pause_scheduler", 0)
|
||||||
|
if currently_paused:
|
||||||
|
config.pop("pause_scheduler", None)
|
||||||
|
new_status = "enabled"
|
||||||
|
else:
|
||||||
|
config["pause_scheduler"] = 1
|
||||||
|
new_status = "disabled"
|
||||||
|
|
||||||
|
with open(site_config_path, "w") as f:
|
||||||
|
json.dump(config, f, indent=1)
|
||||||
|
|
||||||
|
frappe.response["message"] = {
|
||||||
|
"status": new_status,
|
||||||
|
"action": "Scheduler {}".format(new_status)
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_scheduler",
|
||||||
|
"script_type": "API",
|
||||||
|
"api_method": "set_scheduler",
|
||||||
|
"allow_guest": 0,
|
||||||
|
"script": """
|
||||||
|
import json
|
||||||
|
enable = int(frappe.form_dict.get("enable", 0))
|
||||||
|
site_config_path = frappe.get_site_path("site_config.json")
|
||||||
|
with open(site_config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
if enable:
|
||||||
|
config.pop("pause_scheduler", None)
|
||||||
|
new_status = "enabled"
|
||||||
|
else:
|
||||||
|
config["pause_scheduler"] = 1
|
||||||
|
new_status = "disabled"
|
||||||
|
|
||||||
|
with open(site_config_path, "w") as f:
|
||||||
|
json.dump(config, f, indent=1)
|
||||||
|
|
||||||
|
frappe.response["message"] = {
|
||||||
|
"status": new_status,
|
||||||
|
"action": "Scheduler {}".format(new_status)
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for s in scripts:
|
||||||
|
# Delete if exists
|
||||||
|
if frappe.db.exists("Server Script", s["name"]):
|
||||||
|
frappe.db.sql('DELETE FROM "tabServer Script" WHERE name = %s', (s["name"],))
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Deleted existing script:", s["name"])
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabServer Script" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
script_type, api_method, allow_guest, disabled, script
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
||||||
|
%(script_type)s, %(api_method)s, %(allow_guest)s, 0, %(script)s
|
||||||
|
)
|
||||||
|
""", {
|
||||||
|
"name": s["name"],
|
||||||
|
"now": now_str,
|
||||||
|
"script_type": s["script_type"],
|
||||||
|
"api_method": s["api_method"],
|
||||||
|
"allow_guest": s["allow_guest"],
|
||||||
|
"script": s["script"].strip(),
|
||||||
|
})
|
||||||
|
print("Created Server Script:", s["name"])
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Verify current scheduler status
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
import json
|
||||||
|
site_config_path = frappe.get_site_path("site_config.json")
|
||||||
|
with open(site_config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
paused = config.get("pause_scheduler", 0)
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Current scheduler status: {}".format("DISABLED" if paused else "ENABLED"))
|
||||||
|
print("="*60)
|
||||||
|
print("\nAPI endpoints created:")
|
||||||
|
print(" GET /api/method/scheduler_status")
|
||||||
|
print(" POST /api/method/toggle_scheduler")
|
||||||
|
print(" POST /api/method/set_scheduler?enable=1|0")
|
||||||
|
print("\nTo enable on April 1st, call:")
|
||||||
|
print(" curl -X POST https://erp.gigafibre.ca/api/method/set_scheduler?enable=1")
|
||||||
|
print(" Or via AI: 'enable scheduler' → POST to toggle endpoint")
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nDone — cache cleared")
|
||||||
83
scripts/migration/setup_subscription_api.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fix Subscription DocField restrictions so the REST API can update all fields.
|
||||||
|
|
||||||
|
Frappe's standard PUT /api/resource/Subscription/{name} silently ignores
|
||||||
|
fields marked read_only=1, set_only_once=1, etc. These restrictions make
|
||||||
|
sense for the desk UI but block our Ops frontend from managing subscriptions.
|
||||||
|
|
||||||
|
Changes applied (idempotent):
|
||||||
|
1. status : read_only 1 -> 0 (so we can cancel/reactivate)
|
||||||
|
2. cancelation_date : read_only 1 -> 0 (set when cancelling)
|
||||||
|
3. end_date : set_only_once 1 -> 0 (editable for renewals)
|
||||||
|
4. start_date : set_only_once 1 -> 0 (editable for corrections)
|
||||||
|
5. follow_calendar_months : set_only_once 1 -> 0
|
||||||
|
|
||||||
|
Also disables follow_calendar_months on all subs (conflicts with annual
|
||||||
|
billing unless end_date is set).
|
||||||
|
|
||||||
|
Run via direct PostgreSQL (no bench CLI needed):
|
||||||
|
python3 scripts/migration/setup_subscription_api.py
|
||||||
|
"""
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pgc = pg.cursor()
|
||||||
|
|
||||||
|
# 1. Remove read_only on status and cancelation_date
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabDocField"
|
||||||
|
SET read_only = 0
|
||||||
|
WHERE parent = 'Subscription'
|
||||||
|
AND fieldname IN ('status', 'cancelation_date')
|
||||||
|
AND read_only = 1
|
||||||
|
""")
|
||||||
|
n1 = pgc.rowcount
|
||||||
|
print(f" read_only removed: {n1} fields")
|
||||||
|
|
||||||
|
# 2. Remove set_only_once on end_date, start_date, follow_calendar_months
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabDocField"
|
||||||
|
SET set_only_once = 0
|
||||||
|
WHERE parent = 'Subscription'
|
||||||
|
AND fieldname IN ('end_date', 'start_date', 'follow_calendar_months')
|
||||||
|
AND set_only_once = 1
|
||||||
|
""")
|
||||||
|
n2 = pgc.rowcount
|
||||||
|
print(f" set_only_once removed: {n2} fields")
|
||||||
|
|
||||||
|
# 3. Disable follow_calendar_months on all subscriptions
|
||||||
|
# (it requires end_date + monthly billing; breaks annual subs)
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET follow_calendar_months = 0
|
||||||
|
WHERE follow_calendar_months = 1
|
||||||
|
""")
|
||||||
|
n3 = pgc.rowcount
|
||||||
|
print(f" follow_calendar_months disabled on {n3} subscriptions")
|
||||||
|
|
||||||
|
# 4. Fix company name (legacy migration used wrong name)
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabSubscription"
|
||||||
|
SET company = 'TARGO'
|
||||||
|
WHERE company != 'TARGO' OR company IS NULL
|
||||||
|
""")
|
||||||
|
n4 = pgc.rowcount
|
||||||
|
print(f" company fixed to TARGO on {n4} subscriptions")
|
||||||
|
|
||||||
|
pg.commit()
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Done. Clear Frappe cache to apply DocField changes:")
|
||||||
|
print(" bench --site <site> clear-cache")
|
||||||
|
print(" OR restart the backend container")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
288
scripts/migration/setup_user_roles.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"""
|
||||||
|
Map legacy group_ad to ERPNext Role Profiles and assign roles to users.
|
||||||
|
|
||||||
|
Legacy groups:
|
||||||
|
admin → Full admin access (System Manager + all modules)
|
||||||
|
sysadmin → Technical admin (System Manager, HR, all operations)
|
||||||
|
tech → Field technicians (Dispatch, limited Accounts read)
|
||||||
|
support → Customer support (Support Team, Sales read, Dispatch)
|
||||||
|
comptabilite → Accounting (Accounts Manager, HR User)
|
||||||
|
facturation → Billing (Accounts User, Sales User)
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/setup_user_roles.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# ROLE PROFILE DEFINITIONS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Roles each group should have
|
||||||
|
ROLE_MAP = {
|
||||||
|
"admin": [
|
||||||
|
"System Manager", "Accounts Manager", "Accounts User",
|
||||||
|
"Sales Manager", "Sales User", "HR Manager", "HR User",
|
||||||
|
"Support Team", "Dispatch Technician", "Employee",
|
||||||
|
"Projects Manager", "Stock Manager", "Stock User",
|
||||||
|
"Purchase Manager", "Purchase User", "Website Manager",
|
||||||
|
"Report Manager", "Dashboard Manager",
|
||||||
|
],
|
||||||
|
"sysadmin": [
|
||||||
|
"System Manager", "Accounts User",
|
||||||
|
"Sales Manager", "Sales User", "HR User",
|
||||||
|
"Support Team", "Dispatch Technician", "Employee",
|
||||||
|
"Projects Manager", "Stock Manager", "Stock User",
|
||||||
|
"Purchase User", "Website Manager",
|
||||||
|
"Report Manager", "Dashboard Manager",
|
||||||
|
],
|
||||||
|
"tech": [
|
||||||
|
"Dispatch Technician", "Employee",
|
||||||
|
"Support Team", "Stock User",
|
||||||
|
],
|
||||||
|
"support": [
|
||||||
|
"Support Team", "Employee",
|
||||||
|
"Sales User", "Dispatch Technician",
|
||||||
|
"Accounts User",
|
||||||
|
],
|
||||||
|
"comptabilite": [
|
||||||
|
"Accounts Manager", "Accounts User", "Employee",
|
||||||
|
"HR User", "Sales User", "Report Manager",
|
||||||
|
],
|
||||||
|
"facturation": [
|
||||||
|
"Accounts User", "Employee",
|
||||||
|
"Sales User", "Report Manager",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Profile display names
|
||||||
|
PROFILE_NAMES = {
|
||||||
|
"admin": "Admin - Full Access",
|
||||||
|
"sysadmin": "SysAdmin - Technical",
|
||||||
|
"tech": "Technician - Field",
|
||||||
|
"support": "Support - Customer Service",
|
||||||
|
"comptabilite": "Comptabilité - Accounting",
|
||||||
|
"facturation": "Facturation - Billing",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 1: Create/update Role Profiles
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 1: CREATE ROLE PROFILES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
for group_key, roles in ROLE_MAP.items():
|
||||||
|
profile_name = PROFILE_NAMES[group_key]
|
||||||
|
|
||||||
|
# Delete existing profile and its roles
|
||||||
|
frappe.db.sql('DELETE FROM "tabHas Role" WHERE parent = %s AND parenttype = %s',
|
||||||
|
(profile_name, "Role Profile"))
|
||||||
|
if frappe.db.exists("Role Profile", profile_name):
|
||||||
|
frappe.db.sql('DELETE FROM "tabRole Profile" WHERE name = %s', (profile_name,))
|
||||||
|
|
||||||
|
# Create profile
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabRole Profile" (name, creation, modified, modified_by, owner, docstatus, idx, role_profile)
|
||||||
|
VALUES (%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0, %(name)s)
|
||||||
|
""", {"name": profile_name, "now": now_str})
|
||||||
|
|
||||||
|
# Add roles
|
||||||
|
for i, role in enumerate(roles):
|
||||||
|
rname = "rp-{}-{}-{}".format(group_key, i, int(time.time()))
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabHas Role" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
parent, parentfield, parenttype, role
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, %(idx)s,
|
||||||
|
%(parent)s, 'roles', 'Role Profile', %(role)s
|
||||||
|
)
|
||||||
|
""", {"name": rname, "now": now_str, "idx": i + 1, "parent": profile_name, "role": role})
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print(" Created profile '{}' with {} roles: {}".format(
|
||||||
|
profile_name, len(roles), ", ".join(roles[:5]) + ("..." if len(roles) > 5 else "")))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 2: Get employee → user → group mapping
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 2: MAP EMPLOYEES TO ROLES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, username, first_name, last_name, email, group_ad, status
|
||||||
|
FROM staff WHERE status = 1 AND email IS NOT NULL AND email != ''
|
||||||
|
""")
|
||||||
|
active_staff = cur.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Build email → group_ad map
|
||||||
|
email_to_group = {}
|
||||||
|
for s in active_staff:
|
||||||
|
email = s["email"].strip().lower()
|
||||||
|
if email:
|
||||||
|
email_to_group[email] = (s["group_ad"] or "").strip().lower()
|
||||||
|
|
||||||
|
print("Active staff with email: {}".format(len(email_to_group)))
|
||||||
|
|
||||||
|
# Get all ERPNext users
|
||||||
|
erp_users = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabUser"
|
||||||
|
WHERE name NOT IN ('Administrator', 'Guest', 'admin@example.com')
|
||||||
|
AND enabled = 1
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
print("ERPNext users: {}".format(len(erp_users)))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 3: Assign roles to users
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 3: ASSIGN ROLES")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
assigned = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for user in erp_users:
|
||||||
|
email = user["name"].lower()
|
||||||
|
group = email_to_group.get(email)
|
||||||
|
|
||||||
|
if not group or group not in ROLE_MAP:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
profile_name = PROFILE_NAMES[group]
|
||||||
|
target_roles = set(ROLE_MAP[group])
|
||||||
|
# Always add "All" and "Desk User" which are standard
|
||||||
|
target_roles.add("All")
|
||||||
|
target_roles.add("Desk User")
|
||||||
|
|
||||||
|
# Get current roles
|
||||||
|
current_roles = set()
|
||||||
|
current = frappe.db.sql("""
|
||||||
|
SELECT role FROM "tabHas Role"
|
||||||
|
WHERE parent = %s AND parenttype = 'User'
|
||||||
|
""", (user["name"],))
|
||||||
|
for r in current:
|
||||||
|
current_roles.add(r[0])
|
||||||
|
|
||||||
|
# Remove roles not in target (except a few we should keep)
|
||||||
|
keep_always = {"All", "Desk User"}
|
||||||
|
to_remove = current_roles - target_roles - keep_always
|
||||||
|
to_add = target_roles - current_roles
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
for role in to_remove:
|
||||||
|
frappe.db.sql("""
|
||||||
|
DELETE FROM "tabHas Role"
|
||||||
|
WHERE parent = %s AND parenttype = 'User' AND role = %s
|
||||||
|
""", (user["name"], role))
|
||||||
|
|
||||||
|
if to_add:
|
||||||
|
for role in to_add:
|
||||||
|
rname = "ur-{}-{}".format(hashlib.md5("{}{}".format(user["name"], role).encode()).hexdigest()[:10], int(time.time()))
|
||||||
|
frappe.db.sql("""
|
||||||
|
INSERT INTO "tabHas Role" (
|
||||||
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||||
|
parent, parentfield, parenttype, role
|
||||||
|
) VALUES (
|
||||||
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
||||||
|
%(parent)s, 'roles', 'User', %(role)s
|
||||||
|
)
|
||||||
|
""", {"name": rname, "now": now_str, "parent": user["name"], "role": role})
|
||||||
|
|
||||||
|
# Set role profile on user
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabUser" SET role_profile_name = %s WHERE name = %s
|
||||||
|
""", (profile_name, user["name"]))
|
||||||
|
|
||||||
|
assigned += 1
|
||||||
|
if to_add or to_remove:
|
||||||
|
print(" {} → {} (+{} -{})".format(
|
||||||
|
user["name"], profile_name, len(to_add), len(to_remove)))
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("\nAssigned roles to {} users ({} skipped - no legacy group match)".format(assigned, skipped))
|
||||||
|
|
||||||
|
# Also link Employee → User
|
||||||
|
print("\n--- Linking Employee → User ---")
|
||||||
|
linked = 0
|
||||||
|
employees = frappe.db.sql("""
|
||||||
|
SELECT name, company_email FROM "tabEmployee"
|
||||||
|
WHERE status = 'Active' AND company_email IS NOT NULL
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
for emp in employees:
|
||||||
|
email = emp["company_email"]
|
||||||
|
# Check if user exists
|
||||||
|
user_exists = frappe.db.exists("User", email)
|
||||||
|
if user_exists:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabEmployee" SET user_id = %s WHERE name = %s
|
||||||
|
""", (email, emp["name"]))
|
||||||
|
linked += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Linked {} employees to their User accounts".format(linked))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# PHASE 4: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("PHASE 4: VERIFY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Count users per role profile
|
||||||
|
by_profile = frappe.db.sql("""
|
||||||
|
SELECT role_profile_name, COUNT(*) as cnt FROM "tabUser"
|
||||||
|
WHERE role_profile_name IS NOT NULL AND role_profile_name != ''
|
||||||
|
GROUP BY role_profile_name ORDER BY cnt DESC
|
||||||
|
""", as_dict=True)
|
||||||
|
print("Users by Role Profile:")
|
||||||
|
for p in by_profile:
|
||||||
|
print(" {}: {}".format(p["role_profile_name"], p["cnt"]))
|
||||||
|
|
||||||
|
# Sample users with their roles
|
||||||
|
sample_users = frappe.db.sql("""
|
||||||
|
SELECT u.name, u.full_name, u.role_profile_name,
|
||||||
|
STRING_AGG(hr.role, ', ' ORDER BY hr.role) as roles
|
||||||
|
FROM "tabUser" u
|
||||||
|
LEFT JOIN "tabHas Role" hr ON hr.parent = u.name AND hr.parenttype = 'User'
|
||||||
|
WHERE u.role_profile_name IS NOT NULL AND u.role_profile_name != ''
|
||||||
|
GROUP BY u.name, u.full_name, u.role_profile_name
|
||||||
|
ORDER BY u.role_profile_name, u.name
|
||||||
|
""", as_dict=True)
|
||||||
|
print("\nUsers and their roles:")
|
||||||
|
for u in sample_users:
|
||||||
|
print(" {} ({}) → {} roles: {}".format(
|
||||||
|
u["name"], u["full_name"] or "", u["role_profile_name"],
|
||||||
|
u["roles"][:80] if u["roles"] else "(none)"))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("\nDone — cache cleared")
|
||||||
300
scripts/migration/simulate_payment_import.py
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
"""
|
||||||
|
Simulate importing missing payments for Expro Transit Inc (account 3673).
|
||||||
|
DRY RUN — reads legacy data, shows what would be created, and produces
|
||||||
|
a visual timeline of invoice vs payment balance.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/simulate_payment_import.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_ID = 3673
|
||||||
|
CUSTOMER = "CUST-cbf03814b9"
|
||||||
|
CUSTOMER_NAME = "Expro Transit Inc."
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1: Load all legacy payments + allocations
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id, p.date_orig, p.amount, p.applied_amt, p.type, p.reference
|
||||||
|
FROM payment p
|
||||||
|
WHERE p.account_id = %s
|
||||||
|
ORDER BY p.date_orig ASC
|
||||||
|
""", (ACCOUNT_ID,))
|
||||||
|
legacy_payments = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT pi.payment_id, pi.invoice_id, pi.amount
|
||||||
|
FROM payment_item pi
|
||||||
|
JOIN payment p ON p.id = pi.payment_id
|
||||||
|
WHERE p.account_id = %s
|
||||||
|
""", (ACCOUNT_ID,))
|
||||||
|
legacy_allocs = cur.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Build allocation map: payment_id -> [{invoice_id, amount}]
|
||||||
|
alloc_map = {}
|
||||||
|
for a in legacy_allocs:
|
||||||
|
pid = a["payment_id"]
|
||||||
|
if pid not in alloc_map:
|
||||||
|
alloc_map[pid] = []
|
||||||
|
alloc_map[pid].append({
|
||||||
|
"invoice_id": a["invoice_id"],
|
||||||
|
"amount": float(a["amount"] or 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Which PEs already exist in ERPNext?
|
||||||
|
erp_pes = frappe.db.sql("""
|
||||||
|
SELECT name FROM "tabPayment Entry" WHERE party = %s AND docstatus = 1
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
erp_pe_ids = set()
|
||||||
|
for pe in erp_pes:
|
||||||
|
try:
|
||||||
|
erp_pe_ids.add(int(pe["name"].split("-")[1]))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2: Load all ERPNext invoices
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
erp_invoices = frappe.db.sql("""
|
||||||
|
SELECT name, posting_date, grand_total, outstanding_amount, status
|
||||||
|
FROM "tabSales Invoice"
|
||||||
|
WHERE customer = %s AND docstatus = 1
|
||||||
|
ORDER BY posting_date ASC
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
|
||||||
|
inv_map = {}
|
||||||
|
for inv in erp_invoices:
|
||||||
|
inv_map[inv["name"]] = {
|
||||||
|
"date": str(inv["posting_date"]),
|
||||||
|
"total": float(inv["grand_total"]),
|
||||||
|
"outstanding": float(inv["outstanding_amount"]),
|
||||||
|
"status": inv["status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3: Determine which payments to create
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
to_create = []
|
||||||
|
for p in legacy_payments:
|
||||||
|
if p["id"] in erp_pe_ids:
|
||||||
|
continue # Already exists
|
||||||
|
|
||||||
|
dt = datetime.fromtimestamp(p["date_orig"]).strftime("%Y-%m-%d") if p["date_orig"] else None
|
||||||
|
amount = float(p["amount"] or 0)
|
||||||
|
ptype = (p["type"] or "").strip()
|
||||||
|
ref = (p["reference"] or "").strip()
|
||||||
|
|
||||||
|
# Map legacy type to ERPNext mode
|
||||||
|
mode_map = {
|
||||||
|
"paiement direct": "Virement",
|
||||||
|
"cheque": "Chèque",
|
||||||
|
"carte credit": "Carte de crédit",
|
||||||
|
"credit": "Note de crédit",
|
||||||
|
"reversement": "Note de crédit",
|
||||||
|
}
|
||||||
|
mode = mode_map.get(ptype, ptype)
|
||||||
|
|
||||||
|
# Get allocations
|
||||||
|
allocations = alloc_map.get(p["id"], [])
|
||||||
|
refs = []
|
||||||
|
for a in allocations:
|
||||||
|
sinv_name = "SINV-{}".format(a["invoice_id"])
|
||||||
|
refs.append({
|
||||||
|
"reference_doctype": "Sales Invoice",
|
||||||
|
"reference_name": sinv_name,
|
||||||
|
"allocated_amount": a["amount"],
|
||||||
|
})
|
||||||
|
|
||||||
|
to_create.append({
|
||||||
|
"pe_name": "PE-{}".format(p["id"]),
|
||||||
|
"legacy_id": p["id"],
|
||||||
|
"date": dt,
|
||||||
|
"amount": amount,
|
||||||
|
"type": ptype,
|
||||||
|
"mode": mode,
|
||||||
|
"reference": ref,
|
||||||
|
"allocations": refs,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("PAYMENT IMPORT SIMULATION — {} ({})".format(CUSTOMER_NAME, CUSTOMER))
|
||||||
|
print("=" * 70)
|
||||||
|
print("Legacy payments: {}".format(len(legacy_payments)))
|
||||||
|
print("Already in ERPNext: {}".format(len(erp_pe_ids)))
|
||||||
|
print("TO CREATE: {}".format(len(to_create)))
|
||||||
|
total_to_create = sum(p["amount"] for p in to_create)
|
||||||
|
print("Total to import: ${:,.2f}".format(total_to_create))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 4: Build timeline showing running balance
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("VISUAL BALANCE TIMELINE (with new payments)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Combine all events: invoices and payments (existing + to-create)
|
||||||
|
events = []
|
||||||
|
|
||||||
|
# Add invoices
|
||||||
|
for inv in erp_invoices:
|
||||||
|
events.append({
|
||||||
|
"date": str(inv["posting_date"]),
|
||||||
|
"type": "INV",
|
||||||
|
"name": inv["name"],
|
||||||
|
"amount": float(inv["grand_total"]),
|
||||||
|
"detail": inv["status"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add existing ERPNext payments
|
||||||
|
existing_pes = frappe.db.sql("""
|
||||||
|
SELECT name, posting_date, paid_amount
|
||||||
|
FROM "tabPayment Entry"
|
||||||
|
WHERE party = %s AND docstatus = 1
|
||||||
|
""", (CUSTOMER,), as_dict=True)
|
||||||
|
for pe in existing_pes:
|
||||||
|
events.append({
|
||||||
|
"date": str(pe["posting_date"]),
|
||||||
|
"type": "PAY",
|
||||||
|
"name": pe["name"],
|
||||||
|
"amount": float(pe["paid_amount"]),
|
||||||
|
"detail": "existing",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add new (simulated) payments
|
||||||
|
for p in to_create:
|
||||||
|
events.append({
|
||||||
|
"date": p["date"] or "2012-01-01",
|
||||||
|
"type": "PAY",
|
||||||
|
"name": p["pe_name"],
|
||||||
|
"amount": p["amount"],
|
||||||
|
"detail": "NEW " + p["mode"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by date, then INV before PAY on same date
|
||||||
|
events.sort(key=lambda e: (e["date"], 0 if e["type"] == "INV" else 1))
|
||||||
|
|
||||||
|
# Print timeline with running balance
|
||||||
|
balance = 0.0
|
||||||
|
total_invoiced = 0.0
|
||||||
|
total_paid = 0.0
|
||||||
|
|
||||||
|
# Group by year for readability
|
||||||
|
current_year = None
|
||||||
|
print("\n{:<12} {:<5} {:<16} {:>12} {:>12} {}".format(
|
||||||
|
"DATE", "TYPE", "DOCUMENT", "AMOUNT", "BALANCE", "DETAIL"))
|
||||||
|
print("-" * 85)
|
||||||
|
|
||||||
|
for e in events:
|
||||||
|
year = e["date"][:4]
|
||||||
|
if year != current_year:
|
||||||
|
if current_year:
|
||||||
|
print(" {:>55} {:>12}".format("--- year-end ---", "${:,.2f}".format(balance)))
|
||||||
|
current_year = year
|
||||||
|
print("\n ── {} ──".format(year))
|
||||||
|
|
||||||
|
if e["type"] == "INV":
|
||||||
|
balance += e["amount"]
|
||||||
|
total_invoiced += e["amount"]
|
||||||
|
sign = "+"
|
||||||
|
else:
|
||||||
|
balance -= e["amount"]
|
||||||
|
total_paid += e["amount"]
|
||||||
|
sign = "-"
|
||||||
|
|
||||||
|
marker = " ◀ NEW" if "NEW" in e.get("detail", "") else ""
|
||||||
|
print("{:<12} {:<5} {:<16} {:>12} {:>12} {}{}".format(
|
||||||
|
e["date"],
|
||||||
|
e["type"],
|
||||||
|
e["name"],
|
||||||
|
"{}${:,.2f}".format(sign, abs(e["amount"])),
|
||||||
|
"${:,.2f}".format(balance),
|
||||||
|
e["detail"],
|
||||||
|
marker,
|
||||||
|
))
|
||||||
|
|
||||||
|
print("-" * 85)
|
||||||
|
print("\nFINAL SUMMARY:")
|
||||||
|
print(" Total invoiced: ${:,.2f}".format(total_invoiced))
|
||||||
|
print(" Total paid: ${:,.2f}".format(total_paid))
|
||||||
|
print(" Final balance: ${:,.2f}".format(balance))
|
||||||
|
print(" Should be: $0.00" if abs(balance) < 0.02 else " ⚠ MISMATCH!")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 5: Show sample of what the PE documents would look like
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("SAMPLE PAYMENT ENTRY DOCUMENTS (first 5)")
|
||||||
|
print("=" * 70)
|
||||||
|
for p in to_create[:5]:
|
||||||
|
print("\n PE Name: {}".format(p["pe_name"]))
|
||||||
|
print(" Date: {}".format(p["date"]))
|
||||||
|
print(" Amount: ${:,.2f}".format(p["amount"]))
|
||||||
|
print(" Mode: {}".format(p["mode"]))
|
||||||
|
print(" Reference: {}".format(p["reference"] or "—"))
|
||||||
|
print(" Allocations:")
|
||||||
|
for a in p["allocations"]:
|
||||||
|
print(" → {} = ${:,.2f}".format(a["reference_name"], a["allocated_amount"]))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 6: Identify potential issues
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("POTENTIAL ISSUES")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Check for payments referencing invoices that don't exist in ERPNext
|
||||||
|
missing_invs = set()
|
||||||
|
for p in to_create:
|
||||||
|
for a in p["allocations"]:
|
||||||
|
if a["reference_name"] not in inv_map:
|
||||||
|
missing_invs.add(a["reference_name"])
|
||||||
|
|
||||||
|
if missing_invs:
|
||||||
|
print("⚠ {} payments reference invoices NOT in ERPNext:".format(len(missing_invs)))
|
||||||
|
for m in sorted(missing_invs)[:10]:
|
||||||
|
print(" {}".format(m))
|
||||||
|
else:
|
||||||
|
print("✓ All payment allocations reference existing invoices")
|
||||||
|
|
||||||
|
# Check for credits (negative-type payments)
|
||||||
|
credits = [p for p in to_create if p["type"] in ("credit", "reversement")]
|
||||||
|
if credits:
|
||||||
|
print("\n⚠ {} credit/reversement entries (not regular payments):".format(len(credits)))
|
||||||
|
for c in credits:
|
||||||
|
print(" {} date={} amount=${:,.2f} type={}".format(c["pe_name"], c["date"], c["amount"], c["type"]))
|
||||||
|
else:
|
||||||
|
print("✓ No credit entries to handle specially")
|
||||||
|
|
||||||
|
# Payments without allocations
|
||||||
|
no_alloc = [p for p in to_create if not p["allocations"]]
|
||||||
|
if no_alloc:
|
||||||
|
print("\n⚠ {} payments without invoice allocations:".format(len(no_alloc)))
|
||||||
|
for p in no_alloc:
|
||||||
|
print(" {} date={} amount=${:,.2f}".format(p["pe_name"], p["date"], p["amount"]))
|
||||||
|
else:
|
||||||
|
print("✓ All payments have invoice allocations")
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("DRY RUN COMPLETE — no changes made")
|
||||||
|
print("=" * 70)
|
||||||
86
scripts/migration/update_assigned_staff.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Update assigned_staff on Issues from legacy ticket.assign_to → staff name."""
|
||||||
|
import pymysql
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
|
||||||
|
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 600}
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 1. Get staff mapping from legacy
|
||||||
|
mc = pymysql.connect(**LEGACY)
|
||||||
|
cur = mc.cursor(pymysql.cursors.DictCursor)
|
||||||
|
|
||||||
|
cur.execute("SELECT id, first_name, last_name FROM staff ORDER BY id")
|
||||||
|
staff_map = {}
|
||||||
|
for s in cur.fetchall():
|
||||||
|
name = ((s["first_name"] or "") + " " + (s["last_name"] or "")).strip()
|
||||||
|
if name:
|
||||||
|
staff_map[s["id"]] = name
|
||||||
|
|
||||||
|
# Get all tickets with assign_to
|
||||||
|
cur.execute("SELECT id, assign_to FROM ticket WHERE assign_to > 0")
|
||||||
|
assignments = cur.fetchall()
|
||||||
|
mc.close()
|
||||||
|
print(f"{len(assignments)} assignments, {len(staff_map)} staff names")
|
||||||
|
|
||||||
|
# 2. Connect ERPNext
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pgc = pg.cursor()
|
||||||
|
|
||||||
|
# Add column if not exists
|
||||||
|
pgc.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'tabIssue' AND column_name = 'assigned_staff'")
|
||||||
|
if not pgc.fetchone():
|
||||||
|
pgc.execute('ALTER TABLE "tabIssue" ADD COLUMN assigned_staff varchar(140)')
|
||||||
|
pg.commit()
|
||||||
|
print("Column assigned_staff added")
|
||||||
|
else:
|
||||||
|
print("Column assigned_staff already exists")
|
||||||
|
|
||||||
|
# Build mapping: legacy_ticket_id -> staff_name
|
||||||
|
batch = {}
|
||||||
|
for a in assignments:
|
||||||
|
name = staff_map.get(a["assign_to"])
|
||||||
|
if name:
|
||||||
|
batch[a["id"]] = name
|
||||||
|
|
||||||
|
print(f"{len(batch)} tickets to update")
|
||||||
|
|
||||||
|
# Batch update using temp table for speed
|
||||||
|
pgc.execute("""
|
||||||
|
CREATE TEMP TABLE _staff_assign (
|
||||||
|
legacy_ticket_id integer PRIMARY KEY,
|
||||||
|
staff_name varchar(140)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Insert in chunks
|
||||||
|
items = list(batch.items())
|
||||||
|
for i in range(0, len(items), 10000):
|
||||||
|
chunk = items[i:i+10000]
|
||||||
|
args = ",".join(pgc.mogrify("(%s,%s)", (tid, name)).decode() for tid, name in chunk)
|
||||||
|
pgc.execute("INSERT INTO _staff_assign VALUES " + args)
|
||||||
|
print(f" loaded chunk {i//10000+1}")
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
# Single UPDATE join
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabIssue" i
|
||||||
|
SET assigned_staff = sa.staff_name
|
||||||
|
FROM _staff_assign sa
|
||||||
|
WHERE i.legacy_ticket_id = sa.legacy_ticket_id
|
||||||
|
AND (i.assigned_staff IS NULL OR i.assigned_staff = '')
|
||||||
|
""")
|
||||||
|
updated = pgc.rowcount
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
pgc.execute("DROP TABLE _staff_assign")
|
||||||
|
pg.commit()
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
print(f"Updated: {updated} issues with assigned_staff")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
174
scripts/migration/update_item_descriptions.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""
|
||||||
|
Update ERPNext Item descriptions from legacy product data.
|
||||||
|
Uses hijack_desc from services and product_translate for French names.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/update_item_descriptions.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import html
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="*******",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 1: Build a description map from legacy data
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 1: BUILD DESCRIPTION MAP")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Get all products with French translations
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.id, p.sku, p.price, p.category,
|
||||||
|
pt.name as fr_name, pt.description_short as fr_desc
|
||||||
|
FROM product p
|
||||||
|
LEFT JOIN product_translate pt ON pt.product_id = p.id AND pt.language_id = 'fr'
|
||||||
|
WHERE p.active = 1
|
||||||
|
""")
|
||||||
|
products = cur.fetchall()
|
||||||
|
|
||||||
|
# Get category names
|
||||||
|
cur.execute("SELECT id, name FROM product_cat")
|
||||||
|
cats = {r["id"]: html.unescape(r["name"]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
# Get most common hijack_desc per product (the custom description used on services)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT product_id, hijack_desc, COUNT(*) as cnt
|
||||||
|
FROM service
|
||||||
|
WHERE hijack = 1 AND hijack_desc IS NOT NULL AND hijack_desc != ''
|
||||||
|
GROUP BY product_id, hijack_desc
|
||||||
|
ORDER BY product_id, cnt DESC
|
||||||
|
""")
|
||||||
|
hijack_descs = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
pid = r["product_id"]
|
||||||
|
if pid not in hijack_descs:
|
||||||
|
hijack_descs[pid] = r["hijack_desc"]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Build the best description for each SKU
|
||||||
|
desc_map = {}
|
||||||
|
for p in products:
|
||||||
|
sku = p["sku"]
|
||||||
|
if not sku:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Priority: French translation > hijack_desc > category name
|
||||||
|
desc = p["fr_name"] or hijack_descs.get(p["id"]) or ""
|
||||||
|
cat = cats.get(p["category"], "")
|
||||||
|
|
||||||
|
desc_map[sku] = {
|
||||||
|
"description": desc.strip() if desc else "",
|
||||||
|
"item_group": cat,
|
||||||
|
"price": float(p["price"] or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Products mapped: {}".format(len(desc_map)))
|
||||||
|
|
||||||
|
# Show samples
|
||||||
|
for sku in ["FTTB1000I", "TELEPMENS", "RAB24M", "HVIPFIXE", "FTT_HFAR", "CSERV", "RAB2X", "FTTH_LOCMOD"]:
|
||||||
|
d = desc_map.get(sku, {})
|
||||||
|
print(" {} -> desc='{}' group='{}' price={}".format(sku, d.get("description", ""), d.get("item_group", ""), d.get("price", "")))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 2: Update ERPNext Items
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: UPDATE ERPNEXT ITEMS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Get all items that currently have "Legacy product ID" as description
|
||||||
|
items = frappe.db.sql("""
|
||||||
|
SELECT name, item_name, description, item_group
|
||||||
|
FROM "tabItem"
|
||||||
|
WHERE description LIKE 'Legacy product ID%%'
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
print("Items with legacy placeholder description: {}".format(len(items)))
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for item in items:
|
||||||
|
sku = item["name"]
|
||||||
|
legacy = desc_map.get(sku)
|
||||||
|
if not legacy:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_desc = legacy["description"]
|
||||||
|
new_name = new_desc if new_desc else sku
|
||||||
|
|
||||||
|
if new_desc:
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabItem"
|
||||||
|
SET description = %s, item_name = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (new_desc, new_name, sku))
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated: {} items".format(updated))
|
||||||
|
|
||||||
|
# Also update Subscription Plan descriptions (plan_name)
|
||||||
|
plans = frappe.db.sql("""
|
||||||
|
SELECT name, plan_name, item, cost FROM "tabSubscription Plan"
|
||||||
|
""", as_dict=True)
|
||||||
|
|
||||||
|
plan_updated = 0
|
||||||
|
for plan in plans:
|
||||||
|
item_sku = plan["item"]
|
||||||
|
legacy = desc_map.get(item_sku)
|
||||||
|
if not legacy or not legacy["description"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_plan_name = "PLAN-{}".format(item_sku)
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabSubscription Plan"
|
||||||
|
SET plan_name = %s
|
||||||
|
WHERE name = %s
|
||||||
|
""", (new_plan_name, plan["name"]))
|
||||||
|
plan_updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated: {} subscription plans".format(plan_updated))
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# STEP 3: VERIFY
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 3: VERIFY")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Sample items
|
||||||
|
sample = frappe.db.sql("""
|
||||||
|
SELECT name, item_name, description, item_group
|
||||||
|
FROM "tabItem"
|
||||||
|
WHERE name IN ('FTTB1000I', 'TELEPMENS', 'RAB24M', 'HVIPFIXE', 'FTT_HFAR', 'CSERV', 'RAB2X')
|
||||||
|
ORDER BY name
|
||||||
|
""", as_dict=True)
|
||||||
|
for s in sample:
|
||||||
|
print(" {} | name={} | group={} | desc={}".format(
|
||||||
|
s["name"], s["item_name"], s["item_group"], (s["description"] or "")[:80]))
|
||||||
|
|
||||||
|
# Count remaining legacy descriptions
|
||||||
|
remaining = frappe.db.sql("""
|
||||||
|
SELECT COUNT(*) FROM "tabItem" WHERE description LIKE 'Legacy product ID%%'
|
||||||
|
""")[0][0]
|
||||||
|
print("\nRemaining with legacy placeholder: {}".format(remaining))
|
||||||
|
|
||||||
|
frappe.clear_cache()
|
||||||
|
print("Done — cache cleared")
|
||||||
78
scripts/migration/update_opened_by_staff.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Update opened_by_staff on Issues from legacy ticket.open_by → staff name."""
|
||||||
|
import pymysql
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
|
||||||
|
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 600}
|
||||||
|
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
|
||||||
|
"dbname": "_eb65bdc0c4b1b2d6"}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mc = pymysql.connect(**LEGACY)
|
||||||
|
cur = mc.cursor(pymysql.cursors.DictCursor)
|
||||||
|
|
||||||
|
cur.execute("SELECT id, first_name, last_name FROM staff ORDER BY id")
|
||||||
|
staff_map = {}
|
||||||
|
for s in cur.fetchall():
|
||||||
|
name = ((s["first_name"] or "") + " " + (s["last_name"] or "")).strip()
|
||||||
|
if name:
|
||||||
|
staff_map[s["id"]] = name
|
||||||
|
|
||||||
|
cur.execute("SELECT id, open_by FROM ticket WHERE open_by > 0")
|
||||||
|
openers = cur.fetchall()
|
||||||
|
mc.close()
|
||||||
|
print(f"{len(openers)} tickets with open_by, {len(staff_map)} staff names")
|
||||||
|
|
||||||
|
pg = psycopg2.connect(**PG)
|
||||||
|
pgc = pg.cursor()
|
||||||
|
|
||||||
|
# Add column if not exists
|
||||||
|
pgc.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'tabIssue' AND column_name = 'opened_by_staff'")
|
||||||
|
if not pgc.fetchone():
|
||||||
|
pgc.execute('ALTER TABLE "tabIssue" ADD COLUMN opened_by_staff varchar(140)')
|
||||||
|
pg.commit()
|
||||||
|
print("Column opened_by_staff added")
|
||||||
|
else:
|
||||||
|
print("Column opened_by_staff already exists")
|
||||||
|
|
||||||
|
batch = {}
|
||||||
|
for o in openers:
|
||||||
|
name = staff_map.get(o["open_by"])
|
||||||
|
if name:
|
||||||
|
batch[o["id"]] = name
|
||||||
|
|
||||||
|
print(f"{len(batch)} tickets to update")
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
CREATE TEMP TABLE _staff_open (
|
||||||
|
legacy_ticket_id integer PRIMARY KEY,
|
||||||
|
staff_name varchar(140)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
items = list(batch.items())
|
||||||
|
for i in range(0, len(items), 10000):
|
||||||
|
chunk = items[i:i+10000]
|
||||||
|
args = ",".join(pgc.mogrify("(%s,%s)", (tid, name)).decode() for tid, name in chunk)
|
||||||
|
pgc.execute("INSERT INTO _staff_open VALUES " + args)
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
pgc.execute("""
|
||||||
|
UPDATE "tabIssue" i
|
||||||
|
SET opened_by_staff = so.staff_name
|
||||||
|
FROM _staff_open so
|
||||||
|
WHERE i.legacy_ticket_id = so.legacy_ticket_id
|
||||||
|
AND (i.opened_by_staff IS NULL OR i.opened_by_staff = '')
|
||||||
|
""")
|
||||||
|
updated = pgc.rowcount
|
||||||
|
pg.commit()
|
||||||
|
|
||||||
|
pgc.execute("DROP TABLE _staff_open")
|
||||||
|
pg.commit()
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
print(f"Updated: {updated} issues with opened_by_staff")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||