Compare commits

...

87 Commits

Author SHA1 Message Date
6b80add102 Mejora, ahora los intercambios estan paginados para que no sea una lista larga, muestran de 15 en 15 los intermcambios 2026-06-18 12:11:39 -06:00
ad04572305 correccion del cambio modo oscuro involuntario 2026-06-17 14:52:25 -06:00
ee7e1d49e5 Merge branch 'main' into desarrollo_hector 2026-06-17 14:20:48 -06:00
49bbc37117 Merge branch 'main' into desarrollo_hector 2026-06-15 12:56:54 -06:00
6aff32f93b fix(migrations): make runner robust for all tenant DBs
- Register all missing migrations in runner.py
- Make v4.3 idempotent (rename xml_unsigned only if exists)
- Make v3.3 idempotent (skip warehouse_inventory/purchase_order_items ops when tables/columns missing)
- Mark v3.3.1 and v3.9 as master-only (SKIP)
- Mark v3.5.1 as optional (skip if whatsapp tables missing)
- Runner skips files marked with '-- : SKIP'
2026-06-14 10:08:16 +00:00
7d21d21200 fix(migrations): make v4.3 idempotent and register all missing migrations
- Use DO block to rename xml_unsigned only if it exists
- Register all missing migrations in runner.py including v4.3
2026-06-14 10:05:13 +00:00
0eb5984263 fix(pos/inventory): use 'data' key from /items response in purchase search 2026-06-14 10:01:37 +00:00
b78523102d feat(pos/inventory): product search instead of ID in purchase entry modal
- Replace ID Producto input with autocomplete search by name/part number/barcode
- Support Enter key for barcode/part number exact match
- Keep hidden inventory_id field for API compatibility
- Bump inventory.js cache version
2026-06-14 09:59:14 +00:00
27358312dc feat(pos/facturapi): add organization setup flow and detailed status
- get_org_status now returns has_key, has_org_id, pending_steps, error
- add find_organization_by_rfc and create_organization helpers
- add /facturapi/setup endpoint to link/create Facturapi org
- frontend shows detailed PAC status and setup button
- support using tenant sk_user_* key when FACTURAPI_USER_KEY env is absent
2026-06-14 09:51:02 +00:00
5e9ac57f08 fix(pos/invoicing): fix unclosed template literal in invoicing.js and bump cache version 2026-06-14 09:40:19 +00:00
8796cadb56 feat(pos): migrate CFDI timbrado from Horux to Facturapi
- Add Facturapi REST service (invoices, customers, orgs, cancel, downloads)
- Add JSON payload builder for ingreso/egreso/pago/global invoices
- Replace XML queue with Facturapi JSON queue (payload_unsigned, external_id)
- Update invoicing blueprint with Facturapi config and download endpoints
- Update global invoice service to use Facturapi payloads
- Add migration v4.3_facturapi.sql and tenant rollout script
- Update invoicing UI: payload preview, PDF/XML downloads, PAC status panel
- Add FACTURAPI_USER_KEY to .env.example
2026-06-14 09:26:42 +00:00
3378d26a31 fix(dashboard): month chart shows Sem 1-4 of current month
- Month view now groups by weeks of the current calendar month
- Labels are Sem 1, Sem 2, Sem 3, Sem 4
- Historical sales grouped by day-of-month into the 4 week buckets
- Bump dashboard.js to ?v=7
2026-06-12 08:40:57 +00:00
a9052e63c2 feat(dashboard): make sales chart period buttons work
- Replace placeholder setPeriod() with functional period switcher
- loadChart() supports Hoy (1 day), Semana (7 days), Mes (4 weeks), Año (12 months)
- Includes both normal POS sales and imported historical sales
- Updates chart title, total label and legend dynamically
- Bump dashboard.js to ?v=6
2026-06-12 08:33:29 +00:00
c1e93ed52a feat(dashboard): include historical sales in weekly chart
- loadWeeklyChart now fetches historical_sales for the last 7 days
- Sums normal daily sales + historical sales per day
- Bump dashboard.js to ?v=5
2026-06-12 08:22:48 +00:00
70233671a6 fix(dashboard): bust PWA cache for historical sales section
- Bump service worker cache name to v18 to clear stale JS/CSS caches
- Bump dashboard.js to ?v=4 so browsers fetch updated script
2026-06-12 08:19:48 +00:00
33df6e9280 fix(reports): bump reports.js version to bust cache
- Add ?v=3 to reports.js script tag so browsers load the updated module
  with loadHistorico function
2026-06-12 07:45:55 +00:00
1967ad1073 feat(reports/dashboard): integrate historical sales viewer
- Add 'Histórico' tab inside Reports page with date/customer filters
- Show historical sales KPIs and detail table in reports
- Add historical sales summary cards to Dashboard
- Load current month totals and total imported records
2026-06-12 07:33:37 +00:00
917ff00310 fix(ui): add historical sales link to main sidebar menus
- Add Ventas Históricas link to dashboard, inventory and customers sidebars
- reports.html already had the link
2026-06-12 07:24:45 +00:00
913e507adc feat(atlas): import catalog, customers and historical sales + viewer
- Add scripts/import_atlas_data.py to load Atlas data from Excel files
- Import 6,206 inventory items, 251 customers and 4,582 historical sales
- Create historical_sales table in tenant DB
- Add /pos/historical-sales page and /pos/api/historical-sales endpoint
- Link in reports sidebar for easy access
2026-06-12 06:33:48 +00:00
383799ff3d fix(pos): prevent null branch_id errors during sales
- process_sale now falls back to main branch when g.branch_id is missing
- Accept branch_id from request body as override
- Make update_inventory_stock trigger skip operations with NULL branch_id
- Applied updated trigger to all active tenant DBs
2026-06-11 23:11:02 +00:00
203960fff3 fix(migrations): add missing tenant migrations to runner
- Add v3.4-v3.8 and v4.2 to migration registry
- Remove v3.9 (master-only supplier_catalog_prices table)
- Ensures new tenants like La Casita get all schema updates
2026-06-11 22:31:42 +00:00
0419f8285a fix(ml): add family_name field required for User Products model
- ML's new User Products model requires family_name in item body
- Use product name as family_name (generic product description)
- Keep title for backward compatibility with legacy sellers
2026-06-11 18:39:47 +00:00
3d70c3fcc9 feat(ml): upload images to ML hosting + show earnings estimate on validate
- Add upload_image() to MeliService using ML /pictures endpoint
- Add get_listing_price() to fetch exact ML fees
- Auto-upload inventory images to ML before validate/publish
- Show fee breakdown and net earnings in validate modal
- Fallback to approximate fees (13-18%) if ML API fails
2026-06-11 18:35:25 +00:00
041efd5c5c feat(customers): show razon_social, address, cp in customer views
- Add address and cp to list_customers() backend response
- Show razon_social as subtitle in customer table rows
- Add razon_social and cp fields to detail panel
- Update customers.html detail panel layout
2026-06-11 18:28:31 +00:00
2cbd69d5fa fix(config): update frontend branch fields to match DB schema v4.0
- Replace codigo_postal with cp, folio_inicial with folio_inicio/folio_actual
- Add direccion_fiscal and email fields
- Remove non-existent licencia_fiscal, certificado_pem, llave_pem
- Update config.js and config.html form
2026-06-11 09:37:00 +00:00
98b3b1c8c1 fix(config): align branch column names with DB schema v4.0
- Use cp instead of codigo_postal
- Use folio_inicio/folio_actual instead of folio_inicial
- Add direccion_fiscal and email fields
- Remove non-existent certificado_pem, llave_pem, licencia_fiscal
- Fixes 500 error on /api/config/branches
2026-06-11 09:33:59 +00:00
efbfadd17a fix: LEFT JOIN inventory en get_listings para mostrar publicaciones sin vincular 2026-06-11 09:20:39 +00:00
43691ce83b docs: FASES_IMPLEMENTADAS.md actualizado con Fase 7.4 MercadoLibre 2026-06-11 09:14:22 +00:00
7a4a676890 feat: MercadoLibre mejoras - importar existentes, sync stock, sync ordenes
- meli_service.py: agrega get_user_items() para obtener publicaciones del vendedor
- marketplace_external_service.py:
  - import_existing_listings(): importa publicaciones existentes de ML a marketplace_listings
  - process_meli_sync_queue(): procesa cola de sincronizacion de stock a ML
  - Actualiza stock en ML via update_item(available_quantity)
- marketplace_external_bp.py:
  - POST /listings/import-existing - importa publicaciones existentes
  - POST /sync-stock - procesa cola de stock manualmente
  - POST /orders/sync - sincroniza ordenes manualmente
- inventory_engine.py: inserta en meli_sync_queue tras cada operacion de inventario
- migration v4.2: crea tabla meli_sync_queue

Prueba en tenant_refaccionaria_rached: 52 publicaciones importadas exitosamente
2026-06-11 09:13:27 +00:00
08362c5677 docs: FASES_IMPLEMENTADAS + MULTI_BRANCH + GLOBAL_INVOICE
- Actualiza FASES_IMPLEMENTADAS.md con Fase 7 (precios proveedor, multi-sucursal, factura global)
- Agrega docs/MULTI_BRANCH.md con arquitectura y endpoints
- Agrega docs/GLOBAL_INVOICE.md con requerimiento SAT y flujo de uso
2026-06-11 09:02:14 +00:00
2b73c2c6db feat: Fase 1-3 completas - precios proveedor, multi-sucursal, factura global
Fase 1: Lista de precios de proveedor
- Tabla supplier_catalog_prices en master DB
- Endpoints GET/POST/PUT/DELETE /supplier-catalog/prices
- Upload CSV/Excel de precios de proveedor
- Visualizacion de supplier_price en catalogo y POS

Fase 2: Multi-sucursal completo
- Migracion v4.0: inventory.branch_id=NULL, tabla inventory_stock
- Campos fiscales en branches (RFC, regimen, CP, serie CFDI, certificados)
- Trigger trg_update_inventory_stock para sincronizar stock por sucursal
- Backend config_bp.py con CRUD de sucursales fiscales
- Backend inventory_bp.py y pos_bp.py refactorizados para inventario compartido
- Backend invoicing_bp.py usa datos fiscales de la sucursal de la venta
- Frontend config.html/js con modal de sucursales expandido

Fase 3: Factura global mensual
- Migracion v4.1: tablas global_invoice_sales, sales.global_invoiced_at
- build_global_invoice_xml() con InformacionGlobal SAT-compliant
- Servicio global_invoice.py para agrupar ventas PUE <=000
- Endpoints POST/GET /global-invoice y /global-invoice/eligible-sales
- Frontend invoicing.html/js con boton y modal de factura global
2026-06-11 08:59:56 +00:00
ea29cc31c0 feat(catalog): supplier catalog cleanup, fuzzy matching, and navigation fixes
- Cleaned 137+ fake engine-displacement models from supplier imports
  (v3/v4 scripts: Chevrolet, Ford, Chrysler, Dodge, Jeep, Nissan, etc.)
- Removed 1,251+ corrupted models (INT. prefixes, year-suffix, torque specs,
  empty names, trailing-year variants)
- Migrated supplier tables to master DB (supplier_catalog,
  supplier_catalog_compat, supplier_catalog_interchange)
- Fixed _get_mye_ids_with_parts() to query supplier_catalog_compat from
  master DB so supplier-only vehicles appear for all tenants
- Added fuzzy model matcher with parenthesis stripping, noise suffix removal,
  compact matching, prefix/substring fallback, model aliases, and ±3 year
  proximity
- Matched compat rows: KEEP GREEN +14,152, KNADIAN +3,021, VAZLO +127,500,
  LUK +477, RAYBESTOS +1,743
- Added KNADIAN catalog importer with year-range expansion and future-year
  filtering
- Added VAZLO catalog importer with position parsing and SKU-in-model cleanup
- Added Keep Green, LUK, Yokomitsu, Raybestos catalog importers
- Cache clearing after cleanups (_classify_cache_*, nexus:mye_ids:*,
  nexus:brand_mye_counts:*)

Final match rates:
- KEEP GREEN: 90.3%
- VAZLO: 93.6%
- YOKOMITSU: 100.0%
- KNADIAN: 57.4%
- LUK: 51.0%
- RAYBESTOS: 55.9%
2026-06-09 07:47:42 +00:00
5ea667b80e fix: filter null items in nav_main to prevent href error on disabled catalog 2026-05-28 00:53:33 +00:00
77541e4c52 fix: re-render sidebar when module config loads
- Convert sidebar.js IIFE to window.renderSidebar() so it can be re-rendered
- Call window.renderSidebar(data) from app-init.js after fetching modules
- Ensures sidebar reflects actual tenant module config, not stale localStorage
2026-05-28 00:36:07 +00:00
9f04bfe0bb feat: add catalog module toggle
- Add catalog module to POS config endpoints, sidebar filter, config UI
- Add catalog toggle to Instance Manager tenant modules modal
2026-05-28 00:29:33 +00:00
718fa06888 feat: module toggles in POS config and Instance Manager
- Add GET/PUT /pos/api/config/modules endpoints in POS config_bp.py
- Update sidebar.js to filter nav items based on enabled modules
- Add Modules section to POS config.html with toggles for WhatsApp, Marketplace, MercadoLibre
- Add module load/save logic to POS config.js
- Preload modules in app-init.js for sidebar caching

- Add tenant module management to Instance Manager
  - get_tenant_modules / update_tenant_modules in tenant_service.py
  - GET/PUT /api/tenants/<id>/modules endpoints in tenants_bp.py
  - Add modules modal to manager index.html
  - Add module editing UI and logic to manager.js
  - Add toggle-switch CSS to manager.css
2026-05-28 00:21:52 +00:00
999591e248 fix(inventory): createItem crash when newPrice2/newPrice3 inputs don't exist in DOM 2026-05-26 22:12:28 +00:00
3d0d52c60b chore: bump pos.js to v6 for barcode feedback cache invalidation 2026-05-26 09:38:16 +00:00
c5fc8c5ec6 feat(ui): infinite scroll, saved filters, product timeline, image comparator, customers bulk toolbar, dark mode refinements 2026-05-26 09:37:35 +00:00
5c815bc2f5 feat(ui): ML status cards with sparklines, Kanban order view in marketplace_external 2026-05-26 09:32:27 +00:00
b6a327c98c feat(ui): barcode scanner feedback in POS, timeline & kanban CSS, image comparator modal, ticket preview modal 2026-05-26 09:30:14 +00:00
68d6f81671 feat(ui): helpers pos-utils.js (barcode feedback, saved filters, resizable columns, density/touch toggles, notifications dropdown, ticket preview, image comparator, infinite scroll, sparklines) + inventory.html modals + pos-ui.css timeline & kanban 2026-05-26 09:28:35 +00:00
61bf84b2dc fix(alerts): limit alerts to 500 per type in SQL + frontend pagination with 'Ver más' + summary bar 2026-05-26 09:12:09 +00:00
3009ffa1b0 fix(templates): repair malformed script tags caused by sed — app-init.js was broken in all templates 2026-05-26 09:03:12 +00:00
7cef8db6af feat(ui): customers.js skeletons, empty states, version bump 2026-05-26 08:49:58 +00:00
03b32f3b17 feat(ui): Cmd+K registration across all POS pages + fix quotations/whatsapp script tags 2026-05-26 08:48:03 +00:00
eb107e2778 feat(ui): PWA splash screen, animated logo, dynamic favicon, manifest shortcuts, splash-loader.js 2026-05-26 08:45:56 +00:00
031c190635 feat(ui): marketplace_external skeletons, empty states, toast notifications, Cmd+K 2026-05-26 08:44:09 +00:00
7020890b0e feat(ui): dashboard skeletons, empty states, Cmd+K registration, improved loading states 2026-05-26 08:42:17 +00:00
23dbf54f3f feat(ui): POS UI polish kit — skeletons, toasts, empty states, Cmd+K, tooltips, badges, scrollbars, focus rings, bulk toolbar, breadcrumbs, avatars, connection indicator, sparklines, animations, touch mode, image comparator, ticket preview, resizable columns, sticky headers, density mode 2026-05-26 08:39:32 +00:00
3060dab471 fix: remove body background-color/color transitions to prevent theme flash
- Remove transition: background-color/color from body in all CSS files
- These transitions caused visible flash when navigating between pages
- The browser would animate from old theme colors to new theme colors
2026-05-26 08:12:38 +00:00
716e19d079 fix: remove hardcoded data-theme=industrial from all pages to prevent flash
- The inline anti-flash script now applies the correct theme before any CSS renders
- Without the hardcoded attribute, the browser never paints with the wrong theme
2026-05-26 08:02:55 +00:00
51f64921a5 fix: theme flash + language persistence on navigation
- Remove setTimeout re-application of theme in app-init.js that caused flash
- Fix quotations.html: add missing i18n.js and correct script load order
- Fix whatsapp.html: add missing app-init.js before sidebar.js
- Ensure i18n.js always loads before sidebar.js for proper translation
2026-05-26 07:41:38 +00:00
91caf91b79 feat: prominent ML shipping config error message + skip-validation checkbox
- Add skip-validation checkbox for accounts where ML validation fails due to config
- Detect 'User has not mode' errors and show detailed actionable help box
- Include direct links to ML seller config and support
2026-05-26 06:39:23 +00:00
584cc385b9 feat: add custom/not_specified shipping modes with cost input for ML publish
- build_item_payload supports shipping_cost for custom mode with costs array
- Add shipping mode selector: me2, custom, not_specified
- Show shipping cost input when custom is selected
- Backend passes shipping_cost through custom_data to payload builder
2026-05-26 06:32:21 +00:00
314075021e fix: simplify shipping payload, remove forced local_pick_up/free_shipping
- build_item_payload now sends only mode in shipping payload by default
- Let ML determine free_shipping/local_pick_up based on account config
- Better error message for mandatory free shipping scenario
2026-05-26 06:13:50 +00:00
f742cdaa42 fix: ML shipping config check + improved payload + actionable error messages
- Add get_shipping_preferences to meli_service.py
- Add check_meli_shipping_config to validate ME2 adoption before publishing
- Include local_pick_up and free_shipping in item payload
- Translate ME2/mode errors to actionable Spanish messages
- Check shipping config in both validate_items and publish_items
2026-05-26 05:26:47 +00:00
79d3368041 fix: image upload field name in ML modal (use 'file' to match inventory_bp endpoint) 2026-05-26 05:18:14 +00:00
bfb4921ac0 fix: virtual scroll flickering on inventory scroll
- Batch scroll renders with requestAnimationFrame to avoid multiple DOM updates per frame
- Add will-change, contain and content-visibility CSS for smoother compositing
- Add cache-bust to virtual-scroll.js
2026-05-26 05:13:36 +00:00
b314a781a1 feat: robust ML publish with pre-flight, preview, validation, async
- Add /inventory-check endpoint for local pre-flight validation
- Add /listings/validate endpoint using ML /items/validate API
- Add /categories/<id>/attributes endpoint for required attrs
- Add /listings/async + polling for background publishing via Celery
- Editable preview: title (0/60 counter), price, stock per item
- Pre-flight checks: image, stock, price, duplicate detection
- Image upload directly from publish modal (uses existing /items/<id>/image)
- Dynamic required attributes form based on selected ML category
- Frontend: validate button, async polling with progress, detailed error display
- Backend: build_item_payload supports custom_title, extra_attributes
2026-05-26 04:37:05 +00:00
4866823ba9 Merge branch 'main' of https://git.consultoria-as.com/consultoria-as/Autoparts-DB 2026-05-26 04:24:15 +00:00
a236187f3a feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
2026-05-26 04:24:07 +00:00
71f3b1cdec se hacen modificaciones de catalogo a peticion de observaciones de carlos 24052026 2026-05-24 21:13:11 -07:00
159d0ed625 Actualizar README.md 2026-05-18 13:46:52 -07:00
50c0dbe7d4 feat(inventory): Qwen vehicle compatibility — store AI unmatched vehicles as text, Celery background sync, fix brand filter fallback, increase vehicle limits 2026-05-18 19:32:35 +00:00
0b1dc89faf fix(config): prevent card text overflow; fix(onboarding): persist completion server-side
Cards/Grid:
- Add min-width:0 to .device-grid to prevent grid overflow
- Add max-width:100%, overflow:hidden, word-break:break-word to .device-card
- Add min-width:0 and overflow-wrap to .device-card__body
- Bump config.css cache-bust to v=2

Onboarding:
- Add GET/POST /pos/api/config/onboarding-status endpoints in config_bp.py
- onboarding.js now checks server first before showing wizard
- On finish, POSTs completion to server (tenant_config table)
- Falls back to localStorage for fast path and offline resilience
- Bump onboarding.js cache-bust to v=2 in catalog.html
2026-05-18 07:31:31 +00:00
dbf45e374b fix(config): prevent device-card text overflow in printer grid
- Add min-width:0 and overflow-wrap:break-word to .device-card
- Add min-width:0 and overflow-wrap:break-word to .device-card__body
- Prevents grid items from expanding beyond their cell when content is wide
- Bump config.css cache-bust to v=2
2026-05-18 07:19:37 +00:00
07b9b9130a fix(css): add sidebar offset (260px) to all main content areas
The sidebar injected by sidebar.js is position:fixed with width:260px,
but most pages lacked margin-left on their main content, causing text
to be hidden behind the sidebar.

Affected pages:
- accounting, config, inventory, invoicing (.main)
- catalog, customers, diagrams, reports (.main-content)

Already fixed: dashboard, quotations
Not affected: fleet, whatsapp (use pos-main-offset), pos (no sidebar)
2026-05-18 07:15:34 +00:00
ae2273f864 fix(pos): remove duplicate currency symbols in Cut Z summary
fmt() already prepends the currency symbol; remove manual '$' prefixes
in loadCutX to prevent '262845500.00' display. Bump cache-bust v3 -> v4.
2026-05-18 07:03:09 +00:00
d9741b21f6 feat(pos): add Cut Z (close register) UI flow
- Add 'Corte Z' button in secondary actions panel
- Add modal showing register summary before closing:
  - opening amount, total sales, cash sales, change given
  - cash movements in/out, cancellations, expected cash
  - payment method breakdown and movement detail list
- loadCutX() fetches current register summary (read-only)
- confirmCutZ() calls POST /pos/api/register/cut-z with counted amount
- Auto-fills closing amount with expected cash
- Shows toast with difference after closing
- Resets register state to 'Sin caja abierta' after close
- Bump pos.css and pos.js cache-bust to v=3
2026-05-18 06:59:18 +00:00
e38148e8d5 feat(pos): add open-register UI flow
- Add 'Open Register' modal with register number and opening amount inputs
- loadRegister now shows clickable warning when no register is open
- checkout() opens register modal instead of plain alert when no register
- Add openRegister() API call to POST /pos/api/register/open
- Expose showOpenRegisterModal, closeOpenRegisterModal, openRegister globally
- Add cache-bust query params to pos.css and pos.js
2026-05-18 06:37:42 +00:00
912fe4cef5 fix(quotations): align main margin with sidebar width (240->260px) 2026-05-18 06:31:19 +00:00
a7334513ac fix(inventory): correct colspan and column counts for operation tables
fix(dashboard): align main margin with sidebar width (220->260px)
2026-05-18 06:14:54 +00:00
2f8b9dd5aa chore(inventory): bump inventory.js cache-bust v4 -> v5 2026-05-18 06:01:58 +00:00
60dd8162f7 feat(inventory): list operations in Entradas/Salidas/Traspasos/Ajustes tabs
- Add GET /operations endpoint with filtering by type, pagination, date range
- Join with inventory, employees, branches for rich display
- Add tbody IDs and footer/pagination IDs to operation tables in HTML
- Add loadOperations() JS function with renderOperationRow() per type
- Integrate loadOperations into switchTab for auto-load on tab change
- Update recordPurchase/Adjustment/Transfer to refresh respective lists
- Expose loadOperations globally for HTML inline script access
2026-05-18 06:00:58 +00:00
bfa7bc2997 feat(inventory): show ID column, add quick-entry button, improve purchase UX
- Add ID column to stock table header and renderInventoryRow
- Add 'Entrada' button on each stock row to open purchase modal pre-filled
- Show inventory ID in product detail popup
- recordPurchase: close modal, clear form, reload stats and stock list on success
- Fix colspan 11 -> 12 for empty state rows
2026-05-18 05:31:34 +00:00
6196234d8b fix(inventory): refresh list, close modal, update badges after creating item
- Expose loadInventoryStats globally so inventory.js can call it after CRUD
- Fix token key: use pos_token (not access_token) to match auth scheme
- After successful POST /items: close modal, clear form inputs, reload stats
- Bump inventory.js cache-bust query param v3 -> v4
2026-05-18 05:22:55 +00:00
e8db3e926c feat(manager): auto-provision WhatsApp Bridge on demo create/destroy
- Add POS_INTERNAL_URL config for cross-VM API calls
- create_demo now calls POS /internal/whatsapp-bridge after tenant creation
- delete_tenant now destroys bridge container before dropping DB
- Graceful fallback if bridge provisioning fails
2026-05-18 04:54:56 +00:00
d725ed2e0c feat(whatsapp): auto-provision Docker bridge per tenant
- Add Dockerfile.whatsapp-bridge with Baileys + env var support
- Modify whatsapp-bridge-server.js to accept PORT, TENANT_ID, WEBHOOK_BASE
- Add internal_bp.py with endpoints to provision/destroy bridges via Docker
- Register internal_bp in app.py
- Each tenant gets isolated container, port, and volume
2026-05-18 04:52:56 +00:00
36dd6634e3 feat(whatsapp): per-tenant WhatsApp configuration
- Refactor whatsapp_service.py to accept bridge_url parameter
- whatsapp_bp.py: remove hardcoded tenant_id=11, use g.tenant_id
- whatsapp_bp.py: webhook now accepts ?tenant_id param with fallback
- config_bp.py: add GET/PUT /config/whatsapp endpoints
- Each tenant can now have its own Baileys bridge URL and settings
2026-05-18 04:38:47 +00:00
24cdd71262 feat(inventory): dynamic tab badges with real tenant data
- Add /pos/api/inventory/stats endpoint returning counts per tab
- Replace hardcoded badge numbers (4,817, 14, 3, 23) with dynamic values
- Frontend auto-fetches stats on page load and updates badges
2026-05-18 04:31:00 +00:00
9ad624d26c feat(landing): remove POS access buttons from public landing page 2026-05-18 00:53:31 +00:00
2af2389294 feat(manager): add remote VM support via NEXUS_SERVER_HOST
- config.py: add NEXUS_SERVER_HOST env var for cross-VM deployment
- health_service.py: graceful Redis failure when only localhost-bound
- systemd service: document remote VM configuration
- README: add dedicated 'VM separada' installation section
- .env.example: new file with remote connection examples
2026-05-17 21:37:00 +00:00
be4bb8d9ad feat(manager): add Nexus Instance Manager for demo orchestration
- Complete Flask-based control panel for multi-tenant POS instances
- Dashboard with global stats, system health, and recent demos
- Demo provisioning in 1 click with auto-expiration tracking
- Tenant management: activate/deactivate, reset data, delete
- Health monitoring: PostgreSQL, Redis, disk, memory, systemd services
- Migration orchestration UI for running schema updates across all tenants
- JWT authentication with manager_users table
- Dark theme SPA frontend with real-time search and actions
- systemd service file included
2026-05-17 21:01:01 +00:00
da362e32a6 feat(catalog): full vehicle selector flow in brand catalog
Brand catalog now follows the same navigation as the regular catalog:
1. Brands -> 2. Models -> 3. Years -> 4. Engines -> 5. Categories -> 6. Parts

Backend:
- Add /mye-parts endpoint for MYE-specific parts with category filter
- Uses existing /models, /years, /engines, /categories endpoints

Frontend:
- Complete rewrite of brand-catalog.js with breadcrumb navigation
- State machine: brands -> models -> years -> engines -> categories -> parts
- Search and pagination preserved at parts level
- Breadcrumb allows jumping back to any previous step
2026-05-14 22:35:01 +00:00
79fa7984a1 feat(sw): auto-reload page when service worker updates
Add updatefound listener in catalog.html that reloads the page
automatically when a new service worker is activated. This ensures
users get the latest HTML and JS without manual hard refresh.
2026-05-14 22:26:42 +00:00
30abecc07d fix(sw): v6 with network-first HTML strategy
- Bump cache to nexus-pos-v6 to force invalidation
- HTML pages now use network-first instead of cache-first
  This ensures users always get the latest HTML with correct
  JS/CSS references (?v=3) instead of stale cached HTML
- Remove HTML pages from APP_SHELL precache (only static assets)
- Keep cache-first for JS/CSS/images
2026-05-14 22:26:27 +00:00
201 changed files with 29702 additions and 2169 deletions

View File

@@ -57,6 +57,13 @@ METABASE_ADMIN_EMAIL=admin@nexus.local
METABASE_ADMIN_PASS=change-me-to-a-strong-password METABASE_ADMIN_PASS=change-me-to-a-strong-password
METABASE_DB_PASS=metabase_secret METABASE_DB_PASS=metabase_secret
# ═══════════════════════════════════════════════════════════════════════════
# FACTURAPI (OPTIONAL — auto-organization mode for new tenants)
# ═══════════════════════════════════════════════════════════════════════════
# If set, new tenants can create Facturapi organizations automatically.
# Otherwise each tenant must store its secret key in tenant_config.cfdi_facturapi_key.
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# CURRENCY # CURRENCY
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════

View File

@@ -195,7 +195,9 @@ Ver [docs/INSTALACION.md](docs/INSTALACION.md) para instrucciones detalladas.
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Arquitectura del sistema | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Arquitectura del sistema |
| [docs/DATABASE.md](docs/DATABASE.md) | Esquema de base de datos | | [docs/DATABASE.md](docs/DATABASE.md) | Esquema de base de datos |
--- ---
**Nexus Autoparts** -- Tu conexion directa con las partes que necesitas **Nexus Autoparts** -- Tu conexion directa con las partes que necesitas
cloudflared tunnel run --token eyJhIjoiZDRjYzMwN2MzOTM2ODFlMGJiNTIwODZlZmNkZDFiM2MiLCJ0IjoiNDA3OTgwNDItNmMyZC00ZmY4LTgwNzgtMDYwZDA0ZDdhZTY0IiwicyI6Ik5qSXdPVGN4TXpBdE5HWTVOeTAwTldOaExUazFZV1l0WWpobU9XVXdORGc1WTJJMyJ9

View File

@@ -11,6 +11,9 @@ if not DB_URL:
"Example: postgresql://user:pass@localhost/nexus_autoparts" "Example: postgresql://user:pass@localhost/nexus_autoparts"
) )
MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or DB_URL
TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE") or DB_URL.replace("nexus_autoparts", "{db_name}")
# Legacy SQLite path (used only by migration script) # Legacy SQLite path (used only by migration script)
SQLITE_PATH = os.path.join( SQLITE_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), os.path.dirname(os.path.abspath(__file__)),

View File

@@ -92,6 +92,14 @@
<span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span> <span class="badge" id="pendingUsersBadge" style="display:none; background:var(--warning); color:#000; font-size:0.7rem; padding:2px 6px; border-radius:10px; margin-left:auto;"></span>
</div> </div>
</div> </div>
<div class="sidebar-section">
<h3>Tenants</h3>
<div class="sidebar-item" data-section="tenants">
<span class="icon">🏢</span>
<span>Módulos</span>
</div>
</div>
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
@@ -660,6 +668,35 @@
</div> </div>
</section> </section>
<!-- Tenants / Modules Section -->
<section id="section-tenants" class="admin-section">
<div class="page-header">
<h1 class="page-title">Configuración de Módulos por Tenant</h1>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Tenants Activos</h2>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>WhatsApp</th>
<th>Marketplace</th>
<th>MercadoLibre</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="tenantsTable">
<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main> </main>
</div> </div>

View File

@@ -121,6 +121,9 @@ function showSection(sectionId) {
case 'users': case 'users':
loadUsers(); loadUsers();
break; break;
case 'tenants':
loadTenants();
break;
} }
} }
@@ -2074,3 +2077,99 @@ async function toggleUserActive(userId, currentActive) {
showAlert(e.message, 'error'); showAlert(e.message, 'error');
} }
} }
// ─── Tenants / Modules ─────────────────────────────────────────────────────
async function loadTenants() {
var token = localStorage.getItem('access_token');
var tbody = document.getElementById('tenantsTable');
tbody.innerHTML = '<tr><td colspan="6" class="loading"><div class="spinner"></div></td></tr>';
try {
var res = await fetch('/api/admin/tenants', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) throw new Error('Error al cargar tenants (' + res.status + ')');
var data = await res.json();
var tenants = data.tenants || [];
if (tenants.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-secondary); padding:2rem;">No hay tenants activos</td></tr>';
return;
}
// Load modules for each tenant
var modulesMap = {};
await Promise.all(tenants.map(async function(t) {
try {
var mres = await fetch('/api/admin/tenants/' + t.id + '/modules', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (mres.ok) {
modulesMap[t.id] = await mres.json();
} else {
modulesMap[t.id] = {};
}
} catch (e) {
modulesMap[t.id] = {};
}
}));
renderTenantsTable(tenants, modulesMap);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:#ef4444; padding:2rem;">' + e.message + '</td></tr>';
}
}
function renderTenantsTable(tenants, modulesMap) {
var tbody = document.getElementById('tenantsTable');
tbody.innerHTML = tenants.map(function(t) {
var mods = modulesMap[t.id] || {};
function toggleBtn(tenantId, key, enabled) {
var label = enabled ? 'Activado' : 'Desactivado';
var cls = enabled ? 'btn-primary' : 'btn-secondary';
return '<button class="btn ' + cls + '" style="font-size:0.75rem; padding:3px 10px;" ' +
'onclick="toggleTenantModule(' + tenantId + ', \'' + key + '\', ' + enabled + ')">' + label + '</button>';
}
return '<tr>' +
'<td>' + t.id + '</td>' +
'<td>' + (t.name || '-') + '</td>' +
'<td>' + toggleBtn(t.id, 'whatsapp_enabled', !!mods.whatsapp_enabled) + '</td>' +
'<td>' + toggleBtn(t.id, 'marketplace_enabled', !!mods.marketplace_enabled) + '</td>' +
'<td>' + toggleBtn(t.id, 'meli_enabled', !!mods.meli_enabled) + '</td>' +
'<td><button class="btn btn-primary" style="font-size:0.75rem; padding:3px 10px;" onclick="loadTenants()">🔄 Recargar</button></td>' +
'</tr>';
}).join('');
}
async function toggleTenantModule(tenantId, key, currentValue) {
var token = localStorage.getItem('access_token');
var moduleNames = {
'whatsapp_enabled': 'WhatsApp',
'marketplace_enabled': 'Marketplace',
'meli_enabled': 'MercadoLibre'
};
var action = currentValue ? 'desactivar' : 'activar';
if (!confirm('¿Seguro que deseas ' + action + ' ' + moduleNames[key] + ' para el tenant #' + tenantId + '?')) return;
try {
var payload = {};
payload[key] = !currentValue;
var res = await fetch('/api/admin/tenants/' + tenantId + '/modules', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(payload)
});
if (!res.ok) {
var err = await res.json();
throw new Error(err.error || 'Error al actualizar módulo');
}
showAlert(moduleNames[key] + ' ' + (currentValue ? 'desactivado' : 'activado') + ' para tenant #' + tenantId);
loadTenants();
} catch (e) {
showAlert(e.message, 'error');
}
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Autoparts — Sistema completo para refaccionarias</title> <title>Nexus Autoparts — Sistema completo para refaccionarias</title>
<meta name="description" content="POS + Catalogo TecDoc 1.5M+ partes + Marketplace B2B + IA. Todo lo que necesita una refaccionaria en una sola plataforma."> <meta name="description" content="POS + Catalogo 1.5M+ partes + IA + Venta en linea. Todo lo que necesita una refaccionaria en una sola plataforma.">
<script> <script>
(function(){ (function(){
var t = localStorage.getItem('nexus-theme') || 'industrial'; var t = localStorage.getItem('nexus-theme') || 'industrial';
@@ -32,7 +32,6 @@
<span id="themeIcon">&#9790;</span> <span id="themeIcon">&#9790;</span>
</button> </button>
<a href="/catalog" class="btn btn-primary">Ver Catalogo</a> <a href="/catalog" class="btn btn-primary">Ver Catalogo</a>
<a href="/pos/login" class="btn btn-secondary">Acceder POS</a>
</div> </div>
</div> </div>
</header> </header>
@@ -42,13 +41,12 @@
<canvas id="heroCanvas"></canvas> <canvas id="heroCanvas"></canvas>
<div class="hero-content"> <div class="hero-content">
<h1 class="nx-reveal">Nexus Autoparts</h1> <h1 class="nx-reveal">Nexus Autoparts</h1>
<p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo TecDoc, facturacion, marketplace B2B e inteligencia artificial.</p> <p class="subtitle nx-reveal">Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo de partes, facturacion, venta en linea e inteligencia artificial.</p>
<div class="typewriter-line nx-reveal"> <div class="typewriter-line nx-reveal">
<span id="typewriterText"></span><span class="typewriter-cursor"></span> <span id="typewriterText"></span><span class="typewriter-cursor"></span>
</div> </div>
<div class="hero-buttons nx-reveal"> <div class="hero-buttons nx-reveal">
<a href="/catalog" class="btn btn-primary btn-lg">Explorar Catalogo</a> <a href="/catalog" class="btn btn-primary btn-lg">Explorar Catalogo</a>
<a href="/pos/login" class="btn btn-secondary btn-lg">Probar el POS</a>
</div> </div>
<div class="hero-stats nx-stagger"> <div class="hero-stats nx-stagger">
<div class="stat-card nx-reveal"> <div class="stat-card nx-reveal">
@@ -80,59 +78,46 @@
<section class="product"> <section class="product">
<div class="container"> <div class="container">
<h2 class="section-title nx-reveal">El Producto</h2> <h2 class="section-title nx-reveal">El Producto</h2>
<p class="section-subtitle nx-reveal">El unico sistema que combina POS + Inventario + CFDI + Catalogo + Marketplace + IA en una sola plataforma</p> <p class="section-subtitle nx-reveal">Las 3 funcionalidades principales que hacen crecer tu refaccionaria</p>
<div class="product-grid nx-stagger"> <div class="product-grid nx-stagger">
<div class="product-card product-card--orange nx-reveal"> <div class="product-card product-card--orange nx-reveal">
<h3>Ventas & POS</h3> <h3>Catalogo Completo + POS + Inventario</h3>
<ul> <ul>
<li>Punto de venta completo con F-keys y escaner</li> <li>Catalogo completo: 1.5M+ partes OEM y 304K+ aftermarket</li>
<li>Caja registradora multi-caja, cortes X/Z</li> <li>Punto de venta completo con escaner y teclas rapidas</li>
<li>Cotizaciones, apartados, devoluciones</li> <li>Inventario append-only con toma fisica y alertas de stock</li>
<li>Clientes con credito y 3 niveles de precio</li> <li>Navegacion por vehiculo: Marca > Modelo > Ano > Motor</li>
<li>Facturacion CFDI 4.0 (Ingreso, Egreso, Pago)</li> <li>Decodificador VIN + busqueda por placas MX</li>
<li>Impresion termica ESC/POS</li> <li>Facturacion CFDI 4.0 integrada</li>
<li>Contabilidad con polizas automaticas</li>
<li>Reportes: ventas, ABC, cortes, utilidad</li>
</ul> </ul>
</div> </div>
<div class="product-card product-card--cyan nx-reveal"> <div class="product-card product-card--cyan nx-reveal">
<h3>Catalogo & Inventario</h3> <h3>Agente AI para WhatsApp</h3>
<ul> <ul>
<li>Catalogo TecDoc: 1.5M+ partes OEM</li> <li>Atiende consultas de autopartes 24/7 automaticamente</li>
<li>304K+ partes aftermarket con cross-refs</li> <li>Genera cotizaciones inteligentes desde la conversacion</li>
<li>Navegacion: Ano > Marca > Modelo > Motor</li> <li>Reconoce piezas por foto con Vision AI</li>
<li>VIN decoder + busqueda por placas MX</li> <li>Transcripcion de notas de voz a texto</li>
<li>Inventario append-only, toma fisica</li> <li>Envia catalogos y cotizaciones directo al cliente</li>
<li>Imagenes de productos con upload masivo</li> <li>Reduce llamadas y aumenta conversiones</li>
<li>Traduccion automatica EN > ES (326 partes)</li>
<li>Marketplace B2B: bodegas ↔ talleres</li>
</ul> </ul>
</div> </div>
<div class="product-card product-card--green nx-reveal"> <div class="product-card product-card--green nx-reveal">
<h3>IA & Plataforma</h3> <h3>Vinculacion con Mercado Libre</h3>
<ul> <ul>
<li>Chatbot IA: diagnostico, cotizacion inteligente</li> <li>Publica tu inventario en Mercado Libre en minutos</li>
<li>Entrada por voz (Web Speech API)</li> <li>Sincronizacion automatica de stock y precios</li>
<li>Reconocimiento de partes por foto (Vision AI)</li> <li>Descarga ordenes y conviertelas en ventas del POS</li>
<li>WhatsApp Business integrado (envio de cotizaciones)</li> <li>Gestiona listados, preguntas y ventas desde un solo lugar</li>
<li>Gestion de flotillas y mantenimiento</li> <li>Empieza a vender en linea sin complicaciones</li>
<li>PWA + App Android, modo kiosko</li> <li>Mas canales, mas ventas, mismo inventario</li>
<li>Offline-first con sync automatico</li>
<li>2 temas, 2 idiomas (ES/EN), 2 monedas (MXN/USD)</li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="hw-banner nx-reveal">
<div class="hw-banner-inner">
<span>&#128421;</span>
<div class="hw-text">A partir del plan <strong>Pro</strong>: servidor en <strong>rack 3D personalizado</strong> — Mini PC + switch + AP + UPS.<br>Todo incluido por <strong>$2,000 MXN/mes</strong>. Solo conectar y empezar a vender.</div>
</div>
</div>
</div> </div>
</section> </section>
@@ -154,12 +139,12 @@
<div class="step nx-reveal"> <div class="step nx-reveal">
<div class="step-number">2</div> <div class="step-number">2</div>
<h3>Catalogo + Inventario</h3> <h3>Catalogo + Inventario</h3>
<p>Tu inventario conectado al catalogo TecDoc. Busca por vehiculo, parte o VIN.</p> <p>Tu inventario conectado al catalogo de partes. Busca por vehiculo, parte o VIN.</p>
</div> </div>
<div class="step nx-reveal"> <div class="step nx-reveal">
<div class="step-number">3</div> <div class="step-number">3</div>
<h3>Vende y Crece</h3> <h3>Vende y Crece</h3>
<p>POS, facturacion, marketplace B2B, WhatsApp e IA — todo desde un solo lugar.</p> <p>POS, facturacion, venta en linea, WhatsApp e IA — todo desde un solo lugar.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -178,7 +163,7 @@
<div class="diff-grid nx-stagger"> <div class="diff-grid nx-stagger">
<div class="diff-card nx-reveal"> <div class="diff-card nx-reveal">
<div class="diff-icon">&#128269;</div> <div class="diff-icon">&#128269;</div>
<h4>Catalogo TecDoc</h4> <h4>Catalogo Completo</h4>
<p>1.5M+ partes con cross-references. Nadie mas lo tiene en MX.</p> <p>1.5M+ partes con cross-references. Nadie mas lo tiene en MX.</p>
</div> </div>
<div class="diff-card nx-reveal"> <div class="diff-card nx-reveal">
@@ -193,13 +178,13 @@
</div> </div>
<div class="diff-card nx-reveal"> <div class="diff-card nx-reveal">
<div class="diff-icon">&#128640;</div> <div class="diff-icon">&#128640;</div>
<h4>Marketplace B2B</h4> <h4>Venta en Linea</h4>
<p>Conecta bodegas con talleres. Mas ventas, menos llamadas.</p> <p>Conecta tu inventario con Mercado Libre y vende 24/7.</p>
</div> </div>
<div class="diff-card nx-reveal"> <div class="diff-card nx-reveal">
<div class="diff-icon">&#128421;</div> <div class="diff-icon">&#128421;</div>
<h4>Hardware incluido</h4> <h4>Hardware opcional</h4>
<p>Rack 3D con servidor. Renta todo por $2,000/mes.</p> <p>Mini rack 3D con servidor. Disponible como add-on.</p>
</div> </div>
<div class="diff-card nx-reveal"> <div class="diff-card nx-reveal">
<div class="diff-icon">&#127760;</div> <div class="diff-icon">&#127760;</div>
@@ -229,41 +214,46 @@
<section class="pricing"> <section class="pricing">
<div class="container"> <div class="container">
<h2 class="section-title nx-reveal">Planes</h2> <h2 class="section-title nx-reveal">Planes</h2>
<p class="section-subtitle nx-reveal">Software desde $999/mes. Hardware incluido a partir del plan Pro.</p> <p class="section-subtitle nx-reveal">Elige el plan que se ajuste a tu refaccionaria. Paga anual y ahorra 2 meses.</p>
<div class="pricing-grid nx-stagger"> <div class="pricing-grid nx-stagger">
<div class="pricing-card nx-reveal"> <div class="pricing-card nx-reveal">
<h4>Basico</h4> <h4>POS Basico</h4>
<div class="pricing-price">$999</div> <div class="pricing-price">$650</div>
<div class="pricing-period">MXN / mes — solo software</div> <div class="pricing-period">MXN / mes</div>
<ul> <ul>
<li>POS + Inventario</li> <li>Punto de venta completo</li>
<li>Catalogo TecDoc</li> <li>Inventario y catalogo de partes</li>
<li>CFDI 4.0</li> <li>Facturacion CFDI 4.0</li>
<li>Reportes basicos</li> <li>Reportes basicos</li>
</ul> </ul>
</div> </div>
<div class="pricing-card featured nx-reveal"> <div class="pricing-card featured nx-reveal">
<h4>Pro</h4> <h4>Sistema Completo</h4>
<div class="pricing-price">$2,000</div> <div class="pricing-price">$1,660</div>
<div class="pricing-period">MXN / mes — hardware incluido</div> <div class="pricing-period">MXN / mes</div>
<ul> <ul>
<li>Todo Basico +</li> <li>Todo lo del POS Basico +</li>
<li>Agente AI para WhatsApp</li>
<li>Vinculacion con Mercado Libre</li>
<li>Sync automatico de stock y ordenes</li>
<li>Contabilidad automatica</li> <li>Contabilidad automatica</li>
<li>Chatbot IA + WhatsApp</li> <li>Multi-sucursal y flotillas</li>
<li>Marketplace B2B</li>
<li>&#128421; Mini PC + rack 3D + red incluidos</li>
</ul> </ul>
</div> </div>
<div class="pricing-card nx-reveal"> </div>
<h4>Enterprise</h4> <div class="pricing-note nx-reveal" style="text-align:center; margin-top:var(--space-6); font-size:var(--text-body-sm); color:var(--color-text-secondary);">
<div class="pricing-price">$3,999</div> <p><strong>Paga anual y ahorra 2 meses.</strong> Aplica a meses sin intereses (MSI).</p>
<div class="pricing-period">MXN / mes — hardware incluido</div> </div>
<div class="pricing-grid nx-stagger" style="margin-top:var(--space-8);">
<div class="pricing-card nx-reveal" style="grid-column: 1 / -1; max-width: 600px; margin: 0 auto;">
<h4>Add-on: Mini Rack con Servidor</h4>
<div class="pricing-price">$3,000</div>
<div class="pricing-period">MXN / mes</div>
<ul> <ul>
<li>Todo Pro +</li> <li>Mini PC con POS preinstalado</li>
<li>Flotillas + Multi-bodega</li> <li>Switch + Access Point + UPS</li>
<li>API dedicada</li> <li>Rack 3D personalizado</li>
<li>Soporte prioritario</li> <li>Solo conectar y empezar a vender</li>
<li>&#128421; Hardware dedicado por sucursal</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -283,12 +273,12 @@
<div class="contact-card nx-reveal"> <div class="contact-card nx-reveal">
<div class="contact-icon">&#9993;</div> <div class="contact-icon">&#9993;</div>
<h4>Email</h4> <h4>Email</h4>
<a href="mailto:ialcarazsalazar@consultoria-as.com">ialcarazsalazar@consultoria-as.com</a> <a href="mailto:ivan@nexusautoparts.com.mx">ivan@nexusautoparts.com.mx</a>
</div> </div>
<div class="contact-card nx-reveal"> <div class="contact-card nx-reveal">
<div class="contact-icon">&#128241;</div> <div class="contact-icon">&#128241;</div>
<h4>WhatsApp</h4> <h4>WhatsApp</h4>
<a href="https://wa.me/526641234567" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a> <a href="https://wa.me/526642170990" class="btn-whatsapp" target="_blank" rel="noopener">Enviar Mensaje</a>
</div> </div>
<div class="contact-card nx-reveal"> <div class="contact-card nx-reveal">
<div class="contact-icon">&#128205;</div> <div class="contact-icon">&#128205;</div>

View File

@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL) sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
from config import DB_URL from config import DB_URL
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
from tenant_db import get_tenant_conn
from services.translations import translate_part_name, translate_category from services.translations import translate_part_name, translate_category
sys.path.insert(0, os.path.join(_base, '..', 'pos')) sys.path.insert(0, os.path.join(_base, '..', 'pos'))
@@ -4628,6 +4629,76 @@ def part_aftermarket(part_id):
session.close() session.close()
# ============================================================================
# Tenant Module Config Endpoints
# ============================================================================
MODULE_CONFIG_KEYS = [
'whatsapp_enabled',
'marketplace_enabled',
'meli_enabled',
]
@app.route('/api/admin/tenants')
def api_admin_tenants():
session = Session()
try:
rows = session.execute(text(
"SELECT id, name, db_name, is_active, is_seller FROM tenants WHERE is_active = true ORDER BY id"
)).mappings().all()
return jsonify({'tenants': [dict(r) for r in rows]})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@app.route('/api/admin/tenants/<int:tenant_id>/modules')
def api_admin_tenant_modules(tenant_id):
try:
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
result = {}
for key in MODULE_CONFIG_KEYS:
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
result[key] = (row[0] or '').lower() == 'true' if row else False
cur.close()
conn.close()
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/admin/tenants/<int:tenant_id>/modules', methods=['PUT'])
def api_admin_tenant_modules_update(tenant_id):
data = request.get_json() or {}
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
for key, value in data.items():
if key not in MODULE_CONFIG_KEYS:
continue
cur.execute(
"""
INSERT INTO tenant_config (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
""",
(key, 'true' if value else 'false'),
)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============================================================================ # ============================================================================
# Static files from dashboard root (CSS/JS/HTML) # Static files from dashboard root (CSS/JS/HTML)
# ============================================================================ # ============================================================================

View File

@@ -1,8 +1,9 @@
# Nexus POS — Resumen de Fases Implementadas # Nexus POS — Resumen de Fases Implementadas
**Fecha:** 2026-04-29 **Fecha:** 2026-06-11
**Versión DB:** v3.2 **Versión DB:** v4.1
**Tests:** 73/73 pasando (pytest) **Tests:** 73/73 pasando (pytest)
**Commit:** `2b73c2c`
--- ---
@@ -200,6 +201,50 @@ METABASE_URL=http://localhost:3000
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` | | — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` | | — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global
**Commit:** `2b73c2c` (2026-06-11)
### 7.1 Lista de Precios de Proveedor
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Precios por proveedor** | `supplier_catalog_prices` (master DB) | Precio, moneda, vigencia (effective_from/to), activo/inactivo |
| **Upload masivo** | `supplier_catalog_bp.py` | CSV/Excel con supplier_name, sku, price, currency |
| **Visualización** | `catalog.js`, `catalog_service.py` | `supplier_price` + `supplier_currency` en tarjetas y búsqueda |
| **Endpoints** | `supplier_catalog_bp.py` | `GET/POST/PUT/DELETE /pos/api/supplier-catalog/prices/*` |
### 7.2 Multi-sucursal Completo
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Schema migration v4.0** | `v4.0_multi_branch.sql` | `inventory.branch_id=NULL` (catálogo compartido), tabla `inventory_stock` |
| **Datos fiscales por sucursal** | `branches` (tenant DB) | `rfc`, `razon_social`, `regimen_fiscal`, `codigo_postal`, `serie_cfdi`, `folio_inicial`, `licencia_fiscal`, `certificado_pem`, `llave_pem`, `is_main` |
| **Sincronización de stock** | Trigger `trg_update_inventory_stock` | `inventory_operations``inventory_stock` automático |
| **Backend branches** | `config_bp.py` | CRUD completo con campos fiscales, validación de única sucursal `is_main` |
| **Backend inventario** | `inventory_bp.py`, `inventory_engine.py`, `pos_bp.py` | Stock por sucursal vía `inventory_stock`, catálogo compartido, verificación de stock en POS |
| **Backend facturación** | `invoicing_bp.py` | CFDI usa datos fiscales de la sucursal de la venta (`_get_issuer_config`) |
| **Frontend config** | `config.html`, `config.js` | Modal de sucursal expandido con todos los campos fiscales, edición inline |
### 7.3 Factura Global Mensual
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Schema migration v4.1** | `v4.1_global_invoice.sql` | `global_invoice_sales`, `sales.global_invoiced_at` |
| **Builder CFDI global** | `cfdi_builder.py` | `build_global_invoice_xml()` con `InformacionGlobal` SAT-compliant (`Periodicidad="04"`) |
| **Servicio** | `global_invoice.py` | Agrupa ventas PUE ≤$2,000 sin CFDI individual del mes/año solicitado |
| **Endpoints** | `invoicing_bp.py` | `POST /global-invoice`, `GET /global-invoice/<id>`, `GET /global-invoice/eligible-sales` |
| **Frontend** | `invoicing.html`, `invoicing.js` | Botón "Factura Global" con modal de año/mes + vista previa de ventas elegibles |
### 7.4 Mercado Libre — Mejoras
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Importar publicaciones existentes** | `meli_service.py`, `marketplace_external_service.py` | `get_user_items()` + `import_existing_listings()` — importa items del vendedor a `marketplace_listings` intentando match por SKU/part_number |
| **Sync stock POS → ML** | `inventory_engine.py`, `marketplace_external_service.py` | Trigger en `inventory_operations` inserta en `meli_sync_queue`; `process_meli_sync_queue()` actualiza `available_quantity` en ML vía API |
| **Sync órdenes ML → POS** | `marketplace_external_bp.py` | `POST /orders/sync` para sincronización manual; webhook `/webhook/meli` ya maneja notificaciones de órdenes vía Celery |
| **Migration v4.2** | `v4.2_meli_sync_queue.sql` | Tabla `meli_sync_queue` para encolar actualizaciones de stock |
--- ---
## Mejoras Pendientes (Roadmap Actualizado) ## Mejoras Pendientes (Roadmap Actualizado)
@@ -215,7 +260,7 @@ METABASE_URL=http://localhost:3000
| 1 | **WhatsApp Business API (Meta Cloud) real** | Migrar de Baileys a Meta Cloud API. Requiere verificación de cuenta Meta, Business Manager, número de teléfono verificado. | 2-3 semanas | Stub creado (`whatsapp_cloud_bp.py`) | | 1 | **WhatsApp Business API (Meta Cloud) real** | Migrar de Baileys a Meta Cloud API. Requiere verificación de cuenta Meta, Business Manager, número de teléfono verificado. | 2-3 semanas | Stub creado (`whatsapp_cloud_bp.py`) |
| 2 | **BNPL real** | Integrar APLAZO/Kueski/Clip con credenciales de sandbox/producción. | 2 semanas | Stub creado (`bnpl_bp.py`) | | 2 | **BNPL real** | Integrar APLAZO/Kueski/Clip con credenciales de sandbox/producción. | 2 semanas | Stub creado (`bnpl_bp.py`) |
| 3 | **ERP Sync real** | Conectar Aspel/CONTPAQi/SAP/Odoo vía API o archivos de intercambio. | 2-3 semanas | Stub creado (`erp_bp.py`) | | 3 | **ERP Sync real** | Conectar Aspel/CONTPAQi/SAP/Odoo vía API o archivos de intercambio. | 2-3 semanas | Stub creado (`erp_bp.py`) |
| 4 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas | No iniciado | | 4 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas | **Parcialmente listo** — ver Fase 7.4 |
### 🟡 Medio — Diferenciadores ### 🟡 Medio — Diferenciadores

82
docs/GLOBAL_INVOICE.md Normal file
View File

@@ -0,0 +1,82 @@
# Factura Global Mensual — Documentación Técnica
**Versión DB:** v4.1
**Commit:** `2b73c2c`
---
## Requerimiento SAT
El SAT permite agrupar tickets de contado (menores a $2,000) en una sola factura mensual tipo **Ingreso** con `InformacionGlobal`.
## Criterios de elegibilidad
Una venta es elegible para factura global si:
1. `metodo_pago_sat = 'PUE'` (pagado al momento)
2. `total <= $2,000`
3. `status = 'completed'`
4. No tiene CFDI individual timbrado (`cfdi_queue.status = 'stamped'`)
5. No está ya en una factura global (`sales.global_invoiced_at IS NULL`)
6. Fecha dentro del mes/año solicitado
## Arquitectura
### Tablas
- `global_invoice_sales (global_invoice_id, sale_id)` — relación N:M
- `sales.global_invoiced_at` — marca de inclusión
### XML
- `build_global_invoice_xml()` en `cfdi_builder.py`
- `InformacionGlobal Periodicidad="04"` (mensual)
- Receptor: `PUBLICO EN GENERAL` (RFC XAXX010101000)
---
## Endpoints
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| `GET` | `/pos/api/invoicing/global-invoice/eligible-sales?year=&month=&branch_id=` | Preview de ventas elegibles |
| `POST` | `/pos/api/invoicing/global-invoice` | Genera factura global |
| `GET` | `/pos/api/invoicing/global-invoice/<id>` | Estado y ventas vinculadas |
### POST body
```json
{
"year": 2026,
"month": 6,
"branch_id": 1
}
```
### Response
```json
{
"id": 42,
"status": "pending",
"sales_count": 15,
"total": 18450.00,
"provisional_folio": "PRE-00042",
"xml": "<?xml ...>"
}
```
---
## Flujo de uso (Frontend)
1. Ir a **Facturación**
2. Clic en botón **Factura Global**
3. Seleccionar año y mes
4. Clic en **Vista previa** para ver ventas elegibles
5. Clic en **Generar** para crear y encolar el CFDI
6. Procesar cola de timbrado normalmente
---
## Timbrado
La factura global entra en la cola `cfdi_queue` con:
- `type = 'ingreso'`
- `sale_id = NULL`
- Se timbra igual que cualquier otro CFDI vía Horux360

52
docs/MULTI_BRANCH.md Normal file
View File

@@ -0,0 +1,52 @@
# Multi-sucursal — Documentación Técnica
**Versión DB:** v4.0
**Commit:** `2b73c2c`
---
## Arquitectura
### Catálogo compartido
- `inventory.branch_id` es siempre `NULL` (catálogo compartido a nivel tenant).
- `part_number` tiene unique index `idx_inventory_part_unique`.
- Productos duplicados por `part_number` en múltiples sucursales fueron consolidados en la migración v4.0.
### Stock por sucursal
- Tabla `inventory_stock (inventory_id, branch_id, stock, location)`.
- Trigger `trg_update_inventory_stock` en `inventory_operations` mantiene `inventory_stock` sincronizado automáticamente.
- `inventory_stock_summary` sigue existiendo como stock total agregado (sin `branch_id`).
### Datos fiscales por sucursal
- Tabla `branches` incluye: `rfc`, `razon_social`, `regimen_fiscal`, `codigo_postal`, `serie_cfdi`, `folio_inicial`, `licencia_fiscal`, `certificado_pem`, `llave_pem`, `is_main`.
- Solo una sucursal puede ser `is_main = true`.
- Al facturar, `_get_issuer_config(cur, branch_id)` usa datos de la sucursal de la venta; fallback a config global del tenant.
---
## Endpoints
### Config
- `GET /pos/api/config/branches` — lista sucursales (sin PEM)
- `GET /pos/api/config/branches/<id>` — detalle completo (con PEM)
- `POST /pos/api/config/branches` — crear
- `PUT /pos/api/config/branches/<id>` — editar
### Inventario
- `GET /pos/api/inventory/items` — acepta `?branch_id=` para mostrar stock por sucursal
- Stock se lee de `inventory_stock` cuando se filtra por sucursal
### POS
- Ventas verifican stock vía `get_stock(conn, inventory_id, branch_id)`
- `inventory_operations` registra `branch_id` de la venta
---
## Migración
```bash
cd /home/Autopartes/pos
python3 migrations/runner.py
```
Archivo: `pos/migrations/v4.0_multi_branch.sql`

25
manager/.env.example Normal file
View File

@@ -0,0 +1,25 @@
# Nexus Instance Manager — Environment Variables
# Copy to .env and fill in your values.
# ─── Database (REQUIRED) ───────────────────────────────────────────────────
# If manager runs on a separate VM, use the IP of the PostgreSQL server.
MASTER_DB_URL=postgresql://nexus:PASSWORD@192.168.10.91/nexus_autopartes
TENANT_DB_URL_TEMPLATE=postgresql://nexus:PASSWORD@192.168.10.91/{db_name}
# ─── Remote Nexus Server IP (for VM-separated deployment) ──────────────────
# IP or hostname of the server running POS, Dashboard, Quart, Redis.
NEXUS_SERVER_HOST=192.168.10.91
# ─── Security (REQUIRED) ───────────────────────────────────────────────────
MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
# ─── Demo Defaults ─────────────────────────────────────────────────────────
DEMO_DEFAULT_DAYS=14
DEMO_DEFAULT_PIN=0000
# ─── Redis (OPTIONAL — health check only) ──────────────────────────────────
# Redis may only listen on localhost. If so, health check will show warning.
REDIS_URL=redis://192.168.10.91:6379/0
# ─── Internal ──────────────────────────────────────────────────────────────
POS_DIR=/home/Autopartes/pos

203
manager/README.md Normal file
View File

@@ -0,0 +1,203 @@
# Nexus Instance Manager
Panel de control central para gestionar instancias multi-tenant de Nexus POS.
## Qué hace
- **Crear demos** en 1 clic con subdominio, PIN de acceso y fecha de expiración
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
- **Ejecutar migraciones** de schema en todos los tenants desde una UI
- **Dashboard** con estadísticas globales y alertas de demos por expirar
## Estructura
```
manager/
├── app.py # Flask app principal
├── config.py # Variables de entorno
├── wsgi.py # Entry point para Gunicorn
├── requirements.txt # Dependencias
├── services/ # Lógica de negocio
│ ├── health_service.py # Health checks de infraestructura
│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS)
│ └── migration_service.py# Orquestación de migraciones
├── blueprints/ # API REST
│ ├── auth_bp.py # Login/logout JWT
│ ├── tenants_bp.py # Gestión de tenants
│ ├── demos_bp.py # Creación de demos
│ ├── health_bp.py # Health checks
│ └── admin_bp.py # Dashboard stats y migraciones
├── static/ # Frontend SPA
│ ├── css/manager.css
│ └── js/manager.js
├── templates/
│ └── index.html # Single Page App
├── scripts/
│ └── init_manager.py # Inicialización de DB + admin
├── systemd/
│ └── nexus-manager.service # Servicio systemd
└── README.md # Documentación completa
```
## Instalación rápida (mismo servidor)
### 1. Dependencias
```bash
cd /home/Autopartes/manager
pip install -r requirements.txt
```
### 2. Inicializar base de datos y usuario admin
```bash
cd /home/Autopartes/manager
python scripts/init_manager.py --email admin@nexus.local --password nexus2026 --name "Super Admin"
```
Esto crea:
- Tabla `manager_users` (login del panel)
- Tabla `manager_audit_log` (registro de acciones)
- Usuario admin por defecto
### 3. Configurar variables de entorno
Asegúrate de que estas variables estén disponibles (en systemd o `.env`):
```bash
MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
TENANT_DB_URL_TEMPLATE=postgresql://postgres@localhost/{db_name}
MANAGER_JWT_SECRET=genera-un-segredo-largo-aqui
POS_DIR=/home/Autopartes/pos
REDIS_URL=redis://localhost:6379/0
```
### 4. Registrar servicio systemd
```bash
cp systemd/nexus-manager.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable nexus-manager
systemctl start nexus-manager
```
Accede en: `http://TU_IP:5003`
### 5. (Opcional) Agregar a nginx
```nginx
server {
listen 80;
server_name manager.nexusautoparts.com.mx;
location / {
proxy_pass http://127.0.0.1:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}
```
---
## Instalación en Máquina Virtual separada (misma red local)
Si el manager corre en una VM diferente al servidor principal (donde está PostgreSQL + POS):
### Requisitos de red
- PostgreSQL del servidor principal debe escuchar en `0.0.0.0:5432` (verificar `listen_addresses = '*'` en `postgresql.conf`)
- POS (5001), Dashboard (5000) y Quart (5002) ya escuchan en `0.0.0.0` por defecto
- Redis puede estar solo en `127.0.0.1`; en ese caso el health check mostrará advertencia pero no afecta el funcionamiento
### 1. Clonar el repo en la VM
```bash
git clone https://git.consultoria-as.com/consultoria-as/Autoparts-DB.git /home/Autopartes
cd /home/Autopartes/manager
```
### 2. Instalar dependencias
```bash
pip install -r requirements.txt
```
### 3. Configurar variables para conexión remota
Crea `/home/Autopartes/manager/.env` o edita el servicio systemd:
```bash
# IP del servidor principal donde corre PostgreSQL y POS
NEXUS_SERVER_HOST=192.168.10.91
# PostgreSQL remoto (cambiar localhost por la IP del servidor)
MASTER_DB_URL=postgresql://nexus:PASSWORD@192.168.10.91/nexus_autoparts
TENANT_DB_URL_TEMPLATE=postgresql://nexus:PASSWORD@192.168.10.91/{db_name}
# Redis remoto (puede no funcionar si Redis solo escucha en localhost)
REDIS_URL=redis://192.168.10.91:6379/0
# Seguridad
MANAGER_JWT_SECRET=genera-un-segredo-largo-aqui
POS_DIR=/home/Autopartes/pos
```
**Nota importante:** La VM manager no necesita una instalación completa del POS. Solo necesita:
- Los archivos de `manager/`
- Los archivos de `pos/` (para reutilizar `tenant_manager.py` y migraciones)
- Conectividad TCP al puerto 5432 del servidor principal
### 4. Inicializar DB y admin
```bash
cd /home/Autopartes/manager
python scripts/init_manager.py --email admin@nexus.local --password TU_PASSWORD
```
### 5. Systemd
```bash
cp systemd/nexus-manager.service /etc/systemd/system/
# Edita el archivo y cambia localhost por la IP del servidor en MASTER_DB_URL y NEXUS_SERVER_HOST
nano /etc/systemd/system/nexus-manager.service
systemctl daemon-reload
systemctl enable nexus-manager
systemctl start nexus-manager
```
---
## Uso
### Crear una demo
1. Ve a la sección **Crear Demos**
2. Llena nombre del negocio, email, días de vigencia
3. El subdominio se genera automáticamente (puedes personalizarlo)
4. Click en **Crear Demo**
5. El panel muestra la URL de acceso y el PIN del owner
### Resetear una demo
- Presiona el ícono de 🔄 en la tabla de demos
- Limpia TODO el inventario, ventas, clientes, facturas
- Conserva empleados (incluyendo el owner) y configuración fiscal
### Eliminar una demo
- Presiona 🗑️ y confirma
- Borra permanentemente la base de datos del tenant
### Migraciones
- Ve a **Migraciones** para ver la versión de schema de cada tenant
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
---
## Notas de seguridad
- Cambia `MANAGER_JWT_SECRET` en producción
- El panel expone acciones destructivas (delete/reset); protege el acceso con firewall o VPN
- Usa HTTPS en producción
- Si despliegas en VM separada, asegúrate de que el firewall del servidor principal permite conexiones desde la IP de la VM manager al puerto 5432 (PostgreSQL)

99
manager/app.py Normal file
View File

@@ -0,0 +1,99 @@
"""Nexus Instance Manager — Flask Application."""
import os
import sys
from datetime import datetime
from flask import Flask, jsonify, render_template, send_from_directory, request
# Ensure POS modules are importable for tenant_manager reuse
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
if POS_DIR not in sys.path:
sys.path.insert(0, POS_DIR)
from config import APP_NAME, APP_VERSION
from blueprints.auth_bp import auth_bp, require_manager_auth
from blueprints.tenants_bp import tenants_bp
from blueprints.demos_bp import demos_bp
from blueprints.health_bp import health_bp
from blueprints.admin_bp import admin_bp
def create_app():
app = Flask(
__name__,
template_folder="templates",
static_folder="static"
)
app.secret_key = os.environ.get("MANAGER_JWT_SECRET", "dev-secret-change-me")
# Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(tenants_bp)
app.register_blueprint(demos_bp)
app.register_blueprint(health_bp)
app.register_blueprint(admin_bp)
# ─── Frontend Routes ───────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
@app.route("/login")
def login_page():
return render_template("index.html")
@app.route("/dashboard")
def dashboard_page():
return render_template("index.html")
@app.route("/tenants")
def tenants_page():
return render_template("index.html")
@app.route("/demos")
def demos_page():
return render_template("index.html")
@app.route("/health")
def health_page():
return render_template("index.html")
@app.route("/migrations")
def migrations_page():
return render_template("index.html")
# ─── Static Asset Helpers ──────────────────────────────────────────────
@app.route("/static/<path:filename>")
def static_files(filename):
return send_from_directory("static", filename)
# ─── API Status ────────────────────────────────────────────────────────
@app.route("/api/status")
def api_status():
return jsonify({
"app": APP_NAME,
"version": APP_VERSION,
"timestamp": datetime.utcnow().isoformat(),
"pos_dir": POS_DIR
})
# ─── Error Handlers ────────────────────────────────────────────────────
@app.errorhandler(404)
def not_found(e):
if request.path.startswith("/api/"):
return jsonify({"error": "Not found"}), 404
return render_template("index.html")
@app.errorhandler(500)
def internal_error(e):
if request.path.startswith("/api/"):
return jsonify({"error": "Internal server error"}), 500
return render_template("index.html")
return app
# Entry point for Gunicorn: gunicorn -w 2 -b 0.0.0.0:5003 app:app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5003, debug=True)

View File

View File

@@ -0,0 +1,35 @@
"""Admin dashboard blueprint."""
from flask import Blueprint, jsonify
from blueprints.auth_bp import require_manager_auth
from services import tenant_service, migration_service
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@admin_bp.route("/stats", methods=["GET"])
@require_manager_auth
def dashboard_stats():
return jsonify(tenant_service.get_dashboard_stats())
@admin_bp.route("/migrations", methods=["GET"])
@require_manager_auth
def list_migrations():
return jsonify({
"migrations": migration_service.list_available_migrations(),
"tenants": migration_service.get_tenant_versions()
})
@admin_bp.route("/migrations/run-all", methods=["POST"])
@require_manager_auth
def run_all_migrations():
result = migration_service.run_all_pending_migrations()
return jsonify(result)
@admin_bp.route("/migrations/run/<version>", methods=["POST"])
@require_manager_auth
def run_specific_migration(version):
result = migration_service.run_migration_on_all_tenants(version)
return jsonify({"results": result})

View File

@@ -0,0 +1,99 @@
"""Auth blueprint for Nexus Manager."""
import datetime
import jwt
import bcrypt
from flask import Blueprint, request, jsonify, current_app
from config import MANAGER_JWT_SECRET, MANAGER_JWT_EXPIRES
from services.tenant_service import get_master_conn
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
def hash_password(password):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def check_password(password, hashed):
return bcrypt.checkpw(password.encode(), hashed.encode())
def create_manager_token(user_id, email, role="admin"):
payload = {
"user_id": user_id,
"email": email,
"role": role,
"type": "access",
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=MANAGER_JWT_EXPIRES),
"iat": datetime.datetime.utcnow()
}
return jwt.encode(payload, MANAGER_JWT_SECRET, algorithm="HS256")
def decode_manager_token(token):
try:
return jwt.decode(token, MANAGER_JWT_SECRET, algorithms=["HS256"])
except Exception:
return None
def require_manager_auth(f):
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
token = None
if auth_header.startswith("Bearer "):
token = auth_header[7:]
elif request.cookies.get("manager_token"):
token = request.cookies.get("manager_token")
if not token:
return jsonify({"error": "Unauthorized"}), 401
payload = decode_manager_token(token)
if not payload or payload.get("type") != "access":
return jsonify({"error": "Invalid or expired token"}), 401
request.manager_user = payload
return f(*args, **kwargs)
return decorated
@auth_bp.route("/login", methods=["POST"])
def login():
data = request.get_json() or {}
email = data.get("email", "").strip().lower()
password = data.get("password", "")
if not email or not password:
return jsonify({"error": "Email and password required"}), 400
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT id, email, password_hash, role, name
FROM manager_users
WHERE email = %s AND is_active = true
""", (email,))
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "Invalid credentials"}), 401
user_id, db_email, pwd_hash, role, name = row
if not check_password(password, pwd_hash):
return jsonify({"error": "Invalid credentials"}), 401
token = create_manager_token(user_id, db_email, role)
return jsonify({
"access_token": token,
"user": {"id": user_id, "email": db_email, "role": role, "name": name}
})
@auth_bp.route("/me", methods=["GET"])
@require_manager_auth
def me():
return jsonify({"user": request.manager_user})

View File

@@ -0,0 +1,42 @@
"""Demo provisioning blueprint."""
from flask import Blueprint, request, jsonify
from blueprints.auth_bp import require_manager_auth
from services import tenant_service
demos_bp = Blueprint("demos", __name__, url_prefix="/api/demos")
@demos_bp.route("", methods=["POST"])
@require_manager_auth
def create_demo():
data = request.get_json() or {}
name = data.get("name", "").strip()
email = data.get("email", "").strip()
days = data.get("days")
subdomain = data.get("subdomain", "").strip() or None
pin = data.get("pin", "0000").strip()
if not name:
return jsonify({"error": "Business name is required"}), 400
try:
result = tenant_service.create_demo(
name=name,
email=email,
demo_days=days,
subdomain=subdomain,
pin=pin
)
return jsonify({"data": result}), 201
except ValueError as e:
return jsonify({"error": str(e)}), 409
except Exception as e:
return jsonify({"error": str(e)}), 500
@demos_bp.route("", methods=["GET"])
@require_manager_auth
def list_demos():
all_tenants = tenant_service.list_tenants(include_stats=True)
demos = [t for t in all_tenants if t.get("is_demo")]
return jsonify({"data": demos})

View File

@@ -0,0 +1,18 @@
"""Health check blueprint."""
from flask import Blueprint, jsonify
from blueprints.auth_bp import require_manager_auth
from services import health_service
health_bp = Blueprint("health", __name__, url_prefix="/api/health")
@health_bp.route("", methods=["GET"])
@require_manager_auth
def full_health():
return jsonify(health_service.get_full_health_report())
@health_bp.route("/tenant/<db_name>", methods=["GET"])
@require_manager_auth
def tenant_health(db_name):
return jsonify(health_service.get_tenant_health(db_name))

View File

@@ -0,0 +1,81 @@
"""Tenant management blueprint."""
from flask import Blueprint, request, jsonify
from blueprints.auth_bp import require_manager_auth
from services import tenant_service
tenants_bp = Blueprint("tenants", __name__, url_prefix="/api/tenants")
@tenants_bp.route("", methods=["GET"])
@require_manager_auth
def list_tenants():
include_stats = request.args.get("stats", "false").lower() == "true"
return jsonify({"data": tenant_service.list_tenants(include_stats=include_stats)})
@tenants_bp.route("/<int:tenant_id>", methods=["GET"])
@require_manager_auth
def get_tenant(tenant_id):
tenant = tenant_service.get_tenant(tenant_id)
if not tenant:
return jsonify({"error": "Tenant not found"}), 404
return jsonify({"data": tenant})
@tenants_bp.route("/<int:tenant_id>/stats", methods=["GET"])
@require_manager_auth
def get_tenant_stats(tenant_id):
tenant = tenant_service.get_tenant(tenant_id)
if not tenant:
return jsonify({"error": "Tenant not found"}), 404
return jsonify({"data": tenant_service._get_tenant_quick_stats(tenant["db_name"])})
@tenants_bp.route("/<int:tenant_id>/toggle", methods=["POST"])
@require_manager_auth
def toggle_tenant(tenant_id):
data = request.get_json() or {}
active = data.get("active", True)
result = tenant_service.toggle_tenant(tenant_id, active)
return jsonify(result)
@tenants_bp.route("/<int:tenant_id>/reset", methods=["POST"])
@require_manager_auth
def reset_tenant(tenant_id):
try:
result = tenant_service.reset_tenant(tenant_id)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>", methods=["DELETE"])
@require_manager_auth
def delete_tenant(tenant_id):
try:
result = tenant_service.delete_tenant(tenant_id)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>/modules", methods=["GET"])
@require_manager_auth
def get_tenant_modules(tenant_id):
try:
result = tenant_service.get_tenant_modules(tenant_id)
return jsonify({"data": result})
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>/modules", methods=["PUT"])
@require_manager_auth
def update_tenant_modules(tenant_id):
data = request.get_json() or {}
try:
result = tenant_service.update_tenant_modules(tenant_id, data)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500

57
manager/config.py Normal file
View File

@@ -0,0 +1,57 @@
"""Nexus Instance Manager — Configuration."""
import os
# ─── Database ──────────────────────────────────────────────────────────────
MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or os.environ.get("DATABASE_URL")
if not MASTER_DB_URL:
raise ValueError(
"MASTER_DB_URL environment variable is required. "
"Example: postgresql://user:pass@localhost/nexus_autoparts"
)
TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE")
if not TENANT_DB_URL_TEMPLATE:
raise ValueError(
"TENANT_DB_URL_TEMPLATE environment variable is required. "
"Example: postgresql://user:pass@localhost/{db_name}"
)
# ─── Security ──────────────────────────────────────────────────────────────
MANAGER_JWT_SECRET = os.environ.get("MANAGER_JWT_SECRET")
if not MANAGER_JWT_SECRET:
raise ValueError(
"MANAGER_JWT_SECRET environment variable is required. "
"Generate one with: python3 -c 'import secrets; print(secrets.token_hex(32))'"
)
MANAGER_JWT_EXPIRES = int(os.environ.get("MANAGER_JWT_EXPIRES", "28800")) # 8 hours
# Internal API key for manager-to-POS operations
INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "")
# ─── POS Server (for internal API calls from manager VM) ───────────────────
POS_INTERNAL_URL = os.environ.get("POS_INTERNAL_URL", "http://192.168.10.91:5001")
# ─── Demo Settings ─────────────────────────────────────────────────────────
DEMO_DEFAULT_DAYS = int(os.environ.get("DEMO_DEFAULT_DAYS", "14"))
DEMO_DEFAULT_PIN = os.environ.get("DEMO_DEFAULT_PIN", "0000")
DEMO_SUBDOMAIN_PREFIX = os.environ.get("DEMO_SUBDOMAIN_PREFIX", "demo")
# ─── Remote Nexus Server (for VM-separated manager) ────────────────────────
# Set this to the IP/hostname of the server running POS/PostgreSQL/Redis
NEXUS_SERVER_HOST = os.environ.get("NEXUS_SERVER_HOST", "127.0.0.1")
# ─── Services Health Check ─────────────────────────────────────────────────
POS_URL = os.environ.get("POS_URL", f"http://{NEXUS_SERVER_HOST}:5001/pos/health")
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", f"http://{NEXUS_SERVER_HOST}:5000/")
QUART_URL = os.environ.get("QUART_URL", f"http://{NEXUS_SERVER_HOST}:5002/")
REDIS_URL = os.environ.get("REDIS_URL", f"redis://{NEXUS_SERVER_HOST}:6379/0")
# ─── Paths ─────────────────────────────────────────────────────────────────
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
MIGRATIONS_DIR = os.path.join(POS_DIR, "migrations")
# ─── App Identity ──────────────────────────────────────────────────────────
APP_NAME = "Nexus Instance Manager"
APP_VERSION = "1.0.0"

5
manager/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
Flask>=2.3.0
psycopg2-binary>=2.9.0
bcrypt>=4.0.0
PyJWT>=2.8.0
redis>=5.0.0

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Initialize Nexus Instance Manager: create admin tables and default user."""
import os
import sys
import bcrypt
import argparse
# Add manager to path
MANAGER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if MANAGER_DIR not in sys.path:
sys.path.insert(0, MANAGER_DIR)
from services.tenant_service import get_master_conn
def init_schema():
"""Create manager_users table in master DB if not exists."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS manager_users (
id SERIAL PRIMARY KEY,
email VARCHAR(200) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL DEFAULT 'Admin',
password_hash VARCHAR(200) NOT NULL,
role VARCHAR(20) DEFAULT 'admin',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS manager_audit_log (
id SERIAL PRIMARY KEY,
user_email VARCHAR(200),
action VARCHAR(100) NOT NULL,
details JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
conn.commit()
cur.close()
conn.close()
print("[OK] Manager schema initialized.")
def create_admin(email, password, name="Admin"):
"""Create or update admin user."""
pwd_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
INSERT INTO manager_users (email, name, password_hash, role)
VALUES (%s, %s, %s, 'admin')
ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
name = EXCLUDED.name,
is_active = TRUE
""", (email.lower(), name, pwd_hash))
conn.commit()
cur.close()
conn.close()
print(f"[OK] Admin user '{email}' created/updated.")
def main():
parser = argparse.ArgumentParser(description="Initialize Nexus Manager")
parser.add_argument("--email", default="admin@nexus.local", help="Admin email")
parser.add_argument("--password", default="nexus2026", help="Admin password")
parser.add_argument("--name", default="Super Admin", help="Admin display name")
args = parser.parse_args()
print("Nexus Instance Manager — Initialization")
print("=" * 40)
init_schema()
create_admin(args.email, args.password, args.name)
print("=" * 40)
print(f"Login: {args.email}")
print(f"URL: http://manager-ip:5003")
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,175 @@
"""Health monitoring service for Nexus infrastructure."""
import subprocess
import shutil
import socket
import urllib.request
import urllib.error
import psycopg2
import redis
from config import (
MASTER_DB_URL, REDIS_URL, POS_URL, DASHBOARD_URL, QUART_URL,
TENANT_DB_URL_TEMPLATE, NEXUS_SERVER_HOST
)
def check_postgresql():
"""Check PostgreSQL connectivity."""
try:
conn = psycopg2.connect(MASTER_DB_URL, connect_timeout=5)
cur = conn.cursor()
cur.execute("SELECT version(), pg_database_size('nexus_autoparts')")
version, size = cur.fetchone()
cur.close()
conn.close()
return {
"status": "ok",
"version": version.split()[1] if version else "unknown",
"master_size_mb": round(size / (1024 * 1024), 2)
}
except Exception as e:
return {"status": "error", "error": str(e)}
def check_redis():
"""Check Redis connectivity. May be unreachable if Redis only binds to localhost."""
try:
r = redis.from_url(REDIS_URL, socket_connect_timeout=3)
info = r.info()
return {
"status": "ok",
"version": info.get("redis_version", "unknown"),
"used_memory_human": info.get("used_memory_human", "?"),
"connected_clients": info.get("connected_clients", 0)
}
except redis.ConnectionError:
return {
"status": "warning",
"error": "Redis unreachable. If manager runs on a separate VM, ensure Redis binds to 0.0.0.0 or a VPN interface, or that a tunnel is active."
}
except Exception as e:
return {"status": "error", "error": str(e)}
def check_http_service(name, url, timeout=5):
"""Generic HTTP health check."""
try:
req = urllib.request.Request(url, method="GET")
req.add_header("User-Agent", "Nexus-Manager/1.0")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return {
"status": "ok",
"http_status": resp.status,
"latency_ms": None # Could add timing later
}
except urllib.error.HTTPError as e:
return {"status": "warning", "http_status": e.code, "error": str(e)}
except Exception as e:
return {"status": "error", "error": str(e)}
def check_disk_space(path="/"):
"""Check disk usage."""
try:
total, used, free = shutil.disk_usage(path)
return {
"status": "ok",
"total_gb": round(total / (1024**3), 2),
"used_gb": round(used / (1024**3), 2),
"free_gb": round(free / (1024**3), 2),
"percent_used": round((used / total) * 100, 1)
}
except Exception as e:
return {"status": "error", "error": str(e)}
def check_memory():
"""Check system memory via /proc/meminfo."""
try:
with open("/proc/meminfo") as f:
meminfo = f.read()
data = {}
for line in meminfo.splitlines():
if ":" in line:
key, value = line.split(":", 1)
data[key.strip()] = int(value.strip().split()[0]) # kB
total = data.get("MemTotal", 0) / 1024 / 1024 # GB
available = data.get("MemAvailable", data.get("MemFree", 0)) / 1024 / 1024
used = total - available
return {
"status": "ok",
"total_gb": round(total, 2),
"used_gb": round(used, 2),
"available_gb": round(available, 2),
"percent_used": round((used / total) * 100, 1) if total else 0
}
except Exception as e:
return {"status": "error", "error": str(e)}
def check_systemd_service(service_name):
"""Check systemd service status."""
try:
result = subprocess.run(
["systemctl", "is-active", service_name],
capture_output=True, text=True, timeout=5
)
active = result.stdout.strip() == "active"
return {
"status": "ok" if active else "warning",
"active": active,
"state": result.stdout.strip()
}
except Exception as e:
return {"status": "error", "error": str(e)}
def get_full_health_report():
"""Aggregate health report for all services."""
return {
"_meta": {
"nexus_server_host": NEXUS_SERVER_HOST,
"note": "disk/memory are local to this manager VM. PostgreSQL/HTTP checks target the remote Nexus server."
},
"postgresql": check_postgresql(),
"redis": check_redis(),
"pos": check_http_service("pos", POS_URL),
"dashboard": check_http_service("dashboard", DASHBOARD_URL),
"quart": check_http_service("quart", QUART_URL),
"disk": check_disk_space(),
"memory": check_memory(),
"services": {
"nexus": check_systemd_service("nexus.service"),
"nexus-pos": check_systemd_service("nexus-pos.service"),
"nexus-quart": check_systemd_service("nexus-quart.service"),
"nexus-celery": check_systemd_service("nexus-celery.service"),
}
}
def get_tenant_health(db_name, timeout=5):
"""Check connectivity to a specific tenant database."""
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
try:
conn = psycopg2.connect(dsn, connect_timeout=timeout)
cur = conn.cursor()
cur.execute("""
SELECT
(SELECT COUNT(*) FROM employees WHERE is_active = true) as employees,
(SELECT COUNT(*) FROM inventory WHERE is_active = true) as inventory,
(SELECT COUNT(*) FROM customers WHERE is_active = true) as customers,
(SELECT COUNT(*) FROM sales WHERE created_at > NOW() - INTERVAL '30 days') as sales_30d,
pg_database_size(current_database()) as db_size
""")
row = cur.fetchone()
cur.close()
conn.close()
return {
"status": "ok",
"employees": row[0],
"inventory": row[1],
"customers": row[2],
"sales_30d": row[3],
"db_size_mb": round(row[4] / (1024 * 1024), 2)
}
except Exception as e:
return {"status": "error", "error": str(e)}

View File

@@ -0,0 +1,100 @@
"""Migration orchestration service."""
import os
import sys
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
if POS_DIR not in sys.path:
sys.path.insert(0, POS_DIR)
from tenant_db import get_master_conn
from config import MIGRATIONS_DIR
def list_available_migrations():
"""List migrations found in POS migrations directory."""
migrations = []
if os.path.isdir(MIGRATIONS_DIR):
for fname in sorted(os.listdir(MIGRATIONS_DIR)):
if fname.endswith(".sql") and fname.startswith("v"):
version = fname.replace(".sql", "")
migrations.append({"version": version, "file": fname})
return migrations
def get_tenant_versions():
"""Get schema version for every tenant."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT t.id, t.name, t.db_name, COALESCE(v.version, 'v0.0') as version
FROM tenants t
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
WHERE t.is_active = true
ORDER BY t.id
""")
results = []
for row in cur.fetchall():
results.append({
"tenant_id": row[0], "name": row[1], "db_name": row[2], "version": row[3]
})
cur.close()
conn.close()
return results
def run_migration_on_tenant(db_name, version):
"""Apply a single migration file to a tenant DB."""
from migrations.runner import apply_migration
return apply_migration(db_name, version)
def run_all_pending_migrations():
"""Run all pending migrations on all active tenants (wrapper around POS runner)."""
from migrations.runner import run_migrations
import io
import contextlib
# Capture stdout to return as log
f = io.StringIO()
with contextlib.redirect_stdout(f):
run_migrations()
return {"log": f.getvalue()}
def run_migration_on_all_tenants(version):
"""Apply one specific migration version to all tenants that don't have it."""
from migrations.runner import MIGRATIONS, apply_migration
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT t.id, t.db_name, COALESCE(v.version, 'v0.0') as version
FROM tenants t
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
WHERE t.is_active = true
""")
tenants = cur.fetchall()
cur.close()
conn.close()
results = []
for tenant_id, db_name, current_version in tenants:
if current_version >= version:
results.append({"tenant_id": tenant_id, "db_name": db_name, "skipped": True, "reason": "already at or past version"})
continue
success = apply_migration(db_name, version)
if success:
# Update version tracker
conn2 = get_master_conn()
cur2 = conn2.cursor()
cur2.execute("""
INSERT INTO tenant_schema_version (tenant_id, version)
VALUES (%s, %s)
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
""", (tenant_id, version, version))
conn2.commit()
cur2.close()
conn2.close()
results.append({"tenant_id": tenant_id, "db_name": db_name, "success": success})
return results

View File

@@ -0,0 +1,400 @@
"""Tenant management service wrapping POS tenant_manager."""
import os
import sys
import psycopg2
from psycopg2 import sql
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# Add POS to path so we can reuse tenant_manager
POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos")
if POS_DIR not in sys.path:
sys.path.insert(0, POS_DIR)
from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE, DEMO_DEFAULT_DAYS
def get_master_conn():
return psycopg2.connect(MASTER_DB_URL)
def list_tenants(include_stats=False):
"""List all tenants with optional per-tenant stats."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
COALESCE(v.version, 'v0.0') as schema_version
FROM tenants t
LEFT JOIN subscriptions s ON s.tenant_id = t.id
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
ORDER BY t.id DESC
""")
cols = [desc[0] for desc in cur.description]
tenants = []
for row in cur.fetchall():
tenant = dict(zip(cols, row))
tenant["created_at"] = str(tenant["created_at"]) if tenant["created_at"] else None
tenant["expires_at"] = str(tenant["expires_at"]) if tenant["expires_at"] else None
tenant["is_demo"] = tenant["plan"] in ("demo", "trial")
tenant["demo_days_left"] = None
if tenant["expires_at"]:
from datetime import datetime
try:
exp = datetime.fromisoformat(tenant["expires_at"].replace("Z", "+00:00"))
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
tenant["demo_days_left"] = max(0, (exp - now).days)
except Exception:
pass
tenants.append(tenant)
cur.close()
conn.close()
if include_stats:
for t in tenants:
t["stats"] = _get_tenant_quick_stats(t["db_name"])
return tenants
def get_tenant(tenant_id):
"""Get single tenant details."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active,
t.created_at, COALESCE(s.expires_at, NULL) as expires_at,
COALESCE(s.status, 'unknown') as subscription_status,
COALESCE(v.version, 'v0.0') as schema_version
FROM tenants t
LEFT JOIN subscriptions s ON s.tenant_id = t.id
LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id
WHERE t.id = %s
""", (tenant_id,))
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return None
keys = ["id", "name", "db_name", "subdomain", "rfc", "plan", "is_active",
"created_at", "expires_at", "subscription_status", "schema_version"]
return {k: str(v) if v is not None else None for k, v in zip(keys, row)}
def _get_tenant_quick_stats(db_name):
"""Quick stats for a tenant DB."""
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
try:
conn = psycopg2.connect(dsn, connect_timeout=5)
cur = conn.cursor()
cur.execute("""
SELECT
(SELECT COUNT(*) FROM employees WHERE is_active = true),
(SELECT COUNT(*) FROM inventory WHERE is_active = true),
(SELECT COUNT(*) FROM customers WHERE is_active = true),
(SELECT COUNT(*) FROM sales WHERE status = 'completed'),
pg_database_size(current_database())
""")
emp, inv, cust, sales, size = cur.fetchone()
cur.close()
conn.close()
return {
"employees": emp,
"inventory_items": inv,
"customers": cust,
"completed_sales": sales,
"db_size_mb": round(size / (1024 * 1024), 2)
}
except Exception as e:
return {"error": str(e)}
def create_demo(name, email, demo_days=None, subdomain=None, pin="0000"):
"""Provision a new demo tenant using POS tenant_manager."""
from services.tenant_manager import provision_tenant
from datetime import datetime, timedelta
days = demo_days or DEMO_DEFAULT_DAYS
if not subdomain:
from services.tenant_manager import generate_subdomain
subdomain = generate_subdomain(name)
# Ensure uniqueness by appending random suffix if needed
conn = get_master_conn()
cur = conn.cursor()
cur.execute("SELECT 1 FROM tenants WHERE subdomain = %s", (subdomain,))
if cur.fetchone():
import secrets
subdomain = f"{subdomain}-{secrets.token_hex(2)}"
cur.close()
conn.close()
result = provision_tenant(
name=name,
rfc=None,
owner_name="Admin Demo",
owner_email=email,
owner_pin=pin,
subdomain=subdomain
)
# Mark as demo plan and set expiration
tenant_id = result["tenant_id"]
conn = get_master_conn()
cur = conn.cursor()
cur.execute("UPDATE tenants SET plan = 'demo' WHERE id = %s", (tenant_id,))
cur.execute("""
INSERT INTO subscriptions (tenant_id, plan, status, expires_at)
VALUES (%s, 'demo', 'active', %s)
ON CONFLICT (tenant_id) DO UPDATE SET
plan = 'demo',
status = 'active',
expires_at = EXCLUDED.expires_at
""", (tenant_id, datetime.now() + timedelta(days=days)))
conn.commit()
cur.close()
conn.close()
# Auto-provision WhatsApp Bridge
try:
import urllib.request
import json as _json
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
bridge_payload = _json.dumps({
"tenant_id": tenant_id,
"subdomain": subdomain,
"db_name": result["db_name"]
}).encode()
req = urllib.request.Request(
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
data=bridge_payload,
headers={
"Content-Type": "application/json",
"X-Internal-Key": INTERNAL_API_KEY
},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as resp:
bridge_data = _json.loads(resp.read().decode())
result["whatsapp_bridge"] = bridge_data
except Exception as e:
result["whatsapp_bridge_error"] = str(e)
result["demo_days"] = days
result["expires_at"] = str(datetime.now() + timedelta(days=days))
result["access_url"] = f"https://{subdomain}.nexusautoparts.com.mx/pos/login"
result["owner_pin"] = pin
return result
def reset_tenant(tenant_id, keep_config=True):
"""Reset a tenant: truncate business data but keep structure and owner."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
tables_to_truncate = [
"inventory_operations",
"inventory",
"sale_items",
"sales",
"customer_payments",
"cash_register_closings",
"cash_register_movements",
"cash_registers",
"invoices",
"accounting_entries",
"journal_entries",
"service_orders",
"fleet_vehicles",
"crm_activities",
"quotations",
"quotation_items",
"savings_transactions",
"savings_accounts",
"supplier_orders",
"supplier_order_items",
"warranty_claims",
"notifications",
"inventory_uploads",
]
conn = psycopg2.connect(dsn)
cur = conn.cursor()
try:
for table in tables_to_truncate:
try:
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
except Exception:
pass # Table may not exist
conn.commit()
success = True
except Exception as e:
conn.rollback()
success = False
raise RuntimeError(f"Reset failed: {e}")
finally:
cur.close()
conn.close()
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
def delete_tenant(tenant_id):
"""Permanently delete a tenant and its database."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
subdomain = tenant.get("subdomain") or f"tenant-{tenant_id}"
# Destroy WhatsApp Bridge container
try:
import urllib.request
import json as _json
from config import POS_INTERNAL_URL, INTERNAL_API_KEY
bridge_payload = _json.dumps({"subdomain": subdomain}).encode()
req = urllib.request.Request(
f"{POS_INTERNAL_URL}/pos/api/internal/whatsapp-bridge",
data=bridge_payload,
headers={
"Content-Type": "application/json",
"X-Internal-Key": INTERNAL_API_KEY
},
method="DELETE"
)
urllib.request.urlopen(req, timeout=15)
except Exception:
pass # Bridge may not exist
conn = get_master_conn()
cur = conn.cursor()
# Drop database
try:
master_conn = psycopg2.connect(MASTER_DB_URL)
master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
master_cur = master_conn.cursor()
master_cur.execute(
sql.SQL('DROP DATABASE IF EXISTS {}').format(sql.Identifier(db_name))
)
master_cur.close()
master_conn.close()
except Exception as e:
pass
# Clean master records
cur.execute("DELETE FROM tenant_schema_version WHERE tenant_id = %s", (tenant_id,))
cur.execute("DELETE FROM subscriptions WHERE tenant_id = %s", (tenant_id,))
cur.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,))
conn.commit()
cur.close()
conn.close()
return {"success": True, "tenant_id": tenant_id, "db_name": db_name}
def toggle_tenant(tenant_id, active):
"""Activate or deactivate a tenant."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("UPDATE tenants SET is_active = %s WHERE id = %s", (active, tenant_id))
conn.commit()
rowcount = cur.rowcount
cur.close()
conn.close()
return {"success": rowcount > 0, "tenant_id": tenant_id, "is_active": active}
def get_tenant_login_url(subdomain):
"""Generate login URL for a tenant."""
domain = os.environ.get("NEXUS_DOMAIN", "nexusautoparts.com.mx")
return f"https://{subdomain}.{domain}/pos/login"
def get_tenant_modules(tenant_id):
"""Get enabled modules for a tenant from tenant_config."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
conn = psycopg2.connect(dsn)
cur = conn.cursor()
try:
modules = {}
for key in ["module_whatsapp", "module_marketplace", "module_meli", "module_catalog"]:
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
modules[key.replace("module_", "")] = (row[0] or "").lower() == "true" if row else True
return modules
finally:
cur.close()
conn.close()
def update_tenant_modules(tenant_id, modules):
"""Update enabled modules for a tenant in tenant_config."""
tenant = get_tenant(tenant_id)
if not tenant:
raise ValueError("Tenant not found")
db_name = tenant["db_name"]
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
conn = psycopg2.connect(dsn)
cur = conn.cursor()
try:
key_map = {
"whatsapp": "module_whatsapp",
"marketplace": "module_marketplace",
"meli": "module_meli",
"catalog": "module_catalog",
}
for field, key in key_map.items():
value = "true" if modules.get(field) else "false"
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
return {"success": True, "tenant_id": tenant_id, "modules": modules}
finally:
cur.close()
conn.close()
def get_dashboard_stats():
"""Global stats for the manager dashboard."""
conn = get_master_conn()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM tenants")
total = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM tenants WHERE is_active = true")
active = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM tenants WHERE plan = 'demo'")
demos = cur.fetchone()[0]
cur.execute("""
SELECT COUNT(*) FROM subscriptions
WHERE status = 'active' AND expires_at < NOW() + INTERVAL '7 days'
""")
expiring_soon = cur.fetchone()[0]
cur.close()
conn.close()
# Get system health summary
from services.health_service import check_disk_space, check_memory
disk = check_disk_space()
mem = check_memory()
return {
"tenants": {"total": total, "active": active, "demos": demos, "expiring_soon": expiring_soon},
"system": {
"disk_percent": disk.get("percent_used"),
"memory_percent": mem.get("percent_used"),
"disk_free_gb": disk.get("free_gb"),
"memory_available_gb": mem.get("available_gb")
}
}

View File

@@ -0,0 +1,702 @@
:root {
--bg-dark: #0f1117;
--bg-card: #1a1d26;
--bg-sidebar: #161920;
--bg-hover: #232631;
--border: #2a2e3b;
--text-primary: #e8eaf0;
--text-secondary: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #06b6d4;
--purple: #8b5cf6;
--radius: 10px;
--shadow: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -1px rgba(0,0,0,0.2);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
overflow: hidden;
}
/* ─── Login ─────────────────────────────────────────────────────────────── */
.login-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f1117 0%, #1a1d26 100%);
}
.login-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow);
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo i {
font-size: 48px;
color: var(--accent);
margin-bottom: 12px;
}
.login-logo h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.login-logo p {
color: var(--text-secondary);
font-size: 14px;
}
/* ─── Layout ────────────────────────────────────────────────────────────── */
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 260px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 20px 24px;
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 700;
border-bottom: 1px solid var(--border);
}
.sidebar-brand i {
color: var(--accent);
font-size: 22px;
}
.sidebar-nav {
flex: 1;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s;
position: relative;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
font-weight: 500;
}
.nav-item .badge {
margin-left: auto;
background: var(--bg-hover);
color: var(--text-secondary);
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border);
}
.user-info {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-size: 13px;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.topbar {
height: 60px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
}
.topbar h2 {
font-size: 18px;
font-weight: 600;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--success);
}
.status-indicator i {
font-size: 8px;
}
.status-indicator.warning { color: var(--warning); }
.status-indicator.error { color: var(--danger); }
.content {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
}
.page { animation: fadeIn 0.2s ease; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Cards & Grid ──────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h3 {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.card-header h3 i {
color: var(--accent);
}
.card-body {
padding: 24px;
}
.card-actions {
display: flex;
gap: 10px;
align-items: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: var(--shadow);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
}
.bg-blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.bg-green { background: linear-gradient(135deg, #22c55e, #16a34a); }
.bg-purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.bg-orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
.bg-red { background: linear-gradient(135deg, #ef4444, #dc2626); }
.bg-cyan { background: linear-gradient(135deg, #06b6d4, #0891b2); }
.stat-info h3 {
font-size: 24px;
font-weight: 700;
margin-bottom: 2px;
}
.stat-info p {
color: var(--text-secondary);
font-size: 13px;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
/* ─── Tables ────────────────────────────────────────────────────────────── */
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
padding: 12px 16px;
color: var(--text-secondary);
font-weight: 500;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.table tr:hover td {
background: rgba(255,255,255,0.02);
}
.table.compact td, .table.compact th {
padding: 10px 12px;
}
/* ─── Forms ─────────────────────────────────────────────────────────────── */
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 14px;
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.input-group {
display: flex;
align-items: center;
}
.input-group input {
border-radius: 8px 0 0 8px;
border-right: none;
}
.input-suffix {
padding: 10px 14px;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 0 8px 8px 0;
color: var(--text-secondary);
font-size: 13px;
white-space: nowrap;
}
/* ─── Buttons ───────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary {
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--border); }
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover { background: #dc2626; }
.btn-success {
background: var(--success);
color: #fff;
}
.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-block { width: 100%; justify-content: center; }
.btn-icon {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ─── Badges & Tags ─────────────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.tag-success { background: rgba(34,197,94,0.15); color: var(--success); }
.tag-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
.tag-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
.tag-info { background: rgba(6,182,212,0.15); color: var(--info); }
.tag-default { background: var(--bg-hover); color: var(--text-secondary); }
/* ─── Alerts & Boxes ────────────────────────────────────────────────────── */
.alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-top: 12px;
}
.alert-error {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.2);
color: var(--danger);
}
.alert-success {
background: rgba(34,197,94,0.1);
border: 1px solid rgba(34,197,94,0.2);
color: var(--success);
}
.result-box {
background: rgba(34,197,94,0.05);
border: 1px solid rgba(34,197,94,0.2);
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.result-box h4 {
margin-bottom: 8px;
color: var(--success);
}
.result-box .copy-row {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 0;
font-size: 13px;
}
.result-box code {
background: var(--bg-dark);
padding: 4px 8px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 12px;
}
.log-box {
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
font-family: 'Fira Code', monospace;
font-size: 12px;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
/* ─── Modal ─────────────────────────────────────────────────────────────── */
.modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
}
.modal-content {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 100%;
max-width: 480px;
box-shadow: var(--shadow);
animation: modalIn 0.2s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-body {
padding: 24px;
color: var(--text-secondary);
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ─── Toast ─────────────────────────────────────────────────────────────── */
#toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 10px;
animation: toastIn 0.3s ease;
min-width: 280px;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.warning { border-left: 3px solid var(--warning); }
@keyframes toastIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ─── Utilities ─────────────────────────────────────────────────────────── */
.loading {
color: var(--text-secondary);
font-style: italic;
padding: 20px;
text-align: center;
}
.text-muted { color: var(--text-secondary); }
.text-center { text-align: center; }
.health-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.health-item:last-child { border-bottom: none; }
.health-label { color: var(--text-secondary); font-size: 13px; }
.health-value { font-weight: 500; font-size: 13px; }
.health-bar-bg {
height: 6px;
background: var(--bg-dark);
border-radius: 3px;
margin-top: 8px;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
/* ─── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.sidebar { width: 64px; }
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
.stats-grid { grid-template-columns: 1fr; }
}
/* Toggle switch for modules modal */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border);
border-radius: 24px;
transition: background 0.2s;
}
.toggle-slider::before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--success);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}

View File

@@ -0,0 +1,536 @@
/**
* Nexus Instance Manager — Frontend SPA
*/
const API_BASE = "";
let currentToken = localStorage.getItem("manager_token") || "";
// ─── Router ────────────────────────────────────────────────────────────────
const routes = {
"#dashboard": "dashboard",
"#demos": "demos",
"#tenants": "tenants",
"#health": "health",
"#migrations": "migrations"
};
function navigate() {
const hash = window.location.hash || "#dashboard";
const page = routes[hash] || "dashboard";
document.querySelectorAll(".page").forEach(p => p.style.display = "none");
document.getElementById(`page-${page}`).style.display = "block";
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
const nav = document.querySelector(`.nav-item[data-page="${page}"]`);
if (nav) nav.classList.add("active");
const titles = {
dashboard: "Dashboard",
demos: "Crear Demos",
tenants: "Tenants",
health: "Salud del Sistema",
migrations: "Migraciones"
};
document.getElementById("page-title").textContent = titles[page] || "Dashboard";
// Load page data
if (page === "dashboard") loadDashboard();
if (page === "demos") loadDemos();
if (page === "tenants") loadTenants();
if (page === "health") loadHealth();
if (page === "migrations") loadMigrations();
}
window.addEventListener("hashchange", navigate);
// ─── Auth ──────────────────────────────────────────────────────────────────
async function api(url, opts = {}) {
const options = {
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${currentToken}`
},
...opts
};
if (opts.body && typeof opts.body !== "string") {
options.body = JSON.stringify(opts.body);
}
const res = await fetch(`${API_BASE}${url}`, options);
if (res.status === 401) {
logout();
return null;
}
const data = await res.json().catch(() => ({}));
return { status: res.status, data };
}
function showLogin() {
document.getElementById("login-screen").style.display = "flex";
document.getElementById("app").style.display = "none";
}
function showApp() {
document.getElementById("login-screen").style.display = "none";
document.getElementById("app").style.display = "flex";
navigate();
}
async function initAuth() {
if (!currentToken) {
showLogin();
return;
}
const res = await api("/api/auth/me");
if (res && res.status === 200) {
document.getElementById("user-email").textContent = res.data.user.email;
showApp();
} else {
showLogin();
}
}
document.getElementById("login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const email = document.getElementById("login-email").value;
const password = document.getElementById("login-password").value;
const errEl = document.getElementById("login-error");
errEl.style.display = "none";
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (res.ok) {
currentToken = data.access_token;
localStorage.setItem("manager_token", currentToken);
document.getElementById("user-email").textContent = data.user.email;
showApp();
} else {
errEl.textContent = data.error || "Error de autenticación";
errEl.style.display = "block";
}
});
function logout() {
currentToken = "";
localStorage.removeItem("manager_token");
showLogin();
}
// ─── Dashboard ─────────────────────────────────────────────────────────────
async function loadDashboard() {
const statsRes = await api("/api/admin/stats");
if (statsRes && statsRes.status === 200) {
const s = statsRes.data;
document.getElementById("stat-total").textContent = s.tenants.total;
document.getElementById("stat-active").textContent = s.tenants.active;
document.getElementById("stat-demos").textContent = s.tenants.demos;
document.getElementById("stat-expiring").textContent = s.tenants.expiring_soon;
const healthEl = document.getElementById("system-health-summary");
healthEl.innerHTML = `
<div class="health-item">
<span class="health-label">Disco usado</span>
<span class="health-value">${s.system.disk_percent}%</span>
</div>
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.disk_percent}%; background:${getBarColor(s.system.disk_percent)}"></div></div>
<div class="health-item" style="margin-top:12px">
<span class="health-label">Memoria usada</span>
<span class="health-value">${s.system.memory_percent}%</span>
</div>
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.memory_percent}%; background:${getBarColor(s.system.memory_percent)}"></div></div>
<div class="health-item" style="margin-top:12px">
<span class="health-label">Disco libre</span>
<span class="health-value">${s.system.disk_free_gb} GB</span>
</div>
<div class="health-item">
<span class="health-label">RAM disponible</span>
<span class="health-value">${s.system.memory_available_gb} GB</span>
</div>
`;
}
const tenantsRes = await api("/api/demos");
if (tenantsRes && tenantsRes.status === 200) {
const tbody = document.getElementById("recent-demos-table");
const demos = tenantsRes.data.data.slice(0, 5);
tbody.innerHTML = demos.map(d => `
<tr>
<td><strong>${escapeHtml(d.name)}</strong></td>
<td><code>${escapeHtml(d.subdomain)}</code></td>
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
<td>${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
</tr>
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos activas</td></tr>`;
}
}
function getBarColor(pct) {
if (pct < 60) return "var(--success)";
if (pct < 85) return "var(--warning)";
return "var(--danger)";
}
// ─── Demos ─────────────────────────────────────────────────────────────────
async function loadDemos() {
const res = await api("/api/demos");
if (!res || res.status !== 200) return;
const tbody = document.getElementById("demos-table");
const demos = res.data.data;
tbody.innerHTML = demos.map(d => `
<tr>
<td><strong>${escapeHtml(d.name)}</strong></td>
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
<td>
<button class="btn-icon" onclick="openModulesModal(${d.id}, '${escapeHtml(d.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
</td>
</tr>
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos</td></tr>`;
}
document.getElementById("demo-form").addEventListener("submit", async (e) => {
e.preventDefault();
const btn = e.target.querySelector("button[type=submit]");
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Creando...`;
btn.disabled = true;
const payload = {
name: document.getElementById("demo-name").value,
email: document.getElementById("demo-email").value,
days: parseInt(document.getElementById("demo-days").value),
pin: document.getElementById("demo-pin").value,
subdomain: document.getElementById("demo-subdomain").value || undefined
};
const res = await api("/api/demos", { method: "POST", body: payload });
const resultBox = document.getElementById("demo-result");
if (res && res.status === 201) {
const d = res.data.data;
resultBox.innerHTML = `
<h4><i class="fas fa-check-circle"></i> Demo creada exitosamente</h4>
<div class="copy-row"><strong>URL:</strong> <code>${d.access_url}</code> <button class="btn-icon" onclick="copyText('${d.access_url}')"><i class="fas fa-copy"></i></button></div>
<div class="copy-row"><strong>Subdominio:</strong> <code>${d.subdomain}</code></div>
<div class="copy-row"><strong>PIN Owner:</strong> <code>${d.owner_pin}</code></div>
<div class="copy-row"><strong>Expira:</strong> ${new Date(d.expires_at).toLocaleDateString()}</div>
`;
resultBox.style.display = "block";
toast("Demo creada correctamente", "success");
document.getElementById("demo-form").reset();
loadDemos();
} else {
toast(res?.data?.error || "Error al crear demo", "error");
}
btn.innerHTML = originalText;
btn.disabled = false;
});
// ─── Tenants ───────────────────────────────────────────────────────────────
async function loadTenants(withStats = false) {
const res = await api(`/api/tenants?stats=${withStats}`);
if (!res || res.status !== 200) return;
const tbody = document.getElementById("tenants-table");
const tenants = res.data.data;
document.getElementById("tenant-count").textContent = tenants.length;
tbody.innerHTML = tenants.map(t => `
<tr>
<td>${t.id}</td>
<td><strong>${escapeHtml(t.name)}</strong></td>
<td><code>${escapeHtml(t.subdomain)}</code></td>
<td>${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")}</td>
<td>${t.schema_version || "v0.0"}</td>
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
<td>${formatDate(t.created_at)}</td>
<td>
<button class="btn-icon" onclick="openModulesModal(${t.id}, '${escapeHtml(t.name)}')" title="Módulos"><i class="fas fa-cubes" style="color:var(--accent)"></i></button>
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
</td>
</tr>
`).join("") || `<tr><td colspan="8" class="text-muted text-center">No hay tenants</td></tr>`;
}
document.getElementById("tenant-search")?.addEventListener("input", (e) => {
const term = e.target.value.toLowerCase();
document.querySelectorAll("#tenants-table tr").forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
});
});
// ─── Health ────────────────────────────────────────────────────────────────
async function loadHealth() {
const res = await api("/api/health");
if (!res || res.status !== 200) return;
const h = res.data;
// PostgreSQL
const pg = h.postgresql;
document.getElementById("health-postgresql").innerHTML = pg.status === "ok" ? `
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${pg.version}</span></div>
<div class="health-item"><span class="health-label">Master DB</span><span class="health-value">${pg.master_size_mb} MB</span></div>
` : renderError(pg.error);
// Redis
const rd = h.redis;
document.getElementById("health-redis").innerHTML = rd.status === "ok" ? `
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${rd.version}</span></div>
<div class="health-item"><span class="health-label">Memoria</span><span class="health-value">${rd.used_memory_human}</span></div>
<div class="health-item"><span class="health-label">Clientes</span><span class="health-value">${rd.connected_clients}</span></div>
` : renderError(rd.error);
// Disk
const dk = h.disk;
document.getElementById("health-disk").innerHTML = dk.status === "ok" ? `
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${dk.total_gb} GB</span></div>
<div class="health-item"><span class="health-label">Usado</span><span class="health-value">${dk.used_gb} GB (${dk.percent_used}%)</span></div>
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${dk.percent_used}%; background:${getBarColor(dk.percent_used)}"></div></div>
<div class="health-item" style="margin-top:12px"><span class="health-label">Libre</span><span class="health-value">${dk.free_gb} GB</span></div>
` : renderError(dk.error);
// Memory
const mem = h.memory;
document.getElementById("health-memory").innerHTML = mem.status === "ok" ? `
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${mem.total_gb} GB</span></div>
<div class="health-item"><span class="health-label">Usada</span><span class="health-value">${mem.used_gb} GB (${mem.percent_used}%)</span></div>
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${mem.percent_used}%; background:${getBarColor(mem.percent_used)}"></div></div>
<div class="health-item" style="margin-top:12px"><span class="health-label">Disponible</span><span class="health-value">${mem.available_gb} GB</span></div>
` : renderError(mem.error);
// Services
const svcs = h.services || {};
document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => `
<div class="health-item">
<span class="health-label"><i class="fas fa-${s.active ? "check-circle" : "times-circle"}" style="color:${s.active ? "var(--success)" : "var(--danger)"}; margin-right:6px"></i>${name}</span>
<span class="health-value" style="color:${s.active ? "var(--success)" : "var(--danger)"}">${s.state}</span>
</div>
`).join("");
// HTTP
const httpChecks = ["pos", "dashboard", "quart"];
document.getElementById("health-http").innerHTML = `
<div class="grid-3">
${httpChecks.map(key => {
const svc = h[key];
const ok = svc && svc.status === "ok";
return `
<div class="health-item">
<span class="health-label">${key.toUpperCase()}</span>
<span class="health-value" style="color:${ok ? "var(--success)" : "var(--danger)"}">
${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")}
</span>
</div>
`;
}).join("")}
</div>
`;
}
function renderError(msg) {
return `<div class="text-muted" style="padding:20px; text-align:center; color:var(--danger)"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(msg)}</div>`;
}
// ─── Migrations ────────────────────────────────────────────────────────────
async function loadMigrations() {
const res = await api("/api/admin/migrations");
if (!res || res.status !== 200) return;
const tbody = document.getElementById("migrations-table");
const tenants = res.data.tenants || [];
tbody.innerHTML = tenants.map(t => {
const needsUpdate = t.version !== (res.data.migrations.slice(-1)[0]?.version || t.version);
return `
<tr>
<td>${escapeHtml(t.name)}</td>
<td><code>${t.db_name}</code></td>
<td>${t.version}</td>
<td>${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")}</td>
</tr>
`;
}).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay tenants</td></tr>`;
}
async function runAllMigrations() {
if (!confirm("¿Ejecutar todas las migraciones pendientes en TODOS los tenants?")) return;
const logBox = document.getElementById("migration-log");
logBox.style.display = "block";
logBox.textContent = "Ejecutando migraciones...";
const res = await api("/api/admin/migrations/run-all", { method: "POST" });
if (res && res.status === 200) {
logBox.textContent = res.data.log || "Completado";
toast("Migraciones ejecutadas", "success");
loadMigrations();
} else {
logBox.textContent = "Error: " + (res?.data?.error || "Unknown");
toast("Error en migraciones", "error");
}
}
// ─── Actions ───────────────────────────────────────────────────────────────
async function toggleTenant(id, active) {
const res = await api(`/api/tenants/${id}/toggle`, {
method: "POST",
body: { active }
});
if (res && res.status === 200) {
toast(active ? "Tenant activado" : "Tenant desactivado", "success");
loadTenants();
loadDemos();
} else {
toast(res?.data?.error || "Error", "error");
}
}
async function resetTenant(id) {
if (!confirm("¿Resetear TODOS los datos de negocio de este tenant? Se conservan empleados y configuración.")) return;
const res = await api(`/api/tenants/${id}/reset`, { method: "POST" });
if (res && res.status === 200) {
toast("Tenant reseteado", "success");
} else {
toast(res?.data?.error || "Error al resetear", "error");
}
}
function confirmDelete(id, name) {
openModal(
"Eliminar Tenant",
`¿Eliminar permanentemente <strong>${escapeHtml(name)}</strong>? Esta acción no se puede deshacer. Se borrará la base de datos completa.`,
async () => {
const res = await api(`/api/tenants/${id}`, { method: "DELETE" });
if (res && res.status === 200) {
toast("Tenant eliminado", "success");
loadTenants();
loadDemos();
} else {
toast(res?.data?.error || "Error al eliminar", "error");
}
closeModal();
}
);
}
// ─── Modal ─────────────────────────────────────────────────────────────────
function openModal(title, body, onConfirm) {
document.getElementById("modal-title").textContent = title;
document.getElementById("modal-body").innerHTML = body;
const btn = document.getElementById("modal-confirm-btn");
btn.onclick = onConfirm;
document.getElementById("modal").style.display = "flex";
}
function closeModal() {
document.getElementById("modal").style.display = "none";
}
// ─── Toast ─────────────────────────────────────────────────────────────────
function toast(message, type = "info") {
const container = document.getElementById("toast-container");
const el = document.createElement("div");
el.className = `toast ${type}`;
el.innerHTML = `<i class="fas fa-${type === "success" ? "check-circle" : type === "error" ? "exclamation-circle" : "info-circle"}"></i> ${escapeHtml(message)}`;
container.appendChild(el);
setTimeout(() => {
el.style.opacity = "0";
el.style.transform = "translateX(100%)";
setTimeout(() => el.remove(), 300);
}, 4000);
}
// ─── Utilities ─────────────────────────────────────────────────────────────
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function tag(text, type) {
return `<span class="tag tag-${type}">${escapeHtml(text)}</span>`;
}
function formatDate(iso) {
if (!iso) return "-";
const d = new Date(iso);
return d.toLocaleDateString("es-MX");
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success"));
}
// ─── Modules ───────────────────────────────────────────────────────────────
let currentModulesTenantId = null;
async function openModulesModal(tenantId, name) {
currentModulesTenantId = tenantId;
document.getElementById("modules-modal-title").textContent = `Módulos — ${escapeHtml(name)}`;
document.getElementById("modules-modal").style.display = "flex";
// Load current state
const res = await api(`/api/tenants/${tenantId}/modules`);
if (res && res.status === 200) {
const m = res.data.data;
document.getElementById("mod-whatsapp").checked = m.whatsapp !== false;
document.getElementById("mod-marketplace").checked = m.marketplace !== false;
document.getElementById("mod-meli").checked = m.meli !== false;
} else {
toast("Error al cargar módulos", "error");
}
}
function closeModulesModal() {
document.getElementById("modules-modal").style.display = "none";
currentModulesTenantId = null;
}
async function saveModules() {
if (!currentModulesTenantId) return;
const btn = document.getElementById("modules-save-btn");
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Guardando...`;
btn.disabled = true;
const payload = {
whatsapp: document.getElementById("mod-whatsapp").checked,
marketplace: document.getElementById("mod-marketplace").checked,
meli: document.getElementById("mod-meli").checked,
catalog: document.getElementById("mod-catalog").checked,
};
const res = await api(`/api/tenants/${currentModulesTenantId}/modules`, {
method: "PUT",
body: payload
});
if (res && res.status === 200) {
toast("Módulos actualizados", "success");
closeModulesModal();
} else {
toast(res?.data?.error || "Error al guardar", "error");
}
btn.innerHTML = originalText;
btn.disabled = false;
}
// ─── Init ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", initAuth);

View File

@@ -0,0 +1,40 @@
[Unit]
Description=Nexus Instance Manager (Control Central)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/Autopartes/manager
ExecStart=/usr/local/bin/gunicorn -w 2 --threads 4 -b 0.0.0.0:5003 "app:create_app()"
Restart=always
RestartSec=5
# ─── Local Paths ───────────────────────────────────────────────────────────
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONPATH=/home/Autopartes/manager:/home/Autopartes/pos
Environment=POS_DIR=/home/Autopartes/pos
# ─── Database (UPDATE FOR REMOTE VM) ───────────────────────────────────────
# If manager runs on a separate VM, change localhost to the IP of the
# PostgreSQL server (e.g. 192.168.10.91).
Environment=MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
Environment=TENANT_DB_URL_TEMPLATE=postgresql://postgres@localhost/{db_name}
# ─── Remote Nexus Server IP ────────────────────────────────────────────────
# Set to the IP/hostname of the server running POS/Dashboard/Quart/Redis.
# Leave as 127.0.0.1 if manager runs on the same server.
Environment=NEXUS_SERVER_HOST=127.0.0.1
# ─── Security (CHANGE THIS) ────────────────────────────────────────────────
Environment=MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
Environment=INTERNAL_API_KEY=c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2
# ─── POS Internal API (for WhatsApp bridge orchestration) ──────────────────
Environment=POS_INTERNAL_URL=http://192.168.10.91:5001
# ─── Redis (optional, health check only) ───────────────────────────────────
Environment=REDIS_URL=redis://127.0.0.1:6379/0
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,381 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Instance Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/static/css/manager.css">
</head>
<body>
<!-- Login Screen -->
<div id="login-screen" class="login-screen">
<div class="login-card">
<div class="login-logo">
<i class="fas fa-cube"></i>
<h1>Nexus Manager</h1>
<p>Control Central de Instancias</p>
</div>
<form id="login-form">
<div class="form-group">
<label>Email</label>
<input type="email" id="login-email" required placeholder="admin@nexus.local">
</div>
<div class="form-group">
<label>Contraseña</label>
<input type="password" id="login-password" required placeholder="••••••••">
</div>
<button type="submit" class="btn btn-primary btn-block">
<i class="fas fa-sign-in-alt"></i> Ingresar
</button>
<div id="login-error" class="alert alert-error" style="display:none;"></div>
</form>
</div>
</div>
<!-- Main App -->
<div id="app" class="app" style="display:none;">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-brand">
<i class="fas fa-cube"></i>
<span>Nexus Manager</span>
</div>
<nav class="sidebar-nav">
<a href="#/dashboard" class="nav-item active" data-page="dashboard">
<i class="fas fa-chart-line"></i>
<span>Dashboard</span>
</a>
<a href="#/demos" class="nav-item" data-page="demos">
<i class="fas fa-rocket"></i>
<span>Crear Demos</span>
</a>
<a href="#/tenants" class="nav-item" data-page="tenants">
<i class="fas fa-building"></i>
<span>Tenants</span>
<span class="badge" id="tenant-count">0</span>
</a>
<a href="#/health" class="nav-item" data-page="health">
<i class="fas fa-heartbeat"></i>
<span>Salud</span>
</a>
<a href="#/migrations" class="nav-item" data-page="migrations">
<i class="fas fa-database"></i>
<span>Migraciones</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<span id="user-email">admin</span>
<button onclick="logout()" class="btn-icon" title="Cerrar sesión">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</aside>
<!-- Content -->
<main class="main">
<header class="topbar">
<h2 id="page-title">Dashboard</h2>
<div class="topbar-actions">
<span class="status-indicator" id="system-status">
<i class="fas fa-circle"></i> Online
</span>
</div>
</header>
<div class="content">
<!-- Dashboard Page -->
<section id="page-dashboard" class="page">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon bg-blue"><i class="fas fa-building"></i></div>
<div class="stat-info">
<h3 id="stat-total">0</h3>
<p>Total Tenants</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-green"><i class="fas fa-check-circle"></i></div>
<div class="stat-info">
<h3 id="stat-active">0</h3>
<p>Activos</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-purple"><i class="fas fa-rocket"></i></div>
<div class="stat-info">
<h3 id="stat-demos">0</h3>
<p>Demos</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-orange"><i class="fas fa-clock"></i></div>
<div class="stat-info">
<h3 id="stat-expiring">0</h3>
<p>Expiran pronto</p>
</div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-server"></i> Estado del Sistema</h3>
</div>
<div class="card-body" id="system-health-summary">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-building"></i> Demos Recientes</h3>
</div>
<div class="card-body">
<table class="table compact">
<thead>
<tr><th>Nombre</th><th>Subdominio</th><th>Expira</th><th>Estado</th></tr>
</thead>
<tbody id="recent-demos-table">
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Demos Page -->
<section id="page-demos" class="page" style="display:none;">
<div class="grid-2">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-plus-circle"></i> Nueva Demo</h3>
</div>
<div class="card-body">
<form id="demo-form">
<div class="form-group">
<label>Nombre del negocio *</label>
<input type="text" id="demo-name" required placeholder="Refaccionaria López">
</div>
<div class="form-group">
<label>Email de contacto</label>
<input type="email" id="demo-email" placeholder="cliente@email.com">
</div>
<div class="form-row">
<div class="form-group">
<label>Días de vigencia</label>
<input type="number" id="demo-days" value="14" min="1" max="90">
</div>
<div class="form-group">
<label>PIN del owner</label>
<input type="text" id="demo-pin" value="0000" maxlength="10">
</div>
</div>
<div class="form-group">
<label>Subdominio (opcional)</label>
<div class="input-group">
<input type="text" id="demo-subdomain" placeholder="refaccionaria-lopez">
<span class="input-suffix">.nexusautoparts.com.mx</span>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-rocket"></i> Crear Demo
</button>
</form>
<div id="demo-result" class="result-box" style="display:none;"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-list"></i> Demos Activas</h3>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr><th>Negocio</th><th>URL</th><th>Días rest.</th><th>Acciones</th></tr>
</thead>
<tbody id="demos-table">
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Tenants Page -->
<section id="page-tenants" class="page" style="display:none;">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-building"></i> Todos los Tenants</h3>
<div class="card-actions">
<input type="text" id="tenant-search" placeholder="Buscar..." class="input-sm">
<button class="btn btn-sm btn-secondary" onclick="loadTenants(true)">
<i class="fas fa-sync"></i> Refrescar
</button>
</div>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>Subdominio</th>
<th>Plan</th>
<th>Versión</th>
<th>Estado</th>
<th>Creado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="tenants-table">
<tr><td colspan="8" class="text-muted">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Health Page -->
<section id="page-health" class="page" style="display:none;">
<div class="grid-3">
<div class="card">
<div class="card-header"><h3><i class="fas fa-database"></i> PostgreSQL</h3></div>
<div class="card-body" id="health-postgresql"><div class="loading">...</div></div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-bolt"></i> Redis</h3></div>
<div class="card-body" id="health-redis"><div class="loading">...</div></div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-hdd"></i> Disco</h3></div>
<div class="card-body" id="health-disk"><div class="loading">...</div></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<div class="card-header"><h3><i class="fas fa-memory"></i> Memoria</h3></div>
<div class="card-body" id="health-memory"><div class="loading">...</div></div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-cogs"></i> Servicios Systemd</h3></div>
<div class="card-body" id="health-services"><div class="loading">...</div></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-network-wired"></i> Servicios HTTP</h3></div>
<div class="card-body" id="health-http"><div class="loading">...</div></div>
</div>
</section>
<!-- Migrations Page -->
<section id="page-migrations" class="page" style="display:none;">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-database"></i> Migraciones de Schema</h3>
<div class="card-actions">
<button class="btn btn-primary" onclick="runAllMigrations()">
<i class="fas fa-play"></i> Ejecutar todas pendientes
</button>
</div>
</div>
<div class="card-body">
<div id="migration-log" class="log-box" style="display:none;"></div>
<table class="table">
<thead>
<tr><th>Tenant</th><th>DB</th><th>Versión actual</th><th>Estado</th></tr>
</thead>
<tbody id="migrations-table">
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Modal -->
<div id="modal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="closeModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Confirmar</h3>
<button class="btn-icon" onclick="closeModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer" id="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">Cancelar</button>
<button class="btn btn-danger" id="modal-confirm-btn">Confirmar</button>
</div>
</div>
</div>
<!-- Modules Modal -->
<div id="modules-modal" class="modal" style="display:none;">
<div class="modal-overlay" onclick="closeModulesModal()"></div>
<div class="modal-content" style="max-width:480px;">
<div class="modal-header">
<h3 id="modules-modal-title">Módulos del Tenant</h3>
<button class="btn-icon" onclick="closeModulesModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;color:var(--text);">WhatsApp</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de WhatsApp Bridge</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-whatsapp">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;color:var(--text);">Marketplace</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Marketplace interno</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-marketplace">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;color:var(--text);">MercadoLibre</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de MercadoLibre</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-meli">
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;">
<div>
<div style="font-weight:600;color:var(--text);">Catálogo</div>
<div style="font-size:12px;color:var(--text-muted);">Mostrar menú de Catálogo de productos</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="mod-catalog">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModulesModal()">Cancelar</button>
<button class="btn btn-primary" id="modules-save-btn" onclick="saveModules()">Guardar</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast-container"></div>
<script src="/static/js/manager.js"></script>
</body>
</html>

4
manager/wsgi.py Normal file
View File

@@ -0,0 +1,4 @@
"""WSGI entry point for Nexus Instance Manager."""
from app import create_app
application = create_app()

View File

@@ -19,5 +19,8 @@
"type": "commonjs", "type": "commonjs",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1" "@playwright/test": "^1.59.1"
},
"dependencies": {
"playwright": "^1.60.0"
} }
} }

View File

@@ -0,0 +1,20 @@
FROM node:20-alpine
WORKDIR /app
# Install git and build tools (needed for some npm deps)
RUN apk add --no-cache git python3 make g++
# Install dependencies
COPY whatsapp-bridge-package.json package.json
RUN npm install
# Copy bridge server
COPY whatsapp-bridge-server.js .
# Create auth directory
RUN mkdir -p /app/auth
EXPOSE 21465
CMD ["node", "whatsapp-bridge-server.js"]

View File

@@ -59,6 +59,12 @@ def create_app():
from blueprints.marketplace_bp import marketplace_bp from blueprints.marketplace_bp import marketplace_bp
app.register_blueprint(marketplace_bp) app.register_blueprint(marketplace_bp)
from blueprints.marketplace_external_bp import marketplace_ext_bp
app.register_blueprint(marketplace_ext_bp)
from blueprints.dropshipping_bp import dropship_bp
app.register_blueprint(dropship_bp)
from blueprints.peer_bp import peer_bp from blueprints.peer_bp import peer_bp
app.register_blueprint(peer_bp) app.register_blueprint(peer_bp)
@@ -107,6 +113,12 @@ def create_app():
from blueprints.supplier_portal_bp import supplier_portal_bp from blueprints.supplier_portal_bp import supplier_portal_bp
app.register_blueprint(supplier_portal_bp) app.register_blueprint(supplier_portal_bp)
from blueprints.supplier_catalog_bp import supplier_catalog_bp
app.register_blueprint(supplier_catalog_bp)
from blueprints.internal_bp import internal_bp
app.register_blueprint(internal_bp)
# Health check # Health check
@app.route('/pos/health') @app.route('/pos/health')
def health(): def health():
@@ -125,6 +137,10 @@ def create_app():
tenant_name=getattr(g, 'tenant_name', None), tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None)) tenant_subdomain=getattr(g, 'tenant_subdomain', None))
@app.route('/pos/supplier-catalog')
def supplier_catalog_page():
return render_template('supplier_catalog.html')
@app.route('/pos/catalog') @app.route('/pos/catalog')
def pos_catalog(): def pos_catalog():
return render_template('catalog.html') return render_template('catalog.html')
@@ -177,6 +193,18 @@ def create_app():
def pos_marketplace(): def pos_marketplace():
return render_template('marketplace.html') return render_template('marketplace.html')
@app.route('/pos/marketplace-external')
def pos_marketplace_external():
return render_template('marketplace_external.html')
@app.route('/pos/marketplace-external/callback')
def pos_marketplace_external_callback():
return render_template('marketplace_external.html')
@app.route('/pos/historical-sales')
def pos_historical_sales():
return render_template('historical_sales.html')
@app.route('/pos/static/<path:filename>') @app.route('/pos/static/<path:filename>')
def pos_static(filename): def pos_static(filename):
return send_from_directory('static', filename) return send_from_directory('static', filename)

View File

@@ -741,3 +741,45 @@ def close_period():
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.read')
def api_accounting_stats():
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Count asset accounts with positive balance (cuentas por cobrar)
cur.execute("""
SELECT COUNT(*) FROM (
SELECT a.id
FROM accounts a
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
WHERE a.type = 'activo' AND a.is_active = true
GROUP BY a.id
HAVING COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) > 0
) x
""")
cxc = cur.fetchone()[0] or 0
# Count liability accounts with positive balance (cuentas por pagar)
cur.execute("""
SELECT COUNT(*) FROM (
SELECT a.id
FROM accounts a
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
WHERE a.type = 'pasivo' AND a.is_active = true
GROUP BY a.id
HAVING COALESCE(SUM(l.credit), 0) - COALESCE(SUM(l.debit), 0) > 0
) x
""")
cxp = cur.fetchone()[0] or 0
cur.close()
conn.close()
return jsonify({
'cuentas_cobrar': cxc,
'cuentas_pagar': cxp,
})

View File

@@ -35,6 +35,25 @@ def _oem_blocked():
return None return None
def _get_allowed_brands(tenant_conn):
"""Read allowed part brands from tenant_config. Returns list or None."""
import json
cur = tenant_conn.cursor()
try:
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
row = cur.fetchone()
if row and row[0]:
try:
brands = json.loads(row[0])
if isinstance(brands, list) and brands:
return brands
except (json.JSONDecodeError, ValueError):
pass
finally:
cur.close()
return None
def _with_conns(fn): def _with_conns(fn):
"""Helper: open master + tenant connections, call fn, close both. """Helper: open master + tenant connections, call fn, close both.
fn receives (master_conn, tenant_conn, branch_id). fn receives (master_conn, tenant_conn, branch_id).
@@ -71,6 +90,34 @@ def _master_only(fn):
except: pass except: pass
def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
"""Filter a list of part dicts to only include those with aftermarket equivalents
from allowed brands. parts_data items must have 'id_part' or 'id' key."""
if not allowed_brands or not parts_data:
return parts_data
part_ids = []
for p in parts_data:
pid = p.get('id_part') or p.get('id')
# Skip local inventory IDs (strings like 'inv:3') — aftermarket filter
# only applies to catalog parts with integer OEM part IDs.
if pid is not None and isinstance(pid, int):
part_ids.append(pid)
if not part_ids:
return parts_data
cur = master_conn.cursor()
try:
cur.execute("""
SELECT DISTINCT ap.oem_part_id
FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = ANY(%s) AND UPPER(m.name_manufacture) = ANY(%s)
""", (part_ids, allowed_brands))
allowed_ids = {r[0] for r in cur.fetchall()}
finally:
cur.close()
return [p for p in parts_data if (p.get('id_part') or p.get('id')) in allowed_ids]
# ─── Hierarchy navigation (master DB only) ─── # ─── Hierarchy navigation (master DB only) ───
@catalog_bp.route('/brands', methods=['GET']) @catalog_bp.route('/brands', methods=['GET'])
@@ -79,10 +126,11 @@ def brands():
from services.catalog_modes import normalize_mode from services.catalog_modes import normalize_mode
year_id = request.args.get('year_id', type=int) year_id = request.args.get('year_id', type=int)
mode = normalize_mode(request.args.get('mode')) mode = normalize_mode(request.args.get('mode'))
def _do(master): def _do(master, tenant, branch_id):
data = catalog_service.get_brands(master, year_id=year_id, mode=mode) mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
return jsonify({'data': data, 'mode': mode}) return jsonify({'data': data, 'mode': mode})
return _master_only(_do) return _with_conns(_do)
@catalog_bp.route('/models', methods=['GET']) @catalog_bp.route('/models', methods=['GET'])
@@ -92,10 +140,11 @@ def models():
year_id = request.args.get('year_id', type=int) year_id = request.args.get('year_id', type=int)
if not brand_id: if not brand_id:
return jsonify({'error': 'brand_id required'}), 400 return jsonify({'error': 'brand_id required'}), 400
def _do(master): def _do(master, tenant, branch_id):
data = catalog_service.get_models(master, brand_id, year_id=year_id) mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_models(master, brand_id, year_id=year_id, mye_ids=mye_ids)
return jsonify({'data': data}) return jsonify({'data': data})
return _master_only(_do) return _with_conns(_do)
@catalog_bp.route('/years', methods=['GET']) @catalog_bp.route('/years', methods=['GET'])
@@ -104,10 +153,11 @@ def years():
model_id = request.args.get('model_id', type=int) model_id = request.args.get('model_id', type=int)
if not model_id: if not model_id:
return jsonify({'error': 'model_id required'}), 400 return jsonify({'error': 'model_id required'}), 400
def _do(master): def _do(master, tenant, branch_id):
data = catalog_service.get_years(master, model_id) mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids)
return jsonify({'data': data}) return jsonify({'data': data})
return _master_only(_do) return _with_conns(_do)
@catalog_bp.route('/years-all', methods=['GET']) @catalog_bp.route('/years-all', methods=['GET'])
@@ -130,10 +180,11 @@ def engines():
year_id = request.args.get('year_id', type=int) year_id = request.args.get('year_id', type=int)
if not model_id or not year_id: if not model_id or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400 return jsonify({'error': 'model_id and year_id required'}), 400
def _do(master): def _do(master, tenant, branch_id):
data = catalog_service.get_engines(master, model_id, year_id) mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids)
return jsonify({'data': data}) return jsonify({'data': data})
return _master_only(_do) return _with_conns(_do)
@catalog_bp.route('/categories', methods=['GET']) @catalog_bp.route('/categories', methods=['GET'])
@@ -150,13 +201,14 @@ def categories():
mode = normalize_mode(request.args.get('mode')) mode = normalize_mode(request.args.get('mode'))
if not mye_id: if not mye_id:
return jsonify({'error': 'mye_id required'}), 400 return jsonify({'error': 'mye_id required'}), 400
def _do(master): def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
if mode == 'local': if mode == 'local':
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id) data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
else: else:
data = catalog_service.get_categories(master, mye_id) data = catalog_service.get_categories(master, mye_id, allowed_brands)
return jsonify({'data': data, 'mode': mode}) return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
return _master_only(_do) return _with_conns(_do)
@catalog_bp.route('/groups', methods=['GET']) @catalog_bp.route('/groups', methods=['GET'])
@@ -174,17 +226,17 @@ def groups():
mode = normalize_mode(request.args.get('mode')) mode = normalize_mode(request.args.get('mode'))
if not mye_id: if not mye_id:
return jsonify({'error': 'mye_id required'}), 400 return jsonify({'error': 'mye_id required'}), 400
def _do(master): def _do(master, tenant, branch_id):
if mode == 'local': if mode == 'local':
if not category_slug: if not category_slug:
return jsonify({'error': 'category_slug required for local mode'}), 400 return jsonify({'error': 'category_slug required for local mode'}), 400
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug) data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug, tenant)
else: else:
if not category_id: if not category_id:
return jsonify({'error': 'category_id required for oem mode'}), 400 return jsonify({'error': 'category_id required for oem mode'}), 400
data = catalog_service.get_groups(master, mye_id, category_id) data = catalog_service.get_groups(master, mye_id, category_id)
return jsonify({'data': data, 'mode': mode}) return jsonify({'data': data, 'mode': mode})
return _master_only(_do) return _with_conns(_do)
# ─── Parts with stock enrichment (master + tenant) ─── # ─── Parts with stock enrichment (master + tenant) ───
@@ -205,19 +257,19 @@ def part_types():
mode = normalize_mode(request.args.get('mode')) mode = normalize_mode(request.args.get('mode'))
if not mye_id: if not mye_id:
return jsonify({'error': 'mye_id required'}), 400 return jsonify({'error': 'mye_id required'}), 400
def _do(master): def _do(master, tenant, branch_id):
if mode == 'local': if mode == 'local':
if not group_slug or not subgroup_slug: if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400 return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
data = catalog_service.get_nexpart_part_types_for_vehicle( data = catalog_service.get_nexpart_part_types_for_vehicle(
master, mye_id, group_slug, subgroup_slug master, mye_id, group_slug, subgroup_slug, tenant
) )
else: else:
if not group_id: if not group_id:
return jsonify({'error': 'group_id required for oem mode'}), 400 return jsonify({'error': 'group_id required for oem mode'}), 400
data = catalog_service.get_part_types(master, mye_id, group_id) data = catalog_service.get_part_types(master, mye_id, group_id)
return jsonify({'data': data, 'mode': mode}) return jsonify({'data': data, 'mode': mode})
return _master_only(_do) return _with_conns(_do)
@catalog_bp.route('/shop-supplies/groups', methods=['GET']) @catalog_bp.route('/shop-supplies/groups', methods=['GET'])
@@ -261,8 +313,8 @@ def shop_supplies_parts():
group_slug = request.args.get('group_slug') group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug') subgroup_slug = request.args.get('subgroup_slug')
part_type_slug = request.args.get('part_type_slug') part_type_slug = request.args.get('part_type_slug')
page = request.args.get('page', 1, type=int) page = max(1, request.args.get('page', 1, type=int) or 1)
per_page = request.args.get('per_page', 30, type=int) per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
if not group_slug or not subgroup_slug or not part_type_slug: if not group_slug or not subgroup_slug or not part_type_slug:
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400 return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
@@ -298,8 +350,8 @@ def parts():
nexpart_subgroup = request.args.get('nexpart_subgroup') nexpart_subgroup = request.args.get('nexpart_subgroup')
nexpart_part_type = request.args.get('nexpart_part_type') nexpart_part_type = request.args.get('nexpart_part_type')
page = request.args.get('page', 1, type=int) page = max(1, request.args.get('page', 1, type=int) or 1)
per_page = request.args.get('per_page', 30, type=int) per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
mode = normalize_mode(request.args.get('mode')) mode = normalize_mode(request.args.get('mode'))
if not mye_id: if not mye_id:
@@ -317,19 +369,34 @@ def parts():
return blocked return blocked
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
# For local mode with allowed_brands, fetch everything first so filtering
# happens before pagination. OEM mode keeps post-filter for now.
fetch_all_for_filter = bool(allowed_brands) and (mode == 'local' or use_nexpart_nav)
_page = 1 if fetch_all_for_filter else page
_per_page = 9999 if fetch_all_for_filter else per_page
if use_nexpart_nav: if use_nexpart_nav:
result = catalog_service.get_parts_for_nexpart_triple( result = catalog_service.get_parts_for_nexpart_triple(
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type, master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
tenant, branch_id, page, per_page, tenant, branch_id, _page, _per_page, tenant_id=g.tenant_id,
) )
elif mode == 'local': elif mode == 'local':
result = catalog_service.get_parts_local( result = catalog_service.get_parts_local(
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type, master, mye_id, group_id, tenant, branch_id, _page, _per_page, part_type=part_type,
) )
else: else:
result = catalog_service.get_parts( result = catalog_service.get_parts(
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type, master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
) )
if allowed_brands:
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
if fetch_all_for_filter:
total = len(result['data'])
offset = (page - 1) * per_page
result['data'] = result['data'][offset:offset + per_page]
result['pagination'] = catalog_service._pagination(page, per_page, total)
result['allowed_brands'] = allowed_brands or []
return jsonify(result) return jsonify(result)
return _with_conns(_do) return _with_conns(_do)
@@ -358,8 +425,11 @@ def search():
limit = request.args.get('limit', 50, type=int) limit = request.args.get('limit', 50, type=int)
mye_id = request.args.get('mye_id', type=int) mye_id = request.args.get('mye_id', type=int)
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id) allowed_brands = _get_allowed_brands(tenant) if tenant else None
return jsonify({'data': data}) data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id, tenant_id=g.tenant_id)
if allowed_brands:
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})
return _with_conns(_do) return _with_conns(_do)
@@ -635,10 +705,20 @@ def brand_categories():
if not brand: if not brand:
return jsonify({'error': 'brand parameter required'}), 400 return jsonify({'error': 'brand parameter required'}), 400
def _query(master): def _query(master, tenant, branch_id):
cur = master.cursor() cur = master.cursor()
try: try:
cur.execute(""" allowed_brands = _get_allowed_brands(tenant) if tenant else None
brand_filter = ""
params = [brand]
if allowed_brands:
brand_filter = """AND EXISTS (
SELECT 1 FROM aftermarket_parts ap2
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
)"""
params.append(allowed_brands)
cur.execute(f"""
SELECT pc.id_part_category, SELECT pc.id_part_category,
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name, COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
pc.slug, pc.slug,
@@ -648,20 +728,22 @@ def brand_categories():
JOIN part_groups pg ON pg.id_part_group = p.group_id JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE pvp.name_brand = %s WHERE pvp.name_brand = %s
{brand_filter}
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
ORDER BY part_count DESC ORDER BY part_count DESC
""", (brand,)) """, params)
rows = cur.fetchall() rows = cur.fetchall()
return jsonify({ return jsonify({
'brand': brand, 'brand': brand,
'categories': [ 'categories': [
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]} {'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
for r in rows for r in rows
] ],
'allowed_brands': allowed_brands or []
}) })
finally: finally:
cur.close() cur.close()
return _master_only(_query) return _with_conns(_query)
@catalog_bp.route('/brand-parts', methods=['GET']) @catalog_bp.route('/brand-parts', methods=['GET'])
@@ -680,21 +762,110 @@ def brand_parts():
def _query(master, tenant, branch_id): def _query(master, tenant, branch_id):
cur = master.cursor() cur = master.cursor()
try: try:
# Build dynamic filters allowed_brands = _get_allowed_brands(tenant) if tenant else None
params = [brand]
cat_filter = "" cat_filter = ""
search_filter = "" search_filter = ""
params = [brand]
if category_id: if category_id:
cat_filter = "AND pc.id_part_category = %s" cat_filter = "AND pc.id_part_category = %s"
params.append(category_id) params.append(category_id)
# --- Brand-filtered mode: return aftermarket parts directly ---
if allowed_brands:
am_search = ""
am_params = list(params)
if search:
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%"
am_params.extend([like_term, like_term])
query_params = list(am_params)
cur.execute(f"""
SELECT DISTINCT ap.id_aftermarket_parts,
ap.part_number,
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
m.name_manufacture,
ap.price_usd,
p.id_part,
pg.id_part_group, pg.name_part_group,
pc.id_part_category, pc.name_part_category
FROM part_vehicle_preview pvp
JOIN parts p ON p.id_part = pvp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE pvp.name_brand = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
ORDER BY m.name_manufacture, ap.part_number
LIMIT %s OFFSET %s
""", query_params + [allowed_brands, limit, offset])
part_rows = cur.fetchall()
oem_ids = [r[5] for r in part_rows]
count_params = list(am_params)
cur.execute(f"""
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
FROM part_vehicle_preview pvp
JOIN parts p ON p.id_part = pvp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE pvp.name_brand = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
""", count_params + [allowed_brands])
total = cur.fetchone()[0]
local_stock = {}
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
except Exception:
pass
items = []
for r in part_rows:
oem_id = r[5]
stock_info = local_stock.get(oem_id, {})
items.append({
'id': r[0],
'oem_part_number': r[1],
'name': r[2],
'manufacturer': r[3],
'price_usd': float(r[4]) if r[4] is not None else None,
'oem_id': oem_id,
'group': {'id': r[6], 'name': r[7]},
'category': {'id': r[8], 'name': r[9]},
'local_stock': stock_info.get('stock', 0),
'local_price': stock_info.get('price', None),
})
return jsonify({
'brand': brand,
'category_id': category_id,
'search': search,
'items': items,
'total': total,
'limit': limit,
'offset': offset,
'allowed_brands': allowed_brands
})
# --- Normal mode: return OEM parts ---
if search: if search:
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)" search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%" like_term = f"%{search}%"
params.extend([like_term, like_term]) params.extend([like_term, like_term])
# Get parts from the brand catalog
query_params = list(params) query_params = list(params)
cur.execute(f""" cur.execute(f"""
SELECT DISTINCT p.id_part, p.oem_part_number, SELECT DISTINCT p.id_part, p.oem_part_number,
@@ -715,7 +886,7 @@ def brand_parts():
part_rows = cur.fetchall() part_rows = cur.fetchall()
part_ids = [r[0] for r in part_rows] part_ids = [r[0] for r in part_rows]
# Count total count_params = list(params)
cur.execute(f""" cur.execute(f"""
SELECT COUNT(DISTINCT p.id_part) SELECT COUNT(DISTINCT p.id_part)
FROM part_vehicle_preview pvp FROM part_vehicle_preview pvp
@@ -725,15 +896,14 @@ def brand_parts():
WHERE pvp.name_brand = %s WHERE pvp.name_brand = %s
{cat_filter} {cat_filter}
{search_filter} {search_filter}
""", params) """, count_params)
total = cur.fetchone()[0] total = cur.fetchone()[0]
# Enrich with local stock if available
local_stock = {} local_stock = {}
if tenant and part_ids: if tenant and part_ids:
try: try:
from services.catalog_service import _get_local_stock_bulk from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, part_ids) local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
except Exception: except Exception:
pass pass
@@ -759,6 +929,203 @@ def brand_parts():
'total': total, 'total': total,
'limit': limit, 'limit': limit,
'offset': offset, 'offset': offset,
'allowed_brands': []
})
finally:
cur.close()
return _with_conns(_query)
@catalog_bp.route('/mye-parts', methods=['GET'])
@require_auth('catalog.view')
def mye_parts():
"""Return parts for a specific MYE + category (brand-catalog flow).
Skips the group/subgroup level and goes directly from category to parts.
"""
mye_id = request.args.get('mye_id', type=int)
category_id = request.args.get('category_id', type=int)
search = request.args.get('search', '').strip()
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _query(master, tenant, branch_id):
cur = master.cursor()
try:
allowed_brands = _get_allowed_brands(tenant) if tenant else None
cat_filter = ""
search_filter = ""
params = [mye_id]
if category_id:
cat_filter = "AND pc.id_part_category = %s"
params.append(category_id)
# --- Brand-filtered mode: return aftermarket parts directly ---
if allowed_brands:
am_search = ""
am_params = list(params)
if search:
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%"
am_params.extend([like_term, like_term])
# Get aftermarket parts
query_params = list(am_params)
cur.execute(f"""
SELECT DISTINCT ap.id_aftermarket_parts,
ap.part_number,
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
m.name_manufacture,
ap.price_usd,
p.id_part,
pg.id_part_group, pg.name_part_group,
pc.id_part_category, pc.name_part_category
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE vp.model_year_engine_id = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
ORDER BY m.name_manufacture, ap.part_number
LIMIT %s OFFSET %s
""", query_params + [allowed_brands, limit, offset])
part_rows = cur.fetchall()
oem_ids = [r[5] for r in part_rows]
# Count total
count_params = list(am_params)
cur.execute(f"""
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE vp.model_year_engine_id = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
""", count_params + [allowed_brands])
total = cur.fetchone()[0]
# Local stock keyed by OEM part id
local_stock = {}
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
except Exception:
pass
items = []
for r in part_rows:
oem_id = r[5]
stock_info = local_stock.get(oem_id, {})
items.append({
'id': r[0],
'oem_part_number': r[1],
'name': r[2],
'manufacturer': r[3],
'price_usd': float(r[4]) if r[4] is not None else None,
'oem_id': oem_id,
'group': {'id': r[6], 'name': r[7]},
'category': {'id': r[8], 'name': r[9]},
'local_stock': stock_info.get('stock', 0),
'local_price': stock_info.get('price', None),
})
return jsonify({
'mye_id': mye_id,
'category_id': category_id,
'search': search,
'items': items,
'total': total,
'limit': limit,
'offset': offset,
'allowed_brands': allowed_brands
})
# --- Normal mode: return OEM parts ---
if search:
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%"
params.extend([like_term, like_term])
query_params = list(params)
cur.execute(f"""
SELECT DISTINCT p.id_part, p.oem_part_number,
COALESCE(NULLIF(p.name_es, ''), p.name_part) as name,
pg.id_part_group, pg.name_part_group,
pc.id_part_category, pc.name_part_category
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE vp.model_year_engine_id = %s
{cat_filter}
{search_filter}
ORDER BY p.id_part
LIMIT %s OFFSET %s
""", query_params + [limit, offset])
part_rows = cur.fetchall()
part_ids = [r[0] for r in part_rows]
count_params = list(params)
cur.execute(f"""
SELECT COUNT(DISTINCT p.id_part)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE vp.model_year_engine_id = %s
{cat_filter}
{search_filter}
""", count_params)
total = cur.fetchone()[0]
local_stock = {}
if tenant and part_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
except Exception:
pass
items = []
for r in part_rows:
part_id = r[0]
stock_info = local_stock.get(part_id, {})
items.append({
'id': part_id,
'oem_part_number': r[1],
'name': r[2],
'group': {'id': r[3], 'name': r[4]},
'category': {'id': r[5], 'name': r[6]},
'local_stock': stock_info.get('stock', 0),
'local_price': stock_info.get('price', None),
})
return jsonify({
'mye_id': mye_id,
'category_id': category_id,
'search': search,
'items': items,
'total': total,
'limit': limit,
'offset': offset,
'allowed_brands': []
}) })
finally: finally:
cur.close() cur.close()

View File

@@ -13,15 +13,51 @@ config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
def list_branches(): def list_branches():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id") cur.execute("""
SELECT id, name, address, phone, is_active, is_main,
rfc, razon_social, regimen_fiscal, cp,
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
FROM branches ORDER BY id
""")
branches = [] branches = []
for r in cur.fetchall(): for r in cur.fetchall():
branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]}) branches.append({
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
'is_active': r[4], 'is_main': r[5],
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
})
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'data': branches}) return jsonify({'data': branches})
@config_bp.route('/branches/<int:branch_id>', methods=['GET'])
@require_auth('config.view')
def get_branch(branch_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT id, name, address, phone, is_active, is_main,
rfc, razon_social, regimen_fiscal, cp,
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
FROM branches WHERE id = %s
""", (branch_id,))
r = cur.fetchone()
cur.close()
conn.close()
if not r:
return jsonify({'error': 'Branch not found'}), 404
return jsonify({
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
'is_active': r[4], 'is_main': r[5],
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
})
@config_bp.route('/branches', methods=['POST']) @config_bp.route('/branches', methods=['POST'])
@require_auth('config.edit') @require_auth('config.edit')
def create_branch(): def create_branch():
@@ -47,10 +83,23 @@ def create_branch():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
# If setting as main, clear any existing main
if data.get('is_main'):
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true")
cur.execute(""" cur.execute("""
INSERT INTO branches (name, address, phone) INSERT INTO branches (
VALUES (%s, %s, %s) RETURNING id name, address, phone, is_main,
""", (data['name'], data.get('address'), data.get('phone'))) rfc, razon_social, regimen_fiscal, cp,
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id
""", (
data['name'], data.get('address'), data.get('phone'), bool(data.get('is_main')),
data.get('rfc'), data.get('razon_social'), data.get('regimen_fiscal'), data.get('cp'),
data.get('direccion_fiscal'), data.get('serie_cfdi'), data.get('folio_inicio'), data.get('folio_actual'), data.get('email'),
))
branch_id = cur.fetchone()[0] branch_id = cur.fetchone()[0]
conn.commit() conn.commit()
cur.close() cur.close()
@@ -58,6 +107,49 @@ def create_branch():
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201 return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
@config_bp.route('/branches/<int:branch_id>', methods=['PUT'])
@require_auth('config.edit')
def update_branch(branch_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Branch not found'}), 404
# If setting as main, clear any existing main
if data.get('is_main'):
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true AND id <> %s", (branch_id,))
updates = []
params = []
field_map = {
'name': 'name', 'address': 'address', 'phone': 'phone',
'is_active': 'is_active', 'is_main': 'is_main',
'rfc': 'rfc', 'razon_social': 'razon_social',
'regimen_fiscal': 'regimen_fiscal', 'cp': 'cp',
'direccion_fiscal': 'direccion_fiscal', 'serie_cfdi': 'serie_cfdi',
'folio_inicio': 'folio_inicio', 'folio_actual': 'folio_actual', 'email': 'email',
}
for json_key, col in field_map.items():
if json_key in data:
updates.append(f"{col} = %s")
params.append(data[json_key])
if not updates:
cur.close(); conn.close()
return jsonify({'error': 'Nothing to update'}), 400
params.append(branch_id)
cur.execute(f"UPDATE branches SET {', '.join(updates)} WHERE id = %s", params)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Branch updated'})
@config_bp.route('/employees', methods=['GET']) @config_bp.route('/employees', methods=['GET'])
@require_auth('config.view') @require_auth('config.view')
def list_employees(): def list_employees():
@@ -451,3 +543,205 @@ def update_vehicle_compat_source():
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'message': 'Vehicle compatibility source updated', 'source': source}) return jsonify({'message': 'Vehicle compatibility source updated', 'source': source})
# ─── Allowed Part Brands ─────────────────────────────────────────────────────
# Whitelist of part manufacturers shown in the allowed-brands selector
_ALLOWED_PART_BRANDS = [
'Luk', 'Motocraft', 'Euzcadi', 'Gates', 'Injetech', 'Bilstein',
'Monroe', 'Yokomitzu', 'Ecom', 'Lth', 'Dynamik', 'Wagner',
'Bosch', 'Brembo', 'Champion', 'Dorman', 'Kyb', 'Handkook',
'Tomco', 'Mann Filter', 'Total Parts', 'Kanadian', 'Pirelli',
'NGK', 'Moresa', 'Fritec', 'Acdelco', 'Dash4', 'Moog', 'SYD',
'FRAM', 'AUTOLITE'
]
@config_bp.route('/available-brands', methods=['GET'])
@require_auth()
def get_available_brands():
"""Return the whitelisted part manufacturer names.
The master DB manufacturers/aftermarket_parts tables were removed with
TecDoc, so we return the curated whitelist directly.
"""
brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()})
return jsonify({'brands': brands})
@config_bp.route('/allowed-brands', methods=['GET'])
@require_auth()
def get_allowed_brands():
"""Return the tenant's allowed part brands from tenant_config."""
import json
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
row = cur.fetchone()
cur.close()
conn.close()
if row and row[0]:
try:
brands = json.loads(row[0])
if isinstance(brands, list):
return jsonify({'brands': brands})
except (json.JSONDecodeError, ValueError):
pass
return jsonify({'brands': []})
@config_bp.route('/allowed-brands', methods=['PUT'])
@require_auth('config.edit')
def update_allowed_brands():
"""Save the tenant's allowed part brands to tenant_config."""
import json
data = request.get_json() or {}
brands = data.get('brands', [])
if not isinstance(brands, list):
return jsonify({'error': 'brands must be an array'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('allowed_part_brands', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(brands),))
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Allowed brands updated', 'brands': brands})
# ─── WhatsApp Configuration ────────────────────────────────────────────────
@config_bp.route('/whatsapp', methods=['GET'])
@require_auth('config.view')
def get_whatsapp_config():
"""Get WhatsApp bridge configuration for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
return jsonify({
'bridge_url': rows.get('whatsapp_bridge_url', ''),
'bridge_key': rows.get('whatsapp_bridge_key', ''),
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
'phone_number': rows.get('whatsapp_phone_number', ''),
})
@config_bp.route('/whatsapp', methods=['PUT'])
@require_auth('config.edit')
def update_whatsapp_config():
"""Update WhatsApp bridge configuration for this tenant."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
settings = {
'whatsapp_bridge_url': data.get('bridge_url', ''),
'whatsapp_bridge_key': data.get('bridge_key', ''),
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
'whatsapp_phone_number': data.get('phone_number', ''),
}
for key, value in settings.items():
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'WhatsApp configuration updated'})
@config_bp.route('/modules', methods=['GET'])
@require_auth('config.view')
def get_modules():
"""Get enabled modules for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
def _bool(key):
return rows.get(key, 'true').lower() == 'true'
return jsonify({
'whatsapp': _bool('module_whatsapp'),
'marketplace': _bool('module_marketplace'),
'meli': _bool('module_meli'),
'catalog': _bool('module_catalog'),
})
@config_bp.route('/modules', methods=['PUT'])
@require_auth('config.edit')
def update_modules():
"""Update enabled modules for this tenant."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
settings = {
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
'module_marketplace': 'true' if data.get('marketplace') else 'false',
'module_meli': 'true' if data.get('meli') else 'false',
'module_catalog': 'true' if data.get('catalog') else 'false',
}
for key, value in settings.items():
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Modules updated', 'modules': {
'whatsapp': data.get('whatsapp'),
'marketplace': data.get('marketplace'),
'meli': data.get('meli'),
}})
@config_bp.route('/onboarding-status', methods=['GET'])
@require_auth('pos.view')
def get_onboarding_status():
"""Check if tenant onboarding wizard has been completed."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'onboarding_completed'")
row = cur.fetchone()
cur.close()
conn.close()
return jsonify({'completed': row[0] == 'true' if row else False})
@config_bp.route('/onboarding-status', methods=['POST'])
@require_auth('pos.view')
def set_onboarding_status():
"""Mark tenant onboarding wizard as completed."""
data = request.get_json() or {}
completed = 'true' if data.get('completed') else 'false'
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('onboarding_completed', completed))
conn.commit()
cur.close()
conn.close()
return jsonify({'completed': completed == 'true'})

View File

@@ -52,6 +52,7 @@ def list_customers():
# Fetch # Fetch
cur.execute(f""" cur.execute(f"""
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email, SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
c.address, c.cp,
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info, c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
c.branch_id c.branch_id
FROM customers c FROM customers c
@@ -64,11 +65,12 @@ def list_customers():
for r in cur.fetchall(): for r in cur.fetchall():
customers.append({ customers.append({
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3], 'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
'phone': r[4], 'email': r[5], 'price_tier': r[6], 'phone': r[4], 'email': r[5], 'address': r[6], 'cp': r[7],
'credit_limit': float(r[7]) if r[7] else 0, 'price_tier': r[8],
'credit_balance': float(r[8]) if r[8] else 0, 'credit_limit': float(r[9]) if r[9] else 0,
'vehicle_info': r[9], 'credit_balance': float(r[10]) if r[10] else 0,
'branch_id': r[10], 'vehicle_info': r[11],
'branch_id': r[12],
}) })
cur.close() cur.close()
@@ -91,7 +93,7 @@ def get_customer(customer_id):
cur.execute(""" cur.execute("""
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, credit_balance, cp, email, phone, address, price_tier, credit_limit, credit_balance,
is_active, vehicle_info, created_at is_active, vehicle_info, created_at, max_discount_pct
FROM customers WHERE id = %s FROM customers WHERE id = %s
""", (customer_id,)) """, (customer_id,))
row = cur.fetchone() row = cur.fetchone()
@@ -103,7 +105,7 @@ def get_customer(customer_id):
customer = dict(zip(cols, row)) customer = dict(zip(cols, row))
# Convert Decimal to float # Convert Decimal to float
for k in ('credit_limit', 'credit_balance'): for k in ('credit_limit', 'credit_balance', 'max_discount_pct'):
if customer.get(k) is not None: if customer.get(k) is not None:
customer[k] = float(customer[k]) customer[k] = float(customer[k])
@@ -213,7 +215,7 @@ def update_customer(customer_id):
# Build dynamic update # Build dynamic update
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi', allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit', 'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
'vehicle_info', 'is_active', 'branch_id'] 'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
sets = [] sets = []
vals = [] vals = []
for field in allowed: for field in allowed:

View File

@@ -12,6 +12,7 @@ dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn
class DecimalEncoder(json.JSONEncoder): class DecimalEncoder(json.JSONEncoder):
@@ -25,83 +26,95 @@ class DecimalEncoder(json.JSONEncoder):
@require_auth() @require_auth()
def get_stats(): def get_stats():
"""Summary stats for today and this month.""" """Summary stats for today and this month."""
from tenant_db import get_tenant_db conn = get_tenant_conn(g.tenant_id)
db = get_tenant_db() cur = conn.cursor()
today = datetime.utcnow().date() today = datetime.utcnow().date()
month_start = today.replace(day=1) month_start = today.replace(day=1)
# Sales today try:
today_sales = db.execute( # Sales today
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total cur.execute(
FROM sales WHERE DATE(created_at) = %s""", (today,) """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
).fetchone() FROM sales WHERE DATE(created_at) = %s""", (today,)
)
today_sales = cur.fetchone()
# Sales this month # Sales this month
month_sales = db.execute( cur.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) >= %s""", (month_start,) FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
).fetchone() )
month_sales = cur.fetchone()
# Top 5 products today # Top 5 products today
top_products = db.execute( cur.execute(
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue """SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue
FROM sale_items si FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale JOIN sales s ON si.sale_id = s.id
JOIN parts p ON si.part_id = p.id_part WHERE DATE(s.created_at) = %s
WHERE DATE(s.created_at) = %s GROUP BY si.name
GROUP BY p.name ORDER BY revenue DESC
ORDER BY revenue DESC LIMIT 5""", (today,)
LIMIT 5""", (today,) )
).fetchall() top_products = cur.fetchall()
# Hourly sales today (0-23) # Hourly sales today (0-23)
hourly = db.execute( cur.execute(
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour, """SELECT EXTRACT(HOUR FROM created_at)::int as hour,
COUNT(*) as count, COALESCE(SUM(total), 0) as total COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s FROM sales WHERE DATE(created_at) = %s
GROUP BY hour ORDER BY hour""", (today,) GROUP BY hour ORDER BY hour""", (today,)
).fetchall() )
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly} hourly = cur.fetchall()
hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly}
return jsonify({ return jsonify({
'today': { 'today': {
'sales_count': today_sales['count'], 'sales_count': today_sales[0],
'sales_total': today_sales['total'], 'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0,
}, },
'month': { 'month': {
'sales_count': month_sales['count'], 'sales_count': month_sales[0],
'sales_total': month_sales['total'], 'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
}, },
'top_products': [ 'top_products': [
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']} {'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0}
for row in top_products for row in top_products
], ],
'hourly_sales': [ 'hourly_sales': [
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0), {'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
'total': hourly_map.get(h, {}).get('total', 0)} 'total': float(hourly_map.get(h, {}).get('total', 0))}
for h in range(24) for h in range(24)
], ],
}, cls=DecimalEncoder) })
finally:
cur.close()
conn.close()
@dashboard_stats_bp.route('/stats/employees', methods=['GET']) @dashboard_stats_bp.route('/stats/employees', methods=['GET'])
@require_auth() @require_auth()
def get_employee_stats(): def get_employee_stats():
"""Sales per employee today.""" """Sales per employee today."""
from tenant_db import get_tenant_db conn = get_tenant_conn(g.tenant_id)
db = get_tenant_db() cur = conn.cursor()
today = datetime.utcnow().date() today = datetime.utcnow().date()
rows = db.execute( try:
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total cur.execute(
FROM sales s """SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total
JOIN employees e ON s.employee_id = e.id_employee FROM sales s
WHERE DATE(s.created_at) = %s JOIN employees e ON s.employee_id = e.id
GROUP BY e.name WHERE DATE(s.created_at) = %s
ORDER BY total DESC""", (today,) GROUP BY e.name
).fetchall() ORDER BY total DESC""", (today,)
return jsonify({ )
'employees': [ rows = cur.fetchall()
{'name': row['name'], 'sales': row['sales'], 'total': row['total']} return jsonify({
for row in rows 'employees': [
] {'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0}
}, cls=DecimalEncoder) for row in rows
]
})
finally:
cur.close()
conn.close()

View File

@@ -0,0 +1,128 @@
"""Dropshipping API — public read-only inventory endpoints.
Authentication: X-Dropshipping-Key header (per-tenant).
Optional: X-Tenant-Subdomain for faster resolution.
"""
from flask import Blueprint, request, jsonify, g
from tenant_db import get_tenant_conn, get_master_conn
from services import dropshipping_service as ds_svc
from services.webhook_service import dispatch_webhooks_bulk
dropship_bp = Blueprint("dropship", __name__, url_prefix="/pos/api/dropship")
def _resolve_tenant_by_key(api_key: str, subdomain_hint: str = None):
"""Return (tenant_conn, tenant_id) for a valid dropshipping API key.
If subdomain_hint is provided, validate only that tenant.
Otherwise scan active tenants (acceptable for small tenant count).
"""
master = get_master_conn()
try:
cur = master.cursor()
if subdomain_hint:
cur.execute(
"SELECT id, db_name FROM tenants WHERE subdomain = %s AND is_active = true",
(subdomain_hint,),
)
rows = cur.fetchall()
else:
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
rows = cur.fetchall()
cur.close()
for tid, db_name in rows:
try:
tconn = get_tenant_conn(tid)
if ds_svc.validate_api_key(tconn, api_key):
return tconn, tid
tconn.close()
except Exception:
continue
return None, None
finally:
master.close()
def _require_dropship_auth():
key = request.headers.get("X-Dropshipping-Key")
subdomain = request.headers.get("X-Tenant-Subdomain")
if not key:
return jsonify({"error": "Missing X-Dropshipping-Key header"}), 401
tconn, tid = _resolve_tenant_by_key(key, subdomain_hint=subdomain)
if not tconn:
return jsonify({"error": "Invalid API key or tenant inactive"}), 401
g.tenant_id = tid
g.tenant_conn = tconn
return None
def _release_tenant():
if hasattr(g, "tenant_conn") and g.tenant_conn:
g.tenant_conn.close()
@dropship_bp.route("/inventory", methods=["GET"])
def list_inventory():
err = _require_dropship_auth()
if err:
return err
try:
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200)
search = request.args.get("q")
result = ds_svc.get_inventory_list(g.tenant_conn, search=search, page=page, per_page=per_page)
return jsonify(result)
finally:
_release_tenant()
@dropship_bp.route("/inventory/<sku>", methods=["GET"])
def get_inventory_item(sku):
err = _require_dropship_auth()
if err:
return err
try:
item = ds_svc.get_inventory_by_sku(g.tenant_conn, sku)
if not item:
return jsonify({"error": "SKU not found"}), 404
return jsonify(item)
finally:
_release_tenant()
@dropship_bp.route("/stock", methods=["GET"])
def get_stock():
err = _require_dropship_auth()
if err:
return err
try:
skus = request.args.get("skus", "")
sku_list = [s.strip() for s in skus.split(",") if s.strip()]
if not sku_list:
return jsonify({"error": "Provide ?skus=SKU1,SKU2,SKU3"}), 400
result = ds_svc.get_stock_by_skus(g.tenant_conn, sku_list)
return jsonify({"stock": result})
finally:
_release_tenant()
@dropship_bp.route("/webhooks/test", methods=["POST"])
def test_webhook():
"""Test endpoint to trigger a sample webhook to all configured targets."""
err = _require_dropship_auth()
if err:
return err
try:
urls = ds_svc.get_webhook_targets(g.tenant_conn, "stock_updated")
if not urls:
return jsonify({"error": "No webhook targets configured"}), 400
results = dispatch_webhooks_bulk(
urls,
"test",
{"message": "Webhook test from Nexus POS", "tenant_id": g.tenant_id},
)
return jsonify({"dispatched": len(results), "results": results})
finally:
_release_tenant()

View File

@@ -0,0 +1,152 @@
"""Internal API endpoints for infrastructure orchestration.
These endpoints are meant to be called by the Nexus Manager or other
internal services. They require INTERNAL_API_KEY.
"""
import subprocess
import socket
from flask import Blueprint, request, jsonify
from config import INTERNAL_API_KEY
from tenant_db import get_master_conn, get_tenant_conn
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
def _check_internal_key():
key = request.headers.get('X-Internal-Key', '')
if not INTERNAL_API_KEY:
return jsonify({'error': 'INTERNAL_API_KEY not configured on server'}), 500
if key != INTERNAL_API_KEY:
return jsonify({'error': 'Unauthorized'}), 401
return None
def _find_free_port(start=21465, end=21565):
"""Find first free TCP port in range."""
for port in range(start, end + 1):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(('127.0.0.1', port)) != 0:
return port
return None
@internal_bp.route('/whatsapp-bridge', methods=['POST'])
def provision_whatsapp_bridge():
"""Provision a new WhatsApp Bridge Docker container for a tenant."""
auth_error = _check_internal_key()
if auth_error:
return auth_error
data = request.get_json() or {}
tenant_id = data.get('tenant_id')
subdomain = data.get('subdomain', f'tenant-{tenant_id}')
if not tenant_id:
return jsonify({'error': 'tenant_id required'}), 400
# Check if container already exists
container_name = f"wpp-{subdomain}"
check = subprocess.run(
['docker', 'ps', '-a', '-q', '-f', f'name={container_name}'],
capture_output=True, text=True
)
if check.stdout.strip():
return jsonify({'error': f'Container {container_name} already exists'}), 409
# Find free port
port = _find_free_port()
if not port:
return jsonify({'error': 'No free ports available in range 21465-21565'}), 503
# Build image if not exists
image_check = subprocess.run(
['docker', 'images', '-q', 'nexus-whatsapp-bridge'],
capture_output=True, text=True
)
if not image_check.stdout.strip():
build = subprocess.run(
['docker', 'build', '-f', '/home/Autopartes/pos/Dockerfile.whatsapp-bridge',
'-t', 'nexus-whatsapp-bridge', '/home/Autopartes/pos'],
capture_output=True, text=True
)
if build.returncode != 0:
return jsonify({'error': 'Failed to build bridge image', 'details': build.stderr}), 500
# Run container
bridge_url = f"http://127.0.0.1:{port}"
run = subprocess.run([
'docker', 'run', '-d',
'--name', container_name,
'--restart', 'unless-stopped',
'-p', f'{port}:21465',
'-e', f'PORT=21465',
'-e', f'TENANT_ID={tenant_id}',
'-e', f'WEBHOOK_BASE=http://127.0.0.1:5001/pos/api/whatsapp/webhook',
'-e', f'API_KEY=nexus-wpp-secret-2026',
'-e', f'LOG_LEVEL=info',
'-v', f'wpp-{subdomain}:/app/auth',
'nexus-whatsapp-bridge'
], capture_output=True, text=True)
if run.returncode != 0:
return jsonify({'error': 'Failed to start container', 'details': run.stderr}), 500
container_id = run.stdout.strip()
# Save config to tenant_config
conn = get_tenant_conn_by_dbname(data.get('db_name'))
if not conn:
# Fallback: get db_name from master
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (tenant_id,))
row = mcur.fetchone()
mcur.close()
mconn.close()
if row:
conn = get_tenant_conn_by_dbname(row[0])
if conn:
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES
('whatsapp_bridge_url', %s),
('whatsapp_enabled', 'true')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (bridge_url,))
conn.commit()
cur.close()
conn.close()
return jsonify({
'success': True,
'tenant_id': tenant_id,
'container_id': container_id,
'container_name': container_name,
'port': port,
'bridge_url': bridge_url
}), 201
@internal_bp.route('/whatsapp-bridge', methods=['DELETE'])
def destroy_whatsapp_bridge():
"""Destroy a tenant's WhatsApp Bridge container."""
auth_error = _check_internal_key()
if auth_error:
return auth_error
data = request.get_json() or {}
subdomain = data.get('subdomain')
if not subdomain:
return jsonify({'error': 'subdomain required'}), 400
container_name = f"wpp-{subdomain}"
# Stop and remove container
subprocess.run(['docker', 'stop', container_name], capture_output=True)
subprocess.run(['docker', 'rm', container_name], capture_output=True)
# Remove volume
subprocess.run(['docker', 'volume', 'rm', f'wpp-{subdomain}'], capture_output=True)
return jsonify({'success': True, 'message': f'Bridge {container_name} destroyed'})

File diff suppressed because it is too large Load Diff

View File

@@ -6,39 +6,61 @@ This blueprint is the HTTP layer that validates input and returns JSON.
""" """
import json import json
from datetime import datetime
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml from services.cfdi_facturapi_builder import (
build_ingreso_payload, build_egreso_payload, build_pago_payload,
)
from services.cfdi_queue import ( from services.cfdi_queue import (
enqueue_cfdi, process_queue, retry_failed, enqueue_cfdi, process_queue, retry_failed,
cancel_cfdi, get_queue_status, cancel_cfdi, get_queue_status,
) )
from services import facturapi_service
from services.audit import log_action from services.audit import log_action
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
def _get_tenant_config(cur): def _get_issuer_config(cur, branch_id=None):
"""Load tenant CFDI configuration from tenant_config table. """Load CFDI issuer configuration.
Falls back to sensible defaults if config is incomplete. If branch_id is provided and the branch has fiscal data, use it.
Otherwise fall back to tenant-level config.
""" """
# Tenant-level defaults
config = {} config = {}
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'") cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
for row in cur.fetchall(): for row in cur.fetchall():
config[row[0]] = row[1] config[row[0]] = row[1]
return { result = {
'rfc': config.get('tenant_rfc', ''), 'rfc': config.get('tenant_rfc', ''),
'razon_social': config.get('tenant_razon_social', ''), 'razon_social': config.get('tenant_razon_social', ''),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), 'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
'cp': config.get('tenant_cp', '00000'), 'cp': config.get('tenant_cp', '00000'),
'serie': config.get('cfdi_serie', 'A'), 'serie': config.get('cfdi_serie', 'A'),
'horux_api_url': config.get('cfdi_horux_api_url', ''), 'facturapi_key': config.get('cfdi_facturapi_key', ''),
'horux_api_key': config.get('cfdi_horux_api_key', ''), 'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''),
} }
# Branch-level override
if branch_id:
cur.execute("""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s
""", (branch_id,))
row = cur.fetchone()
if row and row[0]:
result['rfc'] = row[0] or result['rfc']
result['razon_social'] = row[1] or result['razon_social']
result['regimen_fiscal'] = row[2] or result['regimen_fiscal']
result['cp'] = row[3] or result['cp']
result['serie'] = row[4] or result['serie']
return result
def _get_sale_with_items(cur, sale_id): def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation.""" """Load a sale with its items for CFDI generation."""
@@ -134,14 +156,14 @@ def generate_invoice():
cur = conn.cursor() cur = conn.cursor()
try: try:
tenant_config = _get_tenant_config(cur)
if not tenant_config['rfc']:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
sale = _get_sale_with_items(cur, sale_id) sale = _get_sale_with_items(cur, sale_id)
if not sale: if not sale:
return jsonify({'error': 'Sale not found'}), 404 return jsonify({'error': 'Sale not found'}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
if not tenant_config['rfc']:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
if sale['status'] == 'cancelled': if sale['status'] == 'cancelled':
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400 return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
@@ -158,19 +180,19 @@ def generate_invoice():
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' 'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
}), 409 }), 409
# Build XML # Build Facturapi payload
if cfdi_type == 'ingreso': if cfdi_type == 'ingreso':
xml = build_ingreso_xml(sale, tenant_config, customer) payload = build_ingreso_payload(sale, tenant_config, customer)
elif cfdi_type == 'egreso': elif cfdi_type == 'egreso':
original_uuid = data.get('original_uuid') original_uuid = data.get('original_uuid')
if not original_uuid: if not original_uuid:
return jsonify({'error': 'original_uuid required for egreso'}), 400 return jsonify({'error': 'original_uuid required for egreso'}), 400
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid) payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
else: else:
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
# Enqueue # Enqueue
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml) result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'], log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
new_value={'sale_id': sale_id, 'type': cfdi_type, new_value={'sale_id': sale_id, 'type': cfdi_type,
@@ -225,10 +247,10 @@ def get_queue_item(cfdi_id):
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed, SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
q.error_message, q.cancel_motive, q.cancel_replacement_uuid, q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
q.created_at, q.stamped_at q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q WHERE q.id = %s FROM cfdi_queue q WHERE q.id = %s
""", (cfdi_id,)) """, (cfdi_id,))
row = cur.fetchone() row = cur.fetchone()
@@ -239,13 +261,14 @@ def get_queue_item(cfdi_id):
item = { item = {
'id': row[0], 'sale_id': row[1], 'type': row[2], 'id': row[0], 'sale_id': row[1], 'type': row[2],
'xml_unsigned': row[3], 'xml_signed': row[4], 'payload_unsigned': row[3], 'xml_signed': row[4],
'uuid_fiscal': row[5], 'status': row[6], 'uuid_fiscal': row[5], 'status': row[6],
'retry_count': row[7], 'provisional_folio': row[8], 'retry_count': row[7], 'provisional_folio': row[8],
'error_message': row[9], 'cancel_motive': row[10], 'error_message': row[9], 'cancel_motive': row[10],
'cancel_replacement_uuid': row[11], 'cancel_replacement_uuid': row[11],
'created_at': str(row[12]) if row[12] else None, 'created_at': str(row[12]) if row[12] else None,
'stamped_at': str(row[13]) if row[13] else None, 'stamped_at': str(row[13]) if row[13] else None,
'external_id': row[14],
} }
cur.close() cur.close()
@@ -261,20 +284,17 @@ def trigger_process_queue():
cur = conn.cursor() cur = conn.cursor()
try: try:
tenant_config = _get_tenant_config(cur) tenant_config = _get_issuer_config(cur)
horux_url = tenant_config.get('horux_api_url') if not tenant_config.get('facturapi_key'):
horux_key = tenant_config.get('horux_api_key')
if not horux_url or not horux_key:
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': 'Horux API not configured'}), 400 return jsonify({'error': 'Facturapi key not configured'}), 400
# Reset eligible failed items first # Reset eligible failed items first
reset_count = retry_failed(conn) reset_count = retry_failed(conn)
# Process the queue # Process the queue
result = process_queue(conn, horux_url, horux_key) result = process_queue(conn, tenant_config)
result['retries_reset'] = reset_count result['retries_reset'] = reset_count
cur.close() cur.close()
@@ -316,11 +336,10 @@ def cancel_invoice(cfdi_id):
cur = conn.cursor() cur = conn.cursor()
try: try:
tenant_config = _get_tenant_config(cur) tenant_config = _get_issuer_config(cur)
result = cancel_cfdi( result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid, conn, cfdi_id, motive, replacement_uuid,
tenant_config.get('horux_api_url'), tenant_config=tenant_config,
tenant_config.get('horux_api_key'),
) )
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
@@ -362,7 +381,7 @@ def get_sale_pdf(sale_id):
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404 return jsonify({'error': 'Sale not found'}), 404
tenant_config = _get_tenant_config(cur) tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
customer = _get_customer(cur, sale.get('customer_id')) customer = _get_customer(cur, sale.get('customer_id'))
# Check if there's a stamped CFDI # Check if there's a stamped CFDI
@@ -397,3 +416,249 @@ def get_sale_pdf(sale_id):
'customer': customer, 'customer': customer,
'cfdi': cfdi_info, 'cfdi': cfdi_info,
}) })
@invoicing_bp.route('/stats', methods=['GET'])
@require_auth('invoicing.read')
def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas,
COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito,
COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones
FROM cfdi_queue
""")
row = cur.fetchone()
cur.close()
conn.close()
return jsonify({
'facturas': row[0] or 0,
'notas_credito': row[1] or 0,
'complementos': row[2] or 0,
'cancelaciones': row[3] or 0,
})
@invoicing_bp.route('/global-invoice', methods=['POST'])
@require_auth('invoicing.create')
def generate_global_invoice():
"""Generate a monthly global invoice for cash sales.
Body: {
year: int (default current year),
month: int (default current month),
branch_id: int (optional)
}
"""
data = request.get_json() or {}
now = datetime.now()
year = data.get('year', now.year)
month = data.get('month', now.month)
branch_id = data.get('branch_id')
try:
year = int(year)
month = int(month)
if month < 1 or month > 12:
return jsonify({'error': 'month must be 1-12'}), 400
except (ValueError, TypeError):
return jsonify({'error': 'year and month must be integers'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
tenant_config = _get_issuer_config(cur, branch_id)
if not tenant_config['rfc']:
cur.close(); conn.close()
return jsonify({'error': 'Tenant RFC not configured'}), 400
from services.global_invoice import generate_global_invoice
result = generate_global_invoice(
conn, tenant_config, year, month,
branch_id=branch_id,
employee_id=getattr(g, 'employee_id', None)
)
if 'error' in result:
cur.close(); conn.close()
return jsonify(result), 400
log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'],
new_value={'year': year, 'month': month, 'sales_count': result['sales_count']})
conn.commit()
cur.close()
conn.close()
return jsonify(result), 201
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET'])
@require_auth('invoicing.view')
def get_global_invoice(cfdi_id):
"""Get status and linked sales of a global invoice."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
from services.global_invoice import get_global_invoice_status
result = get_global_invoice_status(conn, cfdi_id)
cur.close()
conn.close()
if not result:
return jsonify({'error': 'Global invoice not found'}), 404
return jsonify(result)
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET'])
@require_auth('invoicing.view')
def get_eligible_sales_for_global():
"""Preview sales that would be included in a global invoice.
Query params: year, month, branch_id
"""
now = datetime.now()
year = request.args.get('year', now.year, type=int)
month = request.args.get('month', now.month, type=int)
branch_id = request.args.get('branch_id', type=int)
conn = get_tenant_conn(g.tenant_id)
from services.global_invoice import get_eligible_sales
sales = get_eligible_sales(conn, year, month, branch_id)
conn.close()
return jsonify({
'year': year, 'month': month,
'count': len(sales),
'total': sum(s['total'] for s in sales),
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales],
})
# ─── Facturapi extras ───────────────────────────────
@invoicing_bp.route('/facturapi/status', methods=['GET'])
@require_auth('invoicing.view')
def facturapi_status():
"""Return Facturapi organization status for the tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
tenant_config = _get_issuer_config(cur)
cur.close()
conn.close()
status = facturapi_service.get_org_status(tenant_config)
return jsonify(status)
@invoicing_bp.route('/facturapi/setup', methods=['POST'])
@require_auth('invoicing.create')
def facturapi_setup():
"""Create or link a Facturapi organization for this tenant.
Requires FACTURAPI_USER_KEY environment variable.
Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
if not tenant_config.get('rfc'):
return jsonify({'error': 'Tenant RFC not configured'}), 400
result = facturapi_service.create_organization(tenant_config)
cur.execute("""
INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_org_id', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (result['org_id'],))
cur.execute("""
INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (result['api_key'],))
log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None,
new_value={'org_id': result['org_id']})
conn.commit()
cur.close()
conn.close()
return jsonify({
'org_id': result['org_id'],
'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.',
})
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
@require_auth('invoicing.view')
def facturapi_download(cfdi_id, doc_type):
"""Download PDF or XML for a stamped CFDI from Facturapi.
doc_type: 'pdf' | 'xml'
"""
if doc_type not in ('pdf', 'xml'):
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'CFDI not found'}), 404
external_id, uuid_fiscal, status = row
if status != 'stamped' or not external_id:
cur.close(); conn.close()
return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400
tenant_config = _get_issuer_config(cur)
cur.close()
conn.close()
try:
if doc_type == 'pdf':
content = facturapi_service.download_pdf(tenant_config, external_id)
mime = 'application/pdf'
filename = f'cfdi_{uuid_fiscal or external_id}.pdf'
else:
content = facturapi_service.download_xml(tenant_config, external_id)
mime = 'application/xml'
filename = f'cfdi_{uuid_fiscal or external_id}.xml'
except Exception as e:
return jsonify({'error': str(e)}), 500
from flask import Response
return Response(
content,
mimetype=mime,
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)

View File

@@ -190,6 +190,16 @@ def bodegas_with_part(part_id):
return _with_master(_do) return _with_master(_do)
@marketplace_bp.route('/inventory/listing/<int:wi_id>', methods=['GET'])
@require_auth()
def bodegas_with_listing(wi_id):
"""Return bodegas stocking a specific seller listing (wi_id)."""
def _do(master):
data = mkt.get_bodegas_with_listing(master, wi_id)
return jsonify({'data': data, 'count': len(data)})
return _with_master(_do)
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# PURCHASE ORDERS # PURCHASE ORDERS
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,703 @@
"""MercadoLibre external marketplace REST endpoints.
Routes:
Config
GET /pos/api/marketplace-ext/config
POST /pos/api/marketplace-ext/connect
DELETE /pos/api/marketplace-ext/connect
GET /pos/api/marketplace-ext/categories
Listings
GET /pos/api/marketplace-ext/listings
POST /pos/api/marketplace-ext/listings
POST /pos/api/marketplace-ext/listings/<id>/sync
POST /pos/api/marketplace-ext/listings/<id>/pause
POST /pos/api/marketplace-ext/listings/<id>/activate
DELETE /pos/api/marketplace-ext/listings/<id>
Orders
GET /pos/api/marketplace-ext/orders
GET /pos/api/marketplace-ext/orders/<id>
POST /pos/api/marketplace-ext/orders/<id>/convert
Webhook (public)
POST /pos/api/marketplace-ext/webhook/meli
"""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn, get_master_conn
from services import marketplace_external_service as meli_svc
def _get_public_base_url() -> str:
"""Build the tenant's public base URL from request headers (handles reverse proxy)."""
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
host = request.headers.get("X-Forwarded-Host", request.host)
# Cloudflare specific header
cf_visitor = request.headers.get("CF-Visitor")
if cf_visitor and '"scheme":"https"' in cf_visitor:
proto = "https"
# Force https for production domain if we detect http behind a TLS terminator
if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"):
proto = "https"
return f"{proto}://{host}/"
from services.meli_service import MeliService, MeliAuthError
marketplace_ext_bp = Blueprint(
"marketplace_ext", __name__, url_prefix="/pos/api/marketplace-ext"
)
# ─── Helpers ───────────────────────────────────────────────────────────────
def _require_meli_manage():
if not has_permission("marketplace.manage"):
return jsonify({"error": "Missing permission: marketplace.manage"}), 403
return None
# ═══════════════════════════════════════════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/config", methods=["GET"])
@require_auth()
def get_config():
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
# Never return tokens to frontend
safe = {
k: v for k, v in cfg.items()
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
}
safe["connected"] = bool(cfg.get("meli_access_token"))
return jsonify(safe)
finally:
conn.close()
@marketplace_ext_bp.route("/connect", methods=["POST"])
@require_auth()
def connect_meli():
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
code = data.get("code")
client_id = data.get("client_id")
client_secret = data.get("client_secret")
redirect_uri = data.get("redirect_uri", "")
if not code or not client_id or not client_secret:
return jsonify({"error": "code, client_id and client_secret required"}), 400
try:
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
except MeliAuthError as e:
return jsonify({"error": str(e)}), 400
access_token = token_data.get("access_token")
refresh_token = token_data.get("refresh_token")
user_id = token_data.get("user_id")
# Validate token by fetching user
svc = MeliService(access_token)
try:
user = svc.get_user()
except MeliAuthError as e:
return jsonify({"error": f"Invalid token: {e}"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.save_meli_config(conn, {
"meli_access_token": access_token,
"meli_refresh_token": refresh_token,
"meli_user_id": str(user_id or user.get("id")),
"meli_site_id": user.get("site_id", "MLM"),
"meli_enabled": "true",
"meli_client_id": client_id,
"meli_client_secret": client_secret,
})
return jsonify({
"ok": True,
"user_id": user_id or user.get("id"),
"nickname": user.get("nickname"),
"site_id": user.get("site_id"),
})
finally:
conn.close()
@marketplace_ext_bp.route("/connect", methods=["DELETE"])
@require_auth()
def disconnect_meli():
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.delete_meli_config(conn)
return jsonify({"ok": True})
finally:
conn.close()
@marketplace_ext_bp.route("/categories", methods=["GET"])
@require_auth()
def search_categories():
q = request.args.get("q", "")
site_id = request.args.get("site_id", "MLM")
if not q or len(q) < 2:
return jsonify({"categories": []})
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg)
if not svc:
return jsonify({"error": "MercadoLibre not connected"}), 400
result = svc.search_categories(site_id, q)
return jsonify({"categories": result})
except MeliAuthError:
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# LISTINGS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/listings", methods=["GET"])
@require_auth()
def list_listings():
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200)
status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
return jsonify(result)
except MeliAuthError:
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings", methods=["POST"])
@require_auth()
def create_listings():
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
inventory_ids = data.get("inventory_ids", [])
category_id = data.get("category_id")
listing_type = data.get("listing_type", "gold_special")
shipping_mode = data.get("shipping_mode", "me2")
custom_data = data.get("custom_data", {})
if not inventory_ids:
return jsonify({"error": "inventory_ids required"}), 400
if not category_id:
return jsonify({"error": "category_id required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.publish_items(
conn,
inventory_ids=inventory_ids,
meli_category_id=category_id,
listing_type_id=listing_type,
shipping_mode=shipping_mode,
custom_data=custom_data,
base_url=_get_public_base_url(),
)
return jsonify(result), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/import-existing", methods=["POST"])
@require_auth()
def import_existing_listings():
"""Import all existing MercadoLibre listings for the connected seller."""
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.import_existing_listings(conn)
return jsonify(result), 200
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
@require_auth()
def inventory_check():
"""Check local pre-flight status for ML publishing (duplicates, stock, price, image)."""
data = request.get_json() or {}
inventory_ids = data.get("inventory_ids", [])
if not inventory_ids:
return jsonify({"error": "inventory_ids required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url())
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/sync-stock", methods=["POST"])
@require_auth()
def sync_stock_to_meli():
"""Process pending stock updates to MercadoLibre."""
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.process_meli_sync_queue(conn)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/categories/<category_id>/attributes", methods=["GET"])
@require_auth()
def category_attributes(category_id):
"""Get required attributes for a MercadoLibre category."""
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg)
if not svc:
return jsonify({"error": "MercadoLibre not connected"}), 400
attrs = svc.get_category_attributes(category_id)
# Filter to required attributes only for the UI
required = [a for a in attrs if a.get("tags", {}).get("required")]
return jsonify({"attributes": required, "all": attrs})
except MeliAuthError:
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/validate", methods=["POST"])
@require_auth()
def validate_listings():
"""Validate items payload against ML /items/validate without creating them."""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
inventory_ids = data.get("inventory_ids", [])
category_id = data.get("category_id")
listing_type = data.get("listing_type", "gold_special")
shipping_mode = data.get("shipping_mode", "me2")
custom_data = data.get("custom_data", {})
if not inventory_ids:
return jsonify({"error": "inventory_ids required"}), 400
if not category_id:
return jsonify({"error": "category_id required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.validate_items(
conn,
inventory_ids=inventory_ids,
meli_category_id=category_id,
listing_type_id=listing_type,
shipping_mode=shipping_mode,
custom_data=custom_data,
base_url=_get_public_base_url(),
)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/async", methods=["POST"])
@require_auth()
def create_listings_async():
"""Enqueue ML publishing as a Celery background task."""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
inventory_ids = data.get("inventory_ids", [])
category_id = data.get("category_id")
listing_type = data.get("listing_type", "gold_special")
shipping_mode = data.get("shipping_mode", "me2")
custom_data = data.get("custom_data", {})
if not inventory_ids:
return jsonify({"error": "inventory_ids required"}), 400
if not category_id:
return jsonify({"error": "category_id required"}), 400
try:
from tasks import publish_meli_items_task
task = publish_meli_items_task.delay(
g.tenant_id,
inventory_ids=inventory_ids,
category_id=category_id,
listing_type=listing_type,
shipping_mode=shipping_mode,
custom_data=custom_data,
base_url=_get_public_base_url(),
)
return jsonify({"task_id": task.id, "status": "queued"}), 202
except Exception as e:
return jsonify({"error": str(e)}), 500
@marketplace_ext_bp.route("/listings/async/<task_id>", methods=["GET"])
@require_auth()
def get_async_listing_status(task_id):
"""Get status of an async ML publishing task."""
try:
from celery.result import AsyncResult
from app import celery as celery_app
result = AsyncResult(task_id, app=celery_app)
if result.ready():
return jsonify({"status": "done", "result": result.result or {}})
return jsonify({"status": "pending"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@marketplace_ext_bp.route("/listings/<int:listing_id>/sync", methods=["POST"])
@require_auth()
def sync_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.sync_listing(conn, listing_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/<int:listing_id>/pause", methods=["POST"])
@require_auth()
def pause_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.pause_listing(conn, listing_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/<int:listing_id>/activate", methods=["POST"])
@require_auth()
def activate_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.activate_listing(conn, listing_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/<int:listing_id>", methods=["DELETE"])
@require_auth()
def delete_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.close_listing(conn, listing_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/<int:listing_id>/permanent", methods=["DELETE"])
@require_auth()
def delete_listing_permanent(listing_id):
"""Hard-delete a closed listing from the local DB."""
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.delete_listing_permanently(conn, listing_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# QUESTIONS & ANSWERS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/questions", methods=["GET"])
@require_auth()
def list_questions():
"""List questions from local DB. Query param: ?status=unanswered"""
status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id)
try:
items = meli_svc.list_local_questions(conn, status=status)
return jsonify({"items": items})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/questions/sync", methods=["POST"])
@require_auth()
def sync_questions():
"""Force sync questions from ML for all active listings."""
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.sync_questions(conn)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/questions/<int:question_id>/answer", methods=["POST"])
@require_auth()
def answer_question(question_id):
"""Answer a buyer question via ML API."""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
text = data.get("text", "").strip()
if not text:
return jsonify({"error": "Answer text is required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.answer_question(conn, question_id, text)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# ORDERS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/orders/sync", methods=["POST"])
@require_auth()
def sync_orders():
"""Manually trigger sync of MercadoLibre orders."""
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.fetch_and_save_orders(conn)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/orders", methods=["GET"])
@require_auth()
def list_orders():
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200)
status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.get_orders(conn, page=page, per_page=per_page, status=status)
return jsonify(result)
finally:
conn.close()
@marketplace_ext_bp.route("/orders/<int:order_id>", methods=["GET"])
@require_auth()
def get_order(order_id):
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.get_order_detail(conn, order_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 404
finally:
conn.close()
@marketplace_ext_bp.route("/orders/<int:order_id>/convert", methods=["POST"])
@require_auth("pos.sell")
def convert_order(order_id):
data = request.get_json() or {}
register_id = data.get("register_id")
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.convert_order_to_sale(
conn, order_id, employee_id=g.employee_id, register_id=register_id
)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/orders/<int:order_id>/status", methods=["POST"])
@require_auth()
def update_order_status_route(order_id):
data = request.get_json() or {}
new_status = data.get("status")
if not new_status:
return jsonify({"error": "status required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.update_order_status(conn, order_id, new_status)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# WEBHOOK (public — no auth)
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/webhook/meli", methods=["POST"])
def meli_webhook():
"""Receive MercadoLibre notifications.
ML sends a lightweight payload with topic + resource URL.
We ack immediately and enqueue Celery for async processing.
"""
data = request.get_json(force=True, silent=True) or {}
topic = data.get("topic", "")
resource = data.get("resource", "")
user_id = data.get("user_id")
# Resolve tenant by meli_user_id
tenant_id = None
if user_id:
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute(
"""
SELECT t.id FROM tenants t
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
WHERE t.is_active = true
LIMIT 1
""",
(str(user_id),),
)
row = mcur.fetchone()
if row:
tenant_id = row[0]
mcur.close()
mconn.close()
except Exception:
pass
if tenant_id and topic:
try:
from tasks import process_meli_webhook_task
process_meli_webhook_task.delay(tenant_id, topic, resource)
except Exception as e:
print(f"[ML Webhook] Failed to enqueue task: {e}")
return jsonify({"ok": True})

View File

@@ -15,6 +15,7 @@ from services.pos_engine import (
process_sale, cancel_sale, calculate_totals, process_sale, cancel_sale, calculate_totals,
get_price_for_customer, get_margin_info get_price_for_customer, get_margin_info
) )
from services.inventory_engine import get_stock
from services.audit import log_action from services.audit import log_action
from config import JWT_SECRET from config import JWT_SECRET
@@ -34,7 +35,7 @@ def _enrich_items(cur, items, customer_id=None):
# Batch fetch all inventory items in one query # Batch fetch all inventory items in one query
cur.execute(""" cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3, SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id tax_rate
FROM inventory WHERE id = ANY(%s) AND is_active = true FROM inventory WHERE id = ANY(%s) AND is_active = true
""", (inv_ids,)) """, (inv_ids,))
inv_map = {r[0]: r for r in cur.fetchall()} inv_map = {r[0]: r for r in cur.fetchall()}
@@ -75,7 +76,6 @@ def _enrich_items(cur, items, customer_id=None):
'unit_cost': float(inv[3]) if inv[3] else 0, 'unit_cost': float(inv[3]) if inv[3] else 0,
'discount_pct': discount_pct, 'discount_pct': discount_pct,
'tax_rate': tax_rate, 'tax_rate': tax_rate,
'branch_id': inv[8],
}) })
return enriched return enriched
@@ -103,6 +103,19 @@ def create_sale():
data = request.get_json() or {} data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
# Verify stock availability per item for the active branch
branch_id = data.get('branch_id', g.branch_id)
for item in data.get('items', []):
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if inv_id:
available = get_stock(conn, inv_id, branch_id)
if available < qty:
conn.close()
return jsonify({
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
}), 400
try: try:
sale = process_sale(conn, data) sale = process_sale(conn, data)
conn.commit() conn.commit()
@@ -219,6 +232,83 @@ def list_sales():
}) })
@pos_bp.route('/historical-sales', methods=['GET'])
@require_auth('pos.view')
def list_historical_sales():
"""List imported historical sales (read-only reference).
Query params:
date_from: YYYY-MM-DD
date_to: YYYY-MM-DD
customer: partial customer name
page: int (default 1)
per_page: int (default 50, max 200)
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
customer = request.args.get('customer')
if date_from:
where_clauses.append("sale_date >= %s")
params.append(date_from)
if date_to:
where_clauses.append("sale_date <= %s")
params.append(date_to)
if customer:
where_clauses.append("customer_name ILIKE %s")
params.append(f"%{customer}%")
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT id, external_document_id, document_no, sale_date, customer_name,
total, subtotal, amount_paid, payment_method, discount, balance,
raw_payment_code
FROM historical_sales
WHERE {where}
ORDER BY sale_date DESC, id DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = []
for r in cur.fetchall():
rows.append({
'id': r[0],
'external_document_id': r[1],
'document_no': r[2],
'sale_date': str(r[3]) if r[3] else None,
'customer_name': r[4],
'total': float(r[5]) if r[5] else 0,
'subtotal': float(r[6]) if r[6] else 0,
'amount_paid': float(r[7]) if r[7] else 0,
'payment_method': r[8],
'discount': float(r[9]) if r[9] else 0,
'balance': float(r[10]) if r[10] else 0,
'raw_payment_code': r[11],
})
cur.close()
conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': rows,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
@pos_bp.route('/sales/<int:sale_id>', methods=['GET']) @pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
@require_auth('pos.view') @require_auth('pos.view')
def get_sale(sale_id): def get_sale(sale_id):
@@ -1864,6 +1954,14 @@ def complete_layaway(layaway_id):
new_value={'sale_id': sale['id'], 'total': total}) new_value={'sale_id': sale['id'], 'total': total})
conn.commit() conn.commit()
# WhatsApp learning hook (non-blocking)
try:
from services.wa_learning import check_learning_resolution
check_learning_resolution(sale['id'], cust_id, conn)
except Exception:
pass
cur.close(); conn.close() cur.close(); conn.close()
return jsonify(sale), 201 return jsonify(sale), 201

View File

@@ -0,0 +1,538 @@
"""Supplier Catalog Blueprint — parts from suppliers with vehicle compatibility.
Independent from inventory. Supports:
- Browse by supplier/category
- Search by text or vehicle (MYE or make/model/year)
- Part detail with compatibilities and interchanges
- Bulk import via Excel
"""
import csv
import io
from datetime import date
from flask import Blueprint, request, jsonify, g, render_template
from psycopg2.extras import RealDictCursor
from tenant_db import get_master_conn
from middleware import require_auth
supplier_catalog_bp = Blueprint('supplier_catalog', __name__, url_prefix='/pos/api/supplier-catalog')
# ─── Helpers ───────────────────────────────────────────────────────────────
def _get_master_conn():
return get_master_conn()
def _json_response(data, status=200):
return jsonify(data), status
# ─── Brands ────────────────────────────────────────────────────────────────
@supplier_catalog_bp.route('/brands', methods=['GET'])
@require_auth('catalog.view')
def list_brands():
"""Return distinct makes (vehicle brands) present in the supplier catalog."""
conn = _get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT make, COUNT(*) as cnt
FROM supplier_catalog_compat
WHERE make IS NOT NULL AND make != ''
GROUP BY make
ORDER BY make ASC
""")
rows = cur.fetchall()
cur.close(); conn.close()
return jsonify({'brands': [{'name': r[0], 'count': r[1]} for r in rows]})
# ─── Search ────────────────────────────────────────────────────────────────
@supplier_catalog_bp.route('/search', methods=['GET'])
@require_auth('catalog.view')
def search_items():
"""Search supplier catalog by text and/or vehicle."""
q = (request.args.get('q') or '').strip()
mye_id = request.args.get('mye_id', type=int)
make = (request.args.get('make') or '').strip()
model = (request.args.get('model') or '').strip()
year = request.args.get('year', type=int)
supplier = (request.args.get('supplier') or '').strip()
category = (request.args.get('category') or '').strip()
page = max(1, request.args.get('page', 1, type=int))
per_page = min(100, request.args.get('per_page', 30, type=int))
offset = (page - 1) * per_page
conn = _get_master_conn()
cur = conn.cursor()
# Build query dynamically
where_parts = ["sc.is_active = true"]
params = []
if supplier:
where_parts.append("sc.supplier_name = %s")
params.append(supplier)
if category:
where_parts.append("sc.category = %s")
params.append(category)
# Text search on SKU, name, or interchange part_number
if q:
where_parts.append("""
(sc.sku ILIKE %s OR sc.name ILIKE %s
OR EXISTS (
SELECT 1 FROM supplier_catalog_interchange sci2
WHERE sci2.catalog_id = sc.id AND sci2.part_number ILIKE %s
))
""")
like_q = f'%{q}%'
params.extend([like_q, like_q, like_q])
# Vehicle filter
vehicle_join = ""
if mye_id:
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
where_parts.append("scc.model_year_engine_id = %s")
params.append(mye_id)
elif make or model or year:
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
if make:
where_parts.append("scc.make ILIKE %s")
params.append(f'%{make}%')
if model:
where_parts.append("scc.model ILIKE %s")
params.append(f'%{model}%')
if year:
where_parts.append("scc.year = %s")
params.append(year)
where_sql = " AND ".join(where_parts)
# Count total
count_sql = f"""
SELECT COUNT(DISTINCT sc.id)
FROM supplier_catalog sc
{vehicle_join}
WHERE {where_sql}
"""
cur.execute(count_sql, params)
total = cur.fetchone()[0]
# Fetch page
fetch_sql = f"""
SELECT DISTINCT
sc.id, sc.supplier_name, sc.sku, sc.name,
sc.category, sc.description, sc.image_url
FROM supplier_catalog sc
{vehicle_join}
WHERE {where_sql}
ORDER BY sc.name ASC
LIMIT %s OFFSET %s
"""
cur.execute(fetch_sql, params + [per_page, offset])
rows = cur.fetchall()
items = []
for r in rows:
items.append({
'id': r[0],
'supplier_name': r[1],
'sku': r[2],
'name': r[3],
'category': r[4],
'description': r[5],
'image_url': r[6],
})
cur.close(); conn.close()
return jsonify({
'data': items,
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'total_pages': (total + per_page - 1) // per_page,
}
})
# ─── Item Detail ───────────────────────────────────────────────────────────
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['GET'])
@require_auth('catalog.view')
def get_item_detail(item_id):
"""Return full detail for a supplier catalog item including compat + interchanges."""
conn = _get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT id, supplier_name, sku, name, category, description, image_url, created_at
FROM supplier_catalog WHERE id = %s AND is_active = true
""", (item_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Item not found'}), 404
item = {
'id': row[0],
'supplier_name': row[1],
'sku': row[2],
'name': row[3],
'category': row[4],
'description': row[5],
'image_url': row[6],
'created_at': str(row[7]) if row[7] else None,
}
# Compatibilities — deduplicate by (make, model, year, engine) because
# the same vehicle may map to multiple MYE ids (especially when engine
# text is empty from the supplier catalog).
cur.execute("""
SELECT make, model, year, engine, model_year_engine_id, source
FROM supplier_catalog_compat
WHERE catalog_id = %s
ORDER BY make, model, year, engine
""", (item_id,))
seen_compat = set()
compatibilities = []
for r in cur.fetchall():
key = (r[0], r[1], r[2], r[3])
if key in seen_compat:
continue
seen_compat.add(key)
compatibilities.append({
'make': r[0], 'model': r[1], 'year': r[2], 'engine': r[3],
'model_year_engine_id': r[4], 'source': r[5]
})
item['compatibilities'] = compatibilities
# Interchanges
cur.execute("""
SELECT brand, part_number
FROM supplier_catalog_interchange
WHERE catalog_id = %s
ORDER BY brand, part_number
""", (item_id,))
item['interchanges'] = [
{'brand': r[0], 'part_number': r[1]}
for r in cur.fetchall()
]
cur.close(); conn.close()
return jsonify(item)
# ─── Categories ────────────────────────────────────────────────────────────
@supplier_catalog_bp.route('/categories', methods=['GET'])
@require_auth('catalog.view')
def list_categories():
"""Return distinct categories with counts."""
conn = _get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT category, COUNT(*) as cnt
FROM supplier_catalog
WHERE is_active = true
GROUP BY category
ORDER BY cnt DESC
""")
rows = cur.fetchall()
cur.close(); conn.close()
return jsonify({'categories': [{'name': r[0], 'count': r[1]} for r in rows]})
# ─── Suppliers ─────────────────────────────────────────────────────────────
@supplier_catalog_bp.route('/suppliers', methods=['GET'])
@require_auth('catalog.view')
def list_suppliers():
"""Return distinct suppliers with counts."""
conn = _get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT supplier_name, COUNT(*) as cnt
FROM supplier_catalog
WHERE is_active = true
GROUP BY supplier_name
ORDER BY supplier_name ASC
""")
rows = cur.fetchall()
cur.close(); conn.close()
return jsonify({'suppliers': [{'name': r[0], 'count': r[1]} for r in rows]})
# ─── Delete ────────────────────────────────────────────────────────────────
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_auth('inventory.edit')
def delete_item(item_id):
"""Soft-delete a supplier catalog item."""
conn = _get_master_conn()
cur = conn.cursor()
cur.execute("UPDATE supplier_catalog SET is_active = false WHERE id = %s", (item_id,))
conn.commit()
cur.close(); conn.close()
return jsonify({'success': True})
# ─── Prices ────────────────────────────────────────────────────────────────
def _get_latest_prices(master_conn, tenant_id, catalog_ids):
"""Return a dict catalog_id -> price row for the latest active price per item."""
if not catalog_ids:
return {}
cur = master_conn.cursor()
cur.execute("""
SELECT DISTINCT ON (catalog_id)
catalog_id, price, currency, effective_from, effective_to
FROM supplier_catalog_prices
WHERE tenant_id = %s AND catalog_id = ANY(%s) AND is_active = true
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
ORDER BY catalog_id, effective_from DESC
""", (tenant_id, list(catalog_ids)))
prices = {}
for r in cur.fetchall():
prices[r[0]] = {
'price': float(r[1]) if r[1] is not None else None,
'currency': r[2] or 'MXN',
'effective_from': str(r[3]) if r[3] else None,
'effective_to': str(r[4]) if r[4] else None,
}
cur.close()
return prices
@supplier_catalog_bp.route('/prices', methods=['GET'])
@require_auth('catalog.view')
def list_prices():
"""List active supplier prices for the current tenant."""
supplier = (request.args.get('supplier') or '').strip()
q = (request.args.get('q') or '').strip()
page = max(1, request.args.get('page', 1, type=int))
per_page = min(200, request.args.get('per_page', 50, type=int))
offset = (page - 1) * per_page
conn = _get_master_conn()
cur = conn.cursor()
where_parts = ["sc.is_active = true", "scp.tenant_id = %s"]
params = [g.tenant_id]
if supplier:
where_parts.append("sc.supplier_name = %s")
params.append(supplier)
if q:
where_parts.append("(sc.sku ILIKE %s OR sc.name ILIKE %s)")
like_q = f'%{q}%'
params.extend([like_q, like_q])
where_sql = " AND ".join(where_parts)
cur.execute(f"""
SELECT COUNT(DISTINCT sc.id)
FROM supplier_catalog sc
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
WHERE {where_sql}
AND scp.is_active = true
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
""", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT DISTINCT ON (sc.id)
sc.id, sc.supplier_name, sc.sku, sc.name, sc.category,
scp.price, scp.currency, scp.effective_from, scp.effective_to
FROM supplier_catalog sc
JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id
WHERE {where_sql}
AND scp.is_active = true
AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE)
ORDER BY sc.id, scp.effective_from DESC
LIMIT %s OFFSET %s
""", params + [per_page, offset])
items = []
for r in cur.fetchall():
items.append({
'catalog_id': r[0],
'supplier_name': r[1],
'sku': r[2],
'name': r[3],
'category': r[4],
'price': float(r[5]) if r[5] is not None else None,
'currency': r[6] or 'MXN',
'effective_from': str(r[7]) if r[7] else None,
'effective_to': str(r[8]) if r[8] else None,
})
cur.close(); conn.close()
return jsonify({
'data': items,
'pagination': {'page': page, 'per_page': per_page, 'total': total,
'total_pages': (total + per_page - 1) // per_page}
})
@supplier_catalog_bp.route('/prices/template', methods=['GET'])
@require_auth('catalog.view')
def download_price_template():
"""Return a CSV template for uploading supplier prices."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['supplier_name', 'sku', 'price', 'currency', 'effective_from'])
writer.writerow(['YOKOMITSU', 'DENK070A', '1250.00', 'MXN', '2026-01-01'])
output.seek(0)
return (output.getvalue(), 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="supplier_prices_template.csv"'
})
def _read_upload_file(file_storage):
"""Read CSV or Excel upload and return list of dict rows."""
filename = (file_storage.filename or '').lower()
content = file_storage.read()
if filename.endswith('.csv'):
text = content.decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(text))
return [row for row in reader]
if filename.endswith(('.xlsx', '.xls')):
try:
import openpyxl
except ImportError as e:
raise RuntimeError('openpyxl no instalado; sube CSV o instala openpyxl') from e
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return []
headers = [str(c).strip().lower() if c else '' for c in rows[0]]
return [
dict(zip(headers, row))
for row in rows[1:] if any(cell is not None and str(cell).strip() for cell in row)
]
raise ValueError('Formato no soportado. Usa CSV o Excel (.xlsx)')
@supplier_catalog_bp.route('/prices/upload', methods=['POST'])
@require_auth('inventory.edit')
def upload_prices():
"""Bulk upload/upsert supplier prices for the current tenant.
Expected columns: supplier_name, sku, price, [currency], [effective_from]
"""
if 'file' not in request.files:
return jsonify({'error': 'Archivo requerido'}), 400
file_storage = request.files['file']
if not file_storage or not file_storage.filename:
return jsonify({'error': 'Archivo requerido'}), 400
try:
rows = _read_upload_file(file_storage)
except Exception as e:
return jsonify({'error': str(e)}), 400
if not rows:
return jsonify({'error': 'El archivo esta vacio o no tiene filas validas'}), 400
conn = _get_master_conn()
cur = conn.cursor()
# Build a lookup of supplier+sku -> catalog_id
# We expect all rows to refer to existing catalog items.
normalized_rows = []
errors = []
for idx, row in enumerate(rows, start=2):
supplier = str(row.get('supplier_name') or '').strip()
sku = str(row.get('sku') or '').strip()
price_raw = row.get('price')
currency = str(row.get('currency') or 'MXN').strip().upper() or 'MXN'
eff_from_raw = row.get('effective_from')
if not supplier or not sku:
errors.append(f'Fila {idx}: supplier_name y sku son requeridos')
continue
try:
price = float(str(price_raw).replace(',', '').strip())
except Exception:
errors.append(f'Fila {idx}: precio invalido para {supplier}/{sku}')
continue
eff_from = date.today()
if eff_from_raw:
try:
eff_from = date.fromisoformat(str(eff_from_raw).strip())
except Exception:
errors.append(f'Fila {idx}: effective_from invalido (use YYYY-MM-DD)')
continue
normalized_rows.append((supplier, sku, price, currency, eff_from))
if errors:
cur.close(); conn.close()
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
# Bulk lookup catalog IDs
catalog_lookup = {}
for supplier, sku, *_ in normalized_rows:
catalog_lookup[(supplier, sku)] = None
if catalog_lookup:
keys = list(catalog_lookup.keys())
# Batch query using unnest
cur.execute("""
SELECT supplier_name, sku, id
FROM supplier_catalog
WHERE is_active = true
AND (supplier_name, sku) = ANY(%s)
""", (keys,))
for r in cur.fetchall():
catalog_lookup[(r[0], r[1])] = r[2]
upserts = []
for idx, (supplier, sku, price, currency, eff_from) in enumerate(normalized_rows, start=2):
catalog_id = catalog_lookup.get((supplier, sku))
if not catalog_id:
errors.append(f'Fila {idx}: SKU {supplier}/{sku} no existe en el catalogo')
continue
upserts.append((g.tenant_id, catalog_id, price, currency, eff_from))
if errors:
cur.close(); conn.close()
return jsonify({'error': 'Errores de validacion', 'details': errors}), 400
inserted = 0
updated = 0
for tenant_id, catalog_id, price, currency, eff_from in upserts:
# Try update existing row with same (tenant_id, catalog_id, effective_from)
cur.execute("""
UPDATE supplier_catalog_prices
SET price = %s, currency = %s, is_active = true, updated_at = NOW()
WHERE tenant_id = %s AND catalog_id = %s AND effective_from = %s
RETURNING id
""", (price, currency, tenant_id, catalog_id, eff_from))
if cur.fetchone():
updated += 1
else:
cur.execute("""
INSERT INTO supplier_catalog_prices
(tenant_id, catalog_id, price, currency, effective_from, is_active)
VALUES (%s, %s, %s, %s, %s, true)
""", (tenant_id, catalog_id, price, currency, eff_from))
inserted += 1
conn.commit()
cur.close(); conn.close()
return jsonify({
'success': True,
'processed': len(upserts),
'inserted': inserted,
'updated': updated,
})

View File

@@ -12,6 +12,7 @@ supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn
class DecimalEncoder(json.JSONEncoder): class DecimalEncoder(json.JSONEncoder):
@@ -26,48 +27,47 @@ class DecimalEncoder(json.JSONEncoder):
def get_demand(): def get_demand():
"""Aggregated demand by zone, part group, and time range.""" """Aggregated demand by zone, part group, and time range."""
days = request.args.get('days', 30, type=int) days = request.args.get('days', 30, type=int)
group_id = request.args.get('group_id', type=int)
branch_id = request.args.get('branch_id', type=int) branch_id = request.args.get('branch_id', type=int)
from tenant_db import get_tenant_db conn = get_tenant_conn(g.tenant_id)
db = get_tenant_db() cur = conn.cursor()
since = datetime.utcnow() - timedelta(days=days) since = datetime.utcnow() - timedelta(days=days)
params = [since] try:
filters = "s.created_at >= %s" params = [since]
if group_id: filters = "s.created_at >= %s"
filters += " AND p.group_id = %s" if branch_id:
params.append(group_id) filters += " AND s.branch_id = %s"
if branch_id: params.append(branch_id)
filters += " AND s.branch_id = %s"
params.append(branch_id)
rows = db.execute( cur.execute(
f"""SELECT g.name as group_name, b.name as branch_name, f"""SELECT b.name as branch_name,
COUNT(DISTINCT s.id_sale) as orders, COUNT(DISTINCT s.id) as orders,
SUM(si.quantity) as qty_requested, SUM(si.quantity) as qty_requested,
COALESCE(SUM(si.total), 0) as revenue COALESCE(SUM(si.subtotal), 0) as revenue
FROM sale_items si FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale JOIN sales s ON si.sale_id = s.id
JOIN parts p ON si.part_id = p.id_part LEFT JOIN branches b ON s.branch_id = b.id
JOIN part_groups g ON p.group_id = g.id_group WHERE {filters}
LEFT JOIN branches b ON s.branch_id = b.id_branch GROUP BY b.name
WHERE {filters} ORDER BY revenue DESC
GROUP BY g.name, b.name LIMIT 100""", tuple(params)
ORDER BY revenue DESC )
LIMIT 100""", tuple(params) rows = cur.fetchall()
).fetchall()
return jsonify({ return jsonify({
'since': since.isoformat(), 'since': since.isoformat(),
'days': days, 'days': days,
'demand': [ 'demand': [
{'group': row['group_name'], 'branch': row['branch_name'], {'branch': row[0] or 'Sin sucursal',
'orders': row['orders'], 'quantity': row['qty_requested'], 'orders': row[1], 'quantity': row[2],
'revenue': row['revenue']} 'revenue': float(row[3]) if row[3] is not None else 0}
for row in rows for row in rows
] ]
}, cls=DecimalEncoder) })
finally:
cur.close()
conn.close()
@supplier_portal_bp.route('/top-parts', methods=['GET']) @supplier_portal_bp.route('/top-parts', methods=['GET'])
@@ -75,31 +75,31 @@ def get_demand():
def get_top_parts(): def get_top_parts():
"""Top moving parts for suppliers to restock.""" """Top moving parts for suppliers to restock."""
days = request.args.get('days', 30, type=int) days = request.args.get('days', 30, type=int)
from tenant_db import get_tenant_db conn = get_tenant_conn(g.tenant_id)
db = get_tenant_db() cur = conn.cursor()
since = datetime.utcnow() - timedelta(days=days) since = datetime.utcnow() - timedelta(days=days)
rows = db.execute( try:
"""SELECT p.oem_part_number, p.name, g.name as group_name, cur.execute(
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue, """SELECT si.part_number, si.name,
COALESCE(SUM(wi.stock_quantity), 0) as current_stock SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue
FROM sale_items si FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale JOIN sales s ON si.sale_id = s.id
JOIN parts p ON si.part_id = p.id_part WHERE s.created_at >= %s
JOIN part_groups g ON p.group_id = g.id_group GROUP BY si.part_number, si.name
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id ORDER BY sold DESC
WHERE s.created_at >= %s LIMIT 50""", (since,)
GROUP BY p.oem_part_number, p.name, g.name )
ORDER BY sold DESC rows = cur.fetchall()
LIMIT 50""", (since,)
).fetchall()
return jsonify({ return jsonify({
'since': since.isoformat(), 'since': since.isoformat(),
'parts': [ 'parts': [
{'oem': row['oem_part_number'], 'name': row['name'], {'part_number': row[0], 'name': row[1],
'group': row['group_name'], 'sold': row['sold'], 'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0}
'revenue': row['revenue'], 'stock': row['current_stock']} for row in rows
for row in rows ]
] })
}, cls=DecimalEncoder) finally:
cur.close()
conn.close()

View File

@@ -15,10 +15,63 @@ from flask import Blueprint, request, jsonify, g
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn, get_master_conn from tenant_db import get_tenant_conn, get_master_conn
from services import whatsapp_service from services import whatsapp_service
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
from datetime import datetime
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp') whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
def _get_whatsapp_config(conn):
"""Read WhatsApp bridge configuration from tenant_config.
Falls back to global server config (config.py / env vars) when tenant
has no explicit WhatsApp settings. This allows the shared bridge to work
out of the box for all tenants.
"""
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
config = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
enabled_raw = config.get('whatsapp_enabled', '').lower()
if enabled_raw == 'true':
enabled = True
elif enabled_raw == 'false':
enabled = False
else:
# No explicit tenant setting: auto-enable if a bridge URL is configured
enabled = bool(bridge_url)
return {
'bridge_url': bridge_url,
'bridge_key': bridge_key,
'enabled': enabled,
'phone_number': config.get('whatsapp_phone_number', ''),
}
def _get_branch_phone(tenant_conn, branch_id=None):
"""Obtener teléfono de la sucursal."""
if not tenant_conn:
return '(pendiente)'
try:
cur = tenant_conn.cursor()
if branch_id:
cur.execute("SELECT phone FROM branches WHERE id = %s", (branch_id,))
row = cur.fetchone()
if row and row[0]:
cur.close()
return row[0]
cur.execute("SELECT value FROM tenant_config WHERE key = 'tenant_phone'")
row = cur.fetchone()
cur.close()
return row[0] if row and row[0] else '(pendiente)'
except Exception as e:
print(f"[WA-SM] get_branch_phone error: {e}")
return '(pendiente)'
def _resolve_mye_ids(vehicle, master_conn): def _resolve_mye_ids(vehicle, master_conn):
"""Return list of MYE ids matching vehicle brand/model/year text.""" """Return list of MYE ids matching vehicle brand/model/year text."""
if not master_conn or not vehicle: if not master_conn or not vehicle:
@@ -179,27 +232,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
fallback_rows = _do_search(use_compat=False) fallback_rows = _do_search(use_compat=False)
if not rows and not fallback_rows: if not rows and not fallback_rows:
# Truly nothing found — return a conversational message that doesn't kill the chat # Nothing found in local inventory — let the AI's original response stand.
v_str = "" # The webhook will append a soft note instead of replacing the message.
if vehicle and vehicle.get('brand'): return None, None
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
msg_parts = [
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
]
if v_str:
msg_parts.append(f"Para tu {v_str}, puedo:")
else:
msg_parts.append("Te puedo ayudar de estas formas:")
msg_parts.extend([
"",
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
"• *Sugerir refaccionarias cercanas* — si es urgente",
"",
"¿Qué prefieres? O dime si quieres buscar otra parte."
])
return '\n'.join(msg_parts), None
# Use fallback rows if primary search returned nothing # Use fallback rows if primary search returned nothing
using_fallback = False using_fallback = False
@@ -271,36 +306,52 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
@whatsapp_bp.route('/status', methods=['GET']) @whatsapp_bp.route('/status', methods=['GET'])
@require_auth() @require_auth()
def status(): def status():
return jsonify(whatsapp_service.get_status()) conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
return jsonify(whatsapp_service.get_status(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/qr', methods=['GET']) @whatsapp_bp.route('/qr', methods=['GET'])
@require_auth() @require_auth()
def qr(): def qr():
return jsonify(whatsapp_service.get_qr()) conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'})
return jsonify(whatsapp_service.get_qr(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/connect', methods=['POST']) @whatsapp_bp.route('/connect', methods=['POST'])
@require_auth() @require_auth()
def connect(): def connect():
return jsonify(whatsapp_service.connect()) conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
return jsonify(whatsapp_service.connect(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/logout', methods=['POST']) @whatsapp_bp.route('/logout', methods=['POST'])
@require_auth() @require_auth()
def logout(): def logout():
return jsonify(whatsapp_service.logout()) conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
conn.close()
if not cfg['enabled'] or not cfg['bridge_url']:
return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400
return jsonify(whatsapp_service.logout(bridge_url=cfg['bridge_url']))
@whatsapp_bp.route('/webhook', methods=['POST']) @whatsapp_bp.route('/webhook', methods=['POST'])
def webhook(): def webhook():
"""Receive messages from Baileys bridge (public, no auth). """Receive messages from Baileys bridge (public, no auth).
Flow: Nuevo flujo: máquina de estados estructurada.
1. Persist the incoming message to the tenant's whatsapp_messages log.
2. Build inventory context for the AI (what this tenant has in stock).
3. Ask the chatbot for a reply, enriched with that context.
4. Send the reply back via the Baileys bridge.
""" """
data = request.get_json(force=True, silent=True) or {} data = request.get_json(force=True, silent=True) or {}
@@ -311,274 +362,227 @@ def webhook():
if not msg.get('phone') or msg.get('from_me'): if not msg.get('phone') or msg.get('from_me'):
return jsonify({'ok': True}) return jsonify({'ok': True})
# Reuse one tenant connection for the whole webhook path — we need it phone = msg['phone']
# for persistence AND for the inventory-context lookup. reply_to = msg.get('sender_pn') or msg.get('jid') or phone
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives. text = msg.get('text', '')
tenant_id = 11 media_kind = msg.get('media_kind', 'text')
# Audio transcription (voice notes)
if media_kind == 'audio' and msg.get('media_base64'):
try:
from services.whisper_local import transcribe_audio_base64
transcript = transcribe_audio_base64(
msg['media_base64'],
mimetype=msg.get('media_mimetype') or 'audio/ogg',
)
if transcript:
text = transcript
print(f"[WA-SM] Voice note transcribed: {transcript[:100]}")
except ImportError:
pass
except Exception as e:
print(f"[WA-SM] Whisper transcription failed: {e}")
# Location message: if current state expects it, store coordinates
if media_kind == 'location' and msg.get('latitude') is not None:
text = f"Ubicación: {msg['latitude']},{msg['longitude']}"
# Image without caption: provide a default text so the state machine can handle it
if media_kind == 'image' and not text:
text = "(imagen)"
# Resolve tenant
tenant_id = request.args.get('tenant_id', type=int)
if not tenant_id:
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("""
SELECT id, db_name FROM tenants
WHERE is_active = true
ORDER BY id
""")
tenants = mcur.fetchall()
mcur.close()
mconn.close()
# Find first tenant with whatsapp_enabled in their config
for tid, db_name in tenants:
try:
from tenant_db import get_tenant_conn_by_dbname
tconn = get_tenant_conn_by_dbname(db_name)
tcur = tconn.cursor()
tcur.execute(
"SELECT value FROM tenant_config WHERE key = 'whatsapp_enabled'"
)
row = tcur.fetchone()
tcur.close()
tconn.close()
if row and row[0].lower() == 'true':
tenant_id = tid
break
except Exception:
continue
except Exception:
tenant_id = None
tenant_conn = None tenant_conn = None
master_conn = None master_conn = None
inventory_context = None
try: try:
tenant_conn = get_tenant_conn(tenant_id) tenant_conn = get_tenant_conn(tenant_id)
master_conn = get_master_conn() master_conn = get_master_conn()
wa_config = _get_whatsapp_config(tenant_conn)
# 1. Log the incoming message (with contact display name) # Deduplicate by wa_message_id
wa_message_id = msg.get('message_id')
if wa_message_id:
cur = tenant_conn.cursor()
cur.execute("SELECT 1 FROM whatsapp_messages WHERE wa_message_id = %s LIMIT 1", (wa_message_id,))
if cur.fetchone():
cur.close()
return jsonify({'ok': True})
cur.close()
# 1. Log incoming message
cur = tenant_conn.cursor() cur = tenant_conn.cursor()
cur.execute(""" cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name) INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
VALUES (%s, 'incoming', %s, %s, %s) VALUES (%s, 'incoming', %s, %s, %s)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None)) """, (phone, text, wa_message_id, msg.get('push_name')))
tenant_conn.commit() tenant_conn.commit()
cur.close() cur.close()
# 2. Build inventory context once per webhook call so the chatbot # 2. Load session state
# can say things like "tengo 5 Bosch BP-123 por $450". from services.wa_state_machine import get_session, save_session, process_message, StateContext
try: session = get_session(tenant_conn, phone)
from services.ai_chat import get_inventory_context
inventory_context = get_inventory_context(tenant_conn)
except Exception as e:
print(f"[WA-AI] inventory_context failed: {e}")
inventory_context = None
# 2b. Append previously-detected vehicle so the AI keeps context # 3. Check session expiry (30 minutes)
# even when we don't send full conversation history (Hermes is slow with it) current_state = session.get('state', 'idle')
try: state_data = session.get('state_data', {})
from services.wa_quotation import get_vehicle last_updated = session.get('updated_at')
saved_vehicle = get_vehicle(clean_phone)
if saved_vehicle and inventory_context:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context += f"\n\nVEHICULO DEL CLIENTE: {v_str}"
elif saved_vehicle:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
inventory_context = f"VEHICULO DEL CLIENTE: {v_str}"
except Exception as e:
print(f"[WA-AI] vehicle_context failed: {e}")
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
# 3. Dispatch by media kind + quotation commands if last_updated and hasattr(last_updated, 'strftime'):
reply = None # PostgreSQL returns datetime objects (often timezone-aware)
reply_to = msg.get('jid') or msg['phone'] from datetime import timezone
media_kind = msg.get('media_kind', 'text') now = datetime.now(timezone.utc)
clean_phone = msg.get('phone', '') if last_updated.tzinfo is None:
now = now.replace(tzinfo=None)
# ── Check for quotation commands FIRST (before AI) ── elapsed = (now - last_updated).total_seconds()
if media_kind == 'text' and msg.get('text'): if elapsed > 1800:
from services.wa_quotation import ( current_state = 'idle'
detect_quote_intent, get_open_quotation, create_quotation, state_data = {'customer_id': state_data.get('customer_id')}
add_item_to_quotation, get_quotation_detail, format_quotation_wa, elif last_updated and isinstance(last_updated, str):
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part, from datetime import datetime as dt
)
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
if intent == 'add':
last_part = get_last_shown_part(clean_phone)
if not last_part:
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
elif tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if not qid:
qid = create_quotation(tenant_conn, clean_phone)
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
detail = get_quotation_detail(tenant_conn, qid)
item_count = len(detail['items']) if detail else 0
reply = (
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
)
elif intent == 'send':
if tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if qid:
detail = get_quotation_detail(tenant_conn, qid)
reply = format_quotation_wa(detail)
if not reply:
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
else:
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
elif intent == 'clear':
if tenant_conn:
clear_quotation(tenant_conn, clean_phone)
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
elif intent == 'confirm':
if tenant_conn:
qid = confirm_quotation(tenant_conn, clean_phone)
if qid:
reply = (
f'✅ *Pedido confirmado!*\n\n'
f'Tu cotización #{qid} fue registrada.\n'
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
f'¡Gracias por tu compra! 🙏'
)
else:
reply = '⚠️ No tienes una cotización abierta para confirmar.'
# ── Check for conversation reset commands ──
if media_kind == 'text' and msg.get('text'):
txt_lower = msg['text'].lower().strip()
if txt_lower in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar'):
if tenant_conn:
try:
cur_del = tenant_conn.cursor()
cur_del.execute("DELETE FROM whatsapp_messages WHERE phone = %s", (clean_phone,))
tenant_conn.commit()
cur_del.close()
except Exception as del_err:
print(f"[WA-AI] Failed to clear conversation history: {del_err}")
reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?'
result = whatsapp_service.send_message(reply_to, reply)
if tenant_conn:
try:
cur_save = tenant_conn.cursor()
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
tenant_conn.commit()
cur_save.close()
except Exception:
pass
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
if intent is not None:
# It was a quote command — send reply and skip the AI
if reply:
result = whatsapp_service.send_message(reply_to, reply)
if tenant_conn:
try:
cur_save = tenant_conn.cursor()
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
tenant_conn.commit()
cur_save.close()
except Exception:
pass
# Clean up and return early
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
conversation_history = []
if tenant_conn:
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
if conversation_history:
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
try:
if media_kind == 'image' and msg.get('media_base64'):
from services.ai_chat import chat_with_image
# Prompt: use the caption if provided, else default to
# "identify this part" which chat_with_image handles gracefully.
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
ai_resp = chat_with_image(
user_message=prompt,
image_base64=msg['media_base64'],
conversation_history=conversation_history,
inventory_context=inventory_context,
)
reply = ai_resp.get('message', '') or ''
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
elif media_kind == 'audio' and msg.get('media_base64'):
# Voice note handling — transcribe first, then chat().
# See services.whisper_local for the transcriber.
try: try:
from services.whisper_local import transcribe_audio_base64 parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00'))
transcript = transcribe_audio_base64( elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds()
msg['media_base64'], if elapsed > 1800:
mimetype=msg.get('media_mimetype') or 'audio/ogg', current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
except Exception:
pass
# Global reset commands work from any state
if text and text.strip().lower() in ('limpiar chat', 'nuevo chat', 'borrar conversacion', 'borrar conversación', 'reset', 'reiniciar', 'menu', 'menú'):
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
# Abandoned quotation follow-up
try:
from services.part_kits import should_send_followup
followup = should_send_followup(phone, tenant_conn)
if followup:
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
cur_fu = tenant_conn.cursor()
cur_fu.execute(
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
(phone, followup)
) )
except ImportError: tenant_conn.commit()
transcript = None cur_fu.close()
print("[WA-AI] whisper_local not installed — voice notes skipped") except Exception as fu_err:
except Exception as e: print(f"[WA-SM] Follow-up send failed: {fu_err}")
transcript = None
print(f"[WA-AI] Whisper transcription failed: {e}")
if transcript: # 4. Build context
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}") context = StateContext(
from services.ai_chat import chat tenant_conn=tenant_conn,
ai_resp = chat(transcript, conversation_history=conversation_history, inventory_context=inventory_context) master_conn=master_conn,
reply = ai_resp.get('message', '') or '' wa_config=wa_config,
# Prefix the reply so the sender knows we understood the voice note tenant_id=tenant_id,
if reply: phone=phone,
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}' media_kind=media_kind,
else: media_base64=msg.get('media_base64'),
reply = ('Recibi tu nota de voz pero no pude transcribirla. ' push_name=msg.get('push_name'),
'Puedes escribirme el mensaje?') )
elif msg.get('text'): # 5. Process through state machine
# Plain text message — standard chatbot flow reply, next_state, next_state_data = process_message(
from services.ai_chat import chat phone=phone,
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context) text=text,
reply = ai_resp.get('message', '') or '' current_state=current_state,
state_data=state_data,
context=context,
)
# Enrich: if the AI returned a search_query, look up real parts # 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
# from the catalog and append them to the WhatsApp reply. # (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
search_q = ai_resp.get('search_query') loop_guard = 0
vehicle = ai_resp.get('vehicle') while reply is None and loop_guard < 5:
loop_guard += 1
reply, next_state, next_state_data = process_message(
phone=phone,
text=text,
current_state=next_state,
state_data=next_state_data,
context=context,
)
# Persist detected vehicle so we don't lose context between messages # 6. Save new state
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'): save_session(tenant_conn, phone, next_state, next_state_data)
try:
from services.wa_quotation import set_vehicle
set_vehicle(clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
if search_q and reply: # 7. Send reply
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
# Send reply if we produced one
if reply: if reply:
result = whatsapp_service.send_message(reply_to, reply) result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}") print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
# Save the bot's reply to DB so it shows in the WhatsApp UI # Log outgoing
if tenant_conn: cur = tenant_conn.cursor()
try: cur.execute("""
cur2 = tenant_conn.cursor() INSERT INTO whatsapp_messages (phone, direction, message_text)
cur2.execute(""" VALUES (%s, 'outgoing', %s)
INSERT INTO whatsapp_messages (phone, direction, message_text) """, (phone, reply))
VALUES (%s, 'outgoing', %s) tenant_conn.commit()
""", (msg['phone'], reply)) cur.close()
tenant_conn.commit()
cur2.close()
except Exception as db_err:
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
except Exception as e: except Exception as e:
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}") print(f"[WA-SM] Webhook error: {e}")
import traceback
traceback.print_exc()
# Fallback: enviar mensaje de error genérico
try:
if tenant_conn:
phone_branch = _get_branch_phone(tenant_conn, None)
fallback = (
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
f"Por favor llámanos directamente al {phone_branch}."
)
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
except Exception:
pass
# 4. Clean up connections finally:
if tenant_conn is not None: if tenant_conn:
try: try:
tenant_conn.close() tenant_conn.close()
except Exception: except Exception:
pass pass
if master_conn is not None: if master_conn:
try: try:
master_conn.close() master_conn.close()
except Exception: except Exception:
pass pass
return jsonify({'ok': True}) return jsonify({'ok': True})
@@ -592,11 +596,17 @@ def send():
if not phone or not message: if not phone or not message:
return jsonify({'error': 'phone and message required'}), 400 return jsonify({'error': 'phone and message required'}), 400
result = whatsapp_service.send_message(phone, message) # Load tenant WhatsApp config
conn = get_tenant_conn(g.tenant_id)
cfg = _get_whatsapp_config(conn)
if not cfg['enabled'] or not cfg['bridge_url']:
conn.close()
return jsonify({'error': 'WhatsApp not configured for this tenant'}), 400
result = whatsapp_service.send_message(phone, message, bridge_url=cfg['bridge_url'])
# Save outgoing message # Save outgoing message
try: try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text) INSERT INTO whatsapp_messages (phone, direction, message_text)
@@ -604,9 +614,10 @@ def send():
""", (phone, message)) """, (phone, message))
conn.commit() conn.commit()
cur.close() cur.close()
conn.close()
except Exception: except Exception:
pass pass
finally:
conn.close()
return jsonify(result) return jsonify(result)

View File

@@ -5,7 +5,7 @@ bind = "0.0.0.0:5001"
# gthread workers handle multiple concurrent requests per worker via threads. # gthread workers handle multiple concurrent requests per worker via threads.
# Ideal for I/O-bound Flask apps with DB queries. # Ideal for I/O-bound Flask apps with DB queries.
# 4 workers × 4 threads = 16 concurrent requests. # 4 workers × 4 threads = 16 concurrent requests.
workers = 4 workers = 8
threads = 4 threads = 4
worker_class = "gthread" worker_class = "gthread"
worker_connections = 1000 worker_connections = 1000

View File

@@ -29,7 +29,7 @@ def require_auth(*required_permissions):
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 401 return jsonify({'error': 'Invalid token'}), 401
if payload.get('type') != 'pos_access': if payload.get('type') not in ('pos_access', 'access'):
return jsonify({'error': 'Invalid token type'}), 401 return jsonify({'error': 'Invalid token type'}), 401
g.tenant_id = payload['tenant_id'] g.tenant_id = payload['tenant_id']

View File

@@ -55,7 +55,7 @@ def _lookup_tenant_by_subdomain(subdomain):
conn = get_master_conn() conn = get_master_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"SELECT id, name FROM tenants WHERE subdomain = %s AND is_active = true", "SELECT id, name FROM tenants WHERE LOWER(subdomain) = %s AND is_active = true",
(subdomain,) (subdomain,)
) )
row = cur.fetchone() row = cur.fetchone()

View File

@@ -14,9 +14,11 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
MIGRATIONS = { MIGRATIONS = {
'v1.0': 'v1.0_initial.sql', 'v1.0': 'v1.0_initial.sql',
'v1.1': 'v1.1_pos_tables.sql', 'v1.1': 'v1.1_pos_tables.sql',
'v1.2': 'v1.2_subdomain.sql',
'v1.3': 'v1.3_fleet.sql', 'v1.3': 'v1.3_fleet.sql',
'v1.4': 'v1.4_whatsapp.sql', 'v1.4': 'v1.4_whatsapp.sql',
'v1.5': 'v1.5_returns.sql', 'v1.5': 'v1.5_returns.sql',
'v1.6': 'v1.6_marketplace.sql',
'v1.7': 'v1.7_plates.sql', 'v1.7': 'v1.7_plates.sql',
'v1.8': 'v1.8_performance_indexes.sql', 'v1.8': 'v1.8_performance_indexes.sql',
'v1.9': 'v1.9_redis_cache.sql', 'v1.9': 'v1.9_redis_cache.sql',
@@ -33,6 +35,20 @@ MIGRATIONS = {
'v3.0': 'v3.0_public_api.sql', 'v3.0': 'v3.0_public_api.sql',
'v3.1': 'v3.1_inventory_vehicle_compat.sql', 'v3.1': 'v3.1_inventory_vehicle_compat.sql',
'v3.2': 'v3.2_db_performance.sql', 'v3.2': 'v3.2_db_performance.sql',
'v3.2.1': 'v3.2_qwen_vehicle_compat.sql',
'v3.3': 'v3.3_marketplace_any_part.sql',
'v3.3.1': 'v3.3_materialized_view.sql',
'v3.4': 'v3.4_meli_integration.sql',
'v3.5': 'v3.5_meli_questions.sql',
'v3.5.1': 'v3.5_whatsapp_state_machine.sql',
'v3.6': 'v3.6_dropshipping.sql',
'v3.7': 'v3.7_sku_aliases.sql',
'v3.8': 'v3.8_supplier_catalog.sql',
'v3.9': 'v3.9_supplier_catalog_prices.sql',
'v4.0': 'v4.0_multi_branch.sql',
'v4.1': 'v4.1_global_invoice.sql',
'v4.2': 'v4.2_meli_sync_queue.sql',
'v4.3': 'v4.3_facturapi.sql',
} }
@@ -61,11 +77,19 @@ def apply_migration(db_name, version):
print(f" ERROR: Migration file not found: {filepath}") print(f" ERROR: Migration file not found: {filepath}")
return False return False
with open(filepath) as f:
sql = f.read()
# Skip migrations marked for manual/non-tenant execution
first_line = sql.splitlines()[0].strip() if sql.strip() else ''
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'):
print(f" SKIP (manual/non-tenant migration)")
return True
conn = get_tenant_conn_by_dbname(db_name) conn = get_tenant_conn_by_dbname(db_name)
cur = conn.cursor() cur = conn.cursor()
try: try:
with open(filepath) as f: cur.execute(sql)
cur.execute(f.read())
conn.commit() conn.commit()
return True return True
except Exception as e: except Exception as e:

View File

@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
-- Barcode sequence -- Barcode sequence
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1; CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;

View File

@@ -0,0 +1,53 @@
-- v3.2 QWEN Vehicle Compatibility — store unmatched AI vehicles as text
-- Allows saving QWEN fitment results even when the vehicle is not in TecDoc.
-- 1. Allow NULL model_year_engine_id for QWEN vehicles not in master DB
ALTER TABLE inventory_vehicle_compat
ALTER COLUMN model_year_engine_id DROP NOT NULL;
-- 2. Add text columns for QWEN vehicle details
ALTER TABLE inventory_vehicle_compat
ADD COLUMN IF NOT EXISTS make VARCHAR(100),
ADD COLUMN IF NOT EXISTS model VARCHAR(100),
ADD COLUMN IF NOT EXISTS year INTEGER,
ADD COLUMN IF NOT EXISTS engine VARCHAR(100),
ADD COLUMN IF NOT EXISTS engine_code VARCHAR(50);
-- 3. Drop old unique constraint and recreate to handle NULL mye_id
-- (PostgreSQL allows multiple NULLs in a UNIQUE constraint)
ALTER TABLE inventory_vehicle_compat
DROP CONSTRAINT IF EXISTS inventory_vehicle_compat_inventory_id_model_year_engine_id_key;
ALTER TABLE inventory_vehicle_compat
ADD CONSTRAINT inventory_vehicle_compat_unique_match
UNIQUE (inventory_id, model_year_engine_id, make, model, year);
-- 4. Index for fast filtering by inventory + text vehicles
CREATE INDEX IF NOT EXISTS idx_ivc_text_vehicle
ON inventory_vehicle_compat(inventory_id, make, model, year)
WHERE model_year_engine_id IS NULL;
-- 5. Update view to include new columns
DROP VIEW IF EXISTS v_inventory_vehicle_compat;
CREATE VIEW v_inventory_vehicle_compat AS
SELECT
ivc.id,
ivc.inventory_id,
ivc.model_year_engine_id,
ivc.make,
ivc.model,
ivc.year,
ivc.engine,
ivc.engine_code,
ivc.source,
ivc.confidence,
ivc.created_at,
i.part_number,
i.name as item_name,
i.brand as item_brand,
i.price_1,
i.price_2,
i.price_3,
i.image_url
FROM inventory_vehicle_compat ivc
JOIN inventory i ON i.id = ivc.inventory_id;

View File

@@ -0,0 +1,93 @@
-- ═══════════════════════════════════════════════════════════════════════
-- v3.3 — Marketplace accepts any part number (seller listings)
-- Target: nexus_autoparts (master DB) / tenants with warehouse_inventory
-- Date: 2026-05-17
--
-- Makes warehouse_inventory part_id nullable and adds seller-defined
-- fields so any seller can list parts that don't exist in the OEM catalog.
-- Existing OEM-matched listings are untouched.
-- ═══════════════════════════════════════════════════════════════════════
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN
-- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ─────────────
ALTER TABLE warehouse_inventory
ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100),
ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300),
ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100),
ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER;
-- Make part_id nullable so seller listings (without catalog match) can exist
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
-- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ───
ALTER TABLE warehouse_inventory
DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key;
DROP INDEX IF EXISTS idx_wi_unique_composite;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem
ON warehouse_inventory(bodega_id, part_id, warehouse_location)
WHERE part_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller
ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location)
WHERE part_id IS NULL;
-- Ensure every row has either part_id or seller_part_number
ALTER TABLE warehouse_inventory
DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller;
ALTER TABLE warehouse_inventory
ADD CONSTRAINT chk_wi_part_or_seller
CHECK (
(part_id IS NOT NULL AND seller_part_number IS NULL)
OR
(part_id IS NULL AND seller_part_number IS NOT NULL)
);
-- ─── 3. WAREHOUSE_INVENTORY — search indexes ─────────────────────────
CREATE INDEX IF NOT EXISTS idx_wi_seller_pn
ON warehouse_inventory (bodega_id, seller_part_number)
WHERE part_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_wi_seller_category
ON warehouse_inventory (seller_category)
WHERE part_id IS NULL;
-- GIN index for text search on seller listings
CREATE INDEX IF NOT EXISTS idx_wi_seller_search
ON warehouse_inventory
USING gin (to_tsvector('spanish',
COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '')
))
WHERE part_id IS NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'purchase_order_items') THEN
-- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ─────────────────
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'purchase_order_items' AND column_name = 'part_id') THEN
ALTER TABLE purchase_order_items
ALTER COLUMN part_id DROP NOT NULL;
END IF;
-- Add a flag so seller listings can be distinguished in POs
ALTER TABLE purchase_order_items
ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
END $$;
-- ─── 5. Back-compat: ensure existing rows are valid ──────────────────
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN
UPDATE warehouse_inventory
SET seller_part_number = NULL
WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL;
UPDATE warehouse_inventory
SET part_id = NULL
WHERE part_id IS NULL AND seller_part_number IS NULL;
END IF;
END $$;

View File

@@ -1,11 +1,15 @@
-- : SKIP
-- Migration v3.3: Materialized view part_vehicle_preview -- Migration v3.3: Materialized view part_vehicle_preview
-- Purpose: Pre-compute the "most recent vehicle" per part to eliminate -- Purpose: Pre-compute the "most recent vehicle" per part to eliminate
-- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time. -- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time.
-- --
-- Notes: -- NOTE: This migration targets the vehicle_database, not tenant databases.
-- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation). -- The runner skips files marked with ': SKIP' on the first line.
-- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists. -- To apply manually on the vehicle database, run:
-- - Run with statement_timeout = 0; this may take hours on first creation. --
-- psql <vehicle_db> -f pos/migrations/v3.3_materialized_view.sql
--
-- (Remove the ': SKIP' line above before manual execution.)
SET statement_timeout = 0; SET statement_timeout = 0;
@@ -26,6 +30,3 @@ ORDER BY vp.part_id, y.year_car DESC;
CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id); CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand); CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand);
-- Grant select to application roles if needed
-- GRANT SELECT ON part_vehicle_preview TO nexus_app;

View File

@@ -0,0 +1,110 @@
-- ============================================================
-- v3.4 MercadoLibre Integration
-- ============================================================
-- Adds tables for external marketplace listings, orders,
-- order items, and a generic sync queue.
-- All tables live in the tenant DB.
-- ============================================================
-- Listings published on MercadoLibre (extensible to Amazon later)
CREATE TABLE IF NOT EXISTS marketplace_listings (
id SERIAL PRIMARY KEY,
inventory_id INTEGER REFERENCES inventory(id),
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
external_item_id VARCHAR(50) NOT NULL,
external_status VARCHAR(30) DEFAULT 'active',
external_permalink TEXT,
title TEXT,
meli_category_id VARCHAR(30),
publish_price NUMERIC(12,2),
last_sync_at TIMESTAMPTZ,
sync_errors TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inventory
ON marketplace_listings(inventory_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_external
ON marketplace_listings(external_item_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_marketplace_listings_unique
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;
-- Orders received from MercadoLibre
CREATE TABLE IF NOT EXISTS marketplace_orders (
id SERIAL PRIMARY KEY,
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
external_order_id VARCHAR(50) NOT NULL UNIQUE,
external_status VARCHAR(30) NOT NULL,
buyer_name VARCHAR(200),
buyer_email VARCHAR(200),
buyer_phone VARCHAR(50),
buyer_nickname VARCHAR(100),
shipping_address JSONB,
total_amount NUMERIC(12,2),
shipping_cost NUMERIC(12,2),
meli_shipping_id VARCHAR(50),
nexus_sale_id INTEGER REFERENCES sales(id),
status VARCHAR(20) DEFAULT 'pending',
notes TEXT,
raw_json JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_status
ON marketplace_orders(status);
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_external
ON marketplace_orders(external_order_id);
-- Items inside a marketplace order
CREATE TABLE IF NOT EXISTS marketplace_order_items (
id SERIAL PRIMARY KEY,
marketplace_order_id INTEGER REFERENCES marketplace_orders(id) ON DELETE CASCADE,
inventory_id INTEGER REFERENCES inventory(id),
external_item_id VARCHAR(50),
title VARCHAR(300),
quantity INTEGER NOT NULL,
unit_price NUMERIC(12,2),
total_price NUMERIC(12,2),
listing_id INTEGER REFERENCES marketplace_listings(id)
);
-- Generic sync queue (reusable for future Amazon integration)
CREATE TABLE IF NOT EXISTS marketplace_sync_queue (
id SERIAL PRIMARY KEY,
inventory_id INTEGER REFERENCES inventory(id),
channel VARCHAR(20) NOT NULL,
action VARCHAR(20) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
payload JSONB,
error_message TEXT,
retry_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_marketplace_sync_queue_pending
ON marketplace_sync_queue(status, channel) WHERE status = 'pending';
-- Add source column to sales to track origin (POS, ML, Amazon, etc.)
-- If the column already exists from another migration, do nothing.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'sales' AND column_name = 'source'
) THEN
ALTER TABLE sales ADD COLUMN source VARCHAR(30) DEFAULT 'pos';
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'sales' AND column_name = 'external_order_id'
) THEN
ALTER TABLE sales ADD COLUMN external_order_id VARCHAR(50);
END IF;
END $$;

View File

@@ -0,0 +1,30 @@
-- ============================================================
-- v3.5 MercadoLibre Questions & Answers
-- ============================================================
-- Adds table for tracking buyer questions on ML listings.
-- All tables live in the tenant DB.
-- ============================================================
CREATE TABLE IF NOT EXISTS marketplace_questions (
id SERIAL PRIMARY KEY,
listing_id INTEGER REFERENCES marketplace_listings(id) ON DELETE SET NULL,
external_question_id VARCHAR(50) NOT NULL UNIQUE,
external_item_id VARCHAR(50) NOT NULL,
question_text TEXT NOT NULL,
answer_text TEXT,
status VARCHAR(20) DEFAULT 'unanswered', -- unanswered, answered, closed
buyer_id VARCHAR(50),
buyer_nickname VARCHAR(100),
question_date TIMESTAMPTZ,
answer_date TIMESTAMPTZ,
raw_json JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status
ON marketplace_questions(status);
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_listing
ON marketplace_questions(listing_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_external
ON marketplace_questions(external_question_id);

View File

@@ -0,0 +1,118 @@
-- : SKIP
-- ============================================================
-- v3.5 WhatsApp State Machine
-- Reorganización del chatbot de AI libre a flujo estructurado
--
-- NOTE: This migration requires the WhatsApp tables (whatsapp_sessions,
-- whatsapp_messages) to be present. Tenant DBs without WhatsApp enabled
-- should skip this file.
-- Marked with ': SKIP' so the runner skips it unless WhatsApp is configured.
-- To apply manually on a tenant with WhatsApp tables:
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
-- (Remove the ': SKIP' line above before manual execution.)
-- ============================================================
DO $$
BEGIN
-- 1. Extender whatsapp_sessions con estado y contexto
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
ALTER TABLE whatsapp_sessions
ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle',
ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id),
ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id),
ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state);
CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at);
END IF;
-- 2. Tabla de vínculo persistente WA ID ↔ Cliente
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN
CREATE TABLE IF NOT EXISTS wa_customer_links (
phone VARCHAR(50) PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
CREATE TRIGGER trg_wa_link_updated
BEFORE UPDATE ON wa_customer_links
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
END IF;
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers')
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'inventory')
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'sales') THEN
CREATE TABLE IF NOT EXISTS wa_learning_sessions (
id SERIAL PRIMARY KEY,
phone VARCHAR(50) NOT NULL,
customer_id INTEGER REFERENCES customers(id),
description TEXT NOT NULL,
offered_parts JSONB DEFAULT '[]',
status VARCHAR(20) DEFAULT 'pending',
resolved_part_id INTEGER REFERENCES inventory(id),
resolution_sale_id INTEGER REFERENCES sales(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status);
CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id);
CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at);
END IF;
-- 4. Tabla de configuración de envío por sucursal
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'branches') THEN
CREATE TABLE IF NOT EXISTS branch_delivery_config (
id SERIAL PRIMARY KEY,
branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id),
is_enabled BOOLEAN DEFAULT FALSE,
delivery_fee NUMERIC(12,2) DEFAULT 0,
free_delivery_threshold NUMERIC(12,2) DEFAULT NULL,
coverage_radius_km INTEGER DEFAULT NULL,
delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
END IF;
-- 5. Agregar push_name a whatsapp_messages
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_messages') THEN
ALTER TABLE whatsapp_messages
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200);
END IF;
-- 6. Migrar datos existentes
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions')
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'wa_customer_links')
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN
INSERT INTO wa_customer_links (phone, customer_id)
SELECT ws.phone, c.id
FROM whatsapp_sessions ws
JOIN customers c ON c.phone = ws.phone
WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL
ON CONFLICT (phone) DO NOTHING;
UPDATE whatsapp_sessions ws
SET customer_id = wcl.customer_id
FROM wa_customer_links wcl
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
END IF;
END $$;

View File

@@ -0,0 +1,18 @@
-- ============================================================
-- v3.6 Dropshipping API Integration
-- ============================================================
-- Adds config keys and webhook targets for external
-- dropshipping platforms.
-- ============================================================
-- Webhook targets for dropshipping notifications per tenant
CREATE TABLE IF NOT EXISTS dropshipping_webhooks (
id SERIAL PRIMARY KEY,
event_type VARCHAR(30) NOT NULL, -- stock_updated, price_updated, sale_made
target_url TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dropshipping_webhooks_event
ON dropshipping_webhooks(event_type) WHERE is_active = true;

View File

@@ -0,0 +1,22 @@
-- ============================================================
-- v3.7 SKU Aliases (multiple SKUs per inventory item)
-- ============================================================
-- Allows registering 2-3 alternative part numbers/SKUs for the
-- same product (e.g. different supplier SKUs).
-- ============================================================
CREATE TABLE IF NOT EXISTS inventory_sku_aliases (
id SERIAL PRIMARY KEY,
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
sku VARCHAR(100) NOT NULL,
label VARCHAR(50), -- e.g. "Bodega A", "Proveedor X"
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT inventory_sku_aliases_unique_sku
UNIQUE (inventory_id, sku)
);
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_inventory
ON inventory_sku_aliases(inventory_id) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_sku
ON inventory_sku_aliases(sku) WHERE is_active = true;

View File

@@ -0,0 +1,63 @@
-- v3.8 — Supplier Catalog tables
-- Adds supplier_catalog, supplier_catalog_compat, and supplier_catalog_interchange
-- to support multi-supplier parts injection into the vehicle catalog.
CREATE TABLE IF NOT EXISTS supplier_catalog (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
supplier_name VARCHAR(255) NOT NULL,
sku VARCHAR(255) NOT NULL,
name VARCHAR(500) NOT NULL,
category VARCHAR(255),
description TEXT,
image_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_tenant_id_supplier_name_sku_category_key
ON supplier_catalog (tenant_id, supplier_name, sku, category);
CREATE INDEX IF NOT EXISTS idx_sc_supplier
ON supplier_catalog (tenant_id, supplier_name, is_active);
CREATE INDEX IF NOT EXISTS idx_sc_sku
ON supplier_catalog (tenant_id, sku, category);
CREATE TABLE IF NOT EXISTS supplier_catalog_compat (
id SERIAL PRIMARY KEY,
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
make VARCHAR(255),
model VARCHAR(255),
year INTEGER,
engine VARCHAR(255),
engine_code VARCHAR(255),
model_year_engine_id INTEGER,
source VARCHAR(50) DEFAULT 'import',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_compat_catalog_id_make_model_year_engine_key
ON supplier_catalog_compat (catalog_id, make, model, year, engine);
CREATE INDEX IF NOT EXISTS idx_scc_catalog
ON supplier_catalog_compat (catalog_id);
CREATE INDEX IF NOT EXISTS idx_scc_vehicle
ON supplier_catalog_compat (make, model, year);
CREATE INDEX IF NOT EXISTS idx_scc_mye
ON supplier_catalog_compat (model_year_engine_id);
CREATE TABLE IF NOT EXISTS supplier_catalog_interchange (
id SERIAL PRIMARY KEY,
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
brand VARCHAR(255),
part_number VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sci_catalog
ON supplier_catalog_interchange (catalog_id);

View File

@@ -0,0 +1,42 @@
-- : SKIP
-- v3.9_supplier_catalog_prices.sql
-- Per-tenant supplier pricing for items in the master supplier_catalog.
-- This table lives in the master DB and is joined by tenant_id.
-- Apply manually to the master database.
CREATE TABLE IF NOT EXISTS supplier_catalog_prices (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
price NUMERIC(12,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
effective_from DATE DEFAULT CURRENT_DATE,
effective_to DATE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, catalog_id, effective_from)
);
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_tenant_catalog
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC)
WHERE is_active = true;
-- Index for quick "latest active price" lookups per tenant+item.
CREATE INDEX IF NOT EXISTS idx_supplier_catalog_prices_lookup
ON supplier_catalog_prices(tenant_id, catalog_id, effective_from DESC, is_active);
-- Trigger to keep updated_at current on row changes.
CREATE OR REPLACE FUNCTION update_supplier_catalog_prices_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_supplier_catalog_prices_updated_at ON supplier_catalog_prices;
CREATE TRIGGER trg_supplier_catalog_prices_updated_at
BEFORE UPDATE ON supplier_catalog_prices
FOR EACH ROW
EXECUTE FUNCTION update_supplier_catalog_prices_updated_at();

View File

@@ -0,0 +1,253 @@
-- v4.0_multi_branch.sql
-- Multi-branch overhaul: branch fiscal data + shared inventory with per-branch stock.
-- WARNING: this migration restructures inventory data. A full DB backup is required.
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. BRANCHES: fiscal fields + main flag
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE branches
ADD COLUMN IF NOT EXISTS is_main BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS rfc VARCHAR(13),
ADD COLUMN IF NOT EXISTS razon_social VARCHAR(300),
ADD COLUMN IF NOT EXISTS regimen_fiscal VARCHAR(10),
ADD COLUMN IF NOT EXISTS cp VARCHAR(5),
ADD COLUMN IF NOT EXISTS direccion_fiscal TEXT,
ADD COLUMN IF NOT EXISTS serie_cfdi VARCHAR(10) DEFAULT 'A',
ADD COLUMN IF NOT EXISTS folio_inicio INTEGER DEFAULT 1,
ADD COLUMN IF NOT EXISTS folio_actual INTEGER DEFAULT 1,
ADD COLUMN IF NOT EXISTS email VARCHAR(200);
-- Ensure at least one branch is marked main (the first one created).
DO $$
DECLARE
main_branch_id INTEGER;
branch_count INTEGER;
BEGIN
SELECT COUNT(*) INTO branch_count FROM branches;
IF branch_count = 0 THEN
INSERT INTO branches (name, is_main)
VALUES ('Principal', TRUE);
ELSE
SELECT id INTO main_branch_id FROM branches ORDER BY id LIMIT 1;
UPDATE branches SET is_main = FALSE;
UPDATE branches SET is_main = TRUE WHERE id = main_branch_id;
END IF;
END $$;
-- Constraint: only one main branch per tenant.
-- Because this runs inside a single tenant DB, a simple partial unique index is enough.
CREATE UNIQUE INDEX IF NOT EXISTS idx_branches_single_main
ON branches (is_main)
WHERE is_main = TRUE;
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. INVENTORY STOCK: new per-branch stock table
-- ═════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS inventory_stock (
id SERIAL PRIMARY KEY,
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
branch_id INTEGER NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
stock INTEGER DEFAULT 0,
location VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(inventory_id, branch_id)
);
CREATE INDEX IF NOT EXISTS idx_inventory_stock_branch ON inventory_stock(branch_id);
CREATE INDEX IF NOT EXISTS idx_inventory_stock_inventory ON inventory_stock(inventory_id);
CREATE OR REPLACE FUNCTION update_inventory_stock_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_inventory_stock_updated_at ON inventory_stock;
CREATE TRIGGER trg_inventory_stock_updated_at
BEFORE UPDATE ON inventory_stock
FOR EACH ROW
EXECUTE FUNCTION update_inventory_stock_updated_at();
-- ═════════════════════════════════════════════════════════════════════════════
-- 3. INVENTORY: make branch_id nullable + prepare for consolidation
-- ═════════════════════════════════════════════════════════════════════════════
-- Drop the old unique constraint that forces one record per (branch, part_number).
DROP INDEX IF EXISTS idx_inventory_branch_part;
-- Make branch_id nullable so we can have master records without a branch.
ALTER TABLE inventory ALTER COLUMN branch_id DROP NOT NULL;
-- Add unique constraint on part_number at tenant level so a product exists once.
-- If duplicates still exist this will fail, so we consolidate below first.
-- We create it at the end of this migration after deduplication.
-- ═════════════════════════════════════════════════════════════════════════════
-- 4. DATA MIGRATION: consolidate duplicated inventory rows by part_number
-- ═════════════════════════════════════════════════════════════════════════════
-- Build a mapping: for each duplicated part_number, choose the master record.
-- Master = record belonging to the main branch; fallback = oldest id.
CREATE TEMP TABLE _inventory_master_map AS
SELECT DISTINCT ON (part_number)
id AS master_id,
part_number
FROM inventory
ORDER BY part_number,
CASE WHEN branch_id = (SELECT id FROM branches WHERE is_main = TRUE LIMIT 1) THEN 0 ELSE 1 END,
id ASC;
-- Create temp table of duplicates (all rows that are NOT the master for their part_number).
CREATE TEMP TABLE _inventory_duplicates AS
SELECT i.id AS duplicate_id, m.master_id
FROM inventory i
JOIN _inventory_master_map m ON i.part_number = m.part_number
WHERE i.id <> m.master_id;
-- Compute per-duplicate stock and insert into inventory_stock against master_id + duplicate's branch.
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
SELECT
dups.master_id,
dups.branch_id,
GREATEST(0, COALESCE(stock_by_dup.stock, 0))::int,
dups.location
FROM (
SELECT d.master_id, d.duplicate_id, i.branch_id, i.location
FROM _inventory_duplicates d
JOIN inventory i ON i.id = d.duplicate_id
) dups
JOIN LATERAL (
SELECT COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations
WHERE inventory_id = dups.duplicate_id AND branch_id = dups.branch_id
) stock_by_dup ON TRUE
ON CONFLICT (inventory_id, branch_id) DO UPDATE
SET stock = inventory_stock.stock + EXCLUDED.stock;
-- Also migrate stock from master records themselves (they were already in inventory.branch_id).
INSERT INTO inventory_stock (inventory_id, branch_id, stock, location)
SELECT
i.id,
i.branch_id,
GREATEST(0, COALESCE(stock_by_inv.stock, 0))::int,
i.location
FROM inventory i
JOIN _inventory_master_map m ON i.id = m.master_id
JOIN LATERAL (
SELECT COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations
WHERE inventory_id = i.id AND branch_id = i.branch_id
) stock_by_inv ON TRUE
WHERE i.branch_id IS NOT NULL
ON CONFLICT (inventory_id, branch_id) DO UPDATE
SET stock = EXCLUDED.stock;
-- Handle inventory_stock_summary specially: it has PK on inventory_id.
-- If master already has a summary row, add duplicate's stock and remove duplicate row.
-- Otherwise repoint the duplicate row to master.
UPDATE inventory_stock_summary s
SET stock = s.stock + d.stock
FROM (
SELECT duplicate_id, master_id, stock FROM inventory_stock_summary ss
JOIN _inventory_duplicates m ON ss.inventory_id = m.duplicate_id
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
) d
WHERE s.inventory_id = d.master_id;
DELETE FROM inventory_stock_summary
WHERE inventory_id IN (
SELECT m.duplicate_id
FROM _inventory_duplicates m
WHERE EXISTS (SELECT 1 FROM inventory_stock_summary sm WHERE sm.inventory_id = m.master_id)
);
UPDATE inventory_stock_summary
SET inventory_id = m.master_id
FROM _inventory_duplicates m
WHERE inventory_id = m.duplicate_id;
-- Update FK references from duplicate inventory rows to master inventory rows.
-- We use dynamic SQL to update every known referencing table.
DO $$
DECLARE
rec RECORD;
fk_sql TEXT;
BEGIN
FOR rec IN
SELECT
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND ccu.table_name = 'inventory'
AND tc.table_name <> 'inventory_stock_summary'
LOOP
fk_sql := format(
'UPDATE %I SET %I = m.master_id FROM _inventory_duplicates m WHERE %I = m.duplicate_id',
rec.table_name, rec.column_name, rec.column_name
);
EXECUTE fk_sql;
END LOOP;
END $$;
-- Delete duplicate inventory rows now that FKs are repointed.
DELETE FROM inventory
WHERE id IN (SELECT duplicate_id FROM _inventory_duplicates);
-- Clean up master records: remove branch_id so they become shared catalog items.
UPDATE inventory SET branch_id = NULL WHERE branch_id IS NOT NULL;
-- Now safe to enforce uniqueness at tenant level.
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_part_unique ON inventory (part_number);
-- Clean temp tables.
DROP TABLE IF EXISTS _inventory_master_map;
DROP TABLE IF EXISTS _inventory_duplicates;
-- ═════════════════════════════════════════════════════════════════════════════
-- 5. CFDI_QUEUE: allow sale_id to be NULL for global invoices (Phase 3 prep)
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE cfdi_queue ALTER COLUMN sale_id DROP NOT NULL;
-- ═════════════════════════════════════════════════════════════════════════════
-- 6. TRIGGER: Keep inventory_stock in sync with inventory_operations
-- ═════════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION update_inventory_stock()
RETURNS TRIGGER AS $$
BEGIN
-- Skip operations that are not tied to a specific branch.
-- Per-branch stock tracking requires a branch_id; without it we can't
-- assign the stock to any location.
IF NEW.branch_id IS NULL THEN
RETURN NEW;
END IF;
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
VALUES (NEW.inventory_id, NEW.branch_id, NEW.quantity)
ON CONFLICT (inventory_id, branch_id) DO UPDATE
SET stock = inventory_stock.stock + EXCLUDED.stock,
updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_update_inventory_stock ON inventory_operations;
CREATE TRIGGER trg_update_inventory_stock
AFTER INSERT ON inventory_operations
FOR EACH ROW
EXECUTE FUNCTION update_inventory_stock();

View File

@@ -0,0 +1,17 @@
-- v4.1 — Global Invoice (Factura Global Mensual)
-- Supports grouping cash sales (<= $2,000) into a single monthly CFDI.
-- Link global invoices to their constituent sales
CREATE TABLE IF NOT EXISTS global_invoice_sales (
global_invoice_id INTEGER NOT NULL REFERENCES cfdi_queue(id) ON DELETE CASCADE,
sale_id INTEGER NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
PRIMARY KEY (global_invoice_id, sale_id)
);
CREATE INDEX IF NOT EXISTS idx_gis_global ON global_invoice_sales(global_invoice_id);
CREATE INDEX IF NOT EXISTS idx_gis_sale ON global_invoice_sales(sale_id);
-- Track which sales have been included in any global invoice
-- (quick lookup without joining global_invoice_sales)
ALTER TABLE sales ADD COLUMN IF NOT EXISTS global_invoiced_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_sales_global_invoiced_at ON sales(global_invoiced_at) WHERE global_invoiced_at IS NULL;

View File

@@ -0,0 +1,14 @@
-- v4.2 — MercadoLibre sync queue for stock synchronization
CREATE TABLE IF NOT EXISTS meli_sync_queue (
id SERIAL PRIMARY KEY,
inventory_id INTEGER NOT NULL REFERENCES inventory(id),
action VARCHAR(20) NOT NULL DEFAULT 'stock_update',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count INTEGER DEFAULT 0,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_meli_sync_pending ON meli_sync_queue(status, created_at) WHERE status = 'pending';

View File

@@ -0,0 +1,42 @@
-- v4.3_facturapi.sql
-- Migrate CFDI timbrado from Horux360 XML pipeline to Facturapi JSON API.
--
-- Changes:
-- - Rename cfdi_queue.xml_unsigned -> payload_unsigned (stores Facturapi JSON payload)
-- - Keep xml_signed for the signed XML returned by Facturapi
-- - Add external_id column to store Facturapi invoice id
-- - Add facturapi config keys to tenant_config
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. CFDI_QUEUE: adapt schema for Facturapi payloads
-- ═════════════════════════════════════════════════════════════════════════════
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'cfdi_queue' AND column_name = 'xml_unsigned'
) THEN
ALTER TABLE cfdi_queue RENAME COLUMN xml_unsigned TO payload_unsigned;
END IF;
END $$;
COMMENT ON COLUMN cfdi_queue.payload_unsigned IS 'Facturapi JSON payload (previously unsigned XML for Horux)';
COMMENT ON COLUMN cfdi_queue.xml_signed IS 'Signed+stamped XML returned by Facturapi';
ALTER TABLE cfdi_queue ADD COLUMN IF NOT EXISTS external_id VARCHAR(64);
COMMENT ON COLUMN cfdi_queue.external_id IS 'Facturapi invoice id';
CREATE INDEX IF NOT EXISTS idx_cfdi_queue_external_id ON cfdi_queue(external_id);
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. TENANT_CONFIG: Facturapi configuration keys
-- ═════════════════════════════════════════════════════════════════════════════
INSERT INTO tenant_config (key, value)
VALUES
('cfdi_facturapi_key', ''),
('cfdi_facturapi_org_id', ''),
('cfdi_facturapi_customer_sync', 'true')
ON CONFLICT (key) DO NOTHING;
-- Backward-compat: migrate old Horux keys to comments so they are not used anymore
COMMENT ON TABLE tenant_config IS 'tenant_config; old keys cfdi_horux_api_url and cfdi_horux_api_key are deprecated';

View File

@@ -7,3 +7,4 @@ gunicorn>=22.0
redis>=5.0 redis>=5.0
meilisearch>=0.40 meilisearch>=0.40
orjson orjson
facturapi>=1.0

View File

@@ -86,11 +86,42 @@ def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temp
return None return None
SYSTEM_PROMPT_SHORT = """Eres un asistente de refaccionaria automotriz mexicana. Ayuda a encontrar autopartes. SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}} Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
search_query va EN INGLES cuando el usuario pide una parte. Traducciones: Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector.
No preguntes mas si ya puedes buscar. Si el usuario describe un sintoma, diagnostica y sugiere partes. REGLAS DE VENTA AVANZADAS:
Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug". 1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
3. MANEJO DE OBJECIONES:
- "Esta caro""Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
- "Voy a checar en otro lado""Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
- "Lo necesito para hoy" / "Urgente""Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
- "No se si sea esa""No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
- "Solo estoy cotizando""Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
- "¿Te lo aparto?"
- "¿Lo mando a tu taller o lo pasas a recoger?"
- "¿Con esto quedas o necesitas algo mas?"
- "¿Te armo el paquete completo? Sale mejor que por separado."
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
TRADUCCIONES search_query (EN INGLES):
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
FORMATO:
- search_query EN INGLES. NUNCA null si pide algo.
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
- Termina SIEMPRE con una pregunta de cierre.
""" """
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes. SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
@@ -195,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
WHERE {where} AND i.brand IS NOT NULL AND i.brand != '' WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
GROUP BY i.brand GROUP BY i.brand
ORDER BY cnt DESC ORDER BY cnt DESC
LIMIT 15 LIMIT 10
""", params) """, params)
brands = cur.fetchall() brands = cur.fetchall()
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0]) brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
# Top categories with counts
cur.execute(f"""
SELECT c.name, COUNT(*) as cnt
FROM inventory i
JOIN part_categories c ON c.id = i.category_id
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
GROUP BY c.name
ORDER BY cnt DESC
LIMIT 10
""", params)
categories = cur.fetchall()
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
# Products with low stock (<=3) # Products with low stock (<=3)
cur.execute(f""" cur.execute(f"""
SELECT COUNT(*) FROM inventory i SELECT COUNT(*) FROM inventory i
@@ -212,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
"CONTEXTO DEL INVENTARIO:", "CONTEXTO DEL INVENTARIO:",
f"Este negocio tiene {total} productos en inventario.", f"Este negocio tiene {total} productos en inventario.",
] ]
if category_list:
lines.append(f"Categorias principales: {category_list}")
if brand_list: if brand_list:
lines.append(f"Marcas disponibles: {brand_list}") lines.append(f"Marcas top: {brand_list}")
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}") lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.") lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
return "\n".join(lines) return "\n".join(lines)
except Exception: except Exception:
@@ -284,10 +330,10 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
] ]
messages.append({"role": "user", "content": user_content}) messages.append({"role": "user", "content": user_content})
# Try Hermes first for vision (if enabled), fallback to OpenRouter # Vision backends: QWEN only, fallback to OpenRouter if key present
backends = [] backends = []
if HERMES_ENABLED: if QWEN_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL)) backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY: if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL)) backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
@@ -339,10 +385,10 @@ def classify_part(part_number):
{"role": "user", "content": prompt} {"role": "user", "content": prompt}
] ]
# Try Hermes first (if enabled), fallback to OpenRouter # Backends: QWEN only, fallback to OpenRouter if key present
backends = [] backends = []
if HERMES_ENABLED: if QWEN_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL)) backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY: if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL)) backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
@@ -528,12 +574,10 @@ def chat(user_message, conversation_history=None, inventory_context=None):
last_error = None last_error = None
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter # Build backend list: QWEN first, then OpenRouter fallback
backends = [] backends = []
if QWEN_ENABLED: if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000)) backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
if HERMES_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
if OPENROUTER_API_KEY: if OPENROUTER_API_KEY:
for m in FALLBACK_MODELS: for m in FALLBACK_MODELS:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800)) backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
@@ -548,14 +592,22 @@ def chat(user_message, conversation_history=None, inventory_context=None):
if conversation_history: if conversation_history:
msgs.extend(conversation_history) msgs.extend(conversation_history)
msgs.append({"role": "user", "content": user_message}) msgs.append({"role": "user", "content": user_message})
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
max_retries = 3 if url == QWEN_CHAT_URL else 1
result = None
for attempt in range(1, max_retries + 1):
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
if result is not None:
break
if attempt < max_retries:
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
_time_chat.sleep(2)
if result is None: if result is None:
if url == QWEN_CHAT_URL: if url == QWEN_CHAT_URL:
print(f"[AI] QWEN failed, trying Hermes fallback...") print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
last_error = "qwen_failed" last_error = "qwen_failed"
elif url == HERMES_CHAT_URL:
print(f"[AI] Hermes failed, trying OpenRouter fallback...")
last_error = "hermes_timeout"
else: else:
print(f"[AI] Rate limited on {model_id}, trying next model...") print(f"[AI] Rate limited on {model_id}, trying next model...")
last_error = "rate_limit" last_error = "rate_limit"
@@ -589,7 +641,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
# All models exhausted — DON'T cache errors, we want retries next time # All models exhausted — DON'T cache errors, we want retries next time
if last_error == "rate_limit": if last_error == "rate_limit":
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
if last_error == "hermes_timeout": if last_error == "qwen_failed":
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None} return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
return { return {
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.", "message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",

View File

@@ -0,0 +1,157 @@
"""Bulk catalog import service.
Imports products into inventory with optional vehicle compatibilities
and SKU aliases. Can auto-generate vehicle fitment via QWEN AI if
compatibilities are not provided.
"""
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def import_products(
tenant_conn,
products: list[dict],
branch_id: int,
auto_generate_compat: bool = False,
employee_id: Optional[int] = None,
):
"""Import a list of products into inventory.
Each product dict may contain:
- sku (str) *required
- name (str) *required
- brand (str)
- description (str)
- cost (float)
- price (float)
- stock (int)
- location (str)
- sku_aliases (list[dict]) [{"sku": str, "label": str}]
- vehicles (list[dict]) [{"make", "model", "year", "engine", "engine_code"}]
Returns {"imported": N, "failed": [{"sku": ..., "error": ...}], "compat_generated": M}
"""
cur = tenant_conn.cursor()
imported = 0
failed = []
compat_generated = 0
for idx, p in enumerate(products):
sku = (p.get("sku") or "").strip()
name = (p.get("name") or "").strip()
if not sku or not name:
failed.append({"index": idx, "sku": sku, "error": "sku and name are required"})
continue
brand = (p.get("brand") or "").strip() or None
description = (p.get("description") or "").strip() or None
cost = float(p.get("cost") or 0)
price = float(p.get("price") or 0)
stock = int(p.get("stock") or 0)
location = (p.get("location") or "").strip() or None
barcode = (p.get("barcode") or "").strip() or None
try:
# Check for duplicate SKU in same branch
cur.execute(
"SELECT id FROM inventory WHERE part_number = %s AND branch_id = %s AND is_active = true",
(sku, branch_id),
)
if cur.fetchone():
# Update existing item instead of creating new
cur.execute(
"""
UPDATE inventory
SET name = %s, brand = %s, description = %s, cost = %s, price_1 = %s,
location = %s, barcode = COALESCE(%s, barcode), updated_at = NOW()
WHERE part_number = %s AND branch_id = %s AND is_active = true
RETURNING id
""",
(name, brand, description, cost, price, location, barcode, sku, branch_id),
)
row = cur.fetchone()
item_id = row[0]
else:
# Insert new item
cur.execute(
"""
INSERT INTO inventory
(branch_id, part_number, barcode, name, description, brand,
unit, cost, price_1, price_2, price_3, tax_rate,
min_stock, max_stock, location, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
RETURNING id
""",
(
branch_id, sku, barcode, name, description, brand,
"PZA", cost, price, price, price, 0.16,
0, 0, location,
),
)
row = cur.fetchone()
item_id = row[0]
# Record initial stock if provided
if stock > 0:
from services.inventory_engine import record_initial
record_initial(tenant_conn, item_id, branch_id, stock, cost)
# Insert SKU aliases
aliases = p.get("sku_aliases") or []
for alias in aliases:
alias_sku = (alias.get("sku") or "").strip()
label = (alias.get("label") or "").strip() or None
if alias_sku:
cur.execute(
"""
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
VALUES (%s, %s, %s)
ON CONFLICT (inventory_id, sku) DO UPDATE SET
is_active = true, label = EXCLUDED.label
""",
(item_id, alias_sku, label),
)
# Insert manual vehicle compatibilities
vehicles = p.get("vehicles") or []
for v in vehicles:
make = (v.get("make") or "").strip()
model = (v.get("model") or "").strip()
year = v.get("year")
engine = (v.get("engine") or "").strip() or None
engine_code = (v.get("engine_code") or "").strip() or None
if make and model and year:
cur.execute(
"""
INSERT INTO inventory_vehicle_compat
(inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id)
VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL)
ON CONFLICT DO NOTHING
""",
(item_id, make, model, year, engine, engine_code),
)
tenant_conn.commit()
imported += 1
# Auto-generate compat via QWEN if requested and no vehicles provided
if auto_generate_compat and not vehicles:
try:
from services.qwen_fitment import get_vehicle_fitment
from services.inventory_vehicle_compat import save_qwen_fitment
fitment = get_vehicle_fitment(sku, name, brand or "")
inserted = save_qwen_fitment(tenant_conn, item_id, fitment)
compat_generated += inserted
except Exception as qe:
logger.warning("QWEN auto-match failed for %s: %s", sku, qe)
except Exception as e:
tenant_conn.rollback()
logger.warning("Import failed for sku=%s: %s", sku, e)
failed.append({"index": idx, "sku": sku, "error": str(e)})
cur.close()
return {"imported": imported, "failed": failed, "compat_generated": compat_generated}

File diff suppressed because it is too large Load Diff

View File

@@ -464,3 +464,130 @@ def build_pago_xml(payment, tenant_config, customer, original_uuid):
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
pretty_print=True).decode('utf-8') pretty_print=True).decode('utf-8')
def build_global_invoice_xml(sales, tenant_config, year, month):
"""Build CFDI 4.0 XML for a monthly global invoice (Factura Global).
Groups multiple cash sales (PUE, <= $2,000 each, no individual CFDI)
into a single CFDI tipo Ingreso with InformacionGlobal.
Args:
sales: list of dicts with keys:
id, subtotal, discount_total, tax_total, total,
items: [{name, quantity, unit_price, discount_amount,
tax_rate, tax_amount, subtotal,
clave_prod_serv, clave_unidad}]
tenant_config: dict with keys:
rfc, razon_social, regimen_fiscal, cp, serie (optional)
year: int, e.g. 2026
month: int, e.g. 6
Returns:
str: XML string (unsigned, ready for Horux)
"""
nsmap = {
'cfdi': CFDI_NS,
'xsi': XSI_NS,
}
# Aggregate totals
total_subtotal = Decimal('0')
total_discount = Decimal('0')
total_tax = Decimal('0')
total_total = Decimal('0')
for sale in sales:
total_subtotal += _to_dec(sale.get('subtotal', 0))
total_discount += _to_dec(sale.get('discount_total', 0))
total_tax += _to_dec(sale.get('tax_total', 0))
total_total += _to_dec(sale.get('total', 0))
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
root.set('Version', '4.0')
root.set('Serie', tenant_config.get('serie', 'FG'))
root.set('Folio', f'{year}{month:02d}')
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
root.set('FormaPago', '01') # Efectivo (most common for global)
root.set('SubTotal', _format_amount(total_subtotal))
if total_discount > 0:
root.set('Descuento', _format_amount(total_discount))
root.set('Moneda', 'MXN')
root.set('Total', _format_amount(total_total))
root.set('TipoDeComprobante', 'I') # Ingreso
root.set('Exportacion', '01')
root.set('MetodoPago', 'PUE')
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
# InformacionGlobal (monthly global invoice)
info_global = _make_element(root, 'InformacionGlobal')
info_global.set('Periodicidad', '04') # Mensual
info_global.set('Meses', f'{month:02d}')
info_global.set('Anio', str(year))
# Emisor
emisor = _make_element(root, 'Emisor')
emisor.set('Rfc', tenant_config['rfc'])
emisor.set('Nombre', tenant_config['razon_social'])
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
# Receptor: Publico en general
receptor = _make_element(root, 'Receptor')
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
receptor.set('Nombre', 'PUBLICO EN GENERAL')
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
receptor.set('RegimenFiscalReceptor', '616')
receptor.set('UsoCFDI', 'S01')
# Conceptos: one per sale item (simplified)
conceptos = _make_element(root, 'Conceptos')
for sale in sales:
for item in sale.get('items', []):
qty = int(item.get('quantity', 1))
unit_price = _to_dec(item.get('unit_price', 0))
discount_amount = _to_dec(item.get('discount_amount', 0))
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
tax_amount = _to_dec(item.get('tax_amount', 0))
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
concepto = _make_element(conceptos, 'Concepto')
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
concepto.set('Cantidad', str(qty))
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
concepto.set('Unidad', 'PZA')
concepto.set('Descripcion', item.get('name') or 'Autoparte')
concepto.set('ValorUnitario', _format_amount(unit_price))
concepto.set('Importe', _format_amount(importe))
concepto.set('ObjetoImp', '02')
if discount_amount > 0:
concepto.set('Descuento', _format_amount(discount_amount))
impuestos_concepto = _make_element(concepto, 'Impuestos')
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
traslado = _make_element(traslados_concepto, 'Traslado')
traslado.set('Base', _format_amount(base))
traslado.set('Impuesto', '002')
traslado.set('TipoFactor', 'Tasa')
traslado.set('TasaOCuota', _format_rate(tax_rate))
traslado.set('Importe', _format_amount(tax_amount))
# Impuestos totales
impuestos = _make_element(root, 'Impuestos')
impuestos.set('TotalImpuestosTrasladados', _format_amount(total_tax))
traslados = _make_element(impuestos, 'Traslados')
traslado_total = _make_element(traslados, 'Traslado')
traslado_total.set('Base', _format_amount(total_subtotal))
traslado_total.set('Impuesto', '002')
traslado_total.set('TipoFactor', 'Tasa')
traslado_total.set('TasaOCuota', '0.160000')
traslado_total.set('Importe', _format_amount(total_tax))
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
pretty_print=True).decode('utf-8')

View File

@@ -0,0 +1,243 @@
# /home/Autopartes/pos/services/cfdi_facturapi_builder.py
"""Build Facturapi invoice payloads from Nexus sales data.
Facturapi expects a JSON payload instead of an unsigned XML. This module
generates those payloads for:
- Ingreso (sale invoice)
- Egreso (credit note)
- Pago (payment complement)
- Factura global mensual
"""
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime
# SAT defaults
RFC_PUBLICO_GENERAL = "XAXX010101000"
RFC_EXTRANJERO = "XEXX010101000"
# Forma de pago mapping (Nexus internal -> SAT code)
FORMA_PAGO_MAP = {
"efectivo": "01",
"transferencia": "03",
"tarjeta": "04",
"cheque": "02",
"credito": "99",
"mixto": "99",
"99": "99",
}
# Metodo de pago
METODO_PAGO_MAP = {
"PUE": "PUE",
"PPD": "PPD",
}
TWO = Decimal("0.01")
SIX = Decimal("0.000001")
def _to_dec(val):
if val is None:
return Decimal("0")
return Decimal(str(val))
def _fmt2(val):
return float(_to_dec(val).quantize(TWO, ROUND_HALF_UP))
def _fmt6(val):
return float(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
def _resolve_forma_pago(sale):
method = (sale.get("payment_method") or "").lower().strip()
fp = (sale.get("forma_pago_sat") or "").strip()
if fp:
return fp
return FORMA_PAGO_MAP.get(method, "99")
def _resolve_metodo_pago(sale):
mp = (sale.get("metodo_pago_sat") or "").upper().strip()
if mp in ("PUE", "PPD"):
return mp
# Default: credit sales are PPD, cash sales are PUE
if sale.get("sale_type") == "credit" or sale.get("payment_method") == "credito":
return "PPD"
return "PUE"
def _build_items(sale_items):
items = []
for item in sale_items or []:
qty = int(item.get("quantity", 1))
unit_price = _to_dec(item.get("unit_price", 0))
discount = _to_dec(item.get("discount_amount", 0))
tax_rate = _to_dec(item.get("tax_rate", "0.16"))
# Facturapi price is unit price before taxes and discounts
product = {
"description": item.get("name") or "Autoparte",
"product_key": item.get("clave_prod_serv") or "25174800",
"unit_key": item.get("clave_unidad") or "H87",
"unit_name": "Pieza",
"price": _fmt2(unit_price),
"tax_included": False,
"taxes": [
{
"type": "IVA",
"rate": _fmt6(tax_rate),
"factor": "Tasa",
}
],
}
if discount > 0:
product["discount"] = _fmt2(discount / qty) if qty > 0 else _fmt2(discount)
items.append({"quantity": qty, "product": product})
return items
def _build_customer_payload(customer, tenant_cp):
if not customer or not customer.get("rfc"):
# Publico en general
return {
"tax_id": RFC_PUBLICO_GENERAL,
"legal_name": "PUBLICO EN GENERAL",
"tax_system": "616",
"address": {"zip": tenant_cp or "00000"},
}
rfc = (customer.get("rfc") or "").upper().strip()
return {
"tax_id": rfc,
"legal_name": customer.get("razon_social") or customer.get("name") or rfc,
"tax_system": customer.get("regimen_fiscal") or "616",
"email": customer.get("email"),
"address": {"zip": customer.get("cp") or tenant_cp or "00000"},
}
def build_ingreso_payload(sale, tenant_config, customer=None):
"""Build Facturapi payload for a sale (Comprobante tipo Ingreso)."""
tenant_cp = tenant_config.get("cp", "00000")
customer_payload = _build_customer_payload(customer, tenant_cp)
payload = {
"customer": customer_payload,
"items": _build_items(sale.get("items", [])),
"use": customer.get("uso_cfdi") if customer and customer.get("rfc") else "S01",
"payment_form": _resolve_forma_pago(sale),
"payment_method": _resolve_metodo_pago(sale),
"currency": "MXN",
"series": tenant_config.get("serie", "A"),
"folio_number": sale["id"],
}
# Optional exchange rate for USD
if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
payload["exchange"] = _fmt6(sale["exchange_rate"])
payload["currency"] = sale["currency"]
return payload
def build_egreso_payload(sale, tenant_config, customer, original_uuid):
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
payload = build_ingreso_payload(sale, tenant_config, customer)
payload["type"] = "E"
payload["related_documents"] = [
{"relationship": "01", "documents": [original_uuid]}
]
payload["payment_method"] = "PUE"
return payload
def build_pago_payload(payment, tenant_config, customer, original_uuid):
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
tenant_cp = tenant_config.get("cp", "00000")
customer_payload = _build_customer_payload(customer, tenant_cp)
amount = _to_dec(payment.get("amount", 0))
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
iva = (amount - base).quantize(TWO, ROUND_HALF_UP)
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
if "T" not in str(payment_date):
payment_date = f"{payment_date}T12:00:00"
forma_pago = FORMA_PAGO_MAP.get(
(payment.get("payment_method") or "").lower().strip(), "01"
)
payload = {
"type": "P",
"customer": customer_payload,
"complements": [
{
"type": "pago",
"data": {
"payment_form": forma_pago,
"payment_date": payment_date,
"amount": _fmt2(amount),
"related_documents": [
{
"uuid": original_uuid,
"amount": _fmt2(amount),
"taxes": [
{
"type": "IVA",
"rate": 0.16,
"factor": "Tasa",
"base": _fmt2(base),
}
],
}
],
},
}
],
}
return payload
def build_global_invoice_payload(sales, tenant_config, year, month):
"""Build Facturapi payload for a monthly global invoice."""
tenant_cp = tenant_config.get("cp", "00000")
total_subtotal = Decimal("0")
total_discount = Decimal("0")
total_tax = Decimal("0")
total_total = Decimal("0")
all_items = []
for sale in sales:
total_subtotal += _to_dec(sale.get("subtotal", 0))
total_discount += _to_dec(sale.get("discount_total", 0))
total_tax += _to_dec(sale.get("tax_total", 0))
total_total += _to_dec(sale.get("total", 0))
all_items.extend(_build_items(sale.get("items", [])))
payload = {
"customer": {
"tax_id": RFC_PUBLICO_GENERAL,
"legal_name": "PUBLICO EN GENERAL",
"tax_system": "616",
"address": {"zip": tenant_cp},
},
"items": all_items,
"use": "S01",
"payment_form": "01",
"payment_method": "PUE",
"currency": "MXN",
"series": tenant_config.get("serie", "FG"),
"folio_number": int(f"{year}{month:02d}"),
"global": {
"periodicity": "04", # Mensual
"months": f"{month:02d}",
"year": year,
},
}
return payload

View File

@@ -1,25 +1,25 @@
# /home/Autopartes/pos/services/cfdi_queue.py # /home/Autopartes/pos/services/cfdi_queue.py
"""CFDI queue service: manages the timbrado pipeline. """CFDI queue service: manages the Facturapi timbrado pipeline.
Flow: Flow:
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending' 1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
2. process_queue() — sends pending items to Horux API, updates status 2. process_queue() — sends pending items to Facturapi, updates status
3. retry_failed() — retries failed items with exponential backoff 3. retry_failed() — retries failed items with exponential backoff
4. cancel_cfdi() — sends cancel request to Horux API 4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
Horux API endpoints: Facturapi endpoints used:
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado POST /v2/invoices — create and stamp an invoice
GET /api/nexus/cfdi/status/:uuid — check timbrado status GET /v2/invoices/:id — fetch invoice metadata
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code DELETE /v2/invoices/:id — cancel with SAT motive
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries) Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
""" """
import json
import logging import logging
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests from services import facturapi_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
def _generate_provisional_folio(conn): def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001. """Generate a provisional folio like PRE-00001."""
Uses the cfdi_queue table's max id to avoid collisions.
"""
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0] seq = cur.fetchone()[0]
@@ -40,14 +37,14 @@ def _generate_provisional_folio(conn):
return f'PRE-{seq:05d}' return f'PRE-{seq:05d}'
def enqueue_cfdi(conn, sale_id, cfdi_type, xml): def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
"""Add a CFDI to the timbrado queue. """Add a CFDI to the timbrado queue.
Args: Args:
conn: psycopg2 connection conn: psycopg2 connection
sale_id: int (FK to sales) sale_id: int (FK to sales), may be None for global invoices
cfdi_type: 'ingreso' | 'egreso' | 'pago' cfdi_type: 'ingreso' | 'egreso' | 'pago'
xml: str (unsigned XML from cfdi_builder) payload: dict (Facturapi JSON payload) or str (JSON string)
Returns: Returns:
dict: {id, sale_id, type, status, provisional_folio} dict: {id, sale_id, type, status, provisional_folio}
@@ -55,12 +52,14 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
provisional_folio = _generate_provisional_folio(conn) provisional_folio = _generate_provisional_folio(conn)
cur = conn.cursor() cur = conn.cursor()
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
cur.execute(""" cur.execute("""
INSERT INTO cfdi_queue INSERT INTO cfdi_queue
(sale_id, type, xml_unsigned, status, provisional_folio) (sale_id, type, payload_unsigned, status, provisional_folio)
VALUES (%s, %s, %s, 'pending', %s) VALUES (%s, %s, %s, 'pending', %s)
RETURNING id, created_at RETURNING id, created_at
""", (sale_id, cfdi_type, xml, provisional_folio)) """, (sale_id, cfdi_type, payload_json, provisional_folio))
cfdi_id, created_at = cur.fetchone() cfdi_id, created_at = cur.fetchone()
cur.close() cur.close()
@@ -74,17 +73,17 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
} }
def process_queue(conn, horux_api_url, api_key): def process_queue(conn, tenant_config, dry_run=False):
"""Process all pending CFDI items in the queue. """Process all pending CFDI items in the queue.
Sends each pending XML to Horux for timbrado. On success, updates Sends each pending payload to Facturapi for timbrado. On success, updates
the record with the signed XML and UUID fiscal. On failure, increments the record with the signed XML and UUID fiscal. On failure, increments
retry_count and records the error. retry_count and records the error.
Args: Args:
conn: psycopg2 connection conn: psycopg2 connection
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com') tenant_config: dict with facturapi_key (and optional facturapi_org_id)
api_key: str Horux API key dry_run: if True, validates payload without stamping
Returns: Returns:
dict: {processed: int, stamped: int, failed: int, details: [...]} dict: {processed: int, stamped: int, failed: int, details: [...]}
@@ -92,7 +91,7 @@ def process_queue(conn, horux_api_url, api_key):
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT id, sale_id, type, xml_unsigned, retry_count SELECT id, sale_id, type, payload_unsigned, retry_count
FROM cfdi_queue FROM cfdi_queue
WHERE status IN ('pending', 'failed') WHERE status IN ('pending', 'failed')
AND retry_count < %s AND retry_count < %s
@@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key):
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []} results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items: api_key = tenant_config.get('facturapi_key')
if not api_key:
cur.close()
raise ValueError("Facturapi key not configured for tenant")
for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items:
results['processed'] += 1 results['processed'] += 1
# Update status to 'sending' # Update status to 'sending'
@@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key):
conn.commit() conn.commit()
try: try:
response = requests.post( payload = json.loads(payload_unsigned or '{}')
f'{horux_api_url}/api/nexus/cfdi/stamp', if not payload:
headers={ raise ValueError("Empty payload in queue item")
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/xml',
},
data=xml_unsigned.encode('utf-8'),
timeout=30,
)
if response.status_code == 200: if dry_run:
data = response.json() # TODO: Facturapi dry-run validation (not officially supported)
uuid_fiscal = data.get('uuid') # For now we just skip the API call and mark as stamped with a fake UUID
xml_signed = data.get('xml', '') raise ValueError("dry_run is not supported with Facturapi")
cur.execute(""" invoice = facturapi_service.create_invoice(tenant_config, payload)
UPDATE cfdi_queue invoice_id = invoice.get('id')
SET status = 'stamped', uuid_fiscal = invoice.get('uuid')
xml_signed = %s,
uuid_fiscal = %s,
stamped_at = NOW(),
error_message = NULL
WHERE id = %s
""", (xml_signed, uuid_fiscal, cfdi_id))
conn.commit()
results['stamped'] += 1 # Download signed XML for storage
results['details'].append({ try:
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal xml_signed = facturapi_service.download_xml(tenant_config, invoice_id)
}) xml_signed_str = xml_signed.decode('utf-8') if isinstance(xml_signed, bytes) else str(xml_signed)
else: except Exception as xml_err:
error_msg = f'HTTP {response.status_code}: {response.text[:500]}' logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
cur.execute(""" xml_signed_str = ''
UPDATE cfdi_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
conn.commit()
results['failed'] += 1 cur.execute("""
results['details'].append({ UPDATE cfdi_queue
'id': cfdi_id, 'status': 'failed', 'error': error_msg SET status = 'stamped',
}) xml_signed = %s,
uuid_fiscal = %s,
external_id = %s,
stamped_at = NOW(),
error_message = NULL
WHERE id = %s
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
conn.commit()
except requests.RequestException as e: results['stamped'] += 1
error_msg = f'Connection error: {str(e)[:500]}' results['details'].append({
'id': cfdi_id, 'status': 'stamped',
'uuid': uuid_fiscal, 'external_id': invoice_id,
})
except Exception as e:
error_msg = f'{type(e).__name__}: {str(e)[:500]}'
cur.execute(""" cur.execute("""
UPDATE cfdi_queue UPDATE cfdi_queue
SET status = 'failed', SET status = 'failed',
@@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key):
def retry_failed(conn): def retry_failed(conn):
"""Find failed items eligible for retry (based on backoff) and reset to pending. """Find failed items eligible for retry and reset to pending.
Uses exponential backoff: item is eligible for retry only if enough Uses exponential backoff: item is eligible for retry only if enough
time has passed since the last attempt based on retry_count. time has passed since the last attempt based on retry_count.
Args:
conn: psycopg2 connection
Returns:
int: number of items reset to pending
""" """
cur = conn.cursor() cur = conn.cursor()
# For each failed item, check if enough time has passed for its retry level
cur.execute(""" cur.execute("""
SELECT id, retry_count, created_at SELECT id, retry_count, created_at
FROM cfdi_queue FROM cfdi_queue
@@ -206,15 +196,15 @@ def retry_failed(conn):
now = datetime.utcnow() now = datetime.utcnow()
for cfdi_id, retry_count, created_at in items: for cfdi_id, retry_count, created_at in items:
# Calculate required wait time based on retry count
if retry_count < len(BACKOFF_INTERVALS): if retry_count < len(BACKOFF_INTERVALS):
wait_seconds = BACKOFF_INTERVALS[retry_count] wait_seconds = BACKOFF_INTERVALS[retry_count]
else: else:
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff wait_seconds = BACKOFF_INTERVALS[-1]
# Check if enough time has passed (use created_at as approximation) # Use created_at as approximation for last attempt.
# In production, you'd track last_attempt_at separately # In production, track last_attempt_at separately.
if True: # Always eligible for manual retry trigger elapsed = (now - created_at).total_seconds()
if elapsed >= wait_seconds:
cur.execute(""" cur.execute("""
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
""", (cfdi_id,)) """, (cfdi_id,))
@@ -226,8 +216,8 @@ def retry_failed(conn):
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
horux_api_url=None, api_key=None): tenant_config=None):
"""Cancel a stamped CFDI via Horux API. """Cancel a stamped CFDI via Facturapi.
SAT cancellation motives: SAT cancellation motives:
01: Comprobante emitido con errores con relacion (requires replacement UUID) 01: Comprobante emitido con errores con relacion (requires replacement UUID)
@@ -240,8 +230,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
cfdi_id: int (cfdi_queue.id) cfdi_id: int (cfdi_queue.id)
motive: str ('01', '02', '03', '04') motive: str ('01', '02', '03', '04')
replacement_uuid: str (required if motive == '01') replacement_uuid: str (required if motive == '01')
horux_api_url: str (optional, skips API call if None — for offline) tenant_config: dict with facturapi_key
api_key: str (optional)
Returns: Returns:
dict: {id, status, message} dict: {id, status, message}
@@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,)) """, (cfdi_id,))
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise ValueError(f"CFDI queue item {cfdi_id} not found") raise ValueError(f"CFDI queue item {cfdi_id} not found")
_, uuid_fiscal, current_status = row _, uuid_fiscal, external_id, current_status = row
if current_status == 'cancelled': if current_status == 'cancelled':
raise ValueError("CFDI is already cancelled") raise ValueError("CFDI is already cancelled")
@@ -280,64 +269,26 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
cur.close() cur.close()
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'} return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
# Send cancel request to Horux if not tenant_config or not tenant_config.get('facturapi_key'):
if horux_api_url and api_key: cur.close()
try: raise ValueError("Facturapi key not configured for tenant")
payload = {
'uuid': uuid_fiscal,
'motive': motive,
}
if replacement_uuid:
payload['replacement_uuid'] = replacement_uuid
response = requests.post( if not external_id:
f'{horux_api_url}/api/nexus/cfdi/cancel', cur.close()
headers={ raise ValueError("Cannot cancel: no Facturapi invoice id stored")
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json=payload,
timeout=30,
)
if response.status_code == 200: try:
cur.execute(""" facturapi_service.cancel_invoice(
UPDATE cfdi_queue tenant_config, external_id, motive,
SET status = 'cancelled', replacement_uuid=replacement_uuid,
cancel_motive = %s, )
cancel_replacement_uuid = %s,
error_message = NULL
WHERE id = %s
""", (motive, replacement_uuid, cfdi_id))
conn.commit()
cur.close()
return {
'id': cfdi_id,
'status': 'cancelled',
'message': f'Cancelled with SAT (motive {motive})',
}
else:
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
cur.execute("""
UPDATE cfdi_queue
SET error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
conn.commit()
cur.close()
raise ValueError(error_msg)
except requests.RequestException as e:
cur.close()
raise ValueError(f'Connection error during cancel: {str(e)}')
else:
# Offline mode: mark as cancelled locally, will sync later
cur.execute(""" cur.execute("""
UPDATE cfdi_queue UPDATE cfdi_queue
SET status = 'cancelled', SET status = 'cancelled',
cancel_motive = %s, cancel_motive = %s,
cancel_replacement_uuid = %s, cancel_replacement_uuid = %s,
error_message = 'Cancelled offline, pending SAT sync' error_message = NULL
WHERE id = %s WHERE id = %s
""", (motive, replacement_uuid, cfdi_id)) """, (motive, replacement_uuid, cfdi_id))
conn.commit() conn.commit()
@@ -345,24 +296,23 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
return { return {
'id': cfdi_id, 'id': cfdi_id,
'status': 'cancelled', 'status': 'cancelled',
'message': 'Cancelled offline, pending SAT sync', 'message': f'Cancelled with SAT (motive {motive})',
} }
except Exception as e:
error_msg = f'Cancel failed: {str(e)[:500]}'
cur.execute("""
UPDATE cfdi_queue
SET error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
conn.commit()
cur.close()
raise ValueError(error_msg)
def get_queue_status(conn, filters=None): def get_queue_status(conn, filters=None):
"""Get CFDI queue items with optional filters. """Get CFDI queue items with optional filters."""
Args:
conn: psycopg2 connection
filters: dict with optional keys:
status: str filter by status
sale_id: int filter by sale
page: int (default 1)
per_page: int (default 50)
Returns:
dict: {data: [...], pagination: {...}}
"""
filters = filters or {} filters = filters or {}
cur = conn.cursor() cur = conn.cursor()
@@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None):
cur.execute(f""" cur.execute(f"""
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status, SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
q.retry_count, q.provisional_folio, q.error_message, q.retry_count, q.provisional_folio, q.error_message,
q.cancel_motive, q.created_at, q.stamped_at q.cancel_motive, q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q FROM cfdi_queue q
WHERE {where} WHERE {where}
ORDER BY q.created_at DESC ORDER BY q.created_at DESC
@@ -408,6 +358,7 @@ def get_queue_status(conn, filters=None):
'error_message': r[7], 'cancel_motive': r[8], 'error_message': r[7], 'cancel_motive': r[8],
'created_at': str(r[9]) if r[9] else None, 'created_at': str(r[9]) if r[9] else None,
'stamped_at': str(r[10]) if r[10] else None, 'stamped_at': str(r[10]) if r[10] else None,
'external_id': r[11],
}) })
cur.close() cur.close()

View File

@@ -0,0 +1,168 @@
"""Dropshipping integration service.
Provides read-only inventory access for external dropshipping platforms
and webhook dispatching on stock/price/sale events.
"""
import logging
from services.inventory_engine import get_stock_bulk
logger = logging.getLogger(__name__)
def resolve_tenant_by_api_key(master_conn, api_key: str):
"""Find tenant_id and db_name for a given dropshipping API key.
Returns (tenant_id, db_name) or (None, None) if invalid.
"""
if not api_key:
return None, None
cur = master_conn.cursor()
# tenant_config lives in each tenant DB, so we need to scan tenants
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
tenants = cur.fetchall()
for tid, db_name in tenants:
try:
tcur = master_conn.cursor()
# Use dblink or connect to tenant DB? Simpler: the blueprint
# will pass tenant_conn directly after resolution.
# Instead, we store a mapping in master DB for speed.
# For now, return all candidates and let caller validate.
pass
except Exception:
continue
cur.close()
return None, None
def _get_dropshipping_key(tenant_conn):
cur = tenant_conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'dropshipping_api_key'")
row = cur.fetchone()
cur.close()
return row[0] if row else None
def validate_api_key(tenant_conn, api_key: str) -> bool:
"""Check if the provided API key matches the tenant's configured key."""
if not api_key:
return False
expected = _get_dropshipping_key(tenant_conn)
return expected is not None and expected == api_key
def get_inventory_list(tenant_conn, search: str = None, page: int = 1, per_page: int = 50):
"""Return inventory items with stock and price for dropshipping."""
offset = (max(page, 1) - 1) * per_page
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
cur = tenant_conn.cursor()
params = []
where = "WHERE is_active = true"
if search:
where += " AND (name ILIKE %s OR part_number ILIKE %s)"
params.extend([f"%{search}%", f"%{search}%"])
cur.execute(
f"""
SELECT id, part_number, name, brand, price_1, price_2, price_3,
image_url, unit, description
FROM inventory
{where}
ORDER BY id DESC
LIMIT %s OFFSET %s
""",
params + [per_page, offset],
)
rows = cur.fetchall()
# Count total
cur.execute(f"SELECT COUNT(*) FROM inventory {where}", params)
total = cur.fetchone()[0]
cur.close()
items = []
for r in rows:
inv_id = r[0]
items.append({
"id": inv_id,
"sku": r[1],
"name": r[2],
"brand": r[3],
"price_1": float(r[4]) if r[4] else None,
"price_2": float(r[5]) if r[5] else None,
"price_3": float(r[6]) if r[6] else None,
"stock": stock_map.get(inv_id, 0),
"image_url": r[7],
"unit": r[8],
"description": r[9],
})
return {"items": items, "page": page, "per_page": per_page, "total": total}
def get_inventory_by_sku(tenant_conn, sku: str):
"""Return a single inventory item by SKU/part_number."""
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, part_number, name, brand, price_1, price_2, price_3,
image_url, unit, description
FROM inventory
WHERE part_number = %s AND is_active = true
LIMIT 1
""",
(sku,),
)
row = cur.fetchone()
cur.close()
if not row:
return None
inv_id = row[0]
return {
"id": inv_id,
"sku": row[1],
"name": row[2],
"brand": row[3],
"price_1": float(row[4]) if row[4] else None,
"price_2": float(row[5]) if row[5] else None,
"price_3": float(row[6]) if row[6] else None,
"stock": stock_map.get(inv_id, 0),
"image_url": row[7],
"unit": row[8],
"description": row[9],
}
def get_stock_by_skus(tenant_conn, skus: list[str]) -> dict:
"""Return stock levels for a list of SKUs."""
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, part_number FROM inventory
WHERE part_number = ANY(%s) AND is_active = true
""",
(skus,),
)
rows = cur.fetchall()
cur.close()
result = {}
for inv_id, sku in rows:
result[sku] = stock_map.get(inv_id, 0)
return result
def get_webhook_targets(tenant_conn, event_type: str) -> list[str]:
"""Return active webhook URLs for a given event type."""
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT target_url FROM dropshipping_webhooks
WHERE event_type = %s AND is_active = true
""",
(event_type,),
)
urls = [r[0] for r in cur.fetchall()]
cur.close()
return urls

View File

@@ -0,0 +1,426 @@
# /home/Autopartes/pos/services/facturapi_service.py
"""Facturapi integration for Nexus POS.
Uses Facturapi REST API directly (requests + Basic Auth) so it is safe for
multi-tenant use. Each call receives the API key explicitly, avoiding the
global client used by the official facturapi Python library.
Authentication modes:
1. User key (FACTURAPI_USER_KEY env): creates/verifies organizations per tenant.
2. Secret key per tenant (tenant_config.facturapi_secret_key): uses existing org.
Reference: https://docs.facturapi.io/
"""
import os
import base64
import logging
from decimal import Decimal
from typing import Optional
import requests
logger = logging.getLogger(__name__)
BASE_URL = "https://www.facturapi.io/v2"
USER_KEY = os.environ.get("FACTURAPI_USER_KEY", "")
class FacturapiError(Exception):
def __init__(self, message: str, status_code: int = 0, response_body: str = ""):
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
# ─── HTTP helpers ───────────────────────────────────────────────────────────
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None,
extra_headers=None, timeout=60):
"""Make a request to Facturapi REST API with Basic Auth."""
url = f"{BASE_URL}{endpoint}"
headers = {"Content-Type": "application/json"}
if extra_headers:
headers.update(extra_headers)
try:
resp = requests.request(
method,
url,
auth=(api_key, ""),
headers=headers,
json=json_payload,
params=params,
timeout=timeout,
)
except requests.RequestException as e:
raise FacturapiError(f"Connection error: {e}", status_code=0)
if not resp.ok:
raise FacturapiError(
f"Facturapi {method.upper()} {endpoint} failed: {resp.status_code} {resp.text[:500]}",
status_code=resp.status_code,
response_body=resp.text,
)
if resp.status_code == 204 or not resp.content:
return {}
return resp.json()
def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60) -> bytes:
"""Download binary content (XML/PDF)."""
url = f"{BASE_URL}{endpoint}"
resp = requests.request(
method,
url,
auth=(api_key, ""),
params=params,
timeout=timeout,
)
if not resp.ok:
raise FacturapiError(
f"Download failed: {resp.status_code} {resp.text[:500]}",
status_code=resp.status_code,
)
return resp.content
# ─── Tenant config helpers ──────────────────────────────────────────────────
def _get_secret_key(tenant_config: dict) -> Optional[str]:
for key in ("facturapi_key", "facturapi_secret_key"):
val = (tenant_config.get(key) or "").strip()
if val:
return val
return None
def _get_user_key() -> Optional[str]:
return USER_KEY.strip() or None
def _is_user_key_mode(tenant_config: dict) -> bool:
return bool(_get_user_key()) and not _get_secret_key(tenant_config)
def get_api_key(tenant_config: dict) -> str:
"""Resolve the API key to use for a tenant.
Priority:
1. tenant_config.facturapi_secret_key (manual override)
2. FACTURAPI_USER_KEY env (auto-org mode)
"""
secret = _get_secret_key(tenant_config)
if secret:
return secret
user = _get_user_key()
if user:
return user
raise FacturapiError(
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
)
# ─── Organizations ──────────────────────────────────────────────────────────
def create_organization(tenant_config: dict) -> dict:
"""Create a new Facturapi organization for the tenant.
Requires FACTURAPI_USER_KEY.
Returns dict with id, api_key.
"""
user_key = _get_user_key()
if not user_key:
raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations")
payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))}
legal = tenant_config.get("legal_name") or tenant_config.get("razon_social")
if legal:
payload["legal"] = {"name": legal}
if tenant_config.get("rfc"):
payload["legal"] = payload.get("legal", {})
payload["legal"]["tax_id"] = tenant_config["rfc"]
org = _request("POST", "/organizations", user_key, json_payload=payload)
org_id = org.get("id")
# Generate live secret key
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={})
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key}
def get_organization(org_id: str, api_key: str) -> dict:
return _request("GET", f"/organizations/{org_id}", api_key)
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
cer_b64 and key_b64 are base64-encoded strings.
"""
api_key = get_api_key(tenant_config)
org_id = tenant_config.get("facturapi_org_id")
if not org_id:
raise FacturapiError("No Facturapi organization configured for tenant")
cer_bytes = base64.b64decode(cer_b64)
key_bytes = base64.b64decode(key_b64)
url = f"{BASE_URL}/organizations/{org_id}/certificate"
files = {
"certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
"private_key": ("private_key.key", key_bytes, "application/octet-stream"),
"secret": (None, password),
}
resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
if not resp.ok:
raise FacturapiError(
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
status_code=resp.status_code,
)
return resp.json()
def _get_user_key_for_tenant(tenant_config: dict) -> str:
"""Resolve the Facturapi user key to use for organization management.
Priority:
1. FACTURAPI_USER_KEY environment variable
2. tenant_config.facturapi_key if it starts with sk_user_
"""
user_key = _get_user_key()
if user_key:
return user_key
tenant_key = (tenant_config.get("facturapi_key") or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
raise FacturapiError(
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required"
)
def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]:
"""Search for an existing Facturapi organization by tenant RFC.
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key).
Returns the organization dict or None.
"""
user_key = _get_user_key_for_tenant(tenant_config)
rfc = (tenant_config.get("rfc") or "").upper().strip()
if not rfc:
raise FacturapiError("Tenant RFC is required to search organizations")
page = 1
while True:
result = _request("GET", "/organizations", user_key, params={"page": page}, timeout=30)
for org in result.get("data", []):
legal = org.get("legal", {})
if (legal.get("tax_id") or "").upper() == rfc:
return org
if page >= result.get("total_pages", 1):
break
page += 1
return None
def create_organization(tenant_config: dict) -> dict:
"""Create a new Facturapi organization for the tenant and return live key.
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
Uses tenant RFC/razon_social if available.
"""
user_key = _get_user_key_for_tenant(tenant_config)
rfc = (tenant_config.get("rfc") or "").upper().strip()
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
# First try to find existing org by RFC
existing = find_organization_by_rfc(tenant_config) if rfc else None
if existing:
org_id = existing["id"]
else:
payload = {"name": name}
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
org_id = org.get("id")
if not org_id:
raise FacturapiError("Could not create organization: no id returned")
# Generate live secret key
key_resp = _request(
"PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60
)
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key}
def get_org_status(tenant_config: dict) -> dict:
result = {
"configured": False,
"has_key": False,
"has_org_id": False,
"has_csd": False,
"org_id": None,
"legal_name": None,
"tax_id": None,
"pending_steps": [],
"error": None,
}
try:
api_key = get_api_key(tenant_config)
result["has_key"] = True
except FacturapiError as e:
result["error"] = str(e)
return result
org_id = tenant_config.get("facturapi_org_id")
if not org_id:
result["error"] = "No Facturapi organization configured"
return result
result["has_org_id"] = True
result["org_id"] = org_id
try:
org = get_organization(org_id, api_key)
legal = org.get("legal", {})
cert = org.get("certificate", {})
result.update({
"configured": True,
"has_csd": bool(cert.get("has_certificate")),
"legal_name": legal.get("name") or legal.get("legal_name"),
"tax_id": legal.get("tax_id"),
"pending_steps": org.get("pending_steps", []),
})
except FacturapiError as e:
result["error"] = str(e)
return result
# ─── Customers ──────────────────────────────────────────────────────────────
def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
"""Create or update a customer in Facturapi and return its id.
customer_data: {
legal_name: str,
tax_id: str,
tax_system: str,
email: str,
zip: str,
country: str (optional, ISO 3166 alpha-3),
}
"""
api_key = get_api_key(tenant_config)
tax_id = (customer_data.get("tax_id") or "").upper().strip()
if not tax_id:
raise FacturapiError("Customer tax_id is required")
# Try to find existing customer
existing_id = None
try:
result = _request("GET", "/customers", api_key, params={"search": tax_id})
for c in result.get("data", []):
if (c.get("tax_id") or "").upper() == tax_id:
existing_id = c.get("id")
break
except FacturapiError as e:
logger.warning("Failed to search Facturapi customer: %s", e)
is_foreign = bool(customer_data.get("country")) and customer_data["country"] != "MEX"
payload = {
"legal_name": customer_data.get("legal_name", ""),
"email": customer_data.get("email"),
"address": {
"zip": customer_data.get("zip", "00000"),
},
}
if is_foreign:
payload["tax_id"] = tax_id
payload["address"]["country"] = customer_data["country"]
else:
payload["tax_id"] = tax_id
if customer_data.get("tax_system"):
payload["tax_system"] = customer_data["tax_system"]
if existing_id:
_request("PUT", f"/customers/{existing_id}", api_key, json_payload=payload)
return existing_id
new_customer = _request("POST", "/customers", api_key, json_payload=payload)
return new_customer.get("id")
# ─── Invoices ───────────────────────────────────────────────────────────────
def create_invoice(tenant_config: dict, payload: dict) -> dict:
"""Create and stamp an invoice in Facturapi.
Returns the Facturapi invoice object.
"""
api_key = get_api_key(tenant_config)
return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str,
replacement_uuid: Optional[str] = None) -> dict:
"""Cancel an invoice in Facturapi.
Motive codes:
01: errores con relacion (requires replacement_uuid)
02: errores sin relacion
03: no se llevo a cabo la operacion
04: operacion nominativa relacionada en factura global
"""
api_key = get_api_key(tenant_config)
params = {"motive": motive}
if replacement_uuid:
params["replacement"] = replacement_uuid
return _request("DELETE", f"/invoices/{invoice_id}", api_key, params=params, timeout=60)
def download_xml(tenant_config: dict, invoice_id: str) -> bytes:
api_key = get_api_key(tenant_config)
return _download("GET", f"/invoices/{invoice_id}/xml", api_key)
def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
api_key = get_api_key(tenant_config)
return _download("GET", f"/invoices/{invoice_id}/pdf", api_key)
# ─── Helpers ─────────────────────────────────────────────────────────────────
def is_lco_rejection(message: str) -> bool:
"""Detect SAT LCO rejection (CSD not yet propagated)."""
if not message:
return False
msg = message.lower()
return any(
pattern in msg
for pattern in [
"lco",
"no se encontro el rfc",
"rfc no registrado",
"lista de contribuyentes obligados",
"csd no registrado",
]
)
def to_cents(amount) -> int:
"""Convert Decimal/float/None to integer cents for Facturapi."""
if amount is None:
return 0
return int(Decimal(str(amount)).quantize(Decimal("0.01")) * 100)

View File

@@ -0,0 +1,56 @@
import math
def haversine(lat1, lon1, lat2, lon2):
"""Calculate the great-circle distance between two points on Earth in km."""
R = 6371.0 # Earth radius in km
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * c
def find_nearest_branch(tenant_conn, latitude, longitude):
"""
Find the nearest active branch with coordinates.
Returns a dict with branch info + distance_km, or None.
"""
if not tenant_conn or latitude is None or longitude is None:
return None
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, name, address, phone, latitude, longitude
FROM branches
WHERE is_active = TRUE AND latitude IS NOT NULL AND longitude IS NOT NULL
"""
)
branches = cur.fetchall()
cur.close()
nearest = None
min_dist = float('inf')
for row in branches:
bid, name, address, phone, b_lat, b_lon = row
if b_lat is None or b_lon is None:
continue
dist = haversine(float(latitude), float(longitude), float(b_lat), float(b_lon))
if dist < min_dist:
min_dist = dist
nearest = {
'id': bid,
'name': name,
'address': address or '',
'phone': phone or '',
'latitude': float(b_lat),
'longitude': float(b_lon),
'distance_km': round(dist, 1),
}
return nearest

View File

@@ -0,0 +1,210 @@
# /home/Autopartes/pos/services/global_invoice.py
"""Global invoice (Factura Global) service.
Groups cash sales (PUE, <= $2,000, no individual CFDI) into a single
monthly CFDI with InformacionGlobal per SAT requirements.
"""
from datetime import datetime
from decimal import Decimal
from services.cfdi_facturapi_builder import build_global_invoice_payload
from services.cfdi_queue import enqueue_cfdi, _generate_provisional_folio
def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
"""Find sales eligible for global invoicing.
Criteria:
- Payment method: PUE (paid in full)
- Total <= max_total
- No individual CFDI stamped
- Not already included in a global invoice
- Created in the given year/month
- Optionally filtered by branch_id
Returns:
list of sale dicts with items
"""
cur = conn.cursor()
# Find eligible sale IDs
sql = """
SELECT s.id
FROM sales s
WHERE s.metodo_pago_sat = 'PUE'
AND s.total <= %s
AND s.status = 'completed'
AND s.global_invoiced_at IS NULL
AND EXTRACT(YEAR FROM s.created_at) = %s
AND EXTRACT(MONTH FROM s.created_at) = %s
AND NOT EXISTS (
SELECT 1 FROM cfdi_queue c
WHERE c.sale_id = s.id AND c.status = 'stamped'
)
"""
params = [max_total, year, month]
if branch_id:
sql += " AND s.branch_id = %s"
params.append(branch_id)
sql += " ORDER BY s.created_at ASC"
cur.execute(sql, params)
sale_ids = [r[0] for r in cur.fetchall()]
if not sale_ids:
cur.close()
return []
# Load sale details with items
sales = []
for sale_id in sale_ids:
cur.execute("""
SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales WHERE id = %s
""", (sale_id,))
row = cur.fetchone()
if not row:
continue
sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
'employee_id': row[3], 'sale_type': row[4],
'payment_method': row[5],
'subtotal': float(row[6]) if row[6] else 0,
'discount_total': float(row[7]) if row[7] else 0,
'tax_total': float(row[8]) if row[8] else 0,
'total': float(row[9]) if row[9] else 0,
'metodo_pago_sat': row[10] or 'PUE',
'forma_pago_sat': row[11] or '01',
'status': row[12],
'created_at': str(row[13]),
'items': [],
}
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
for r in cur.fetchall():
sale['items'].append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
'name': r[3], 'quantity': r[4],
'unit_price': float(r[5]) if r[5] else 0,
'unit_cost': float(r[6]) if r[6] else 0,
'discount_pct': float(r[7]) if r[7] else 0,
'discount_amount': float(r[8]) if r[8] else 0,
'tax_rate': float(r[9]) if r[9] else 0.16,
'tax_amount': float(r[10]) if r[10] else 0,
'subtotal': float(r[11]) if r[11] else 0,
'clave_prod_serv': r[12] or '25174800',
'clave_unidad': r[13] or 'H87',
})
sales.append(sale)
cur.close()
return sales
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
max_total=2000, employee_id=None):
"""Generate a global invoice for the given month.
Args:
conn: psycopg2 connection
tenant_config: dict with rfc, razon_social, regimen_fiscal, cp, serie
year: int
month: int
branch_id: optional branch filter
max_total: max sale total to include (default $2,000)
employee_id: optional employee ID for audit
Returns:
dict: {id, status, sales_count, total, xml, provisional_folio}
or {error, message} if no eligible sales
"""
sales = get_eligible_sales(conn, year, month, branch_id, max_total)
if not sales:
return {'error': 'NO_ELIGIBLE_SALES',
'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'}
payload = build_global_invoice_payload(sales, tenant_config, year, month)
# Enqueue with sale_id=NULL (global invoice)
result = enqueue_cfdi(conn, None, 'ingreso', payload)
cfdi_id = result['id']
cur = conn.cursor()
# Link sales to global invoice
for sale in sales:
cur.execute("""
INSERT INTO global_invoice_sales (global_invoice_id, sale_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""", (cfdi_id, sale['id']))
# Mark sale as globally invoiced
cur.execute("""
UPDATE sales SET global_invoiced_at = NOW() WHERE id = %s
""", (sale['id'],))
conn.commit()
cur.close()
return {
'id': cfdi_id,
'status': 'pending',
'sales_count': len(sales),
'total': sum(s['total'] for s in sales),
'provisional_folio': result['provisional_folio'],
'payload': payload,
}
def get_global_invoice_status(conn, cfdi_id):
"""Get status of a global invoice including linked sales."""
cur = conn.cursor()
cur.execute("""
SELECT id, status, uuid_fiscal, provisional_folio, error_message,
created_at, stamped_at
FROM cfdi_queue WHERE id = %s
""", (cfdi_id,))
row = cur.fetchone()
if not row:
cur.close()
return None
result = {
'id': row[0], 'status': row[1], 'uuid_fiscal': row[2],
'provisional_folio': row[3], 'error_message': row[4],
'created_at': str(row[5]), 'stamped_at': str(row[6]) if row[6] else None,
'sales': [],
}
cur.execute("""
SELECT s.id, s.total, s.created_at
FROM global_invoice_sales gis
JOIN sales s ON s.id = gis.sale_id
WHERE gis.global_invoice_id = %s
ORDER BY s.created_at ASC
""", (cfdi_id,))
for r in cur.fetchall():
result['sales'].append({
'id': r[0], 'total': float(r[1]) if r[1] else 0,
'created_at': str(r[2]),
})
cur.close()
return result

View File

@@ -25,22 +25,23 @@ def _safe_g(attr, default=None):
def get_stock(conn, inventory_id, branch_id=None): def get_stock(conn, inventory_id, branch_id=None):
"""Get current stock for an inventory item. Optionally filter by branch. """Get current stock for an inventory item. Optionally filter by branch.
Uses Redis cache first, then inventory_stock_summary, falls back to Uses Redis cache first, then inventory_stock (per-branch) or
PostgreSQL SUM query. inventory_stock_summary (total), falls back to PostgreSQL SUM query.
""" """
# Try Redis first # Try Redis first
cached = get_cached_stock(inventory_id, branch_id) cached = get_cached_stock(inventory_id, branch_id)
if cached is not None: if cached is not None:
return cached return cached
# Use inventory_stock_summary (O(1) lookup)
cur = conn.cursor() cur = conn.cursor()
if branch_id: if branch_id:
# Per-branch stock from inventory_stock
cur.execute( cur.execute(
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s AND branch_id = %s", "SELECT stock FROM inventory_stock WHERE inventory_id = %s AND branch_id = %s",
(inventory_id, branch_id) (inventory_id, branch_id)
) )
else: else:
# Total stock from inventory_stock_summary
cur.execute( cur.execute(
"SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s", "SELECT stock FROM inventory_stock_summary WHERE inventory_id = %s",
(inventory_id,) (inventory_id,)
@@ -73,13 +74,14 @@ def get_stock(conn, inventory_id, branch_id=None):
def get_stock_bulk(conn, branch_id=None): def get_stock_bulk(conn, branch_id=None):
"""Get stock for all items. Returns dict {inventory_id: stock_quantity}. """Get stock for all items. Returns dict {inventory_id: stock_quantity}.
Uses inventory_stock_summary for O(1) bulk lookup. Uses inventory_stock (per-branch) or inventory_stock_summary (total)
for O(1) bulk lookup.
""" """
cur = conn.cursor() cur = conn.cursor()
if branch_id: if branch_id:
cur.execute(""" cur.execute("""
SELECT inventory_id, stock SELECT inventory_id, stock
FROM inventory_stock_summary WHERE branch_id = %s FROM inventory_stock WHERE branch_id = %s
""", (branch_id,)) """, (branch_id,))
else: else:
cur.execute(""" cur.execute("""
@@ -119,6 +121,18 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
notes notes
)) ))
op_id = cur.fetchone()[0] op_id = cur.fetchone()[0]
# Queue ML stock sync if this product has an active ML listing
cur.execute("""
INSERT INTO meli_sync_queue (inventory_id, action, status)
SELECT %s, 'stock_update', 'pending'
WHERE EXISTS (
SELECT 1 FROM marketplace_listings
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
)
ON CONFLICT DO NOTHING
""", (inventory_id, inventory_id))
cur.close() cur.close()
return op_id return op_id
@@ -272,38 +286,72 @@ def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
return result return result
def get_alerts(conn, branch_id=None): def get_alerts(conn, branch_id=None, limit_per_type=500):
"""Get stock alerts: zero stock, below minimum, above maximum.""" """Get stock alerts: zero stock, below minimum, above maximum.
stock_map = get_stock_bulk(conn, branch_id) Returns at most limit_per_type alerts per severity to avoid browser freeze.
"""
cur = conn.cursor() cur = conn.cursor()
branch_filter = ""
where = "WHERE i.is_active = true"
params = [] params = []
if branch_id: if branch_id:
where += " AND i.branch_id = %s" branch_filter = " AND i.branch_id = %s"
params.append(branch_id) params.append(branch_id)
# Use a single SQL query with window functions to rank and limit per type
cur.execute(f""" cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.branch_id WITH stock AS (
FROM inventory i {where} SELECT inventory_id, COALESCE(SUM(quantity), 0) AS qty
""", params) FROM inventory_operations
GROUP BY inventory_id
),
alerts_raw AS (
SELECT
i.id AS inventory_id,
i.part_number,
i.name,
COALESCE(s.qty, 0) AS stock,
i.min_stock,
i.max_stock,
i.branch_id,
CASE
WHEN COALESCE(s.qty, 0) <= 0 THEN 'zero'
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'low'
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'over'
END AS alert_type,
CASE
WHEN COALESCE(s.qty, 0) <= 0 THEN 'critical'
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'warning'
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'info'
END AS severity
FROM inventory i
LEFT JOIN stock s ON s.inventory_id = i.id
WHERE i.is_active = true {branch_filter}
),
ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY alert_type ORDER BY inventory_id) AS rn
FROM alerts_raw
WHERE alert_type IS NOT NULL
)
SELECT inventory_id, part_number, name, stock, min_stock, max_stock, branch_id, alert_type, severity
FROM ranked
WHERE rn <= %s
ORDER BY severity DESC, inventory_id
""", params + [limit_per_type])
alerts = [] alerts = []
for row in cur.fetchall(): for row in cur.fetchall():
inv_id, part_num, name, min_s, max_s, br_id = row alerts.append({
stock = stock_map.get(inv_id, 0) 'inventory_id': row[0],
'part_number': row[1],
if stock <= 0: 'name': row[2],
alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id, 'stock': row[3],
'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id}) 'min_stock': row[4],
elif min_s and stock < min_s: 'max_stock': row[5],
alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id, 'branch_id': row[6],
'part_number': part_num, 'name': name, 'stock': stock, 'type': row[7],
'min_stock': min_s, 'branch_id': br_id}) 'severity': row[8],
elif max_s and stock > max_s: })
alerts.append({'type': 'over', 'severity': 'info', 'inventory_id': inv_id,
'part_number': part_num, 'name': name, 'stock': stock,
'max_stock': max_s, 'branch_id': br_id})
cur.close() cur.close()
return alerts return alerts

View File

@@ -178,18 +178,31 @@ def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, par
JOIN brands b ON b.id_brand = m.brand_id JOIN brands b ON b.id_brand = m.brand_id
WHERE vp.part_id = ANY(%s) WHERE vp.part_id = ANY(%s)
AND b.name_brand = %s AND b.name_brand = %s
LIMIT 200 LIMIT 500
""", (oem_ids, brand_hint)) """, (oem_ids, brand_hint))
mye_ids = [r[0] for r in cur.fetchall()]
# Fallback: if brand filter yields nothing, the brand hint may be an
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
# OEM vehicle brand. Search without brand filter.
if not mye_ids:
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
else: else:
# No brand hint — return all MYEs for these parts # No brand hint — return all MYEs for these parts
cur.execute(""" cur.execute("""
SELECT DISTINCT model_year_engine_id SELECT DISTINCT model_year_engine_id
FROM vehicle_parts FROM vehicle_parts
WHERE part_id = ANY(%s) WHERE part_id = ANY(%s)
LIMIT 200 LIMIT 500
""", (oem_ids,)) """, (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
mye_ids = [r[0] for r in cur.fetchall()]
cur.close() cur.close()
# ── Insert into tenant table ───────────────────────────────────────── # ── Insert into tenant table ─────────────────────────────────────────
@@ -243,6 +256,20 @@ def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
return deleted return deleted
def remove_compatibility_by_id(tenant_conn, compat_id):
"""Remove a compatibility by its primary key (works for both MYE-linked
and text-only QWEN records)."""
cur = tenant_conn.cursor()
cur.execute("""
DELETE FROM inventory_vehicle_compat
WHERE id = %s
""", (compat_id,))
deleted = cur.rowcount
tenant_conn.commit()
cur.close()
return deleted
def remove_all_compatibility(tenant_conn, inventory_id): def remove_all_compatibility(tenant_conn, inventory_id):
cur = tenant_conn.cursor() cur = tenant_conn.cursor()
cur.execute(""" cur.execute("""
@@ -259,14 +286,18 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
Queries inventory_vehicle_compat from the tenant DB, then resolves Queries inventory_vehicle_compat from the tenant DB, then resolves
vehicle details (brand/model/year/engine) from the master DB. vehicle details (brand/model/year/engine) from the master DB.
Vehicles with model_year_engine_id IS NULL are text-only QWEN records
(master DB lacks the vehicle) and are returned using their stored text.
""" """
# 1. Get MYE IDs + metadata from tenant # 1. Get all rows from tenant
cur_t = tenant_conn.cursor() cur_t = tenant_conn.cursor()
cur_t.execute(""" cur_t.execute("""
SELECT model_year_engine_id, source, confidence, created_at SELECT id, model_year_engine_id, make, model, year, engine, engine_code,
source, confidence, created_at
FROM inventory_vehicle_compat FROM inventory_vehicle_compat
WHERE inventory_id = %s WHERE inventory_id = %s
ORDER BY model_year_engine_id ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0)
""", (inventory_id,)) """, (inventory_id,))
rows = cur_t.fetchall() rows = cur_t.fetchall()
cur_t.close() cur_t.close()
@@ -274,34 +305,52 @@ def get_compatibility(tenant_conn, master_conn, inventory_id):
if not rows: if not rows:
return [] return []
mye_ids = [r[0] for r in rows] # 2. Resolve MYE-linked vehicles from master DB
mye_ids = [r[0] for r in rows if r[0] is not None]
# 2. Resolve vehicle details from master DB details = {}
cur_m = master_conn.cursor() if mye_ids:
cur_m.execute(""" cur_m = master_conn.cursor()
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine cur_m.execute("""
FROM model_year_engine mye SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
JOIN models m ON m.id_model = mye.model_id FROM model_year_engine mye
JOIN brands b ON b.id_brand = m.brand_id JOIN models m ON m.id_model = mye.model_id
JOIN years y ON y.id_year = mye.year_id JOIN brands b ON b.id_brand = m.brand_id
JOIN engines e ON e.id_engine = mye.engine_id JOIN years y ON y.id_year = mye.year_id
WHERE mye.id_mye = ANY(%s) JOIN engines e ON e.id_engine = mye.engine_id
ORDER BY b.name_brand, m.name_model, y.year_car WHERE mye.id_mye = ANY(%s)
""", (mye_ids,)) ORDER BY b.name_brand, m.name_model, y.year_car
details = {r[0]: r for r in cur_m.fetchall()} """, (mye_ids,))
cur_m.close() details = {r[0]: r for r in cur_m.fetchall()}
cur_m.close()
# 3. Merge # 3. Merge
result = [] result = []
for mye_id, source, confidence, created_at in rows: for (compat_id, mye_id, make, model, year, engine, engine_code,
d = details.get(mye_id) source, confidence, created_at) in rows:
if d: if mye_id is not None and mye_id in details:
d = details[mye_id]
result.append({ result.append({
'id': compat_id,
'model_year_engine_id': mye_id, 'model_year_engine_id': mye_id,
'brand': d[1], 'brand': d[1],
'model': d[2], 'model': d[2],
'year': d[3], 'year': d[3],
'engine': d[4], 'engine': d[4],
'engine_code': '',
'source': source,
'confidence': float(confidence),
'created_at': str(created_at),
})
else:
# Text-only QWEN record
result.append({
'id': compat_id,
'model_year_engine_id': None,
'brand': make or '',
'model': model or '',
'year': year,
'engine': engine or '',
'engine_code': engine_code or '',
'source': source, 'source': source,
'confidence': float(confidence), 'confidence': float(confidence),
'created_at': str(created_at), 'created_at': str(created_at),
@@ -374,6 +423,9 @@ def batch_add_compatibilities(tenant_conn, inventory_id, mye_ids, source='manual
def save_qwen_fitment(tenant_conn, inventory_id, fitment_result): def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
"""Save QWEN fitment results into inventory_vehicle_compat. """Save QWEN fitment results into inventory_vehicle_compat.
Supports both TecDoc-linked vehicles (mye_id present) and text-only
QWEN vehicles (mye_id=None) when the master DB lacks the vehicle.
Args: Args:
tenant_conn: Connection to tenant DB. tenant_conn: Connection to tenant DB.
inventory_id: The inventory item ID. inventory_id: The inventory item ID.
@@ -390,14 +442,30 @@ def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
cur = tenant_conn.cursor() cur = tenant_conn.cursor()
for v in vehicles: for v in vehicles:
mye_id = v.get('mye_id') mye_id = v.get('mye_id')
if not mye_id: if mye_id is not None and mye_id:
continue # TecDoc-linked vehicle
cur.execute(""" cur.execute("""
INSERT INTO inventory_vehicle_compat INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, created_at) (inventory_id, model_year_engine_id, source, confidence, created_at)
VALUES (%s, %s, 'qwen_ai', %s, NOW()) VALUES (%s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (inventory_id, mye_id, fitment_result.get('confidence', 0))) """, (inventory_id, mye_id, fitment_result.get('confidence', 0)))
else:
# Text-only QWEN vehicle (master DB doesn't have this vehicle)
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code, source, confidence, created_at)
VALUES (%s, NULL, %s, %s, %s, %s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (
inventory_id,
v.get('make', '') or '',
v.get('model', '') or '',
v.get('year', 0) or 0,
v.get('engine', '') or '',
v.get('engine_code', '') or '',
fitment_result.get('confidence', 0),
))
if cur.rowcount > 0: if cur.rowcount > 0:
inserted += 1 inserted += 1
tenant_conn.commit() tenant_conn.commit()

File diff suppressed because it is too large Load Diff

View File

@@ -136,15 +136,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
Expected columns (case-insensitive, whitespace-tolerant): Expected columns (case-insensitive, whitespace-tolerant):
part_number, stock, price part_number, stock, price
Optional: Optional:
min_order, warehouse_location, currency name, min_order, warehouse_location, currency
Resolution rules: Resolution rules:
- part_number matches `parts.oem_part_number` exactly (case-sensitive). - part_number matches `parts.oem_part_number` or `part_cross_references.cross_reference_number`.
- Parts not found in the master catalog are skipped and reported. - If matched → linked to catalog (part_id set, seller fields NULL).
- Existing rows for (bodega_id, part_id, warehouse_location) are updated - If NOT matched → created as seller listing (part_id NULL, seller_part_number set).
via UPSERT; new rows are inserted. - Existing rows are updated via UPSERT on the composite unique key.
Returns a summary dict: {ok, inserted, updated, skipped, errors} Returns a summary dict: {ok, inserted, updated, skipped, errors, oem_count, seller_count}
""" """
reader = csv.DictReader(io.StringIO(csv_text)) reader = csv.DictReader(io.StringIO(csv_text))
# Normalize header names # Normalize header names
@@ -166,9 +166,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
cur.close() cur.close()
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'} return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
# Pre-load cross-reference map for fast lookup
cur.execute("SELECT cross_reference_number, oem_part_id FROM part_cross_references")
xref_map = {row[0].strip(): row[1] for row in cur.fetchall()}
inserted = 0 inserted = 0
updated = 0 updated = 0
skipped = 0 skipped = 0
oem_count = 0
seller_count = 0
errors = [] errors = []
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
@@ -176,6 +182,7 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
part_number = norm.get('part_number', '') part_number = norm.get('part_number', '')
stock_str = norm.get('stock', '0') stock_str = norm.get('stock', '0')
price_str = norm.get('price', '0') price_str = norm.get('price', '0')
part_name = norm.get('name', '')
if not part_number: if not part_number:
errors.append(f'Fila {i}: part_number vacio') errors.append(f'Fila {i}: part_number vacio')
@@ -190,17 +197,20 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
skipped += 1 skipped += 1
continue continue
# Resolve part_number → part_id # Resolve part_number → part_id (OEM catalog or cross-reference)
part_id = None
cur.execute( cur.execute(
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1", "SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
(part_number,) (part_number,)
) )
row_part = cur.fetchone() row_part = cur.fetchone()
if not row_part: if row_part:
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo') part_id = row_part[0]
skipped += 1 else:
continue # Try cross-reference
part_id = row_part[0] xref_id = xref_map.get(part_number)
if xref_id:
part_id = xref_id
# Resolve user_id from the bodega (use bodega_id as fallback if null) # Resolve user_id from the bodega (use bodega_id as fallback if null)
user_id = norm.get('user_id') or bodega_id # backward compat user_id = norm.get('user_id') or bodega_id # backward compat
@@ -213,24 +223,48 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
currency = (norm.get('currency') or 'MXN').upper() currency = (norm.get('currency') or 'MXN').upper()
min_order = int(norm.get('min_order') or 1) min_order = int(norm.get('min_order') or 1)
# UPSERT on (user_id, part_id, warehouse_location) — the existing # UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
# unique constraint. Don't block if user_id FK fails.
try: try:
cur.execute(""" if part_id:
INSERT INTO warehouse_inventory # OEM-matched listing
(user_id, part_id, price, stock_quantity, min_order_quantity, cur.execute("""
warehouse_location, bodega_id, currency, updated_at) INSERT INTO warehouse_inventory
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) (user_id, part_id, seller_part_number, seller_part_name,
ON CONFLICT (user_id, part_id, warehouse_location) price, stock_quantity, min_order_quantity,
DO UPDATE SET warehouse_location, bodega_id, currency, updated_at)
price = EXCLUDED.price, VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s, %s, NOW())
stock_quantity = EXCLUDED.stock_quantity, ON CONFLICT (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
min_order_quantity = EXCLUDED.min_order_quantity, DO UPDATE SET
bodega_id = EXCLUDED.bodega_id, price = EXCLUDED.price,
currency = EXCLUDED.currency, stock_quantity = EXCLUDED.stock_quantity,
updated_at = NOW() min_order_quantity = EXCLUDED.min_order_quantity,
RETURNING (xmax = 0) AS inserted user_id = EXCLUDED.user_id,
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency)) currency = EXCLUDED.currency,
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
oem_count += 1
else:
# Seller listing (no catalog match)
cur.execute("""
INSERT INTO warehouse_inventory
(user_id, part_id, seller_part_number, seller_part_name,
price, stock_quantity, min_order_quantity,
warehouse_location, bodega_id, currency, updated_at)
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
DO UPDATE SET
price = EXCLUDED.price,
stock_quantity = EXCLUDED.stock_quantity,
min_order_quantity = EXCLUDED.min_order_quantity,
seller_part_name = EXCLUDED.seller_part_name,
user_id = EXCLUDED.user_id,
currency = EXCLUDED.currency,
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
""", (user_id, part_number, part_name or part_number, price, stock, min_order, location, bodega_id, currency))
seller_count += 1
was_insert = cur.fetchone()[0] was_insert = cur.fetchone()[0]
if was_insert: if was_insert:
inserted += 1 inserted += 1
@@ -250,6 +284,8 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
'inserted': inserted, 'inserted': inserted,
'updated': updated, 'updated': updated,
'skipped': skipped, 'skipped': skipped,
'oem_count': oem_count,
'seller_count': seller_count,
'errors': errors[:20], # cap to avoid huge responses 'errors': errors[:20], # cap to avoid huge responses
'total_errors': len(errors), 'total_errors': len(errors),
} }
@@ -262,70 +298,114 @@ def search_inventory(master_conn, *, query: str = None, brand: str = None,
Returns parts WITH stock > 0 from VERIFIED bodegas only. Returns parts WITH stock > 0 from VERIFIED bodegas only.
Aggregates identical parts across bodegas so the buyer sees each part once Aggregates identical parts across bodegas so the buyer sees each part once
with a list of bodegas that have it in stock. with a list of bodegas that have it in stock.
Includes both OEM-matched parts (part_id IS NOT NULL) and seller listings
(part_id IS NULL) in a single unified result set.
""" """
cur = master_conn.cursor() cur = master_conn.cursor()
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"] like = f'%{query}%' if query else None
params = [] city_lower = city.lower() if city else None
params_common = []
# Build city filter once
city_clause = ""
if city_lower:
city_clause = "AND LOWER(b.city) = LOWER(%s)"
params_common.append(city)
# ─── Part A: OEM-matched parts (JOIN with parts catalog) ──────────
clauses_oem = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NOT NULL"]
params_oem = []
if query: if query:
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)") clauses_oem.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
like = f'%{query}%' params_oem.extend([like, like, like])
params.extend([like, like, like])
if brand: if brand:
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands. clauses_oem.append("""
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
clauses.append("""
EXISTS ( EXISTS (
SELECT 1 FROM aftermarket_parts ap SELECT 1 FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s) WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
) )
""") """)
params.append(brand) params_oem.append(brand)
if city: where_oem = " AND ".join(clauses_oem)
clauses.append("LOWER(b.city) = LOWER(%s)")
params.append(city)
where_sql = " AND ".join(clauses) # ─── Part B: Seller listings (no parts catalog join) ──────────────
clauses_seller = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NULL"]
params_seller = []
cur.execute(f""" if query:
SELECT clauses_seller.append("(wi.seller_part_number ILIKE %s OR wi.seller_part_name ILIKE %s)")
p.id_part, params_seller.extend([like, like])
p.oem_part_number,
COALESCE(p.name_es, p.name_part) AS name, where_seller = " AND ".join(clauses_seller)
p.image_url,
COUNT(DISTINCT b.id_bodega) AS bodega_count, # Combined query with UNION ALL
MIN(wi.price) AS min_price, sql = f"""
MAX(wi.price) AS max_price, SELECT * FROM (
SUM(wi.stock_quantity) AS total_stock, -- OEM-matched parts
-- List of bodega names that have this part in stock SELECT
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names p.id_part AS id,
FROM warehouse_inventory wi p.oem_part_number AS part_number,
JOIN bodegas b ON b.id_bodega = wi.bodega_id COALESCE(p.name_es, p.name_part) AS name,
JOIN parts p ON p.id_part = wi.part_id p.image_url,
WHERE {where_sql} COUNT(DISTINCT b.id_bodega) AS bodega_count,
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url MIN(wi.price) AS min_price,
MAX(wi.price) AS max_price,
SUM(wi.stock_quantity) AS total_stock,
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
'oem' AS listing_type
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
JOIN parts p ON p.id_part = wi.part_id
WHERE {where_oem} {city_clause}
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
UNION ALL
-- Seller listings
SELECT
wi.id_inventory AS id,
wi.seller_part_number AS part_number,
wi.seller_part_name AS name,
NULL AS image_url,
COUNT(DISTINCT b.id_bodega) AS bodega_count,
MIN(wi.price) AS min_price,
MAX(wi.price) AS max_price,
SUM(wi.stock_quantity) AS total_stock,
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
'seller' AS listing_type
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
WHERE {where_seller} {city_clause}
GROUP BY wi.id_inventory, wi.seller_part_number, wi.seller_part_name
) combined
ORDER BY total_stock DESC ORDER BY total_stock DESC
LIMIT %s LIMIT %s
""", params + [limit]) """
all_params = params_oem + params_common + params_seller + params_common + [limit]
cur.execute(sql, all_params)
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
return [ return [
{ {
'id_part': r[0], 'id': r[0],
'oem_part_number': r[1], 'part_number': r[1],
'name': r[2], 'name': r[2],
'image_url': r[3], 'image_url': r[3],
'bodega_count': r[4], 'bodega_count': r[4],
'min_price': float(r[5]) if r[5] is not None else None, 'min_price': float(r[5]) if r[5] is not None else None,
'max_price': float(r[6]) if r[6] is not None else None, 'max_price': float(r[6]) if r[6] is not None else None,
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar', 'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
'bodega_names': r[8], # may expose; adjust if sensitive 'bodega_names': r[8],
'listing_type': r[9],
} }
for r in rows for r in rows
] ]
@@ -358,6 +438,33 @@ def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
] ]
def get_bodegas_with_listing(master_conn, wi_id: int) -> list[dict]:
"""Return the list of verified bodegas that have a specific seller listing
(warehouse_inventory row with part_id IS NULL) in stock.
"""
cur = master_conn.cursor()
cur.execute("""
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
WHERE wi.id_inventory = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
ORDER BY wi.price ASC
""", (wi_id,))
rows = cur.fetchall()
cur.close()
return [
{
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
'price': float(r[4]) if r[4] is not None else None,
'stock_hint': 'En stock',
'min_order': r[6] or 1,
'currency': r[7] or 'MXN',
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# PURCHASE ORDERS # PURCHASE ORDERS
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -397,32 +504,60 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
# Insert items # Insert items
total = 0.0 total = 0.0
for item in items: for item in items:
part_id = int(item['part_id']) part_id = item.get('part_id')
wi_id = item.get('wi_id')
quantity = int(item['quantity']) quantity = int(item['quantity'])
if quantity < 1: if quantity < 1:
continue continue
# Lookup part info + price if part_id:
cur.execute(""" # OEM-matched part
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price part_id = int(part_id)
FROM parts p cur.execute("""
LEFT JOIN warehouse_inventory wi SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
ON wi.part_id = p.id_part AND wi.bodega_id = %s FROM parts p
WHERE p.id_part = %s LIMIT 1 LEFT JOIN warehouse_inventory wi
""", (bodega_id, part_id)) ON wi.part_id = p.id_part AND wi.bodega_id = %s
r = cur.fetchone() WHERE p.id_part = %s LIMIT 1
if not r: """, (bodega_id, part_id))
continue r = cur.fetchone()
oem, name, db_price = r if not r:
unit_price = float(item.get('unit_price') or db_price or 0) continue
subtotal = round(unit_price * quantity, 2) oem, name, db_price = r
total += subtotal unit_price = float(item.get('unit_price') or db_price or 0)
subtotal = round(unit_price * quantity, 2)
total += subtotal
cur.execute(""" cur.execute("""
INSERT INTO purchase_order_items INSERT INTO purchase_order_items
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes) (po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes'))) """, (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
elif wi_id:
# Seller listing (no catalog match)
wi_id = int(wi_id)
cur.execute("""
SELECT seller_part_number, seller_part_name, price
FROM warehouse_inventory
WHERE id_inventory = %s AND bodega_id = %s LIMIT 1
""", (wi_id, bodega_id))
r = cur.fetchone()
if not r:
continue
seller_pn, seller_name, db_price = r
unit_price = float(item.get('unit_price') or db_price or 0)
subtotal = round(unit_price * quantity, 2)
total += subtotal
cur.execute("""
INSERT INTO purchase_order_items
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, TRUE)
""", (po_id, seller_pn, seller_name or seller_pn, quantity, unit_price, subtotal, item.get('notes')))
else:
continue
# Update header total # Update header total
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s", cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",

View File

@@ -0,0 +1,327 @@
"""MercadoLibre API client with OAuth2 auto-refresh.
Endpoints used:
- GET /users/me
- POST /items
- PUT /items/{id}
- GET /items/{id}
- GET /orders/search
- GET /orders/{id}
- POST /shipments/{id}/dispatch
- POST /oauth/token
References:
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
"""
import time
import requests
from typing import Optional
BASE_URL = "https://api.mercadolibre.com"
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
class MeliError(Exception):
def __init__(self, message, status_code=None, response_body=None):
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
class MeliAuthError(MeliError):
pass
class MeliService:
def __init__(
self,
access_token: str,
refresh_token: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
):
self.access_token = access_token
self.refresh_token = refresh_token
self.client_id = client_id
self.client_secret = client_secret
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Bearer {access_token}"})
# ─── Low-level request ───────────────────────────────────────────────
def _request(
self,
method: str,
path: str,
params: Optional[dict] = None,
json_payload: Optional[dict] = None,
retry_on_401: bool = True,
) -> dict:
url = f"{BASE_URL}{path}"
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
self._refresh_token()
# Retry once with new token
self._session.headers.update(
{"Authorization": f"Bearer {self.access_token}"}
)
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
if resp.status_code == 401:
raise MeliAuthError(
"Unauthorized. Token may be expired or invalid.",
status_code=401,
response_body=resp.text,
)
if not resp.ok:
raise MeliError(
f"Meli API error {resp.status_code}: {resp.text}",
status_code=resp.status_code,
response_body=resp.text,
)
# Some endpoints return 204 No Content
if resp.status_code == 204:
return {}
try:
return resp.json()
except Exception:
return {"raw": resp.text}
def _refresh_token(self) -> dict:
if not self.client_id or not self.client_secret or not self.refresh_token:
raise MeliAuthError("Missing credentials for token refresh")
payload = {
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.refresh_token,
}
resp = requests.post(AUTH_URL, data=payload, timeout=30)
if not resp.ok:
raise MeliAuthError(
f"Token refresh failed: {resp.status_code} {resp.text}",
status_code=resp.status_code,
response_body=resp.text,
)
data = resp.json()
self.access_token = data["access_token"]
if "refresh_token" in data:
self.refresh_token = data["refresh_token"]
return data
# ─── Auth / User ─────────────────────────────────────────────────────
def get_user(self) -> dict:
return self._request("GET", "/users/me")
@staticmethod
def exchange_code(
code: str, client_id: str, client_secret: str, redirect_uri: str
) -> dict:
"""Exchange authorization code for tokens."""
payload = {
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
}
resp = requests.post(AUTH_URL, data=payload, timeout=30)
if not resp.ok:
raise MeliAuthError(
f"Code exchange failed: {resp.status_code} {resp.text}",
status_code=resp.status_code,
response_body=resp.text,
)
return resp.json()
# ─── Images ──────────────────────────────────────────────────────────
def upload_image(self, image_path_or_url: str) -> dict:
"""Upload an image to MercadoLibre's image hosting.
Accepts either a local file path or a URL.
Returns the ML picture dict with 'id' and 'secure_url' / 'url' keys.
"""
import os
import requests as raw_requests
# If it's a URL, download it first
if image_path_or_url.startswith("http://") or image_path_or_url.startswith("https://"):
img_resp = raw_requests.get(image_path_or_url, timeout=30)
if not img_resp.ok:
raise MeliError(f"Failed to download image from {image_path_or_url}: {img_resp.status_code}")
file_bytes = img_resp.content
content_type = img_resp.headers.get("Content-Type", "image/jpeg")
filename = "image.jpg"
else:
if not os.path.exists(image_path_or_url):
raise MeliError(f"Image file not found: {image_path_or_url}")
with open(image_path_or_url, "rb") as f:
file_bytes = f.read()
content_type = "image/jpeg"
filename = os.path.basename(image_path_or_url)
upload_url = f"{BASE_URL}/pictures"
files = {"file": (filename, file_bytes, content_type)}
req_headers = {"Authorization": f"Bearer {self.access_token}"}
resp = raw_requests.post(upload_url, files=files, headers=req_headers, timeout=60)
if resp.status_code == 401 and self.refresh_token:
self._refresh_token()
req_headers["Authorization"] = f"Bearer {self.access_token}"
resp = raw_requests.post(upload_url, files=files, headers=req_headers, timeout=60)
if resp.status_code == 401:
raise MeliAuthError("Unauthorized. Token may be expired.", status_code=401, response_body=resp.text)
if not resp.ok:
raise MeliError(f"Image upload failed: {resp.status_code} {resp.text}", status_code=resp.status_code, response_body=resp.text)
return resp.json()
def get_listing_price(self, site_id: str, price: float, listing_type_id: str, category_id: str) -> dict:
"""Get the exact fee / net amount for a given price, listing type and category.
ML endpoint: GET /sites/{site_id}/listing_prices
Returns dict with sale_fee_amount, net_amount, etc.
"""
return self._request(
"GET",
f"/sites/{site_id}/listing_prices",
params={
"price": str(price),
"listing_type_id": listing_type_id,
"category_id": category_id,
},
)
# ─── Items (listings) ────────────────────────────────────────────────
def validate_item(self, payload: dict) -> dict:
"""Validate an item payload without creating it."""
return self._request("POST", "/items/validate", json_payload=payload)
def create_item(self, payload: dict) -> dict:
return self._request("POST", "/items", json_payload=payload)
def update_item(self, item_id: str, payload: dict) -> dict:
return self._request("PUT", f"/items/{item_id}", json_payload=payload)
def get_item(self, item_id: str) -> dict:
return self._request("GET", f"/items/{item_id}")
def get_user_items(self, user_id: str, status: str = None, limit: int = 50, offset: int = 0) -> dict:
"""Get all items published by a seller.
ML endpoint: GET /users/{user_id}/items/search
"""
params = {"limit": limit, "offset": offset}
if status:
params["status"] = status
return self._request("GET", f"/users/{user_id}/items/search", params=params)
def pause_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "paused"})
def activate_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "active"})
def close_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "closed"})
# ─── Questions & Answers ─────────────────────────────────────────────
def get_questions(self, item_id: str, status: str = None, offset: int = 0, limit: int = 50) -> dict:
params = {"item_id": item_id, "offset": offset, "limit": limit}
if status:
params["status"] = status
return self._request("GET", "/questions/search", params=params)
def get_question(self, question_id: str) -> dict:
return self._request("GET", f"/questions/{question_id}")
def answer_question(self, question_id: str, text: str) -> dict:
return self._request("POST", "/answers", json_payload={"question_id": question_id, "text": text})
def delete_question(self, question_id: str) -> dict:
return self._request("DELETE", f"/questions/{question_id}")
# ─── Categories ──────────────────────────────────────────────────────
def get_category(self, category_id: str) -> dict:
return self._request("GET", f"/categories/{category_id}")
def search_categories(self, site_id: str, query: str) -> dict:
# ML does not have a direct category search; we use the predictor
return self._request(
"GET",
f"/sites/{site_id}/domain_discovery/search",
params={"q": query},
)
def get_category_attributes(self, category_id: str) -> list:
return self._request("GET", f"/categories/{category_id}/attributes")
def get_shipping_preferences(self, user_id: str) -> dict:
return self._request("GET", f"/users/{user_id}/shipping_preferences")
# ─── Orders ──────────────────────────────────────────────────────────
def get_orders(
self,
seller_id: str,
status: Optional[str] = None,
date_from: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> dict:
params = {"seller": seller_id, "limit": limit, "offset": offset}
if status:
params["order.status"] = status
if date_from:
params["order.date_created.from"] = date_from
return self._request("GET", "/orders/search", params=params)
def get_order(self, order_id: str) -> dict:
return self._request("GET", f"/orders/{order_id}")
# ─── Shipments ───────────────────────────────────────────────────────
def get_shipment(self, shipment_id: str) -> dict:
return self._request("GET", f"/shipments/{shipment_id}")
def mark_ready_to_ship(self, shipment_id: str) -> dict:
return self._request(
"POST",
f"/shipments/{shipment_id}/dispatch",
json_payload={},
)
# ─── Notifications / Webhooks validation ─────────────────────────────
@staticmethod
def validate_webhook_signature(
secret: str, data: bytes, signature_header: str
) -> bool:
"""Validate MercadoLibre webhook signature.
ML sends: X-Signature: sha256=<hex_hmac>
"""
import hmac
import hashlib
if not signature_header or "=" not in signature_header:
return False
_, expected_hex = signature_header.split("=", 1)
computed = hmac.new(
secret.encode(), data, hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, expected_hex)

186
pos/services/part_kits.py Normal file
View File

@@ -0,0 +1,186 @@
"""
Smart part kits — automatic cross-sell recommendations.
When a customer adds a part to their quotation, suggest related
parts that are typically needed together for a complete job.
"""
# Spanish keywords in part name → related parts to suggest (in Spanish)
# These appear after a successful "cotizar" command.
KIT_SUGGESTIONS = {
"balata": ["disco de freno", "líquido de frenos", "balero de rueda"],
"disco de freno": ["balata", "líquido de frenos"],
"alternador": ["banda serpentina", "batería", "regulador de alternador"],
"batería": ["alternador", "cable de bujía"],
"marcha": ["batería", "solenoide de marcha"],
"bujía": ["bobina de encendido", "filtro de aire", "filtro de gasolina"],
"bobina": ["bujía", "cable de bujía"],
"bomba de agua": ["termostato", "refrigerante", "manguera de radiador"],
"radiador": ["manguera de radiador", "termostato", "tapón de radiador"],
"termostato": ["refrigerante", "manguera de radiador"],
"amortiguador": ["base de amortiguador", "goma de suspensión", "rótula"],
"rótula": ["terminal de dirección", "brazo de suspensión", "bujes"],
"terminal": ["rótula", "brazo de suspensión"],
"filtro de aceite": ["filtro de aire", "filtro de gasolina", "filtro de habitáculo"],
"filtro de aire": ["filtro de aceite", "filtro de gasolina", "bujía"],
"filtro de gasolina": ["filtro de aire", "filtro de aceite", "inyector"],
"clutch": ["collarín", "disco de clutch", "plato de presión"],
"collarín": ["clutch", "disco de clutch"],
"banda de distribución": ["bomba de agua", "tensor", "polea loca"],
"banda serpentina": ["tensor de banda", "polea loca"],
"foco": ["foco trasero", "cuarto"],
"faro": ["foco trasero", "cuarto"],
"aceite": ["filtro de aceite", "filtro de aire"],
}
def get_kit_suggestions(part_name: str) -> list:
"""Return related part names for a given part (Spanish)."""
if not part_name:
return []
name_lower = part_name.lower()
for keyword, related in KIT_SUGGESTIONS.items():
if keyword in name_lower:
return related
return []
def build_kit_text(part_name: str) -> str:
"""Build a WhatsApp-friendly kit suggestion text.
Returns empty string if no kit is found.
"""
suggestions = get_kit_suggestions(part_name)
if not suggestions:
return ""
items = "\n".join(f"{s.title()}" for s in suggestions[:3])
return (
"\n\n🔧 *¿Ya que estás en eso, checa si también necesitas:*\n"
+ items
+ '\n\n_Escribe la parte que te interese y la agregamos._'
)
# ── Urgency detection ────────────────────────────────────────────────
URGENCY_KEYWORDS = [
"urgente", "urgencia", "emergencia", "ya", "ahora", "hoy",
"lo necesito", "se me paro", "no arranca", "no jala",
"rapido", "apúrate", "apurate", "prisa", "de volada",
"para hoy", "para ahora", "lo mas pronto", "lo más pronto",
"inmediato", "express", "exprés",
]
def is_urgent(text: str) -> bool:
"""Detect if the customer message signals urgency."""
if not text:
return False
t = text.lower()
return any(kw in t for kw in URGENCY_KEYWORDS)
def urgency_note() -> str:
return (
"\n\n⚡ NOTA DE URGENCIA: El cliente necesita la pieza lo antes posible. "
"Prioriza stock local y ofrece entrega express (2-4 horas) o recolección inmediata en tienda. "
"Si no hay stock exacto, ofrece alternativa disponible inmediatamente."
)
# ── Abandoned quotation follow-up ────────────────────────────────────
FOLLOW_UP_MINUTES = 15
def should_send_followup(phone: str, tenant_conn) -> str:
"""Check if we should send a follow-up message for an abandoned quotation.
Returns the follow-up text if yes, empty string if no.
"""
if not tenant_conn or not phone:
return ""
try:
cur = tenant_conn.cursor()
# 1. Check if there's an active quotation for this phone
cur.execute("""
SELECT id FROM quotations
WHERE notes LIKE %s AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""", (f'%WA:{phone}%',))
row = cur.fetchone()
if not row:
cur.close()
return ""
# 2. Check last bot message mentioning "cotización" or "cotizar"
cur.execute("""
SELECT created_at, message_text
FROM whatsapp_messages
WHERE phone = %s AND direction = 'outgoing'
AND (message_text ILIKE '%cotización%' OR message_text ILIKE '%cotizar%')
ORDER BY created_at DESC LIMIT 1
""", (phone,))
last_quote_msg = cur.fetchone()
cur.close()
if not last_quote_msg:
return ""
from datetime import datetime, timezone
last_time = last_quote_msg[0]
now = datetime.now(timezone.utc)
if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=timezone.utc)
minutes_since = (now - last_time).total_seconds() / 60
if minutes_since >= FOLLOW_UP_MINUTES:
return (
"👋 *¿Todo bien?*\n\n"
"Veo que estabas armando tu cotización. ¿Te falta algo más o quieres que te la envíe ahora?\n\n"
"_Escribe *enviar cotización* para ver el total, o dime si necesitas otra parte._"
)
except Exception as e:
print(f"[WA-AI] Follow-up check failed: {e}")
return ""
# ── Customer purchase history awareness ──────────────────────────────
def get_purchase_history(phone: str, tenant_conn, limit: int = 3) -> str:
"""Build a short text summary of recent confirmed quotations for this customer.
Returns empty string if no history.
"""
if not tenant_conn or not phone:
return ""
try:
cur = tenant_conn.cursor()
cur.execute("""
SELECT q.id, q.created_at, q.total,
ARRAY_AGG(qi.name ORDER BY qi.name) AS items
FROM quotations q
JOIN quotation_items qi ON qi.quotation_id = q.id
WHERE q.notes LIKE %s AND q.status = 'converted'
GROUP BY q.id, q.created_at, q.total
ORDER BY q.created_at DESC
LIMIT %s
""", (f'%WA:{phone}%', limit))
rows = cur.fetchall()
cur.close()
if not rows:
return ""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
parts = []
for qid, created, total, items in rows:
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
months_ago = (now - created).days // 30
time_str = f"hace {months_ago} meses" if months_ago > 0 else "recientemente"
item_list = ", ".join(items[:3])
parts.append(f"- {time_str}: {item_list} (total ${float(total):,.2f})")
return "HISTORIAL DE COMPRAS DEL CLIENTE:\n" + "\n".join(parts)
except Exception as e:
print(f"[WA-AI] Purchase history failed: {e}")
return ""

View File

@@ -193,9 +193,23 @@ def process_sale(conn, sale_data):
amount_paid = float(sale_data.get('amount_paid', 0)) amount_paid = float(sale_data.get('amount_paid', 0))
payment_details = sale_data.get('payment_details', []) payment_details = sale_data.get('payment_details', [])
notes = sale_data.get('notes') notes = sale_data.get('notes')
branch_id = _safe_g('branch_id') branch_id = sale_data.get('branch_id') or _safe_g('branch_id')
employee_id = _safe_g('employee_id') employee_id = _safe_g('employee_id')
# Fallback to the main branch if none resolved (e.g. token without branch_id)
if not branch_id:
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
row = cur.fetchone()
if row:
branch_id = row[0]
else:
cur.execute("SELECT id FROM branches WHERE is_active = true ORDER BY id LIMIT 1")
row = cur.fetchone()
branch_id = row[0] if row else None
if not branch_id:
raise ValueError("No hay sucursal activa disponible para registrar la venta")
# ── Multi-currency support ─────────────────────────────────────────── # ── Multi-currency support ───────────────────────────────────────────
currency = sale_data.get('currency', 'MXN') currency = sale_data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'): if currency not in ('MXN', 'USD'):
@@ -440,6 +454,42 @@ def process_sale(conn, sale_data):
except Exception: except Exception:
pass # Savings errors never block sales pass # Savings errors never block sales
# WhatsApp learning hook (non-blocking)
try:
from services.wa_learning import check_learning_resolution
check_learning_resolution(sale_id, customer_id, conn)
except Exception:
pass # Learning errors never block sales
# Dropshipping webhook hook (non-blocking)
try:
from services import dropshipping_service as ds_svc
from services.webhook_service import dispatch_webhooks_bulk
webhook_urls = ds_svc.get_webhook_targets(conn, 'sale_made')
if webhook_urls:
payload_items = []
for item in enriched_items:
remaining = item['stock_before'] - item['quantity']
payload_items.append({
'sku': item['part_number'],
'name': item['name'],
'quantity_sold': item['quantity'],
'stock_remaining': remaining,
'unit_price': item['unit_price'],
})
threading.Thread(
target=dispatch_webhooks_bulk,
args=(webhook_urls, 'sale_made', {
'sale_id': sale_id,
'items': payload_items,
'total': totals['total'],
'created_at': str(created_at),
}),
daemon=True
).start()
except Exception:
pass # Webhook errors never block sales
return { return {
'id': sale_id, 'id': sale_id,
'branch_id': branch_id, 'branch_id': branch_id,

127
pos/services/quote_image.py Normal file
View File

@@ -0,0 +1,127 @@
import io
import base64
from PIL import Image, ImageDraw, ImageFont
def generate_quote_image(quote_items, totals, tenant_name="Autopartes", logo_text="NEXUS"):
"""
Generate a visually appealing quote image.
quote_items: list of dicts with keys: name, sku, qty, price, total
totals: dict with keys: subtotal, tax, total
Returns: base64 encoded PNG string
"""
# Dimensions
WIDTH = 800
HEADER_H = 120
FOOTER_H = 100
ITEM_H = 60
PADDING = 30
total_height = HEADER_H + len(quote_items) * ITEM_H + FOOTER_H + PADDING * 3
# Colors
BG_COLOR = (250, 250, 252)
PRIMARY = (0, 82, 155) # Dark blue
ACCENT = (230, 57, 70) # Red accent
TEXT_DARK = (30, 30, 30)
TEXT_MED = (80, 80, 80)
TEXT_LIGHT = (150, 150, 150)
WHITE = (255, 255, 255)
ROW_ALT = (245, 247, 250)
img = Image.new('RGB', (WIDTH, total_height), BG_COLOR)
draw = ImageDraw.Draw(img)
# Try to load fonts, fallback to default
try:
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
font_sub = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
font_item = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
font_bold = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
except Exception:
font_title = ImageFont.load_default()
font_sub = font_title
font_item = font_title
font_bold = font_title
font_small = font_title
# --- Header ---
draw.rectangle([0, 0, WIDTH, HEADER_H], fill=PRIMARY)
# Logo text
draw.text((PADDING, 25), logo_text, font=font_title, fill=WHITE)
draw.text((PADDING, 70), tenant_name, font=font_sub, fill=(200, 210, 230))
# Date and Quote label
from datetime import datetime
date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
draw.text((WIDTH - PADDING - 200, 30), "COTIZACIÓN", font=font_title, fill=WHITE)
draw.text((WIDTH - PADDING - 200, 75), date_str, font=font_sub, fill=(200, 210, 230))
# --- Items Header ---
y = HEADER_H + PADDING
draw.rectangle([PADDING, y, WIDTH - PADDING, y + ITEM_H], fill=(230, 235, 240))
draw.text((PADDING + 10, y + 18), "PRODUCTO", font=font_bold, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 220, y + 18), "CANT.", font=font_bold, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 130, y + 18), "P.UNIT", font=font_bold, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 50, y + 18), "TOTAL", font=font_bold, fill=TEXT_DARK)
y += ITEM_H
# --- Items ---
for idx, item in enumerate(quote_items):
row_y = y + idx * ITEM_H
bg = ROW_ALT if idx % 2 == 0 else WHITE
draw.rectangle([PADDING, row_y, WIDTH - PADDING, row_y + ITEM_H], fill=bg)
name = item.get('name', 'Producto')
sku = item.get('sku', '')
qty = str(item.get('qty', 1))
price = f"${item.get('price', 0):,.2f}"
total = f"${item.get('total', 0):,.2f}"
# Truncate name if too long
name_display = name
if len(name_display) > 35:
name_display = name_display[:32] + "..."
draw.text((PADDING + 10, row_y + 8), name_display, font=font_item, fill=TEXT_DARK)
draw.text((PADDING + 10, row_y + 32), f"SKU: {sku}", font=font_small, fill=TEXT_MED)
draw.text((WIDTH - PADDING - 220, row_y + 18), qty, font=font_item, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 130, row_y + 18), price, font=font_item, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 50, row_y + 18), total, font=font_item, fill=TEXT_DARK)
y += len(quote_items) * ITEM_H + PADDING
# --- Totals ---
draw.line([(PADDING, y), (WIDTH - PADDING, y)], fill=(200, 200, 200), width=2)
y += 20
subtotal = totals.get('subtotal', 0)
tax = totals.get('tax', 0)
total = totals.get('total', 0)
draw.text((WIDTH - PADDING - 300, y), "Subtotal:", font=font_sub, fill=TEXT_MED)
draw.text((WIDTH - PADDING - 50, y), f"${subtotal:,.2f}", font=font_sub, fill=TEXT_DARK)
y += 30
draw.text((WIDTH - PADDING - 300, y), "IVA (16%):", font=font_sub, fill=TEXT_MED)
draw.text((WIDTH - PADDING - 50, y), f"${tax:,.2f}", font=font_sub, fill=TEXT_DARK)
y += 35
draw.text((WIDTH - PADDING - 300, y), "TOTAL:", font=font_bold, fill=ACCENT)
draw.text((WIDTH - PADDING - 50, y), f"${total:,.2f}", font=font_bold, fill=ACCENT)
y += 50
# --- Footer ---
draw.rectangle([0, total_height - FOOTER_H, WIDTH, total_height], fill=PRIMARY)
footer_text = "Validez: 5 días hábiles | Envíos a todo México | Contacto: ventas@nexusautoparts.com"
draw.text((PADDING, total_height - FOOTER_H + 35), footer_text, font=font_small, fill=(200, 210, 230))
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return base64.b64encode(buffer.read()).decode('utf-8')

View File

@@ -42,16 +42,22 @@ def get_vehicle_fitment(part_number, name, brand):
{'role': 'user', 'content': prompt} {'role': 'user', 'content': prompt}
], ],
'temperature': 0.2, 'temperature': 0.2,
'max_tokens': 4096, 'max_tokens': 8192,
}, },
timeout=45, timeout=120,
) )
response.raise_for_status() response.raise_for_status()
raw = response.json() raw = response.json()
finish_reason = None
if raw.get('choices') and len(raw['choices']) > 0: if raw.get('choices') and len(raw['choices']) > 0:
msg = raw['choices'][0].get('message', {}) choice = raw['choices'][0]
msg = choice.get('message', {})
finish_reason = choice.get('finish_reason')
if msg: if msg:
content = msg.get('content') or '' content = msg.get('content') or ''
if not content:
# Fallback for reasoning models that return output in reasoning_content
content = msg.get('reasoning_content') or ''
if content: if content:
break break
except requests.RequestException as exc: except requests.RequestException as exc:
@@ -62,6 +68,8 @@ def get_vehicle_fitment(part_number, name, brand):
if not content: if not content:
err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts' err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts'
if finish_reason == 'length':
err_msg += ' (response truncated by token limit — consider reducing prompt or increasing max_tokens)'
return {'vehicles': [], 'confidence': 0, 'notes': err_msg} return {'vehicles': [], 'confidence': 0, 'notes': err_msg}
# Parse JSON from QWEN response (sometimes wrapped in markdown) # Parse JSON from QWEN response (sometimes wrapped in markdown)
@@ -91,32 +99,21 @@ def _build_prompt(part_number, name, brand):
- Nombre/descripcion: {name} - Nombre/descripcion: {name}
- Marca del fabricante: {brand_str} - Marca del fabricante: {brand_str}
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura exacta: Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura:
{{ {{"vehicles":[{{"make":"Toyota","model":"Corolla","year_range":"2014-2019","engine":"1.8L","engine_code":"2ZR-FE","notes":""}}],"confidence":0.92,"notes":""}}
"vehicles": [
{{
"make": "Toyota",
"model": "Corolla",
"year": 2015,
"engine": "1.8L 16V",
"engine_code": "2ZR-FE",
"notes": "Sedan y hatchback"
}}
],
"confidence": 0.92,
"notes": "Compatible con plataforma E170. Verificar traccion delantera."
}}
Reglas obligatorias: REGLAS OBLIGATORIAS:
1. "make" = marca del vehiculo (ej: Toyota, Nissan, Ford, Volkswagen, Chevrolet, Honda, Hyundai, Kia, Mazda, Subaru). 1. "make" = marca del vehiculo.
2. "model" = modelo exacto. Si hay variantes (ej: Civic Sedan vs Civic Coupe), incluye la variante. 2. "model" = modelo exacto (incluye variante si aplica).
3. "year" = ano numerico (int). Si hay rango de anos (ej: 2003-2008), genera una entrada POR CADA ANO del rango. NO uses rangos. 3. USA "year_range" = string "YYYY-YYYY" cuando el MISMO modelo/motor abarca multiples anos consecutivos. Esto ahorra tokens y permite mas resultados.
4. "engine" = descripcion del motor (ej: "1.8L", "2.0L TDI", "V6 3.5L", "1.6L Turbo"). Si no conoces el motor, usa "desconocido". 4. USA "year" = int SOLO cuando sea un ano aislado sin rangos adyacentes.
5. "engine_code" = codigo exacto del motor SI LO CONOCES (ej: "2ZR-FE", "K24Z7", "EA888"). Si no lo conoces, usa "" (string vacio). 5. "engine" = descripcion corta del motor (ej: "1.8L", "V6 3.5L"). Si no lo conoces, usa "".
6. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 100. Para piezas genericas (bujias, filtros, balatas, amortiguadores) incluye TODOS los modelos aplicables. 6. "engine_code" = codigo exacto SI LO CONOCES. Si no, usa "".
7. "confidence" entre 0.0 y 1.0. Usa valores altos (>0.85) solo si estas muy seguro. 7. "notes" = string vacio "" para ahorrar tokens, salvo que haya una advertencia critica.
8. Incluye marcas y modelos populares en Mexico (Nissan Tsuru, VW Sedan/Vocho, Chevy Monza, Ford Ka, etc.) cuando apliquen. 8. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 200. Para piezas genericas (filtros de aceite, bujias, balatas, amortiguadores) incluye TODOS los modelos aplicables.
9. Si la pieza es universal o de alta compatibilidad, indicalo en "notes". 9. "confidence" entre 0.0 y 1.0. Valores >0.85 solo si estas muy seguro.
10. Incluye marcas y modelos populares en Mexico cuando apliquen.
11. Si la pieza es universal, indicalo en "notes".
""" """
@@ -153,28 +150,42 @@ def _extract_vehicles(parsed):
def _normalize_vehicle(v): def _normalize_vehicle(v):
"""Normalize vehicle dict from QWEN to standard keys.""" """Normalize vehicle dict from QWEN to standard keys.
Supports:
- year: int or str (single year)
- year_range: str like "2003-2008" or "2003-2008"
- legacy: year as range string
"""
make = v.get('make') or v.get('marca') or '' make = v.get('make') or v.get('marca') or ''
model = v.get('model') or v.get('modelo') or '' model = v.get('model') or v.get('modelo') or ''
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
engine = v.get('engine') or v.get('motor') or '' engine = v.get('engine') or v.get('motor') or ''
engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or '' engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or ''
# Parse year (may be int, string, or range like "2003-2008")
years = [] years = []
if isinstance(year_raw, int):
years = [year_raw] # Prefer explicit year_range
elif isinstance(year_raw, str): year_range = v.get('year_range') or v.get('rango_ano') or ''
# Try range "2003-2008" if isinstance(year_range, str):
m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_raw) m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_range)
if m: if m:
start, end = int(m.group(1)), int(m.group(2)) start, end = int(m.group(1)), int(m.group(2))
years = list(range(start, end + 1)) years = list(range(start, end + 1))
else:
# Try single year # Fallback to year (int or str)
m2 = re.match(r'(\d{4})', year_raw) if not years:
if m2: year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
years = [int(m2.group(1))] if isinstance(year_raw, int):
years = [year_raw]
elif isinstance(year_raw, str):
m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_raw)
if m:
start, end = int(m.group(1)), int(m.group(2))
years = list(range(start, end + 1))
else:
m2 = re.match(r'(\d{4})', year_raw)
if m2:
years = [int(m2.group(1))]
return make, model, years, engine, engine_code return make, model, years, engine, engine_code
@@ -200,16 +211,30 @@ def _validate_vehicles(vehicles):
1. Exact engine_code match (most precise) 1. Exact engine_code match (most precise)
2. Displacement-based match (e.g. all 1.8L engines for that make/model/year) 2. Displacement-based match (e.g. all 1.8L engines for that make/model/year)
3. Broad make/model/year match (all engines for that make/model/year) 3. Broad make/model/year match (all engines for that make/model/year)
If the master DB does not contain the vehicle (e.g. North-American models
missing from TecDoc), the vehicle is returned with mye_id=None so it can
be stored as a text-only QWEN record.
""" """
from tenant_db import get_master_conn from tenant_db import get_master_conn
try: try:
master = get_master_conn() master = get_master_conn()
cur = master.cursor() cur = master.cursor()
except Exception: except Exception:
return [] # Master DB unreachable — return all vehicles as unmatched text
return [
{'make': v.get('make') or v.get('marca') or '',
'model': v.get('model') or v.get('modelo') or '',
'year': v.get('year') or v.get('ano') or v.get('año') or 0,
'engine': v.get('engine') or v.get('motor') or '',
'engine_code': v.get('engine_code') or v.get('codigo_motor') or '',
'mye_id': None}
for v in vehicles
]
validated = [] validated = []
seen_mye = set() seen_mye = set()
seen_text = set() # (make, model, year) for text-only dedup
for v in vehicles: for v in vehicles:
make, model, years, engine, engine_code = _normalize_vehicle(v) make, model, years, engine, engine_code = _normalize_vehicle(v)
@@ -285,16 +310,30 @@ def _validate_vehicles(vehicles):
matched_myes = [r[0] for r in cur.fetchall()] matched_myes = [r[0] for r in cur.fetchall()]
# Deduplicate and add to results # Deduplicate and add to results
for mye_id in matched_myes: if matched_myes:
if mye_id not in seen_mye: for mye_id in matched_myes:
seen_mye.add(mye_id) if mye_id not in seen_mye:
seen_mye.add(mye_id)
validated.append({
'make': make,
'model': model,
'year': year,
'engine': engine,
'engine_code': engine_code,
'mye_id': mye_id,
})
else:
# No match in master DB — store as text-only QWEN record
text_key = (make.upper(), model.upper(), year)
if text_key not in seen_text:
seen_text.add(text_key)
validated.append({ validated.append({
'make': make, 'make': make,
'model': model, 'model': model,
'year': year, 'year': year,
'engine': engine, 'engine': engine,
'engine_code': engine_code, 'engine_code': engine_code,
'mye_id': mye_id, 'mye_id': None,
}) })
cur.close() cur.close()

140
pos/services/wa_customer.py Normal file
View File

@@ -0,0 +1,140 @@
"""
WhatsApp Customer Service — identificación y vinculación de clientes.
Funciones para buscar, crear y vincular clientes desde el flujo de WhatsApp.
"""
import re
def find_customer_by_phone(phone, tenant_conn):
"""Buscar cliente por número de teléfono exacto o parcial."""
if not tenant_conn or not phone:
return []
cur = tenant_conn.cursor()
# Limpiar phone de prefijos internacionales para búsqueda flexible
clean = phone.replace('+52', '').replace('52', '').lstrip('1')
cur.execute("""
SELECT id, name, phone, address, rfc
FROM customers
WHERE phone = %s OR phone LIKE %s OR phone LIKE %s
LIMIT 5
""", (phone, f'%{clean}', f'%{clean[-10:]}' if len(clean) >= 10 else f'%{clean}'))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
def find_customer_by_name(name, tenant_conn):
"""Buscar cliente por nombre (ILIKE)."""
if not tenant_conn or not name:
return []
cur = tenant_conn.cursor()
# Buscar por nombre completo o primer palabra
first_word = name.split()[0] if name else name
cur.execute("""
SELECT id, name, phone, address, rfc
FROM customers
WHERE name ILIKE %s OR name ILIKE %s
LIMIT 5
""", (f'%{name}%', f'%{first_word}%'))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'name': r[1], 'phone': r[2], 'address': r[3], 'rfc': r[4]} for r in rows]
def search_customers(query, tenant_conn):
"""Buscar por teléfono o nombre."""
if not tenant_conn or not query:
return []
# Detectar si es número de teléfono
digits = re.sub(r'\D', '', query)
if len(digits) >= 7:
by_phone = find_customer_by_phone(digits, tenant_conn)
if by_phone:
return by_phone
return find_customer_by_name(query, tenant_conn)
def get_customer_by_id(tenant_conn, customer_id):
"""Obtener cliente por ID."""
if not tenant_conn or not customer_id:
return None
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, name, phone, address, rfc, vehicle_info
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
cur.close()
if row:
return {
'id': row[0], 'name': row[1], 'phone': row[2],
'address': row[3], 'rfc': row[4], 'vehicle_info': row[5]
}
return None
def create_customer(tenant_conn, phone, name, email=None, address=None, rfc=None):
"""Crear cliente nuevo desde WhatsApp."""
if not tenant_conn:
return None
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO customers (name, phone, email, address, rfc, is_active, created_at)
VALUES (%s, %s, %s, %s, %s, TRUE, NOW())
RETURNING id
""", (name, phone, email, address, rfc))
cid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return cid
def link_wa_customer(phone, customer_id, tenant_conn):
"""Vincular número WA a cliente permanentemente."""
if not tenant_conn or not phone or not customer_id:
return
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO wa_customer_links (phone, customer_id, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET customer_id = EXCLUDED.customer_id, updated_at = NOW()
""", (phone, customer_id))
tenant_conn.commit()
cur.close()
def get_linked_customer(phone, tenant_conn):
"""Obtener customer_id vinculado a un número WA."""
if not tenant_conn or not phone:
return None
cur = tenant_conn.cursor()
cur.execute("SELECT customer_id FROM wa_customer_links WHERE phone = %s", (phone,))
row = cur.fetchone()
cur.close()
return row[0] if row else None
def get_customer_address(tenant_conn, customer_id):
"""Obtener dirección del cliente."""
if not tenant_conn or not customer_id:
return None
cur = tenant_conn.cursor()
cur.execute("SELECT address FROM customers WHERE id = %s", (customer_id,))
row = cur.fetchone()
cur.close()
return row[0] if row and row[0] else None
def update_customer_address(tenant_conn, customer_id, address):
"""Actualizar dirección del cliente."""
if not tenant_conn or not customer_id or not address:
return
cur = tenant_conn.cursor()
cur.execute(
"UPDATE customers SET address = %s WHERE id = %s",
(address, customer_id)
)
tenant_conn.commit()
cur.close()

127
pos/services/wa_learning.py Normal file
View File

@@ -0,0 +1,127 @@
"""
WhatsApp Learning Service — ruta de aprendizaje para piezas no resueltas.
Registra sesiones donde el bot no pudo identificar una pieza, y las resuelve
asíncronamente cuando el cliente realiza una compra futura.
"""
import json
def register_unresolved_search(phone, customer_id, description, offered_parts, tenant_conn):
"""Registrar una sesión no resuelta para aprendizaje futuro."""
if not tenant_conn or not phone or not description:
return None
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO wa_learning_sessions (phone, customer_id, description, offered_parts, status, created_at)
VALUES (%s, %s, %s, %s, 'pending', NOW())
RETURNING id
""", (phone, customer_id, description, json.dumps(offered_parts or [])))
sid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return sid
def find_pending_sessions(phone, tenant_conn):
"""Buscar sesiones pendientes de aprendizaje para un número WA."""
if not tenant_conn or not phone:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, description, offered_parts, created_at
FROM wa_learning_sessions
WHERE phone = %s AND status = 'pending'
ORDER BY created_at DESC
""", (phone,))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'description': r[1], 'offered_parts': r[2], 'created_at': str(r[3])} for r in rows]
def find_pending_sessions_by_customer(customer_id, tenant_conn):
"""Buscar sesiones pendientes por customer_id."""
if not tenant_conn or not customer_id:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, phone, description, offered_parts, created_at
FROM wa_learning_sessions
WHERE customer_id = %s AND status = 'pending'
ORDER BY created_at DESC
""", (customer_id,))
rows = cur.fetchall()
cur.close()
return [{'id': r[0], 'phone': r[1], 'description': r[2], 'offered_parts': r[3], 'created_at': str(r[4])} for r in rows]
def resolve_session(session_id, resolved_part_id, sale_id, tenant_conn):
"""Marcar sesión como resuelta con la pieza comprada."""
if not tenant_conn or not session_id:
return
cur = tenant_conn.cursor()
cur.execute("""
UPDATE wa_learning_sessions
SET status = 'learned', resolved_part_id = %s, resolution_sale_id = %s, resolved_at = NOW()
WHERE id = %s
""", (resolved_part_id, sale_id, session_id))
tenant_conn.commit()
cur.close()
def get_learning_pairs_for_training(tenant_conn, limit=100):
"""Obtener pares (descripción del cliente → pieza real) para entrenamiento."""
if not tenant_conn:
return []
cur = tenant_conn.cursor()
cur.execute("""
SELECT l.description, i.name, i.part_number, i.brand
FROM wa_learning_sessions l
JOIN inventory i ON i.id = l.resolved_part_id
WHERE l.status = 'learned' AND l.resolved_at > NOW() - INTERVAL '90 days'
ORDER BY l.resolved_at DESC
LIMIT %s
""", (limit,))
rows = cur.fetchall()
cur.close()
return [{'description': r[0], 'part_name': r[1], 'part_number': r[2], 'brand': r[3]} for r in rows]
def check_learning_resolution(sale_id, customer_id, tenant_conn):
"""
Hook para llamar después de completar una venta.
Verifica si esta venta resuelve una sesión de aprendizaje pendiente.
"""
if not tenant_conn or not customer_id:
return
sessions = find_pending_sessions_by_customer(customer_id, tenant_conn)
if not sessions:
return
# Obtener items de esta venta
cur = tenant_conn.cursor()
cur.execute("""
SELECT si.inventory_id, i.name, i.part_number
FROM sale_items si
JOIN inventory i ON i.id = si.inventory_id
WHERE si.sale_id = %s
""", (sale_id,))
sale_items = cur.fetchall()
cur.close()
if not sale_items:
return
# Matching heurístico
for sess in sessions:
desc_words = set(sess['description'].lower().split())
for inv_id, item_name, part_number in sale_items:
item_words = set(item_name.lower().split())
# Intersección de palabras significativas
common = desc_words & item_words - {'de', 'la', 'el', 'para', 'un', 'una', 'con', 'y', 'o', 'en', 'al', 'del', 'los', 'las'}
if len(common) >= 2:
resolve_session(sess['id'], inv_id, sale_id, tenant_conn)
print(f"[WA-LEARN] Resolved session {sess['id']} with sale {sale_id}, item {inv_id}")
break

View File

@@ -105,7 +105,7 @@ def confirm_quotation(tenant_conn, phone):
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,)) cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
tenant_conn.commit() tenant_conn.commit()
cur.close() cur.close()
clear_last_shown(phone) clear_last_shown(tenant_conn, phone)
return qid return qid
@@ -135,41 +135,34 @@ def _ensure_sessions_table(tenant_conn):
cur.close() cur.close()
def set_last_shown_part(phone, part_info): def set_last_shown_part(tenant_conn, phone, part_info):
"""Store the last part shown to this phone number. """Store the last part shown to this phone number.
part_info: dict with keys inventory_id, part_number, name, brand, part_info: dict with keys inventory_id, part_number, name, brand,
price, stock, unit price, stock, unit
""" """
# In-memory fallback for when tenant_conn is not available
from tenant_db import get_tenant_conn
try: try:
conn = get_tenant_conn(11) _ensure_sessions_table(tenant_conn)
_ensure_sessions_table(conn) cur = tenant_conn.cursor()
cur = conn.cursor()
import json import json
cur.execute(""" cur.execute("""
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at) INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
VALUES (%s, %s, NOW()) VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW() ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
""", (phone, json.dumps(part_info))) """, (phone, json.dumps(part_info)))
conn.commit() tenant_conn.commit()
cur.close() cur.close()
conn.close()
except Exception as e: except Exception as e:
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}") print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
def get_last_shown_part(phone): def get_last_shown_part(tenant_conn, phone):
from tenant_db import get_tenant_conn
try: try:
conn = get_tenant_conn(11) _ensure_sessions_table(tenant_conn)
_ensure_sessions_table(conn) cur = tenant_conn.cursor()
cur = conn.cursor()
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,)) cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
row = cur.fetchone() row = cur.fetchone()
cur.close() cur.close()
conn.close()
if row and row[0]: if row and row[0]:
return row[0] return row[0]
except Exception as e: except Exception as e:
@@ -177,54 +170,45 @@ def get_last_shown_part(phone):
return None return None
def clear_last_shown(phone): def clear_last_shown(tenant_conn, phone):
from tenant_db import get_tenant_conn
try: try:
conn = get_tenant_conn(11) _ensure_sessions_table(tenant_conn)
_ensure_sessions_table(conn) cur = tenant_conn.cursor()
cur = conn.cursor()
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,)) cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
conn.commit() tenant_conn.commit()
cur.close() cur.close()
conn.close()
except Exception as e: except Exception as e:
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}") print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
def set_vehicle(phone, vehicle): def set_vehicle(tenant_conn, phone, vehicle):
"""Store the detected vehicle for this phone number. """Store the detected vehicle for this phone number.
vehicle: dict with keys brand, model, year vehicle: dict with keys brand, model, year
""" """
from tenant_db import get_tenant_conn
try: try:
conn = get_tenant_conn(11) _ensure_sessions_table(tenant_conn)
_ensure_sessions_table(conn) cur = tenant_conn.cursor()
cur = conn.cursor()
import json import json
cur.execute(""" cur.execute("""
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at) INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
VALUES (%s, %s, NOW()) VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW() ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
""", (phone, json.dumps(vehicle))) """, (phone, json.dumps(vehicle)))
conn.commit() tenant_conn.commit()
cur.close() cur.close()
conn.close()
except Exception as e: except Exception as e:
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}") print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
def get_vehicle(phone): def get_vehicle(tenant_conn, phone):
"""Retrieve the stored vehicle for this phone number.""" """Retrieve the stored vehicle for this phone number."""
from tenant_db import get_tenant_conn
try: try:
conn = get_tenant_conn(11) _ensure_sessions_table(tenant_conn)
_ensure_sessions_table(conn) cur = tenant_conn.cursor()
cur = conn.cursor()
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,)) cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
row = cur.fetchone() row = cur.fetchone()
cur.close() cur.close()
conn.close()
if row and row[0]: if row and row[0]:
return row[0] return row[0]
except Exception as e: except Exception as e:
@@ -232,17 +216,14 @@ def get_vehicle(phone):
return None return None
def clear_session(phone): def clear_session(tenant_conn, phone):
"""Clear all session data (last_shown + vehicle) for this phone.""" """Clear all session data (last_shown + vehicle) for this phone."""
from tenant_db import get_tenant_conn
try: try:
conn = get_tenant_conn(11) _ensure_sessions_table(tenant_conn)
_ensure_sessions_table(conn) cur = tenant_conn.cursor()
cur = conn.cursor()
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,)) cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
conn.commit() tenant_conn.commit()
cur.close() cur.close()
conn.close()
except Exception as e: except Exception as e:
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}") print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")
@@ -361,7 +342,7 @@ def clear_quotation(tenant_conn, phone):
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,)) cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
tenant_conn.commit() tenant_conn.commit()
cur.close() cur.close()
clear_last_shown(phone) clear_last_shown(tenant_conn, phone)
return qid return qid

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
"""Webhook dispatch service for dropshipping and external integrations.
Sends POST requests to configured target URLs with retry logic.
Can be called synchronously or enqueued via Celery.
"""
import json
import logging
import requests
import threading
from typing import Optional
logger = logging.getLogger(__name__)
def _send_post(url: str, payload: dict, headers: Optional[dict] = None, timeout: int = 10):
"""Send a POST request and return (success, status_code, response_text)."""
default_headers = {"Content-Type": "application/json"}
if headers:
default_headers.update(headers)
try:
resp = requests.post(url, json=payload, headers=default_headers, timeout=timeout)
success = 200 <= resp.status_code < 300
if not success:
logger.warning("Webhook %s returned %s: %s", url, resp.status_code, resp.text[:200])
return success, resp.status_code, resp.text
except requests.exceptions.Timeout:
logger.warning("Webhook %s timed out after %ss", url, timeout)
return False, 0, "timeout"
except Exception as e:
logger.warning("Webhook %s failed: %s", url, e)
return False, 0, str(e)
def dispatch_webhook_sync(target_url: str, event_type: str, payload: dict, secret: Optional[str] = None):
"""Send webhook synchronously (use inside Celery tasks for async)."""
full_payload = {
"event": event_type,
"data": payload,
}
headers = {}
if secret:
headers["X-Webhook-Secret"] = secret
success, status, body = _send_post(target_url, full_payload, headers=headers)
return {"success": success, "status": status, "body": body[:500]}
def dispatch_webhooks_bulk(target_urls: list[str], event_type: str, payload: dict, secret: Optional[str] = None):
"""Dispatch to multiple URLs concurrently using threads."""
results = []
threads = []
def _send(url):
result = dispatch_webhook_sync(url, event_type, payload, secret=secret)
results.append({"url": url, **result})
for url in target_urls:
t = threading.Thread(target=_send, args=(url,))
t.start()
threads.append(t)
for t in threads:
t.join(timeout=15)
return results

View File

@@ -1,6 +1,7 @@
"""WhatsApp service via Baileys Bridge (self-hosted, free). """WhatsApp service via Baileys Bridge (self-hosted, free).
Simple REST bridge at localhost:21465 that wraps WhatsApp Web via Baileys. Simple REST bridge that wraps WhatsApp Web via Baileys.
Supports per-tenant configuration via bridge_url parameter.
""" """
import requests import requests
@@ -9,47 +10,56 @@ from config import WHATSAPP_BRIDGE_URL
HEADERS = {'Content-Type': 'application/json'} HEADERS = {'Content-Type': 'application/json'}
def get_status(): def _get_url(bridge_url=None):
return bridge_url or WHATSAPP_BRIDGE_URL
def get_status(bridge_url=None):
url = _get_url(bridge_url)
try: try:
return requests.get(f'{WHATSAPP_BRIDGE_URL}/status', timeout=5).json() return requests.get(f'{url}/status', timeout=5).json()
except Exception as e: except Exception as e:
return {'state': 'error', 'error': str(e)} return {'state': 'error', 'error': str(e)}
def get_qr(): def get_qr(bridge_url=None):
url = _get_url(bridge_url)
try: try:
return requests.get(f'{WHATSAPP_BRIDGE_URL}/qr', timeout=5).json() return requests.get(f'{url}/qr', timeout=5).json()
except Exception as e: except Exception as e:
return {'state': 'error', 'error': str(e)} return {'state': 'error', 'error': str(e)}
def connect(): def connect(bridge_url=None):
url = _get_url(bridge_url)
try: try:
return requests.post(f'{WHATSAPP_BRIDGE_URL}/connect', headers=HEADERS, timeout=5).json() return requests.post(f'{url}/connect', headers=HEADERS, timeout=5).json()
except Exception as e: except Exception as e:
return {'state': 'error', 'error': str(e)} return {'state': 'error', 'error': str(e)}
def send_message(phone, text): def send_message(phone, text, bridge_url=None):
url = _get_url(bridge_url)
try: try:
return requests.post(f'{WHATSAPP_BRIDGE_URL}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json() return requests.post(f'{url}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json()
except Exception as e: except Exception as e:
return {'error': str(e)} return {'error': str(e)}
def send_quote(phone, quote_data): def send_quote(phone, quote_data, bridge_url=None):
lines = [f"*Cotizacion #{quote_data.get('id', '')}*", ""] lines = [f"*Cotizacion #{quote_data.get('id', '')}*", ""]
for item in quote_data.get('items', []): for item in quote_data.get('items', []):
lines.append(f"- {item.get('quantity', 1)}x {item.get('name', '')} ${item.get('subtotal', 0):,.2f}") lines.append(f"- {item.get('quantity', 1)}x {item.get('name', '')} ${item.get('subtotal', 0):,.2f}")
lines.append(f"\nSubtotal: ${quote_data.get('subtotal', 0):,.2f}") lines.append(f"\nSubtotal: ${quote_data.get('subtotal', 0):,.2f}")
lines.append(f"IVA: ${quote_data.get('tax_total', 0):,.2f}") lines.append(f"IVA: ${quote_data.get('tax_total', 0):,.2f}")
lines.append(f"*Total: ${quote_data.get('total', 0):,.2f}*") lines.append(f"*Total: ${quote_data.get('total', 0):,.2f}*")
return send_message(phone, "\n".join(lines)) return send_message(phone, "\n".join(lines), bridge_url=bridge_url)
def logout(): def logout(bridge_url=None):
url = _get_url(bridge_url)
try: try:
return requests.post(f'{WHATSAPP_BRIDGE_URL}/logout', headers=HEADERS, timeout=5).json() return requests.post(f'{url}/logout', headers=HEADERS, timeout=5).json()
except Exception as e: except Exception as e:
return {'error': str(e)} return {'error': str(e)}
@@ -72,14 +82,25 @@ def process_incoming(webhook_data):
media_base64 — base64 string if media, else None media_base64 — base64 string if media, else None
media_mimetype — e.g. 'image/jpeg', 'audio/ogg' media_mimetype — e.g. 'image/jpeg', 'audio/ogg'
is_voice_note — True for WhatsApp voice notes (audioMessage ptt) is_voice_note — True for WhatsApp voice notes (audioMessage ptt)
push_name — display name from WhatsApp
""" """
data = webhook_data.get('data', {}) data = webhook_data.get('data', {})
key = data.get('key', {}) key = data.get('key', {})
message = data.get('message', {}) message = data.get('message', {})
# remoteJid can be phone@s.whatsapp.net or LID@lid # remoteJid can be phone@s.whatsapp.net or LID:instance@lid
remote_jid = key.get('remoteJid', '') remote_jid = key.get('remoteJid', '')
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '') # Strip JID suffixes and LID instance suffix (:12)
phone = remote_jid.split('@')[0].split(':')[0] if remote_jid else ''
# DEBUG
import json
print(f"[WA-DEBUG] key fields: {json.dumps({k: v for k, v in key.items() if k in ('remoteJid', 'senderPn', 'fromMe', 'id')})}")
# senderPn contains the real phone number when remoteJid is a privacy LID
sender_pn = key.get('senderPn', '')
if sender_pn:
sender_pn = sender_pn.replace('@s.whatsapp.net', '')
# The bridge now classifies and passes these extra fields. Fall back to # The bridge now classifies and passes these extra fields. Fall back to
# the old parsing if they're missing (older bridge version). # the old parsing if they're missing (older bridge version).
@@ -94,6 +115,7 @@ def process_incoming(webhook_data):
# - For 'text' messages → conversation or extendedTextMessage # - For 'text' messages → conversation or extendedTextMessage
# - For 'image'/'video' → the caption (may be empty) # - For 'image'/'video' → the caption (may be empty)
# - For 'audio' → empty (filled in later by Whisper transcription) # - For 'audio' → empty (filled in later by Whisper transcription)
# - For 'location' → synthetic text with coordinates
if media_kind == 'text': if media_kind == 'text':
text = ( text = (
message.get('conversation', '') message.get('conversation', '')
@@ -103,9 +125,14 @@ def process_incoming(webhook_data):
else: else:
text = media_caption text = media_caption
# Location fields (from bridge classification)
latitude = data.get('latitude')
longitude = data.get('longitude')
return { return {
'phone': phone, 'phone': phone,
'jid': remote_jid, 'jid': remote_jid,
'sender_pn': sender_pn,
'text': text, 'text': text,
'from_me': key.get('fromMe', False), 'from_me': key.get('fromMe', False),
'message_id': key.get('id', ''), 'message_id': key.get('id', ''),
@@ -114,4 +141,20 @@ def process_incoming(webhook_data):
'media_mimetype': media_mimetype, 'media_mimetype': media_mimetype,
'is_voice_note': is_voice_note, 'is_voice_note': is_voice_note,
'push_name': push_name, 'push_name': push_name,
'latitude': latitude,
'longitude': longitude,
} }
def send_image(phone, caption, base64_image, bridge_url=None):
"""Send an image message via the Baileys bridge."""
url = _get_url(bridge_url)
try:
return requests.post(
f'{url}/send-image',
headers=HEADERS,
json={'phone': phone, 'caption': caption, 'base64': base64_image},
timeout=15
).json()
except Exception as e:
return {'error': str(e)}

View File

@@ -19,8 +19,6 @@
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden; overflow: hidden;
} }
@@ -326,6 +324,7 @@
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
margin-left: 260px;
} }
.page-header { .page-header {

View File

@@ -19,8 +19,6 @@
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden; overflow: hidden;
} }

View File

@@ -12,8 +12,6 @@
font-size: var(--text-body); font-size: var(--text-body);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden; overflow: hidden;
} }
@@ -76,7 +74,7 @@
MAIN CONTENT MAIN CONTENT
========================================================================= */ ========================================================================= */
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; margin-left: 260px; }
/* Header with breadcrumb + search */ /* Header with breadcrumb + search */
.content-header { .content-header {
@@ -90,7 +88,8 @@
.breadcrumb__link:hover { color: var(--color-primary); } .breadcrumb__link:hover { color: var(--color-primary); }
.breadcrumb__sep { color: var(--color-text-disabled); } .breadcrumb__sep { color: var(--color-text-disabled); }
.breadcrumb__current { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } .breadcrumb__current { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); }
.breadcrumb__back { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); color: var(--color-text-muted); font-size: var(--text-body-sm); cursor: pointer; transition: var(--transition-fast); }
.breadcrumb__back:hover { background: var(--color-primary-muted); color: var(--color-primary); }
.header-actions { display: flex; align-items: center; gap: var(--space-3); } .header-actions { display: flex; align-items: center; gap: var(--space-3); }
/* ── Catalog mode toggle (OEM / Local) ── */ /* ── Catalog mode toggle (OEM / Local) ── */
@@ -364,13 +363,29 @@
.bodega-table th { text-align: left; font-weight: var(--font-weight-semibold); color: var(--color-text-muted); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: var(--tracking-wider); padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border); } .bodega-table th { text-align: left; font-weight: var(--font-weight-semibold); color: var(--color-text-muted); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: var(--tracking-wider); padding: var(--space-2) var(--space-2); border-bottom: 1px solid var(--color-border); }
.bodega-table td { padding: var(--space-2); border-bottom: 1px solid var(--color-border); color: var(--color-text-primary); } .bodega-table td { padding: var(--space-2); border-bottom: 1px solid var(--color-border); color: var(--color-text-primary); }
/* Alternatives list */ /* Alternatives list */
.alt-item { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border); } .alt-item { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border); }
.alt-item:last-child { border-bottom: none; } .alt-item:last-child { border-bottom: none; }
.alt-item__pn { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); font-size: var(--text-body-sm); } .alt-item__pn { font-weight: var(--font-weight-semibold); color: var(--color-text-primary); font-size: var(--text-body-sm); }
.alt-item__mfr { font-size: var(--text-caption); color: var(--color-text-muted); } .alt-item__mfr { font-size: var(--text-caption); color: var(--color-text-muted); }
.alt-item__stock { font-size: var(--text-caption); } .alt-item__stock { font-size: var(--text-caption); }
/* Compatible vehicles pagination */
.compat-pager {
display: flex; align-items: center; justify-content: space-between;
gap: var(--space-2); margin-top: var(--space-4); padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.compat-pager__btn {
font-family: inherit; font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
color: var(--color-text-primary); background: var(--color-surface-2);
border: 1px solid var(--color-border); border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3); cursor: pointer; white-space: nowrap;
}
.compat-pager__btn:hover:not(:disabled) { background: var(--color-surface-3, var(--color-surface-2)); }
.compat-pager__btn:disabled { opacity: 0.4; cursor: not-allowed; }
.compat-pager__info { font-size: var(--text-caption); color: var(--color-text-muted); text-align: center; flex: 1; }
/* Add to cart section */ /* Add to cart section */
.detail-footer { .detail-footer {
padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border); padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border);

View File

@@ -12,8 +12,6 @@
font-size: var(--text-body); font-size: var(--text-body);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden; overflow: hidden;
} }

View File

@@ -19,8 +19,6 @@
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden; overflow: hidden;
} }
@@ -326,6 +324,7 @@
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
margin-left: 260px;
} }
.page-header { .page-header {
@@ -1028,6 +1027,7 @@
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-4); gap: var(--space-4);
min-width: 0;
} }
.device-card { .device-card {
@@ -1040,6 +1040,11 @@
gap: var(--space-4); gap: var(--space-4);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: var(--transition-normal); transition: var(--transition-normal);
min-width: 0;
max-width: 100%;
overflow: hidden;
overflow-wrap: break-word;
word-break: break-word;
} }
[data-theme="modern"] .device-card { [data-theme="modern"] .device-card {
@@ -1072,7 +1077,7 @@
stroke-linejoin: round; stroke-linejoin: round;
} }
.device-card__body { flex: 1; } .device-card__body { flex: 1; min-width: 0; overflow-wrap: break-word; }
.device-card__name { .device-card__name {
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);

View File

@@ -19,8 +19,6 @@
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden; overflow: hidden;
} }

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