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>
This commit is contained in:
louispaulb 2026-03-31 07:34:41 -04:00
parent 26a0077015
commit 101faa21f1
100 changed files with 34808 additions and 143 deletions

13
.gitignore vendored
View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

36
apps/client/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,3 @@
module.exports = {
plugins: [],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

View 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 = '.'
},
},
}
})

View 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$/] }
)
)
}

View 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
View 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;
}
}

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

View 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/'
}

View 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 || []
}

View File

@ -0,0 +1,6 @@
import { boot } from 'quasar/wrappers'
import { createPinia } from 'pinia'
export default boot(({ app }) => {
app.use(createPinia())
})

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

View File

@ -0,0 +1 @@
export const BASE_URL = ''

View 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;
}

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

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

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

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

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

View 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,
})

View 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 }
})

View File

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

File diff suppressed because it is too large Load Diff

View 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
View 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;
}
}

View File

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

View File

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

View File

@ -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>&middot; Legacy: {{ customer.legacy_customer_id }}</span></template> <template v-if="customer.legacy_customer_id"><span>&middot; 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'] },

View File

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

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

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

View File

@ -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"> &middot; 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"> &middot; 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)"> &middot; <em>Aucun abonnement</em></template> <template v-if="!locHasSubs(loc.name)"> &middot; <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"> &middot; 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"> &middot; {{ loc.network_id }}</template> type="select" :options="['Fibre', 'Coax', 'DSL', 'Wireless', 'LTE']"
placeholder="Type" @saved="v => loc.connection_type = v.value" />
&middot; 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" />
&middot; <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 })

View File

@ -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"> &middot; {{ 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

View File

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

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

View File

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

View File

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

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

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

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

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

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

View 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()

File diff suppressed because it is too large Load Diff

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

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

View 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()

View 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()

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

View 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()

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

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

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

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

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

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

View 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()

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

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

View 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("&#039;", "'").strip()
last_name = (s["last_name"] or "").replace("&#039;", "'").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)

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

View 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]")

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

View 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()

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

View 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()

File diff suppressed because it is too large Load Diff

View 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()

View File

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

View 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()

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

View 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("&#039;", "'") | replace("&amp;", "&") | replace("&lt;", "<") | replace("&gt;", ">") | replace("&quot;", '"') 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"> de compte</td></tr>
<tr><td class="val">{{ doc.customer }}</td></tr>
<tr><td class="lbl" style="padding-top:6px"> 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 }} &nbsp;|&nbsp; {{ 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 {% 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">&#9986; 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"> 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 }} &bull; {{ company_addr }}, {{ company_city }} &bull; {{ 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">
de facture: <strong>{{ doc.name }}</strong><br>
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 }} &bull; {{ company_addr }}, {{ company_city }} &bull; {{ 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'")

View 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: '...' }")

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

View 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()

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

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

View 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()

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

View 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()