Compare commits

...

131 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
521455f156 fix(brand-catalog): separate search input from content grid
- Add #brandCatalogSearch container in HTML for search inputs
- Move brand search input out of renderBrandList so it persists while typing
- Move parts search input out of renderPartsList so it persists while typing
- Reset now clears search container
- Bump JS cache bust to v=3
2026-05-14 22:24:09 +00:00
24db5eff43 fix(sw): bump cache to v5, add brand-catalog.js to precache
Update service worker cache name to nexus-pos-v5 to force cache
invalidation. Add brand-catalog.js to APP_SHELL precache list.
This should resolve stale cached JS causing parse errors.
2026-05-14 22:11:13 +00:00
4d6a7d9f32 fix(catalog): filter vehicle-brands to North America OEM brands only
/vehicle-brands now uses get_brands_for_mode('oem') to return the same
36 North American brands (Mexico/USA/Canada) as the regular catalog flow,
instead of all 619 brands in the database.
2026-05-14 21:38:36 +00:00
c6b3ca9bdf fix(brand-catalog): add JWT auth token to all API requests
brand-catalog.js was missing Authorization header on fetch calls,
causing 401 Unauthorized errors. Now reads pos_token from localStorage
and includes Bearer token in every request. Also handles 401 responses
by redirecting to /pos/login. Bump JS cache bust to v=2.
2026-05-14 21:26:10 +00:00
9da14e40da feat(catalog): brand search, parts pagination, and parts search
Backend:
- Add 'search' param to /brand-parts endpoint (filters oem_part_number and name via ILIKE)
- Keep count query accurate with search filter

Frontend (brand-catalog.js):
- Brand search input: filters 619 brands locally while typing
- Parts pagination: Previous/Next buttons with page counter (50 per page)
- Parts search within category: search input + Enter key triggers backend search
- Visual polish: stock badges, empty-state messages, responsive layout
- Loading states and breadcrumbs improved
2026-05-14 21:23:02 +00:00
e61063bdd7 feat(domain): separate POS to pos.nexusautoparts.com.mx subdomain
- nexusautoparts.com.mx -> Dashboard/Landing (port 5000)
- pos.nexusautoparts.com.mx -> POS (port 5001) with static assets proxy
- admin.nexusautoparts.com.mx -> Dashboard (port 5000)
- Update mobile app configs to point to pos.nexusautoparts.com.mx
- Update Caddy docs with new subdomain layout
2026-05-14 09:30:43 +00:00
6734993508 fix(nginx): fix static assets 404 on new domain
Remove broken location blocks for static files that had no proxy_pass,
which caused all .js/.css files to return 404 or HTML.
Add explicit /pos/static/ location with proxy_pass + cache headers.
Update nexus-pos.conf in repo to match live config.
2026-05-14 09:19:00 +00:00
2b0215d6b8 chore(domain): migrate to nexusautoparts.com.mx
- Update Nginx config:
  - nexusautoparts.com.mx -> POS (port 5001)
  - admin.nexusautoparts.com.mx -> Dashboard (port 5000)
  - nexus.consultoria-as.com -> legacy redirect (dashboard)
  - Add redirect / -> /pos/login for main domain
- Update domain references in code:
  - capacitor.config.json (mobile apps)
  - pos/mobile/README.md
  - pos/config.py SMTP_FROM
- Add Caddy config docs for reverse proxy VM (192.168.10.74)
2026-05-14 08:59:29 +00:00
ee9eea58c1 feat(catalog): wire up brand-first OEM catalog UI
- Add brand-catalog.js overlay: Brands -> Categories -> Parts flow
- Update catalog.html: 'Por Marca' button opens BrandCatalog overlay
- Optimize /vehicle-brands to query brands table (fast) instead of 256M part_vehicle_preview
- Keep /brand-categories and /brand-parts using exact match on part_vehicle_preview
- Integrate addToCart with existing CatalogApp cart
2026-05-14 08:37:37 +00:00
ff45905b49 feat(whatsapp): QWEN primary AI backend, Hermes fallback, conversation history, vehicle persistence, demo prompts
- Add QWEN (qwen3.6) as primary AI backend with short system prompt
- Hermes remains as fallback with 45s timeout
- Increase QWEN timeout to 35s, max_tokens to 4000
- Add conversation history loading from whatsapp_messages (last 4 msgs)
- Persist detected vehicle in whatsapp_sessions table
- Add 'limpiar chat' / 'nuevo chat' / 'reset' commands to clear history
- Fix CSS conflict: rename whatsapp chat-panel classes to wa-chat-panel
- Fix JS ID conflicts with chat.js widget (waChatPanel, waChatMessages, etc.)
- Improve no-stock response: conversational with alternatives
- Split search_query by | for multi-part lookups
- Add DEMO_PROMPTS.md and DEMO_PROMPTS_V2.md
2026-05-06 20:27:14 +00:00
371d72887e refactor: centralize QWEN fitment saving via save_qwen_fitment()
- Added save_qwen_fitment() in inventory_vehicle_compat.py to centralize
  inserting QWEN results into inventory_vehicle_compat
- Simplified inventory_bp.py create_item() and auto_match_item_vehicles()
  to use the centralized function, removing duplicated INSERT logic
2026-05-01 07:03:04 +00:00
af7b010e55 feat: configurable vehicle compatibility source (TecDoc / QWEN / Both)
Backend:
- Added GET/PUT /pos/api/config/vehicle-compat-source endpoints
- Added get_compat_source() helper reading from tenant_config
- create_item() now respects config: runs TecDoc and/or QWEN accordingly
- auto_match_item_vehicles() respects config: runs only configured source

Frontend:
- Added 'Compatibilidad de Vehiculos' section in config.html
- Added loadVehicleCompatSource() / saveVehicleCompatSource() in config.js
- Regenerated config.min.js
2026-05-01 06:52:06 +00:00
5421c47ffc fix(compat): get_compatibility used wrong connection for master tables
inventory_vehicle_compat.get_compatibility was trying to JOIN tenant
inventory_vehicle_compat with master tables (model_year_engine, brands,
models, years, engines) on a single tenant connection. Those tables only
exist in the master DB, causing the query to fail silently.

Fix: split into two queries:
  1. Fetch MYE IDs from tenant's inventory_vehicle_compat
  2. Resolve vehicle details from master DB via ANY(%s)
  3. Merge results

Also fixes the argument mismatch: inventory_bp passed (tenant, master,
item_id) but the function only accepted 2 args.
2026-05-01 06:41:22 +00:00
2e80ba7400 feat(auto_match): exhaustive multi-strategy vehicle compatibility search
Replaced simple exact-match with 8-layer fallback strategy:

1. Exact normalized part number (parts.oem_part_number)
2. Exact normalized aftermarket part number
3. Exact normalized cross-reference number
4. Partial ILIKE match on OEM numbers
5. Partial ILIKE match on aftermarket numbers
6. Partial ILIKE match on cross-reference numbers
7. Separator-stripped fallback (KYB-343412 → KYB343412)
8. Name-based search on parts.name_part / parts.name_es
   and aftermarket_parts.name_aftermarket_parts when no part_number hit

Brand-aware filtering: when brand hint is provided and not 'GENERAL',
only returns MYEs for vehicles of that brand.

Limits: max 20 part IDs per layer, max 200 MYEs total.

Test: BPR5ES + TOYOTA → matched True, 2 parts, 200 MYEs inserted.
2026-05-01 06:22:17 +00:00
0e549e7746 fix: connection pool exhaustion + cross_ref column name
- tenant_db.py: add rollback() before returning conn to pool to prevent
  'idle in transaction (aborted)' state that exhausts the pool
- tenant_db.py: increase pool maxconn from 10 to 20 for better concurrency
- inventory_vehicle_compat.py: fix column name cross_ref_number ->
  cross_reference_number to match actual schema
2026-05-01 02:25:58 +00:00
2b418701b6 fix(inventory): add cache-buster v=2 to inventory.js to force reload
Nginx auto-serves .min.js when .js is requested with try_files.
The browser had the old file cached with 6M expiry. Adding ?v=2
forces clients to fetch the new version with autoMatchCompat exposed.
2026-05-01 01:11:09 +00:00
91826487f9 fix: remove _oem_blocked() from catalog search/part + expose autoMatchCompat
- catalog_bp.py: /search and /part/<id> no longer blocked by CATALOG_OEM_ENABLED
  These endpoints query the master parts DB and enrich with local stock;
  they should work in both local and OEM modes.
- inventory.js: expose autoMatchCompat and removeCompat to window for
  onclick handlers in dynamically generated HTML.
- Regenerated inventory.min.js
2026-05-01 00:30:10 +00:00
b27dd720aa feat(catalog): expand LOCAL_BODEGA_BRANDS to 96 Nort America brands
- Added all brands with vehicles >= 1980 relevant to Mexico-USA-Canada
- Covers: American, Japanese, Korean, German, UK, Italian, French,
  Swedish, Spanish, Chinese (with MX presence), Indian, and commercial
- All 96 brands verified against master DB with year >= 1980
2026-04-30 07:43:39 +00:00
b94b194217 docs: update FASES_IMPLEMENTADAS.md with QWEN 3.6 AI Vehicle Fitment
- Added QWEN env vars section
- Added completed QWEN fitment feature with architecture details
- Documented retry logic, fuzzy matching, and fail-safe behavior
2026-04-29 08:44:26 +00:00
623c57bb08 fix(qwen_fitment): resolve DB schema mismatch and double-fetchone bug
- Fixed column names: brands.name_brand, models.name_model, engines.name_engine
- Added fuzzy model matching with ILIKE %%pattern%% for TecDoc-style names
- Removed erroneous double cur.fetchone() that always returned None
- Added retry logic (3 attempts) for QWEN API empty responses
- Added fallback engine-less query when engine description doesn't match DB
- Protected _extract_json against None input
2026-04-29 08:38:17 +00:00
3cd2874ed7 test(e2e): improve catalog test with mocked APIs and auth
- catalog.spec.js: added fake JWT auth setup, mocked brand/search APIs
  with Playwright route.fulfill, asserts actual rendered cards and
  search dropdown visibility
2026-04-29 07:11:40 +00:00
cf46790ed8 feat(pwa): improve service worker with background sync, push, IndexedDB
- Bumped cache version to nexus-pos-v3
- Background sync for cart (nexus-cart-sync): replays pending
  requests from IndexedDB, clears queue on success
- Push notifications: parse payload, show notification, focus/open
  /pos/sale on click
- Offline cart strategy: queue failed POST /pos/api/cart/* in
  IndexedDB, return queued JSON response
- Message handlers: SKIP_WAITING (preserved) + CLEAR_CACHES
- Periodic background sync stub commented for future cache warming
2026-04-29 07:10:47 +00:00
45b69bcae8 test(e2e): add Playwright smoke tests for catalog, inventory, checkout, auth
- catalog.spec.js: brand grid loads, search interaction
- inventory.spec.js: table loads, detail modal opens
- pos-checkout.spec.js: cart visible, catalog search from POS
- auth-guard.spec.js: unauthenticated redirect to login
2026-04-29 07:10:34 +00:00
3792e4053c feat(monitoring): add Alertmanager with alert rules
- docker-compose.monitoring.yml: added alertmanager service (port 9093)
- prometheus.yml: alerting config + rule_files entry
- alerts.yml: 5 alert rules (PostgreSQLDown, RedisDown, HighDiskUsage,
  HighMemoryUsage, NodeDown)
- alertmanager.yml: SMTP + webhook receiver, inhibit rules
2026-04-29 07:10:22 +00:00
5a913dcac1 feat(monitoring): add Grafana dashboards for PostgreSQL, Redis, System, App
- nexus-postgresql.json: connections, transactions, cache hit, WAL,
  slow queries, table bloat
- nexus-redis.json: memory, commands/sec, clients, cache hit,
  keyspace hits/misses, evicted keys
- nexus-system.json: CPU, memory, disk, network, load average
- nexus-gunicorn.json: request rate, response time, workers,
  5xx errors, memory per worker
- dashboards.yml: auto-provisioning config
2026-04-29 07:10:01 +00:00
cc9a0cf57c feat(backup): automated daily backup script + systemd timer
- scripts/backup.sh: pg_dump + project tar, S3 upload (optional),
  local retention (7 days), dry-run support
- systemd/nexus-backup.service + nexus-backup.timer: daily at 02:00 UTC
- AWS CLI v2 installed locally in tools/ for S3 uploads
2026-04-29 07:09:43 +00:00
f78d4c9b44 docs: sync FASES_IMPLEMENTADAS.md with actual project status
- Moved completed items from this session to 'Completados recientemente'
- Cleared critical debt section (PostgreSQL restart done)
- Marked stubs as 'creado' with file references
- Added new polish items: Grafana dashboards, Alertmanager, SW improvements
- Updated infrastructure table with Prometheus/Grafana
2026-04-29 06:44:27 +00:00
ca239a458b docs: update API, architecture, installation guides and README
- API-POS.md: added sections 11-15 (BNPL, ERP, WhatsApp Cloud,
  Supplier Portal, Dashboard Stats)
- ARCHITECTURE.md: added infra/monitoring section with systemd,
  Prometheus/Grafana, PWA, and integration stubs
- INSTALACION.md: added systemd setup, monitoring docker compose,
  PWA install notes, and Playwright test commands
- README.md: updated endpoint count, tech stack, infrastructure table
2026-04-29 06:34:40 +00:00
fb591c7de6 chore(config): add .env.example and initial catalog seed SQL
- .env.example: complete environment variable template for new installs
- pos/seed/initial_catalog.sql: seed data for catalog setup
2026-04-29 06:31:46 +00:00
b803950fae chore(assets): regenerate minified JS bundles
- customers.min.js, fleet.min.js, inventory.min.js, pos-utils.min.js,
  sidebar.min.js, virtual-scroll.min.js
2026-04-29 06:31:25 +00:00
bd2cf307f7 docs: update FASES_IMPLEMENTADAS.md with completed items and current roadmap
- Added 'Completados recientemente' section (partitioning, Quart,
  minify fix, voice AI, chat.js fix, PostgreSQL tuning)
- Reordered and renumbered remaining roadmap items
- Updated infrastructure table: Quart now shows production status
2026-04-29 06:31:18 +00:00
9b02005116 fix(blueprints): correct auth import and decorator call in tasks_bp
- Changed 'from auth import require_auth' → 'from middleware import require_auth'
- Added missing parentheses: @require_auth → @require_auth()
- Prevents 'No module named auth' and endpoint name collision errors
2026-04-29 06:31:11 +00:00
2cfe4b3913 feat(api): add BNPL, ERP, WhatsApp Cloud, Supplier Portal stubs
- bnpl_bp.py: APLAZO/Kueski/Clip application workflow (mock)
- erp_bp.py: Aspel/CONTPAQi/SAP/Odoo sync jobs (mock)
- whatsapp_cloud_bp.py: Meta Cloud API webhook, messages, templates
- supplier_portal_bp.py: demand by zone/branch and top-parts analytics
- app.py: register all new blueprints
2026-04-29 06:31:03 +00:00
12989e30be feat(dashboard): add real-time in-app stats with Chart.js
- dashboard_stats_bp.py: endpoints /pos/api/dashboard/stats and
  /pos/api/dashboard/stats/employees (sales today/month, hourly,
  top products, employee productivity)
- dashboard-stats.js: renders hourly sales bar chart and top products
  doughnut chart using Chart.js
- chart.umd.min.js: vendored Chart.js v4.4.2
2026-04-29 06:30:54 +00:00
c4db5e7550 test(e2e): setup Playwright with login smoke test
- Installed @playwright/test + Chromium
- playwright.config.js: baseURL localhost:5001, Desktop Chrome
- tests/e2e/login.spec.js: validates login form loads and invalid
  credentials show error
2026-04-29 06:30:46 +00:00
3b8224d15e feat(pwa): add install prompt banner and register in all POS templates
- pwa-install.js: captures beforeinstallprompt, shows dismissible
  banner with 7-day localStorage cooldown, handles appinstalled
- Registered in 12 POS templates alongside existing service worker
2026-04-29 06:30:38 +00:00
4b3b0f8313 feat(monitoring): deploy Prometheus + Grafana stack via Docker
- prometheus.yml: scrapes node, postgres, redis, nexus-pos, nexus-quart
- docker-compose.monitoring.yml: Prometheus, Grafana, node-exporter,
  postgres-exporter, redis-exporter
- Grafana auto-provisions Prometheus datasource
- Access: Grafana :3001 (admin/nexus2026), Prometheus :9090
2026-04-29 06:30:30 +00:00
c766571b7d docs(infra): add PostgreSQL tuning and systemd service documentation
- POSTGRESQL_TUNING.md: documents applied config (8GB shared_buffers,
  64MB work_mem, 8GB max_wal_size, SSD params)
- SYSTEMD_SERVICES.md: lists all production systemd services
- systemd/: versioned copies of all .service and .timer files
- .gitignore: ignore package-lock.json and backups/
2026-04-29 06:30:22 +00:00
44c3a6c910 fix(chat): add missing chatTtsToggle button to prevent null reference error
The chat.js init() template did not include #chatTtsToggle, causing
a runtime TypeError when hasTTS was true. Added the toggle button
inside .chat-header-actions, matching chat-public.js structure.
Regenerated chat.min.js.
2026-04-29 06:30:13 +00:00
f24f25e74e feat(infra): particiona vehicle_parts en 16 particiones HASH + fix script
- Corrige UNIQUE constraint que fallaba por duplicados → índice normal
- Aumenta BATCH_SIZE a 10M + synchronous_commit=off para velocidad
- Particionamiento completado: 2.16B filas en 16 particiones
- vehicle_parts_old conservada como rollback (254 GB)
- Minify script y Quart producción ya commiteados
2026-04-28 11:52:12 +00:00
b829e4f026 fix(infra): 3 mejoras críticas — minify script + Quart producción + particionamiento bloqueado
- scripts/minify-assets.sh: excluye archivos .min.* para evitar .min.min.*
- nginx/nexus-pos.conf: agrega upstream nexus_quart + location /pos/api/catalog/async-search
- nexus-quart.service: servicio systemd para hypercorn en puerto 5002
- particionamiento vehicle_parts: BLOQUEADO — tabla 254 GB, disco solo 177 GB libres
2026-04-28 06:52:52 +00:00
c75e2a75c9 docs: actualiza FASES_IMPLEMENTADAS.md con estado post-voz y roadmap pendiente 2026-04-28 05:17:31 +00:00
27cb4ee683 fix(dashboard): arregla landing.css 404 y APIs 500
- Agrega ruta genérica en server.py para servir CSS/JS/HTML desde root
- Configura DATABASE_URL y JWT_SECRET en nexus.service systemd
- Agrega trust en pg_hba.conf para postgres@localhost en nexus_autoparts
2026-04-28 04:53:34 +00:00
afb3b2405c feat(voice): implementa voz y TTS en chats POS y dashboard
- Agrega TTS (speechSynthesis) a chat.js del POS para leer respuestas IA
- Copia lógica de voz completa (STT + TTS) a dashboard/chat-public.js
- Extiende estilos TTS en chat.css y chat-public.css
- Agrega chat widget a 13 templates POS que no lo tenían
- Corrige duplicado de chat.css en diagrams.html
- Minifica assets actualizados
- 73/73 tests pasan
2026-04-28 00:53:57 +00:00
267 changed files with 36948 additions and 2181 deletions

71
.env.example Normal file
View File

@@ -0,0 +1,71 @@
# Nexus Autoparts — Environment Variables
# Copy this file to .env and fill in your values.
# NEVER commit .env to git.
# ═══════════════════════════════════════════════════════════════════════════
# DATABASE (REQUIRED)
# ═══════════════════════════════════════════════════════════════════════════
DATABASE_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
MASTER_DB_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
TENANT_DB_URL_TEMPLATE=postgresql://nexus:YOUR_DB_PASSWORD@localhost/{db_name}
# ═══════════════════════════════════════════════════════════════════════════
# SECURITY (REQUIRED)
# ═══════════════════════════════════════════════════════════════════════════
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET=change-me-to-a-random-64-char-hex-string
POS_JWT_SECRET=change-me-to-a-different-random-64-char-hex-string
# ═══════════════════════════════════════════════════════════════════════════
# AI / OpenRouter (OPTIONAL — enables chatbot)
# ═══════════════════════════════════════════════════════════════════════════
OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key
# ═══════════════════════════════════════════════════════════════════════════
# WHATSAPP BRIDGE (OPTIONAL — enables WhatsApp integration)
# ═══════════════════════════════════════════════════════════════════════════
WHATSAPP_BRIDGE_URL=http://localhost:21465
WHATSAPP_BRIDGE_KEY=your-whatsapp-bridge-secret
# ═══════════════════════════════════════════════════════════════════════════
# SMTP (OPTIONAL — enables email quotations)
# ═══════════════════════════════════════════════════════════════════════════
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
# ═══════════════════════════════════════════════════════════════════════════
# REDIS CACHE (OPTIONAL — enables sub-millisecond stock lookups)
# ═══════════════════════════════════════════════════════════════════════════
REDIS_URL=redis://localhost:6379/0
REDIS_ENABLED=true
REDIS_STOCK_TTL=300
# ═══════════════════════════════════════════════════════════════════════════
# MEILISEARCH (OPTIONAL — enables sub-100ms catalog search)
# ═══════════════════════════════════════════════════════════════════════════
MEILI_URL=http://localhost:7700
MEILI_API_KEY=nexus-master-key-change-me
# ═══════════════════════════════════════════════════════════════════════════
# METABASE KPIs (OPTIONAL — Business Intelligence dashboards)
# ═══════════════════════════════════════════════════════════════════════════
METABASE_URL=http://localhost:3000
METABASE_ADMIN_EMAIL=admin@nexus.local
METABASE_ADMIN_PASS=change-me-to-a-strong-password
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
# ═══════════════════════════════════════════════════════════════════════════
DEFAULT_CURRENCY=MXN
EXCHANGE_RATE_USD_MXN=17.5

11
.gitignore vendored
View File

@@ -80,3 +80,14 @@ node_modules/
# Diagram images (served from static, too large for git) # Diagram images (served from static, too large for git)
dashboard/static/diagrams/ dashboard/static/diagrams/
# Playwright / Node
package-lock.json
# Backups
backups/
# Local tools (AWS CLI)
tools/

39
.kimi/plan.md Normal file
View File

@@ -0,0 +1,39 @@
# Plan: Catálogo por Marca de Vehículo
## Resumen
Reorganizar el catálogo para que la navegación principal sea:
**Marca de vehículo → Categoría/Sistema → Partes compatibles**
Ejemplo: Toyota → Frenos → [balatas Bosch, discos Brembo, pastillas NGK...]
## Opción recomendada: Materialized View
No tocamos la tabla masiva `vehicle_parts` (billones de rows). Creamos una materialized view que agregue por marca + categoría.
### Cambios DB (Master)
1. Crear `brand_catalog_parts` MV desde `vehicle_parts → MYE → models → brands`
2. Agregar índices: `(brand_id, category_id)`, `(brand_id, part_id)`
3. Crear función `refresh_brand_catalog()` para refrescar
### Cambios Backend
1. Nuevos endpoints:
- `GET /catalog/vehicle-brands` → lista marcas con conteo de partes
- `GET /catalog/brand-categories?brand_id=` → categorías disponibles para esa marca
- `GET /catalog/brand-parts?brand_id=&category_id=` → partes compatibles
2. Modificar `catalog_service.py` con filtros por marca
### Cambios Frontend
1. Nueva vista inicial: grid de marcas de vehículo (tarjetas con logo/contador)
2. Click en marca → lista de categorías/sistemas (frenos, motor, suspensión...)
3. Click en categoría → grid de partes compatibles con esa marca
4. Filtro opcional: modelo/año/motor para refinar resultados
### Datos
- `vehicle_parts` ya tiene todo. La MV solo agrega/distinct.
- Las marcas fabricantes (Bosch, NGK) se muestran como badges en cada parte.
## Tiempo estimado
- DB + Backend: 2-3 horas
- Frontend: 2-3 horas
- Testing: 1 hora
- Total: ~6 horas

181
DEMO_PROMPTS.md Normal file
View File

@@ -0,0 +1,181 @@
# 🧪 Prompts de Prueba — Demo WhatsApp Agent
> **Fecha:** mañana
> **Backend primario:** QWEN (qwen3.6) — ~1-3 segundos
> **Fallback:** Hermes (hermes-agent) — ~10-30 segundos si QWEN falla
> **Contexto persistente:** vehículo guardado en sesión, historial de últimos 4 mensajes
---
## 1. Saludo + búsqueda simple con vehículo
**Prompt:**
```
hola, necesito balatas para un Nissan Tsuru 2015
```
**¿Qué validar?**
- Responde en ~2-4 segundos
- Detecta vehículo: `{"brand": "Nissan", "model": "Tsuru", "year": 2015}`
- `search_query`: `Brake Pad` (o similar en inglés)
- Muestra resultados de inventario si hay stock
---
## 2. Síntoma mecánico (diagnóstico)
**Prompt:**
```
mi carro vibra al frenar, que puede ser?
```
**¿Qué validar?**
- Responde en ~2-5 segundos
- Identifica síntoma: discos de freno / balatas desgastadas
- `search_query`: `Brake Disc`
- Da diagnóstico + lista de partes probables
---
## 3. Tune-up completo (cotización múltiple)
**Prompt:**
```
quiero hacer el tune up a mi Renault Duster 2018
```
**¿Qué validar?**
- Responde en ~15-20 segundos (el prompt más complejo, paciencia aquí)
- Detecta vehículo: `{"brand": "RENAULT", "model": "Duster", "year": 2018}`
- `search_query`: `Spark Plug|Air Filter|Oil Filter|Fuel Filter` (separado por `|`)
- Muestra tabla de partes recomendadas
---
## 4. Seguimiento — "Sí, pásame la cotización"
**Prompt:**
```
si, pasame la cotizacion
```
**¿Qué validar?**
- **Crítico:** Recuerda el contexto (Renault Duster 2018 + tune-up)
- No pida "¿qué vehículo?" de nuevo
- `search_query` contiene múltiples partes separadas por `|`
- Responde en ~2-5 segundos (muy rápido porque ya tiene historial)
---
## 5. Agregar a cotización
**Prompt:**
```
cotizar
```
**¿Qué validar?**
- Detecta intent de cotización
- Agrega la última parte mostrada a la cotización abierta
- Responde con conteo de ítems y total parcial
- Incluye botón "Enviar Cotización" si se usa web
---
## 6. Preguntar por otra parte (contexto mixto)
**Prompt:**
```
y cuanto cuesta un alternador para el mismo carro?
```
**¿Qué validar?**
- Recuerda "el mismo carro" = Renault Duster 2018
- `search_query`: `Alternator`
- Muestra precio + stock del alternador
---
## 7. Enviar cotización final
**Prompt:**
```
enviar cotizacion
```
**¿Qué validar?**
- Envía la cotización completa formateada
- Muestra todos los ítems agregados con precios
- Incluye mensaje: *"Escribe 'sí' para confirmar tu pedido"*
---
## 8. Confirmar pedido
**Prompt:**
```
si
```
**¿Qué validar?**
- Confirma la cotización como pedido
- Responde: *"✅ Pedido confirmado! Tu cotización #X fue registrada..."*
- Guarda la cotización con estado `confirmed`
---
## 9. Limpiar conversación
**Prompt:**
```
limpiar chat
```
**¿Qué validar?**
- Borra historial de mensajes de la DB
- Responde: *"🗑️ Conversación reiniciada. ¡Hola de nuevo! ¿En qué puedo ayudarte?"*
- Próximo mensaje debe comportarse como conversación nueva
---
## 10. Parte sin stock / no en inventario
**Prompt:**
```
necesito un turbo para BMW X5 2022
```
**¿Qué validar?**
- Detecta vehículo: `{"brand": "BMW", "model": "X5", "year": 2022}`
- Si no hay en inventario, responde de forma conversacional:
- *"No encontré ese turbo en stock, pero puedo..."*
- Ofrece: pedido por encargo, alternativas, o sugerir tiendas
- **NO** responde con mensaje seco tipo *"❌ No tenemos esa parte"*
---
## ⚡ Checklist rápido antes de la demo
- [ ] WhatsApp Bridge está `state: open` (verificar en UI)
- [ ] Gunicorn está corriendo (`systemctl status nexus-pos`)
- [ ] El QR está escaneado y la instancia está conectada
- [ ] Limpiar historial de conversaciones de prueba anteriores
- [ ] Probar al menos 3 prompts de los de arriba en vivo
- [ ] Tener plan B: si QWEN falla, Hermes responderá (más lento pero funciona)
## 🚨 Qué hacer si algo falla durante la demo
1. **Timeout / "El asistente tardó mucho"**
- Esperar 10-15 segundos y reenviar el mensaje
- QWEN a veces tiene picos de latencia
2. **El agente "olvida" el vehículo**
- Escribir `limpiar chat` y empezar de nuevo
- O mencionar el vehículo explícitamente en cada mensaje
3. **No abre el panel de WhatsApp en la web**
- Hard refresh: **Ctrl+F5**
- O abrir en pestaña de incógnito
4. **Error de conexión del Bridge**
- En la UI de WhatsApp, clic en **"Conectar WhatsApp"** y re-escanear QR

198
DEMO_PROMPTS_V2.md Normal file
View File

@@ -0,0 +1,198 @@
# 🧪 10 Prompts de Demo — Funcionalidades del Agente WhatsApp
> Usa estos prompts en orden o saltando entre ellos para mostrar la versatilidad del agente.
---
## 1. Búsqueda directa con vehículo clásico mexicano
**Prompt:**
```
Necesito bujías para un Nissan Tsuru 2015
```
**¿Qué demuestra?**
- Detección precisa de vehículo mexicano clásico (Tsuru)
- Traducción automática a inglés: `Spark Plug`
- Búsqueda en inventario local con compatibilidad de vehículo
**Respuesta esperada:** Tabla de bujías NGK/Bosch con stock y precios.
---
## 2. Diagnóstico por síntoma — suspensión
**Prompt:**
```
Mi carro se jala hacia la izquierda al frenar, qué puede ser?
```
**¿Qué demuestra?**
- Capacidad de diagnóstico sin mencionar una parte específica
- Relaciona síntoma con partes probables: terminales, rotulas, balatas del lado izquierdo
- Genera `search_query`: `Tie Rod End`
**Respuesta esperada:** Diagnóstico + lista de partes probables ordenadas por probabilidad.
---
## 3. Cotización de kit completo — embrague
**Prompt:**
```
Cuánto cuesta cambiar el clutch de un Pointer 2010?
```
**¿Qué demuestra?**
- Detecta "Pointer" como modelo mexicano
- Interpreta "cambiar el clutch" como kit completo (embrague + plato + collarín)
- Genera múltiples `search_query` separados por `|`: `Clutch Kit|Clutch Plate|Release Bearing`
**Respuesta esperada:** Lista de componentes del kit de embrague con precios.
---
## 4. Mantenimiento preventivo — servicio de 50,000 km
**Prompt:**
```
Quiero el servicio de 50 mil kilómetros para mi Jetta 2019
```
**¿Qué demuestra?**
- Entiende "servicio de 50,000 km" como paquete de mantenimiento
- Genera lista completa: aceite, filtros, bujías, refrigerante, frenos
- `search_query`: `Oil Filter|Air Filter|Spark Plug|Coolant|Brake Pad`
**Respuesta esperada:** Paquete de servicio con todas las partes necesarias.
---
## 5. Parte sin especificar vehículo (prueba de persistencia)
**Prompt:**
```
Y el filtro de aceite cuánto cuesta?
```
**¿Qué demuestra?**
- **Contexto persistente:** recuerda que se habló del Jetta 2019 (prompt 4)
- No pide "¿para qué carro?" de nuevo
- Usa el vehículo guardado en sesión automáticamente
**Respuesta esperada:** Precio del filtro de aceite compatible con Jetta 2019.
---
## 6. Falla eléctrica — no arranca
**Prompt:**
```
Mi camioneta no quiere arrancar en las mañanas, qué le puede faltar?
```
**¿Qué demuestra?**
- Diagnóstico eléctrico sin mencionar parte específica
- Sugiere: batería, motor de arranque, alternador, cables de bujías
- `search_query`: `Starter Motor` (la parte más probable)
**Respuesta esperada:** Diagnóstico con 3-4 partes posibles + la más probable primero.
---
## 7. Combo de frenos completos
**Prompt:**
```
Cotízame frenos completos delanteros para un Aveo 2017
```
**¿Qué demuestra?**
- Interpreta "frenos completos delanteros" como combo: balatas + discos + líquido
- Genera `search_query`: `Brake Pad|Brake Disc|Brake Fluid`
- Ofrece cotización de múltiples ítems de una sola vez
**Respuesta esperada:** Tabla con balatas, discos y líquido de frenos compatibles.
---
## 8. Parte para vehículo europeo (sin stock local)
**Prompt:**
```
Busco un radiador para Audi A4 2021
```
**¿Qué demuestra?**
- Detección de vehículo europeo con formato correcto
- Cuando no hay stock, responde de forma conversacional (NO un mensaje seco)
- Ofrece alternativas: pedido por encargo, equivalentes, o tiendas cercanas
**Respuesta esperada:**
> "No encontré ese radiador en stock para tu Audi A4 2021, pero puedo:
> • Pedirlo por encargo con 3-5 días de entrega
> • Buscar un equivalente de otra marca
> ¿Qué prefieres?"
---
## 9. Agregar segunda parte a cotización abierta
**Prompt:**
```
También agrega un filtro de aire
```
**¿Qué demuestra?**
- Flujo conversacional de cotización multi-paso
- Agrega ítem adicional a la cotización ya abierta
- Actualiza conteo de productos y total parcial
**Respuesta esperada:**
> "✅ *Filtro de aire* × 1 agregado. Llevas 4 productos — total parcial: $1,240.50"
---
## 10. Confirmar pedido y cerrar venta
**Prompt:**
```
Sí, todo bien, confirmo el pedido
```
**¿Qué demuestra?**
- Detecta intención de confirmación ("sí", "confirmo", "todo bien")
- Cierra la cotización como pedido confirmado
- Genera número de pedido y mensaje de cierre profesional
**Respuesta esperada:**
> "✅ *Pedido confirmado!*
> Tu cotización #42 fue registrada.
> Nos pondremos en contacto contigo para coordinar la entrega.
> ¡Gracias por tu compra! 🙏"
---
## 🎬 Sugerencia de guión para la demo (8-10 minutos)
| Minuto | Prompt | Efecto demo |
|--------|--------|-------------|
| 0:00 | Prompt 1 (Tsuru bujías) | Saludo rápido, resultados en 2s |
| 0:45 | Prompt 2 (se jala al frenar) | Diagnóstico inteligente |
| 1:45 | Prompt 4 (servicio Jetta) | Paquete completo de mantenimiento |
| 3:00 | Prompt 5 (filtro de aceite) | "¿Recuerdas el carro?" — contexto persistente |
| 3:45 | Prompt 7 (frenos Aveo) | Cotización múltiple con tabla |
| 4:45 | Prompt 9 (agregar filtro de aire) | Cotización conversacional |
| 5:30 | Prompt 10 (confirmo pedido) | Cierre de venta |
| 6:00 | Prompt 8 (Audi sin stock) | Manejo elegante de "no tengo" |
| 6:45 | Prompt 3 (Pointer clutch) | Kit completo con precios |
| 7:30 | Prompt 6 (no arranca) | Diagnóstico final |
---
## 🛡️ Plan de contingencia
Si en algún momento QWEN tarda más de 30 segundos:
1. Decir: *"Voy a reenviar el mensaje, a veces el asistente necesita un segundo intento"*
2. Reenviar el mismo prompt
3. Si sigue lento, usar `limpiar chat` y empezar ese flujo de nuevo

View File

@@ -27,7 +27,7 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
- **Multi-tenant**: base de datos aislada por cliente - **Multi-tenant**: base de datos aislada por cliente
- **PWA**: instalable en tablets/celulares, modo offline - **PWA**: instalable en tablets/celulares, modo offline
- **10 pantallas**: Login, Catalogo, Inventario, POS, Clientes, Facturacion, Contabilidad, Dashboard, Reportes, Configuracion - **10 pantallas**: Login, Catalogo, Inventario, POS, Clientes, Facturacion, Contabilidad, Dashboard, Reportes, Configuracion
- **81+ endpoints API** organizados en 9 blueprints - **100+ endpoints API** organizados en 15+ blueprints
- **2 temas**: Industrial oscuro + Moderno claro (toggle en sidebar) - **2 temas**: Industrial oscuro + Moderno claro (toggle en sidebar)
- **Auth por PIN** con JWT + rate limiting + bloqueo por dispositivo - **Auth por PIN** con JWT + rate limiting + bloqueo por dispositivo
- **5 roles**: Dueno, Admin, Cajero, Almacenista, Contador - **5 roles**: Dueno, Admin, Cajero, Almacenista, Contador
@@ -43,7 +43,7 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
| Facturacion | CFDI 4.0 (Ingreso, Egreso, Pago), cola de timbrado, cancelacion SAT | | Facturacion | CFDI 4.0 (Ingreso, Egreso, Pago), cola de timbrado, cancelacion SAT |
| Contabilidad | Polizas automaticas, catalogo SAT, balanza, estado de resultados, balance general, antiguedad | | Contabilidad | Polizas automaticas, catalogo SAT, balanza, estado de resultados, balance general, antiguedad |
| Caja Registradora | Apertura, movimientos, corte X, corte Z, multi-caja | | Caja Registradora | Apertura, movimientos, corte X, corte Z, multi-caja |
| Dashboard | Ventas del dia vs meta, cajas activas, alertas | | Dashboard | Ventas del dia vs meta, cajas activas, alertas, graficos en tiempo real |
| Reportes | Financieros y operativos | | Reportes | Financieros y operativos |
| Configuracion | Negocio, sucursales, empleados, roles, temas | | Configuracion | Negocio, sucursales, empleados, roles, temas |
@@ -60,8 +60,13 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
| CFDI | lxml (XML builder CFDI 4.0) | | CFDI | lxml (XML builder CFDI 4.0) |
| Frontend | HTML/CSS/JS vanilla (sin framework) | | Frontend | HTML/CSS/JS vanilla (sin framework) |
| Estilos | CSS custom properties (design tokens) | | Estilos | CSS custom properties (design tokens) |
| PWA | Service Worker + manifest | | PWA | Service Worker + manifest + install prompt |
| Data import | TecDoc via Apify, NHTSA VIN API | | Data import | TecDoc via Apify, NHTSA VIN API |
| Monitoreo | Prometheus + Grafana (Docker) |
| Tests E2E | Playwright |
| BNPL | APLAZO / Kueski / Clip (stub) |
| ERP Sync | Aspel / CONTPAQi / SAP / Odoo (stub) |
| WhatsApp | Baileys webhook + Meta Cloud API (stub) |
--- ---
@@ -77,6 +82,18 @@ Sistema integral que combina un catalogo publico de autopartes con un Punto de V
--- ---
## Infraestructura Desplegada
| Servicio | Puerto | Estado |
|----------|--------|--------|
| POS (Gunicorn) | 5001 | Production |
| Dashboard (Flask) | 5000 | Production |
| Quart Async Catalog | 5002 | Production |
| Prometheus | 9090 | Docker |
| Grafana | 3001 | Docker |
| Meilisearch | 7700 | Docker |
| Metabase | 3000 | Docker |
## Quick Start ## Quick Start
### Requisitos ### Requisitos
@@ -178,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

@@ -1,5 +1,5 @@
/* ========================================================================== /* ==========================================================================
NEXUS — Public Catalog Chat Widget NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
Reuses design tokens from tokens.css Reuses design tokens from tokens.css
========================================================================== */ ========================================================================== */
@@ -228,6 +228,99 @@
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); } .chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; } .chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Header Actions (TTS toggle + close) ─── */
.chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-tts-toggle {
background: none;
border: none;
color: #fff;
font-size: 1rem;
cursor: pointer;
padding: 4px;
line-height: 1;
opacity: 0.9;
transition: opacity 0.15s ease;
}
.chat-tts-toggle:hover { opacity: 1; }
.chat-tts-toggle.off { opacity: 0.4; }
/* ─── Mic Button (Voice Input) ─── */
.chat-mic-btn {
width: 38px;
height: 38px;
border-radius: 8px;
border: 1px solid var(--color-border, #333);
background: var(--color-bg-base, #111);
color: var(--color-text-secondary, #aaa);
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chat-mic-btn:hover {
border-color: var(--color-accent, #F5A623);
color: var(--color-accent, #F5A623);
}
.chat-mic-btn.listening {
background: #f85149;
border-color: #f85149;
color: #fff;
animation: micPulse 1.4s infinite;
}
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
}
/* ─── TTS Button ─── */
.chat-tts-btn {
background: none;
border: none;
color: #8b949e;
font-size: 0.85rem;
cursor: pointer;
padding: 2px 4px;
margin-left: 6px;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
.chat-tts-btn.tts-active { color: #58a6ff; }
/* ─── Voice Toast ─── */
.chat-voice-toast {
position: fixed;
bottom: 160px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 8px 18px;
border-radius: 8px;
font-size: 0.85rem;
z-index: 9999;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
.chat-voice-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 480px) { @media (max-width: 480px) {
.chat-panel { .chat-panel {
width: calc(100vw - 16px); width: calc(100vw - 16px);

View File

@@ -1,15 +1,20 @@
// /home/Autopartes/dashboard/chat-public.js // /home/Autopartes/dashboard/chat-public.js
// Public catalog chatbot — no auth required, calls /api/chat // Public catalog chatbot — voice + TTS enabled
(function () { (function () {
'use strict'; 'use strict';
var isOpen = false; var isOpen = false;
var isSending = false; var isSending = false;
var isListening = false;
var recognition = null;
var history = []; var history = [];
var ttsEnabled = true;
var ttsUtterance = null;
var hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
var hasTTS = ('speechSynthesis' in window);
function init() { function init() {
// FAB button
var fab = document.createElement('button'); var fab = document.createElement('button');
fab.className = 'chat-fab'; fab.className = 'chat-fab';
fab.id = 'chatFab'; fab.id = 'chatFab';
@@ -17,14 +22,16 @@
fab.innerHTML = '&#x1F4AC;'; fab.innerHTML = '&#x1F4AC;';
fab.setAttribute('aria-label', 'Abrir asistente IA'); fab.setAttribute('aria-label', 'Abrir asistente IA');
// Chat panel
var panel = document.createElement('div'); var panel = document.createElement('div');
panel.className = 'chat-panel'; panel.className = 'chat-panel';
panel.id = 'chatPanel'; panel.id = 'chatPanel';
panel.innerHTML = panel.innerHTML =
'<div class="chat-header">' + '<div class="chat-header">' +
'<h3>Asistente — Buscar partes</h3>' + '<h3>Asistente — Buscar partes</h3>' +
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>' + '<div class="chat-header-actions">' +
(hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">&#128266;</button>' : '') +
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>' +
'</div>' +
'</div>' + '</div>' +
'<div class="chat-messages" id="chatMessages">' + '<div class="chat-messages" id="chatMessages">' +
'<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' + '<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' +
@@ -32,6 +39,7 @@
'</div>' + '</div>' +
'<div class="chat-input-area">' + '<div class="chat-input-area">' +
'<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' + '<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' +
(hasSpeechAPI ? '<button class="chat-mic-btn" id="chatMic" aria-label="Entrada por voz" title="Entrada por voz">&#127908;</button>' : '') +
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">&#9654;</button>' + '<button class="chat-send-btn" id="chatSend" aria-label="Enviar">&#9654;</button>' +
'</div>'; '</div>';
@@ -52,8 +60,139 @@
this.style.height = 'auto'; this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 80) + 'px'; this.style.height = Math.min(this.scrollHeight, 80) + 'px';
}); });
if (hasSpeechAPI) {
document.getElementById('chatMic').addEventListener('click', toggleVoice);
}
if (hasTTS) {
document.getElementById('chatTtsToggle').addEventListener('click', toggleTTS);
}
// Stop TTS when closing chat
document.getElementById('chatClose').addEventListener('click', function () {
if (hasTTS) stopSpeaking();
});
} }
// ─── TTS ───
function toggleTTS() {
ttsEnabled = !ttsEnabled;
var btn = document.getElementById('chatTtsToggle');
if (btn) {
btn.classList.toggle('off', !ttsEnabled);
btn.setAttribute('title', ttsEnabled ? 'Desactivar lectura de respuestas' : 'Activar lectura de respuestas');
}
if (!ttsEnabled) stopSpeaking();
}
function speak(text) {
if (!hasTTS || !ttsEnabled || !text) return;
stopSpeaking();
ttsUtterance = new SpeechSynthesisUtterance(text);
ttsUtterance.lang = 'es-MX';
ttsUtterance.rate = 1.1;
ttsUtterance.pitch = 1;
window.speechSynthesis.speak(ttsUtterance);
}
function stopSpeaking() {
if (hasTTS && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
ttsUtterance = null;
}
// ─── Voice Input ───
function toggleVoice() {
if (isListening) { stopVoice(); return; }
startVoice();
}
function startVoice() {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) return;
recognition = new SpeechRecognition();
recognition.lang = 'es-MX';
recognition.continuous = false;
recognition.interimResults = true;
var input = document.getElementById('chatInput');
var micBtn = document.getElementById('chatMic');
var savedPlaceholder = input.placeholder;
recognition.onstart = function () {
isListening = true;
micBtn.classList.add('listening');
input.placeholder = 'Escuchando...';
input.value = '';
stopSpeaking();
};
recognition.onresult = function (e) {
var interim = '';
var finalTranscript = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
finalTranscript += e.results[i][0].transcript;
} else {
interim += e.results[i][0].transcript;
}
}
if (finalTranscript) {
input.value = finalTranscript;
} else {
input.value = interim;
}
};
recognition.onend = function () {
isListening = false;
micBtn.classList.remove('listening');
input.placeholder = savedPlaceholder;
recognition = null;
if (input.value.trim()) {
sendMessage();
}
};
recognition.onerror = function (e) {
isListening = false;
micBtn.classList.remove('listening');
input.placeholder = savedPlaceholder;
recognition = null;
if (e.error === 'no-speech' || e.error === 'audio-capture' || e.error === 'not-allowed') {
showVoiceToast('No se detecto voz');
}
};
recognition.start();
}
function stopVoice() {
if (recognition) {
recognition.abort();
recognition = null;
}
isListening = false;
var micBtn = document.getElementById('chatMic');
if (micBtn) micBtn.classList.remove('listening');
}
function showVoiceToast(msg) {
var toast = document.createElement('div');
toast.className = 'chat-voice-toast';
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(function () { toast.classList.add('visible'); }, 10);
setTimeout(function () {
toast.classList.remove('visible');
setTimeout(function () { toast.remove(); }, 300);
}, 2000);
}
// ─── Chat UI ───
function toggleChat() { function toggleChat() {
isOpen = !isOpen; isOpen = !isOpen;
var panel = document.getElementById('chatPanel'); var panel = document.getElementById('chatPanel');
@@ -104,6 +243,8 @@
addBubble(aiMsg, 'ai'); addBubble(aiMsg, 'ai');
history.push({ role: 'assistant', content: aiMsg }); history.push({ role: 'assistant', content: aiMsg });
if (ttsEnabled) speak(aiMsg);
if (data.search_results && data.search_results.length > 0) { if (data.search_results && data.search_results.length > 0) {
addPartResults(data.search_results); addPartResults(data.search_results);
} }
@@ -149,7 +290,6 @@
card.style.cursor = 'pointer'; card.style.cursor = 'pointer';
card.addEventListener('click', function () { card.addEventListener('click', function () {
// Search in catalog
var searchInput = document.getElementById('searchInput'); var searchInput = document.getElementById('searchInput');
if (searchInput && partNum) { if (searchInput && partNum) {
searchInput.value = partNum; searchInput.value = partNum;

View File

@@ -1,5 +1,5 @@
/* ========================================================================== /* ==========================================================================
NEXUS — Public Catalog Chat Widget NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
Reuses design tokens from tokens.css Reuses design tokens from tokens.css
========================================================================== */ ========================================================================== */
@@ -228,6 +228,99 @@
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); } .chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; } .chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Header Actions (TTS toggle + close) ─── */
.chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-tts-toggle {
background: none;
border: none;
color: #fff;
font-size: 1rem;
cursor: pointer;
padding: 4px;
line-height: 1;
opacity: 0.9;
transition: opacity 0.15s ease;
}
.chat-tts-toggle:hover { opacity: 1; }
.chat-tts-toggle.off { opacity: 0.4; }
/* ─── Mic Button (Voice Input) ─── */
.chat-mic-btn {
width: 38px;
height: 38px;
border-radius: 8px;
border: 1px solid var(--color-border, #333);
background: var(--color-bg-base, #111);
color: var(--color-text-secondary, #aaa);
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chat-mic-btn:hover {
border-color: var(--color-accent, #F5A623);
color: var(--color-accent, #F5A623);
}
.chat-mic-btn.listening {
background: #f85149;
border-color: #f85149;
color: #fff;
animation: micPulse 1.4s infinite;
}
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
}
/* ─── TTS Button ─── */
.chat-tts-btn {
background: none;
border: none;
color: #8b949e;
font-size: 0.85rem;
cursor: pointer;
padding: 2px 4px;
margin-left: 6px;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
.chat-tts-btn.tts-active { color: #58a6ff; }
/* ─── Voice Toast ─── */
.chat-voice-toast {
position: fixed;
bottom: 160px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 8px 18px;
border-radius: 8px;
font-size: 0.85rem;
z-index: 9999;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
.chat-voice-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 480px) { @media (max-width: 480px) {
.chat-panel { .chat-panel {
width: calc(100vw - 16px); width: calc(100vw - 16px);

File diff suppressed because one or more lines are too long

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

@@ -1,4 +1,4 @@
from flask import Flask, jsonify, request, send_from_directory, redirect, g from flask import Flask, jsonify, request, send_from_directory, redirect, g, abort
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -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,87 @@ 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)
# ============================================================================
@app.route('/<filename>')
def serve_root_static(filename):
if filename.endswith(('.css', '.js', '.html')) and os.path.isfile(filename):
return send_from_directory('.', filename)
abort(404)
# ============================================================================ # ============================================================================
# Main Block # Main Block
# ============================================================================ # ============================================================================

View File

@@ -0,0 +1,34 @@
global:
smtp_smarthost: 'localhost:587'
smtp_from: 'alerts@nexus.local'
smtp_require_tls: false
route:
group_by: ['alertname', 'severity']
group_wait: 10s
group_interval: 10s
repeat_interval: 1h
receiver: 'default'
receivers:
- name: 'default'
email_configs:
- to: 'admin@nexus.local'
subject: 'Nexus Alert: {{ .GroupLabels.alertname }}'
body: |
{{ range .Alerts }}
Alert: {{ .Annotations.summary }}
Description: {{ .Annotations.description }}
Severity: {{ .Labels.severity }}
Time: {{ .StartsAt }}
{{ end }}
webhook_configs:
- url: 'http://localhost:5001/pos/api/notifications/webhook'
send_resolved: true
inhibit_rules:
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname']

View File

@@ -0,0 +1,74 @@
version: "3.8"
services:
prometheus:
image: prom/prometheus:v2.51.0
container_name: nexus-prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus:/etc/prometheus
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
grafana:
image: grafana/grafana:10.4.1
container_name: nexus-grafana
restart: unless-stopped
ports:
- "3001:3000"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=nexus2026
- GF_USERS_ALLOW_SIGN_UP=false
node-exporter:
image: prom/node-exporter:v1.7.0
container_name: nexus-node-exporter
restart: unless-stopped
network_mode: host
postgres-exporter:
image: prometheuscommunity/postgres-exporter:v0.15.0
container_name: nexus-postgres-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://postgres@172.17.0.1:5432/nexus_autoparts?sslmode=disable"
ports:
- "9187:9187"
redis-exporter:
image: oliver006/redis_exporter:v1.58.0
container_name: nexus-redis-exporter
restart: unless-stopped
environment:
REDIS_ADDR: "redis://172.17.0.1:6379"
ports:
- "9121:9121"
alertmanager:
image: prom/alertmanager:v0.27.0
container_name: nexus-alertmanager
restart: unless-stopped
ports:
- "9093:9093"
volumes:
- ./alertmanager:/etc/alertmanager
- alertmanager-data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
volumes:
prometheus-data:
grafana-data:
alertmanager-data:

View File

@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: 'nexus-dashboards'
orgId: 1
folder: 'Nexus'
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards

View File

@@ -0,0 +1,149 @@
{
"uid": "nexus-app",
"title": "Nexus — Application",
"tags": ["gunicorn", "flask"],
"timezone": "browser",
"schemaVersion": 36,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"query": "prometheus",
"current": {
"selected": false,
"text": "Prometheus",
"value": "Prometheus"
}
}
]
},
"panels": [
{
"id": 1,
"title": "Request Rate (nginx)",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(nginx_http_requests_total[5m])",
"legendFormat": "Requests/sec",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "reqps",
"min": 0
},
"overrides": []
}
},
{
"id": 2,
"title": "Response Time (nginx)",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "histogram_quantile(0.95, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "histogram_quantile(0.99, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p99",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "s",
"min": 0
},
"overrides": []
}
},
{
"id": 3,
"title": "Active Workers",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "count by (instance) (node_processes_state{state=\"S\", cmdline=~\".*gunicorn.*\"})",
"legendFormat": "Workers {{instance}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "short",
"min": 0
},
"overrides": []
}
},
{
"id": 4,
"title": "5xx Errors",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(nginx_http_requests_total{status=~\"5..\"}[5m])",
"legendFormat": "5xx/sec",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "reqps",
"min": 0
},
"overrides": []
}
},
{
"id": 5,
"title": "Memory per Worker",
"type": "timeseries",
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "process_resident_memory_bytes{cmdline=~\".*gunicorn.*\"} / 1024 / 1024",
"legendFormat": "{{cmdline}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "mbytes",
"min": 0
},
"overrides": []
}
}
]
}

View File

@@ -0,0 +1,185 @@
{
"uid": "nexus-postgresql",
"title": "Nexus — PostgreSQL",
"tags": ["postgres", "database"],
"timezone": "browser",
"schemaVersion": 36,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"query": "prometheus",
"current": {
"selected": false,
"text": "Prometheus",
"value": "Prometheus"
}
}
]
},
"panels": [
{
"id": 1,
"title": "Active Connections",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "pg_stat_activity_count",
"legendFormat": "Connections",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "short",
"min": 0
},
"overrides": []
}
},
{
"id": 2,
"title": "Transactions / sec",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(pg_stat_database_xact_commit[5m])",
"legendFormat": "Commits",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(pg_stat_database_xact_rollback[5m])",
"legendFormat": "Rollbacks",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "tps",
"min": 0
},
"overrides": []
}
},
{
"id": 3,
"title": "Cache Hit Ratio",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "pg_stat_database_blks_hit / (pg_stat_database_blks_hit + pg_stat_database_blks_read)",
"legendFormat": "Hit Ratio",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"id": 4,
"title": "WAL Generation",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(pg_stat_bgwriter_buffers_backend_fsync[5m])",
"legendFormat": "Backend fsync",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(pg_stat_bgwriter_buffers_backend[5m])",
"legendFormat": "Backend buffers",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "ops",
"min": 0
},
"overrides": []
}
},
{
"id": 5,
"title": "Slow Queries",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "pg_stat_activity_count{state=\"active\"}",
"legendFormat": "Active queries",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "short",
"min": 0
},
"overrides": []
}
},
{
"id": 6,
"title": "Table Bloat",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "pg_stat_user_tables_n_live_tup",
"legendFormat": "Live tuples",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "pg_stat_user_tables_n_dead_tup",
"legendFormat": "Dead tuples",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "short",
"min": 0
},
"overrides": []
}
}
]
}

View File

@@ -0,0 +1,173 @@
{
"uid": "nexus-redis",
"title": "Nexus — Redis",
"tags": ["redis", "cache"],
"timezone": "browser",
"schemaVersion": 36,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"query": "prometheus",
"current": {
"selected": false,
"text": "Prometheus",
"value": "Prometheus"
}
}
]
},
"panels": [
{
"id": 1,
"title": "Memory Usage",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "redis_memory_used_bytes",
"legendFormat": "Used memory",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "bytes",
"min": 0
},
"overrides": []
}
},
{
"id": 2,
"title": "Commands / sec",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(redis_commands_processed_total[5m])",
"legendFormat": "Commands/sec",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "cps",
"min": 0
},
"overrides": []
}
},
{
"id": 3,
"title": "Connected Clients",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "redis_connected_clients",
"legendFormat": "Clients",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "short",
"min": 0
},
"overrides": []
}
},
{
"id": 4,
"title": "Cache Hit Ratio",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)",
"legendFormat": "Hit Ratio",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"id": 5,
"title": "Keyspace Hits / Misses",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(redis_keyspace_hits_total[5m])",
"legendFormat": "Hits/sec",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(redis_keyspace_misses_total[5m])",
"legendFormat": "Misses/sec",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "cps",
"min": 0
},
"overrides": []
}
},
{
"id": 6,
"title": "Evicted Keys",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(redis_evicted_keys_total[5m])",
"legendFormat": "Evicted/sec",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "cps",
"min": 0
},
"overrides": []
}
}
]
}

View File

@@ -0,0 +1,164 @@
{
"uid": "nexus-system",
"title": "Nexus — System",
"tags": ["node", "system"],
"timezone": "browser",
"schemaVersion": 36,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"templating": {
"list": [
{
"name": "datasource",
"type": "datasource",
"query": "prometheus",
"current": {
"selected": false,
"text": "Prometheus",
"value": "Prometheus"
}
}
]
},
"panels": [
{
"id": 1,
"title": "CPU Usage %",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU %",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
}
},
{
"id": 2,
"title": "Memory Usage %",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)",
"legendFormat": "Memory %",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
}
},
{
"id": 3,
"title": "Disk Usage %",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "100 * (1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|ramfs\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|ramfs\"})",
"legendFormat": "{{mountpoint}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
}
},
{
"id": 4,
"title": "Network I/O",
"type": "timeseries",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(node_network_receive_bytes_total[5m])",
"legendFormat": "Receive {{device}}",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "rate(node_network_transmit_bytes_total[5m])",
"legendFormat": "Transmit {{device}}",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "Bps",
"min": 0
},
"overrides": []
}
},
{
"id": 5,
"title": "Load Average",
"type": "timeseries",
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "node_load1",
"legendFormat": "1m load",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "node_load5",
"legendFormat": "5m load",
"refId": "B"
},
{
"datasource": {"type": "prometheus", "uid": "${datasource}"},
"expr": "node_load15",
"legendFormat": "15m load",
"refId": "C"
}
],
"fieldConfig": {
"defaults": {
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
"unit": "short",
"min": 0
},
"overrides": []
}
}
]
}

View File

@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@@ -0,0 +1,47 @@
groups:
- name: nexus-alerts
rules:
- alert: PostgreSQLDown
expr: pg_up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "PostgreSQL is down"
description: "PostgreSQL has been down for more than 1 minute."
- alert: RedisDown
expr: redis_up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Redis is down"
description: "Redis has been down for more than 1 minute."
- alert: HighDiskUsage
expr: (node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 < 10
for: 5m
labels:
severity: warning
annotations:
summary: "Disk usage is high"
description: "Disk usage is above 90% on {{ $labels.device }}."
- alert: HighMemoryUsage
expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100 > 85
for: 5m
labels:
severity: warning
annotations:
summary: "Memory usage is high"
description: "Memory usage is above 85%."
- alert: NodeDown
expr: up{job="node"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Node exporter is down"
description: "Node exporter has been down for more than 2 minutes."

View File

@@ -0,0 +1,40 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
# Alertmanager configuration
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
# Load rules once and periodically evaluate them
rule_files:
- 'alerts.yml'
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']
- job_name: 'postgres'
static_configs:
- targets: ['postgres-exporter:9187']
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
- job_name: 'nexus-pos'
static_configs:
- targets: ['host.docker.internal:5001']
metrics_path: /metrics
- job_name: 'nexus-quart'
static_configs:
- targets: ['host.docker.internal:5002']
metrics_path: /metrics

View File

@@ -1430,3 +1430,153 @@ Todos los errores retornan:
```json ```json
{"error": "Descripcion del error"} {"error": "Descripcion del error"}
``` ```
---
## 11. BNPL (Buy Now Pay Later) — Stub
Prefix: `/pos/api/bnpl`
Stubs ready for APLAZO, Kueski, Clip integration.
### GET /providers
List configured BNPL providers.
**Response 200:**
```json
{
"providers": [
{"id": "ap lazo", "name": "APLAZO", "enabled": false, "config_needed": ["api_key", "merchant_id"]},
{"id": "kueski", "name": "Kueski Pay", "enabled": false, "config_needed": ["api_key", "secret"]},
{"id": "clip", "name": "Clip Pagos", "enabled": false, "config_needed": ["api_key"]}
]
}
```
### POST /applications
Create a BNPL application for a sale.
**Request body:**
```json
{
"sale_id": 123,
"amount": 1500.00,
"provider": "ap lazo",
"customer": {"name": "Juan Perez", "phone": "5512345678"}
}
```
### GET /applications/:id
Get application status.
### POST /webhook/:provider
Receive provider webhooks.
---
## 12. ERP Sync — Stub
Prefix: `/pos/api/erp`
Stubs ready for Aspel SAE, CONTPAQi, SAP B1, Odoo.
### GET /providers
List supported ERP systems.
### POST /sync
Start a sync job.
**Request body:**
```json
{
"provider": "aspel_sae",
"sync_type": "sales"
}
```
### GET /sync/:job_id
Get sync job status.
### POST /sync/:job_id/run
Mock execute sync job.
---
## 13. WhatsApp Business API (Meta Cloud) — Stub
Prefix: `/pos/api/whatsapp-cloud`
### GET/POST /webhook
Meta Cloud API webhook verification and message reception.
### GET /status
Check Meta Cloud API connection status.
### GET /templates
List approved message templates.
### POST /messages
Send a message.
**Request body:**
```json
{
"to": "5215512345678",
"body": "Su orden esta lista",
"template": "order_ready"
}
```
---
## 14. Supplier Portal
Prefix: `/pos/api/supplier-portal`
### GET /demand
Aggregated demand by zone, part group, and time range.
**Query params:** `days` (default 30), `group_id`, `branch_id`
### GET /top-parts
Top 50 moving parts with current stock.
**Query params:** `days` (default 30)
---
## 15. Dashboard Stats
Prefix: `/pos/api/dashboard`
### GET /stats
Summary stats for today and this month.
**Response 200:**
```json
{
"today": {"sales_count": 42, "sales_total": 12500.00},
"month": {"sales_count": 1200, "sales_total": 450000.00},
"top_products": [...],
"hourly_sales": [...]
}
```
### GET /stats/employees
Sales per employee today.

View File

@@ -447,3 +447,42 @@ const data = await res.json();
### Nota sobre NUMERIC de PostgreSQL ### Nota sobre NUMERIC de PostgreSQL
PostgreSQL retorna valores `NUMERIC` como strings. Todas las funciones de formato en JS usan `parseFloat()` para convertir antes de mostrar. PostgreSQL retorna valores `NUMERIC` como strings. Todas las funciones de formato en JS usan `parseFloat()` para convertir antes de mostrar.
---
## Infraestructura y Monitoreo (2026-04)
### Servicios Systemd
| Servicio | Descripcion | Puerto |
|----------|-------------|--------|
| `nexus-pos.service` | Gunicorn POS (Flask) | 5001 |
| `nexus.service` | Dashboard (Flask) | 5000 |
| `nexus-quart.service` | Hypercorn async catalog | 5002 |
| `nexus-celery.service` | Celery workers | — |
| `nexus-mv-refresh.timer` | Refresh MV diario 03:00 UTC | — |
| `nexus-cache-warm.timer` | Cache warming diario 04:00 UTC | — |
### Monitoreo (Prometheus + Grafana)
- **Prometheus** :9090 — métricas de sistema, PostgreSQL, Redis
- **Grafana** :3001 — dashboards visuales (login admin/nexus2026)
- Exporters: node-exporter, postgres-exporter, redis-exporter
### Stubs de Integraciones de Negocio
- **BNPL** (`bnpl_bp.py`): APLAZO, Kueski, Clip
- **ERP Sync** (`erp_bp.py`): Aspel SAE, CONTPAQi, SAP B1, Odoo
- **WhatsApp Meta Cloud** (`whatsapp_cloud_bp.py`): reemplazo escalable de Baileys
- **Supplier Portal** (`supplier_portal_bp.py`): demanda por zona y top partes
### PWA
- Service Worker (`pos/static/pwa/sw.js`) con cache-first para assets
- Install prompt (`pos/static/js/pwa-install.js`) captura `beforeinstallprompt`
- Manifest registrado en 14 templates POS
### Tests E2E
- Playwright + Chromium
- Primer smoke test: login page loads and rejects invalid credentials

46
docs/CADDY_CONFIG.md Normal file
View File

@@ -0,0 +1,46 @@
# Caddy Config for nexusautoparts.com.mx
## VM with Caddy: 192.168.10.74
## VM with POS/Dashboard: 192.168.10.91
Add this to `/etc/caddy/Caddyfile` on the Caddy VM (192.168.10.74):
```caddyfile
# Landing page / Dashboard
nexusautoparts.com.mx, www.nexusautoparts.com.mx {
reverse_proxy 192.168.10.91:80
}
# POS (point of sale app)
pos.nexusautoparts.com.mx {
reverse_proxy 192.168.10.91:80
}
# Dashboard admin (optional alternative access)
admin.nexusautoparts.com.mx {
reverse_proxy 192.168.10.91:80
}
# Legacy domain (optional, redirect or keep)
nexus.consultoria-as.com {
reverse_proxy 192.168.10.91:80
}
```
Then reload Caddy:
```bash
sudo systemctl reload caddy
# or
sudo caddy reload --config /etc/caddy/Caddyfile
```
Caddy will automatically obtain Let's Encrypt certificates for all domains.
## DNS Records needed in Hostinger
| Record | Name | Target |
|---|---|---|
| A | @ | IP of Caddy VM |
| CNAME | www | nexusautoparts.com.mx |
| CNAME | pos | nexusautoparts.com.mx |
| CNAME | admin | nexusautoparts.com.mx |

View File

@@ -1,8 +1,9 @@
# Nexus POS — Resumen de Fases Implementadas # Nexus POS — Resumen de Fases Implementadas
**Fecha:** 2026-04-27 **Fecha:** 2026-06-11
**Versión DB:** v3.2 **Versión DB:** v4.1
**Tests:** 108/108 pasando (pytest) + 207 checks (scripts standalone) **Tests:** 73/73 pasando (pytest)
**Commit:** `2b73c2c`
--- ---
@@ -17,8 +18,6 @@
## FASE 3: Multi-sucursal + Alertas + Garantías ## FASE 3: Multi-sucursal + Alertas + Garantías
### Migración: v2.2
| Feature | Archivos | Endpoints | | Feature | Archivos | Endpoints |
|---------|----------|-----------| |---------|----------|-----------|
| **Multi-sucursal** | `inventory_engine.py`, `inventory_bp.py` | `GET /pos/api/inventory/stock-by-branch`, `POST /pos/api/inventory/transfers`, `POST /pos/api/inventory/sync-prices` | | **Multi-sucursal** | `inventory_engine.py`, `inventory_bp.py` | `GET /pos/api/inventory/stock-by-branch`, `POST /pos/api/inventory/transfers`, `POST /pos/api/inventory/sync-prices` |
@@ -27,8 +26,6 @@
## FASE 4: Infraestructura + Escalabilidad ## FASE 4: Infraestructura + Escalabilidad
### Migraciones: v1.9, v2.0, v2.1, v2.3
| Feature | Archivos | Infra | | Feature | Archivos | Infra |
|---------|----------|-------| |---------|----------|-------|
| **Redis Cache** | `redis_stock_cache.py`, `inventory_engine.py` | Redis 8.0.2, TTL 300s, fallback a PostgreSQL | | **Redis Cache** | `redis_stock_cache.py`, `inventory_engine.py` | Redis 8.0.2, TTL 300s, fallback a PostgreSQL |
@@ -39,8 +36,6 @@
## FASE 5: CRM + Service Orders + Imágenes ## FASE 5: CRM + Service Orders + Imágenes
### Migraciones: v2.4, v2.5, v2.6
| Feature | Archivos | Capacidades | | Feature | Archivos | Capacidades |
|---------|----------|-------------| |---------|----------|-------------|
| **CRM Mejorado** | `crm_engine.py`, `crm_bp.py` | Activities timeline, tags de segmentación, loyalty program (bronze/silver/gold/platinum), analytics (LTV, churn risk, categorías favoritas) | | **CRM Mejorado** | `crm_engine.py`, `crm_bp.py` | Activities timeline, tags de segmentación, loyalty program (bronze/silver/gold/platinum), analytics (LTV, churn risk, categorías favoritas) |
@@ -53,8 +48,6 @@
## FASE 6: Notificaciones + Ahorro + Logística + API Pública ## FASE 6: Notificaciones + Ahorro + Logística + API Pública
### Migraciones: v2.7, v2.8, v2.9, v3.0
| Feature | Archivos | Capacidades | | Feature | Archivos | Capacidades |
|---------|----------|-------------| |---------|----------|-------------|
| **Notificaciones** | `notification_engine.py`, `notification_bp.py` | Templates por evento+canal, dispatch automático (push/WhatsApp/email/in-app), logs con estados, eventos: low_stock, order_ready, maintenance_due, new_sale, po_received, reorder_alert, warranty_expiring | | **Notificaciones** | `notification_engine.py`, `notification_bp.py` | Templates por evento+canal, dispatch automático (push/WhatsApp/email/in-app), logs con estados, eventos: low_stock, order_ready, maintenance_due, new_sale, po_received, reorder_alert, warranty_expiring |
@@ -64,8 +57,6 @@
## FASE 7: Performance Optimización ## FASE 7: Performance Optimización
### Migración: v3.2
| Sub-fase | Archivos | Optimizaciones | | Sub-fase | Archivos | Optimizaciones |
|----------|----------|----------------| |----------|----------|----------------|
| **7a — Quick Wins Frontend** | `nginx/nexus-pos.conf`, `pos/templates/*.html`, `pos/static/js/catalog.js` | gzip nginx, `defer` en scripts, fix `innerHTML +=` (8 lugares), event delegation cart, AbortController, sessionStorage cache years/brands | | **7a — Quick Wins Frontend** | `nginx/nexus-pos.conf`, `pos/templates/*.html`, `pos/static/js/catalog.js` | gzip nginx, `defer` en scripts, fix `innerHTML +=` (8 lugares), event delegation cart, AbortController, sessionStorage cache years/brands |
@@ -81,18 +72,71 @@
- Ventas 20 ítems: 21 queries → 1 query - Ventas 20 ítems: 21 queries → 1 query
- Cache hit rate: 6% → 80%+ - Cache hit rate: 6% → 80%+
## Opción C — Consolidación Técnica (COMPLETADA)
| Item | Estado | Commit |
|------|--------|--------|
| **C1: MV `part_vehicle_preview`** | ✅ En producción, refresh automático vía systemd timer (03:00 UTC) | `f893391` |
| **C2: Cache warming script** | ✅ Autónomo con auto-sudo fallback, args CLI | `f893391` |
| **C3: CSS dinámico residual** | ✅ `sidebar.js``sidebar.css`, `pos-utils.js``common.css` | `042acd6` |
| **C4: Load testing script** | ✅ `scripts/load_test.py` con `locust` | `042acd6` |
| **C5: Docs audit** | ✅ `FASES_IMPLEMENTADAS.md`, `performance_audit_2026.md` | `042acd6` |
## Opción A — Arquitectura Avanzada (COMPLETADA)
| Item | Estado | Commit |
|------|--------|--------|
| **A1: `orjson` como JSON provider** | ✅ Hereda `DefaultJSONProvider`, fix indent en `pos_bp.py` | `a1be8dd` |
| **A2: Virtual scroll** | ✅ `inventory.js`, `customers.js`, `fleet.js` | `a1be8dd` |
| **A3: Celery worker queue** | ✅ `celery_app.py`, `tasks.py`, `tasks_bp.py`, systemd service activo | `a1be8dd` |
| **A4: Quart + asyncpg PoC** | ✅ `async_catalog.py` en puerto 5002, benchmark script | `a1be8dd` |
| **A5: Particionamiento `vehicle_parts`** | ✅ Script `partition_vehicle_parts.py` listo (HASH 16 particiones, dry-run) | `a1be8dd` |
## IA por Voz — Chalán de Nexus (COMPLETADA)
| Componente | Estado |
|------------|--------|
| **STT (Speech-to-Text)** | ✅ POS + Dashboard público, `es-MX`, auto-send, animación micrófono |
| **TTS (Text-to-Speech)** | ✅ Botón 🔊 en burbujas de IA, `speechSynthesis`, preferencia guardada en `localStorage` |
| **Cobertura templates POS** | ✅ 14/14 templates tienen chat widget |
| **Dashboard público** | ✅ Chat público con voz completa (sin cámara) |
## QWEN 3.6 AI Vehicle Fitment (COMPLETADA)
| Componente | Archivo | Descripción |
|------------|---------|-------------|
| **Servicio QWEN** | `pos/services/qwen_fitment.py` | Consulta API OpenAI-compatible (`qwen3.6`) con part_number + name + brand; parsea JSON robusto (vehicles/confidence/notes); valida contra `model_year_engine` |
| **Integración Inventario** | `pos/blueprints/inventory_bp.py` | `create_item()` llama QWEN después de TecDoc auto-match; inserta en `inventory_vehicle_compat` con `source='qwen_ai'` |
| **UI** | `pos/static/js/inventory.js`, `pos/templates/inventory.html` | Toast muestra count de vehículos asignados por IA; tabla de compatibilidad muestra columna "Origen" |
| **Retry / Fallback** | `qwen_fitment.py` | 3 reintentos ante respuesta vacía; fallback sin filtro de motor (descripciones de motor rara vez coinciden entre QWEN y TecDB); búsqueda parcial de modelo (`%Corolla%`) para nombres TecDoc |
**Flujo:**
1. Usuario crea ítem de inventario (part_number, name, brand)
2. TecDoc auto-match ejecuta primero (si hay coincidencia exacta)
3. QWEN 3.6 recibe los datos y devuelve lista de vehículos compatibles en JSON
4. Cada vehículo se busca en la DB maestra (fuzzy match por modelo, fallback sin motor)
5. Los `model_year_engine_id` válidos se insertan en `inventory_vehicle_compat` con `source='qwen_ai'`
6. Frontend muestra toast: "27 vehículo(s) asignado(s) por IA"
**Fail-safe:** Si QWEN no está configurado o la API falla, el ítem se crea normalmente; la asignación de vehículos se omite silenciosamente.
--- ---
## Infraestructura Desplegada ## Infraestructura Desplegada
| Servicio | Versión | Puerto | Estado | | Servicio | Versión | Puerto | Estado |
|----------|---------|--------|--------| |----------|---------|--------|--------|
| PostgreSQL | 17 | 5432 | ✅ Master + 2 tenants | | PostgreSQL | 17 | 5432 | ✅ Optimizado (8GB shared_buffers, 64MB work_mem, 8GB max_wal_size) |
| Redis | 8.0.2 | 6379 | ✅ Stock cache + classify cache | | Redis | 8.0.2 | 6379 | ✅ Stock cache + classify cache |
| Meilisearch | v1.12 | 7700 | ✅ 1,546,976 documentos | | Meilisearch | v1.12 | 7700 | ✅ 1,546,976 documentos |
| Metabase | v0.53 | 3000 | ✅ Dashboard ID 2 | | Metabase | v0.53 | 3000 | ✅ Dashboard ID 2 |
| Nginx | — | 80/443 | ✅ gzip, cache 6M, auto-serve .min | | Nginx | — | 80/443 | ✅ gzip, cache 6M, auto-serve .min |
| Gunicorn | — | 5001 | ✅ gthread, 4×4, max_requests=1000 | | Gunicorn POS | — | 5001 | ✅ systemd `nexus-pos.service`, gthread 4×4 |
| Gunicorn Dashboard | — | 5000 | ✅ systemd `nexus.service` |
| Quart Catalog | — | 5002 | ✅ systemd `nexus-quart.service`, hypercorn |
| Celery | — | — | ✅ 4 prefork workers, broker redis://localhost:6379/1 |
| Prometheus | v2.51 | 9090 | ✅ Docker, node/postgres/redis exporters |
| Grafana | v10.4 | 3001 | ✅ Docker, auto-provisioned Prometheus datasource |
--- ---
@@ -125,6 +169,11 @@ WHATSAPP_BRIDGE_KEY=
# AI (opcional) # AI (opcional)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# QWEN AI Fitment (opcional)
QWEN_API_URL=https://api.nan.builders/v1
QWEN_API_KEY=
QWEN_MODEL=qwen3.6
# Metabase (opcional) # Metabase (opcional)
METABASE_DB_PASS= METABASE_DB_PASS=
METABASE_URL=http://localhost:3000 METABASE_URL=http://localhost:3000
@@ -132,28 +181,104 @@ METABASE_URL=http://localhost:3000
--- ---
## Próximos Pasos (Roadmap restante) ## ✅ Completados recientemente
### Opción C — Consolidación Técnica (en progreso) | # | Mejora | Fecha | Commit |
1. **Materialized view `part_vehicle_preview`** — Fallback robusto al Redis cache para vehicle info |---|--------|-------|--------|
2. **Fix cache warming script** — Autonomía sin `sudo -u postgres` | — | **Particionar `vehicle_parts` en producción** | 2026-04-26 | `f24f25e` |
3. **CSS dinámico residual** — Extraer CSS inyectado por JS a archivos externos | — | **Quart async catalog en producción** | 2026-04-26 | `b829e4f` |
4. **Load testing script** — Benchmark básico de endpoints críticos | — | **Arreglar `scripts/minify-assets.sh`** | 2026-04-26 | `b829e4f` |
5. **Docs audit** — Corregir métricas y marcar estado post-FASE 7 | — | **Dashboard outage fix (env vars + static files)** | 2026-04-26 | `27cb4ee` |
| — | **IA por Voz (STT + TTS) en POS y Dashboard** | 2026-04-26 | `afb3b24` |
| — | **Fix chat.js null reference (`chatTtsToggle`)** | 2026-04-29 | `44c3a6c` |
| — | **Optimizar PostgreSQL config + restart** | 2026-04-29 | — |
| — | **Cache warming systemd timer** | 2026-04-29 | `c766571` |
| — | **Monitoreo Prometheus + Grafana** | 2026-04-29 | `4b3b0f8` |
| — | **PWA install prompt** | 2026-04-29 | `3b8224d` |
| — | **Playwright E2E tests** | 2026-04-29 | `c4db5e7` |
| — | **Dashboard in-app charts** | 2026-04-29 | `12989e3` |
| — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` |
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
### Opción A — Arquitectura (pendiente) ## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global
1. **Serialización `orjson`** — 2-10× faster JSON
2. **Virtual scroll** — Tablas grandes sin lag
3. **Celery worker queue** — Tareas pesadas async
4. **Asyncpg + Quart PoC** — Evaluar I/O no bloqueante para catálogo
5. **Particionar `vehicle_parts`** — Escalabilidad ilimitada (254 GB → particiones)
### Features de Negocio (futuro) **Commit:** `2b73c2c` (2026-06-11)
1. **Mercado Libre / Amazon sync** — Publicar inventario en marketplaces
2. **IA por voz (Chalán de Nexus)** — Web Speech API → chatbot existente ### 7.1 Lista de Precios de Proveedor
3. **PWA mejorada** — Offline mode, install prompt, background sync
4. **Portal de proveedores** — Demand analytics, heatmaps, stock recommendations | Feature | Archivos | Capacidades |
5. **Dashboard in-app** — Gráficos de rendimiento en tiempo real |---------|----------|-------------|
| **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)
### 🔴 Crítico — Deuda Técnica
*Sin items críticos pendientes.*
### 🟠 Alto — Features de Negocio (requieren integración con terceros)
| # | Mejora | Descripción | Esfuerzo | Notas |
|---|--------|-------------|----------|-------|
| 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`) |
| 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 | **Parcialmente listo** — ver Fase 7.4 |
### 🟡 Medio — Diferenciadores
| # | Mejora | Descripción | Esfuerzo |
|---|--------|-------------|----------|
| 5 | **App móvil nativa (Capacitor)** | Wrap del POS como app iOS/Android. Camera nativa, push notifications, biometrics. | 3-4 semanas |
| 6 | **Crédito basado en comportamiento** | Evaluación automática de línea de crédito por historial de pagos del cliente. | 2 semanas |
| 7 | **Programa de embajadores** | Referidos con recompensas, tracking de conversiones. | 1 semana |
### 🟢 Bajo — Polish
| # | Mejora | Descripción |
|---|--------|-------------|
| 8 | **Backup automatizado** | Último backup 2026-04-27. Automatizar con cron + S3/GCS. |
| 9 | **Grafana dashboards predefinidos** | Actualmente solo datasource auto-provisionado. Falta crear dashboards JSON para PostgreSQL, Redis, Gunicorn. |
| 10 | **Alertas Prometheus** | Alertmanager para notificaciones cuando PostgreSQL, Redis o Gunicorn fallen. |
| 11 | **Tests E2E adicionales** | Playwright: checkout, búsqueda de catálogo, flujo de inventario. |
| 12 | **Service Worker mejorado** | Background sync real para carrito offline, notificaciones push. |
--- ---

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

View File

@@ -280,3 +280,39 @@ sudo -u postgres psql tenant_mi_refaccionaria -c "SELECT name, role FROM employe
| "Tenant not found" | Verificar que `provision_tenant` se ejecuto correctamente | | "Tenant not found" | Verificar que `provision_tenant` se ejecuto correctamente |
| PIN bloqueado | Esperar 15 minutos o reiniciar el servicio POS | | PIN bloqueado | Esperar 15 minutos o reiniciar el servicio POS |
| Base de datos no conecta | Verificar credenciales en `pos/config.py` | | Base de datos no conecta | Verificar credenciales en `pos/config.py` |
---
## Servicios Systemd (Produccion)
Despues de la instalacion, copiar los servicios y habilitarlos:
```bash
sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nexus-pos.service nexus.service nexus-celery.service nexus-quart.service
sudo systemctl enable --now nexus-mv-refresh.timer nexus-cache-warm.timer
```
## Monitoreo (Opcional)
Levantar Prometheus + Grafana:
```bash
cd docker
docker compose -f docker-compose.monitoring.yml up -d
```
- Grafana: http://servidor:3001 (admin / nexus2026)
- Prometheus: http://servidor:9090
## PWA (Instalable)
El POS ya incluye Service Worker y manifest. Al abrir cualquier pagina del POS en Chrome/Edge, aparecera un banner para instalar la app. Requiere HTTPS en produccion para el prompt automatico.
## Tests E2E (Playwright)
```bash
npm install
npx playwright test
```

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`

40
docs/POSTGRESQL_TUNING.md Normal file
View File

@@ -0,0 +1,40 @@
# PostgreSQL Tuning — Nexus Autoparts
**Server:** 48 GB RAM, 8 cores, SSD (QEMU)
**Applied:** 2026-04-26
**Requires restart:** Yes (done)
## Configuration Changes
File: `/etc/postgresql/17/main/postgresql.conf`
| Parameter | Before | After | Rationale |
|-----------|--------|-------|-----------|
| `shared_buffers` | 128 MB | **8 GB** | ~25% of RAM for PostgreSQL buffer cache |
| `work_mem` | 4 MB | **64 MB** | Larger sorts/joins without disk spilling |
| `maintenance_work_mem` | 64 MB | **1 GB** | Faster VACUUM, CREATE INDEX, ALTER |
| `effective_cache_size` | 4 GB | **36 GB** | Planner knows OS cache is large |
| `max_wal_size` | 1 GB | **8 GB** | Fewer checkpoints under heavy write load |
| `checkpoint_completion_target` | 0.5 | **0.9** | Spread checkpoint I/O over more time |
| `wal_buffers` | - | **16 MB** | WAL buffer sizing |
| `random_page_cost` | 4.0 | **1.1** | SSD-appropriate random read cost |
| `effective_io_concurrency` | 1 | **200** | SSD can handle many concurrent requests |
| `max_connections` | 100 | **200** | Headroom for Celery, Quart, Dashboard, PgBouncer |
## Verification
```bash
sudo -u postgres psql -d nexus_autoparts -c "SHOW shared_buffers;"
```
## Backup
A backup of the previous config is stored at:
`/etc/postgresql/17/main/postgresql.conf.backup.<timestamp>`
## pg_hba Adjustment for Monitoring
Added Docker network access for postgres-exporter:
```
host nexus_autoparts postgres 172.17.0.0/16 trust
```

35
docs/SYSTEMD_SERVICES.md Normal file
View File

@@ -0,0 +1,35 @@
# Systemd Services — Nexus Autoparts
All production services are managed via systemd. Files are versioned in `systemd/`.
## Services
| Service | Description | Status |
|---------|-------------|--------|
| `nexus-pos.service` | Gunicorn POS (Flask), port 5001 | Active |
| `nexus.service` | Dashboard (Flask), port 5000 | Active |
| `nexus-quart.service` | Hypercorn async catalog, port 5002 | Active |
| `nexus-celery.service` | Celery worker (4 prefork) | Active |
| `nexus-mv-refresh.timer` | Daily MV refresh at 03:00 UTC | Active |
| `nexus-cache-warm.timer` | Daily Redis cache warming at 04:00 UTC | Active |
## Commands
```bash
# Reload all
systemctl daemon-reload
# Restart POS
systemctl restart nexus-pos.service
# View logs
journalctl -u nexus-pos.service -f
```
## Installation
```bash
sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now nexus-pos.service nexus-cache-warm.timer
```

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

@@ -1,106 +1,134 @@
# Wildcard subdomain routing for Nexus POS
# DNS: *.nexusautoparts.com -> server IP (Cloudflare wildcard)
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=pos_login:10m rate=10r/s;
# Upstream backends
upstream nexus_main {
server 127.0.0.1:5000;
}
upstream nexus_pos { upstream nexus_pos {
server 127.0.0.1:5001; server 127.0.0.1:5001;
} }
# Gzip compression upstream nexus_dashboard {
gzip on; server 127.0.0.1:5000;
gzip_vary on; }
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# Main site (no subdomain) upstream nexus_quart {
server 127.0.0.1:5002;
}
# ─── Landing page / Dashboard (primary domain) ───
server { server {
listen 80; listen 80;
server_name nexusautoparts.com www.nexusautoparts.com; server_name nexusautoparts.com.mx www.nexusautoparts.com.mx;
# Static asset caching client_max_body_size 10M;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 6M;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
}
# Auto-serve minified JS/CSS when available (transparent to templates) # Gzip compression
location ~* ^(.+)\.js$ { gzip on;
try_files $1.min.js $uri =404; gzip_vary on;
expires 6M; gzip_proxied any;
add_header Cache-Control "public, immutable"; gzip_comp_level 6;
add_header X-Content-Type-Options nosniff always; gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
}
location ~* ^(.+)\.css$ {
try_files $1.min.css $uri =404;
expires 6M;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
}
location / { location / {
proxy_pass http://nexus_main; proxy_pass http://nexus_dashboard;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s; proxy_read_timeout 300s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
} }
} }
# POS subdomains (wildcard) # ─── POS (dedicated subdomain) ───
server { server {
listen 80; listen 80;
server_name ~^(?<tenant>.+)\.nexusautoparts\.com$; server_name pos.nexusautoparts.com.mx;
# Security headers client_max_body_size 10M;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
# Static asset caching # Gzip compression
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# Static assets with caching (proxy to Flask)
location /pos/static/ {
proxy_pass http://nexus_pos;
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_set_header X-Forwarded-Proto $scheme;
expires 6M; expires 6M;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
} }
# Async catalog search via Quart+asyncpg (non-blocking I/O)
location /pos/api/catalog/async-search {
proxy_pass http://nexus_quart;
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_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering off;
}
location = / {
return 302 /pos/login;
}
location / { location / {
proxy_pass http://nexus_pos; proxy_pass http://nexus_pos;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Tenant-Subdomain $tenant; proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
} }
}
# Rate limit login endpoint # ─── Dashboard admin (alternative access) ───
location /pos/api/auth/login { server {
limit_req zone=pos_login burst=5 nodelay; listen 80;
proxy_pass http://nexus_pos; server_name admin.nexusautoparts.com.mx;
client_max_body_size 10M;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
location / {
proxy_pass http://nexus_dashboard;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Tenant-Subdomain $tenant; proxy_read_timeout 300s;
}
}
# ─── Legacy domain (keep for migration period) ───
server {
listen 80;
server_name nexus.consultoria-as.com;
client_max_body_size 10M;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
location / {
proxy_pass http://nexus_dashboard;
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_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
} }
} }

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "autopartes",
"version": "1.0.0",
"description": "**POS + Catalogo de autopartes para refaccionarias mexicanas.**",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "playwright test"
},
"repository": {
"type": "git",
"url": "https://consultoria-as:b708144ceef22fef31217f1259a695005d67477b@git.consultoria-as.com/consultoria-as/Autoparts-DB.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@playwright/test": "^1.59.1"
},
"dependencies": {
"playwright": "^1.60.0"
}
}

21
playwright.config.js Normal file
View File

@@ -0,0 +1,21 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

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

@@ -32,6 +32,9 @@ def create_app():
from blueprints.pos_bp import pos_bp from blueprints.pos_bp import pos_bp
app.register_blueprint(pos_bp) app.register_blueprint(pos_bp)
from blueprints.public_bp import public_bp
app.register_blueprint(public_bp)
from blueprints.customers_bp import customers_bp from blueprints.customers_bp import customers_bp
app.register_blueprint(customers_bp) app.register_blueprint(customers_bp)
@@ -56,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)
@@ -89,6 +98,27 @@ def create_app():
from blueprints.tasks_bp import tasks_bp from blueprints.tasks_bp import tasks_bp
app.register_blueprint(tasks_bp) app.register_blueprint(tasks_bp)
from blueprints.bnpl_bp import bnpl_bp
app.register_blueprint(bnpl_bp)
from blueprints.erp_bp import erp_bp
app.register_blueprint(erp_bp)
from blueprints.whatsapp_cloud_bp import whatsapp_cloud_bp
app.register_blueprint(whatsapp_cloud_bp)
from blueprints.dashboard_stats_bp import dashboard_stats_bp
app.register_blueprint(dashboard_stats_bp)
from blueprints.supplier_portal_bp import 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():
@@ -107,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')
@@ -159,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,
})

90
pos/blueprints/bnpl_bp.py Normal file
View File

@@ -0,0 +1,90 @@
"""BNPL Blueprint — Buy Now Pay Later integrations (stub architecture).
Providers: APLAZO, Kueski, Clip (configured per tenant).
All endpoints are stubs with mock responses until real credentials are provided.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
import uuid
from datetime import datetime, timedelta
bnpl_bp = Blueprint('bnpl', __name__, url_prefix='/pos/api/bnpl')
# ─── Auth helper ───
from middleware import require_auth
# ─── Mock store ───
_mock_applications = {}
@bnpl_bp.route('/providers', methods=['GET'])
@require_auth()
def list_providers():
"""List configured BNPL providers."""
return jsonify({
'providers': [
{'id': 'ap lazo', 'name': 'APLAZO', 'enabled': False, 'config_needed': ['api_key', 'merchant_id']},
{'id': 'kueski', 'name': 'Kueski Pay', 'enabled': False, 'config_needed': ['api_key', 'secret']},
{'id': 'clip', 'name': 'Clip Pagos', 'enabled': False, 'config_needed': ['api_key']},
]
})
@bnpl_bp.route('/applications', methods=['POST'])
@require_auth()
def create_application():
"""Create a BNPL application for a sale."""
data = request.get_json() or {}
sale_id = data.get('sale_id')
amount = data.get('amount')
provider = data.get('provider', 'ap lazo')
customer = data.get('customer', {})
if not sale_id or amount is None:
return jsonify({'error': 'sale_id and amount are required'}), 400
app_id = str(uuid.uuid4())
_mock_applications[app_id] = {
'id': app_id,
'sale_id': sale_id,
'provider': provider,
'amount': float(amount),
'status': 'pending',
'customer': customer,
'created_at': datetime.utcnow().isoformat(),
'expires_at': (datetime.utcnow() + timedelta(hours=24)).isoformat(),
'approval_url': f'/pos/api/bnpl/applications/{app_id}/approve',
'webhook_url': f'/pos/api/bnpl/webhook/{provider}',
}
return jsonify(_mock_applications[app_id]), 201
@bnpl_bp.route('/applications/<app_id>', methods=['GET'])
@require_auth()
def get_application(app_id):
"""Get BNPL application status."""
app = _mock_applications.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
return jsonify(app)
@bnpl_bp.route('/applications/<app_id>/approve', methods=['POST'])
@require_auth()
def approve_application(app_id):
"""Mock approve an application (admin/override)."""
app = _mock_applications.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
app['status'] = 'approved'
app['approved_at'] = datetime.utcnow().isoformat()
return jsonify(app)
@bnpl_bp.route('/webhook/<provider>', methods=['POST'])
def webhook(provider):
"""Receive webhooks from BNPL providers."""
data = request.get_json() or {}
# In production, verify signature per provider
return jsonify({'received': True, 'provider': provider, 'payload': data}), 200

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)
@@ -337,9 +404,8 @@ def parts():
@catalog_bp.route('/part/<int:part_id>', methods=['GET']) @catalog_bp.route('/part/<int:part_id>', methods=['GET'])
@require_auth('catalog.view') @require_auth('catalog.view')
def part_detail(part_id): def part_detail(part_id):
blocked = _oem_blocked() # Part detail is available in both local and OEM modes
if blocked: # — it reads from the master parts DB and enriches with local stock.
return blocked
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
result = catalog_service.get_part_detail(master, part_id, tenant, branch_id) result = catalog_service.get_part_detail(master, part_id, tenant, branch_id)
if not result: if not result:
@@ -351,16 +417,19 @@ def part_detail(part_id):
@catalog_bp.route('/search', methods=['GET']) @catalog_bp.route('/search', methods=['GET'])
@require_auth('catalog.view') @require_auth('catalog.view')
def search(): def search():
blocked = _oem_blocked() # Search is available in both local and OEM modes
if blocked: # — it reads from the master parts DB and enriches with local stock.
return blocked
q = request.args.get('q', '').strip() q = request.args.get('q', '').strip()
if not q or len(q) < 2: if not q or len(q) < 2:
return jsonify({'data': []}) return jsonify({'data': []})
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)
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
data = catalog_service.smart_search(master, q, tenant, branch_id, limit) 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)
@@ -592,3 +661,472 @@ def _match_vin_to_catalog(master_conn, vin_info):
return None return None
finally: finally:
cur.close() cur.close()
# ─── Brand Catalog (vehicle-brand-first navigation) ───
@catalog_bp.route('/vehicle-brands', methods=['GET'])
@require_auth('catalog.view')
def vehicle_brands():
"""Return North American vehicle brands for brand-first catalog browsing.
Uses the same OEM_BRANDS_NA filter as the regular catalog so that
the brand list is consistent across both navigation modes.
"""
from services.catalog_modes import get_brands_for_mode
allowed = list(get_brands_for_mode('oem'))
def _query(master):
cur = master.cursor()
try:
cur.execute("""
SELECT id_brand, name_brand
FROM brands
WHERE name_brand = ANY(%s)
ORDER BY name_brand ASC
""", (allowed,))
rows = cur.fetchall()
return jsonify({
'brands': [
{'id': r[0], 'name': r[1], 'part_count': 0}
for r in rows
]
})
finally:
cur.close()
return _master_only(_query)
@catalog_bp.route('/brand-categories', methods=['GET'])
@require_auth('catalog.view')
def brand_categories():
"""Return part categories available for a given vehicle brand."""
brand = request.args.get('brand', '')
if not brand:
return jsonify({'error': 'brand parameter required'}), 400
def _query(master, tenant, branch_id):
cur = master.cursor()
try:
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,
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
pc.slug,
COUNT(DISTINCT p.id_part) as part_count
FROM part_vehicle_preview pvp
JOIN parts p ON p.id_part = pvp.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 pvp.name_brand = %s
{brand_filter}
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
ORDER BY part_count DESC
""", params)
rows = cur.fetchall()
return jsonify({
'brand': brand,
'categories': [
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
for r in rows
],
'allowed_brands': allowed_brands or []
})
finally:
cur.close()
return _with_conns(_query)
@catalog_bp.route('/brand-parts', methods=['GET'])
@require_auth('catalog.view')
def brand_parts():
"""Return parts for a given vehicle brand + category, optionally filtered by search term."""
brand = request.args.get('brand', '')
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 brand:
return jsonify({'error': 'brand parameter 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 = [brand]
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])
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:
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 part_vehicle_preview pvp
JOIN parts p ON p.id_part = pvp.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 pvp.name_brand = %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 part_vehicle_preview pvp
JOIN parts p ON p.id_part = pvp.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 pvp.name_brand = %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({
'brand': brand,
'category_id': category_id,
'search': search,
'items': items,
'total': total,
'limit': limit,
'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:
cur.close()
return _with_conns(_query)

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():
@@ -409,3 +501,247 @@ def upgrade_billing():
if 'error' in result: if 'error' in result:
return jsonify(result), 400 return jsonify(result), 400
return jsonify(result) return jsonify(result)
# ─── Vehicle Compatibility Source ────────────────────
@config_bp.route('/vehicle-compat-source', methods=['GET'])
@require_auth()
def get_vehicle_compat_source():
"""Get the configured vehicle compatibility source.
Returns: {'source': 'tecdoc' | 'qwen' | 'both'}
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'vehicle_compat_source'")
row = cur.fetchone()
cur.close()
conn.close()
source = row[0] if row else 'both'
if source not in ('tecdoc', 'qwen', 'both'):
source = 'both'
return jsonify({'source': source})
@config_bp.route('/vehicle-compat-source', methods=['PUT'])
@require_auth('config.edit')
def update_vehicle_compat_source():
"""Set the vehicle compatibility source."""
data = request.get_json() or {}
source = data.get('source', 'both')
if source not in ('tecdoc', 'qwen', 'both'):
return jsonify({'error': 'source must be tecdoc, qwen, or both'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('vehicle_compat_source', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (source,))
conn.commit()
cur.close()
conn.close()
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

@@ -0,0 +1,120 @@
"""Dashboard Stats Blueprint — In-app real-time analytics.
Endpoints for sales, productivity, and top products charts.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
from datetime import datetime, timedelta
from decimal import Decimal
import json
dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api/dashboard')
from middleware import require_auth
from tenant_db import get_tenant_conn
class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
return float(o)
return super().default(o)
@dashboard_stats_bp.route('/stats', methods=['GET'])
@require_auth()
def get_stats():
"""Summary stats for today and this month."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
today = datetime.utcnow().date()
month_start = today.replace(day=1)
try:
# Sales today
cur.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s""", (today,)
)
today_sales = cur.fetchone()
# Sales this month
cur.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
)
month_sales = cur.fetchone()
# Top 5 products today
cur.execute(
"""SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
WHERE DATE(s.created_at) = %s
GROUP BY si.name
ORDER BY revenue DESC
LIMIT 5""", (today,)
)
top_products = cur.fetchall()
# Hourly sales today (0-23)
cur.execute(
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s
GROUP BY hour ORDER BY hour""", (today,)
)
hourly = cur.fetchall()
hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly}
return jsonify({
'today': {
'sales_count': today_sales[0],
'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0,
},
'month': {
'sales_count': month_sales[0],
'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
},
'top_products': [
{'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0}
for row in top_products
],
'hourly_sales': [
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
'total': float(hourly_map.get(h, {}).get('total', 0))}
for h in range(24)
],
})
finally:
cur.close()
conn.close()
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
@require_auth()
def get_employee_stats():
"""Sales per employee today."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
today = datetime.utcnow().date()
try:
cur.execute(
"""SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total
FROM sales s
JOIN employees e ON s.employee_id = e.id
WHERE DATE(s.created_at) = %s
GROUP BY e.name
ORDER BY total DESC""", (today,)
)
rows = cur.fetchall()
return jsonify({
'employees': [
{'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0}
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()

79
pos/blueprints/erp_bp.py Normal file
View File

@@ -0,0 +1,79 @@
"""ERP Sync Blueprint — Integration with Aspel, CONTPAQi, SAP, Odoo.
Stubs with architecture ready for real connectors.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
import uuid
from datetime import datetime
erp_bp = Blueprint('erp', __name__, url_prefix='/pos/api/erp')
from middleware import require_auth
# ─── Mock sync jobs ───
_mock_jobs = {}
@erp_bp.route('/providers', methods=['GET'])
@require_auth()
def list_providers():
return jsonify({
'providers': [
{'id': 'aspel_sae', 'name': 'Aspel SAE', 'type': 'file_exchange', 'enabled': False},
{'id': 'contpaqi', 'name': 'CONTPAQi', 'type': 'file_exchange', 'enabled': False},
{'id': 'sap_b1', 'name': 'SAP Business One', 'type': 'api', 'enabled': False},
{'id': 'odoo', 'name': 'Odoo', 'type': 'api', 'enabled': False},
]
})
@erp_bp.route('/sync', methods=['POST'])
@require_auth()
def start_sync():
data = request.get_json() or {}
provider = data.get('provider')
sync_type = data.get('sync_type', 'sales') # sales, inventory, customers
if not provider:
return jsonify({'error': 'provider is required'}), 400
job_id = str(uuid.uuid4())
_mock_jobs[job_id] = {
'id': job_id,
'provider': provider,
'sync_type': sync_type,
'status': 'queued',
'records_synced': 0,
'errors': [],
'created_at': datetime.utcnow().isoformat(),
'started_at': None,
'finished_at': None,
}
return jsonify(_mock_jobs[job_id]), 201
@erp_bp.route('/sync/<job_id>', methods=['GET'])
@require_auth()
def get_sync_status(job_id):
job = _mock_jobs.get(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify(job)
@erp_bp.route('/sync/<job_id>/run', methods=['POST'])
@require_auth()
def run_sync(job_id):
"""Mock execute sync (in production this triggers a Celery task)."""
job = _mock_jobs.get(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
job['status'] = 'running'
job['started_at'] = datetime.utcnow().isoformat()
# Mock completion
job['status'] = 'completed'
job['records_synced'] = 42
job['finished_at'] = datetime.utcnow().isoformat()
return jsonify(job)

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

@@ -6,15 +6,18 @@ that validates input, calls the engine, and returns JSON responses.
""" """
import json import json
import jwt
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g, render_template_string
from middleware import require_auth, has_permission from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn from tenant_db import get_tenant_conn
from services.pos_engine import ( 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
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api') pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
@@ -32,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()}
@@ -73,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
@@ -101,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()
@@ -217,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):
@@ -485,6 +577,16 @@ def create_quotation():
currency, exchange_rate currency, exchange_rate
)) ))
# Reserve stock for quotation
from services.quote_reservation import reserve_for_quotation, get_quotation_items_for_reservation
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
reserve_for_quotation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception as res_err:
# Log but don't fail the quote creation
import logging
logging.getLogger('pos').warning(f'Quote reservation failed for #{quot_id}: {res_err}')
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id, log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
new_value={'total': totals['total'], 'items_count': len(items)}) new_value={'total': totals['total'], 'items_count': len(items)})
@@ -766,6 +868,270 @@ def get_quotation(quot_id):
return jsonify(quot) return jsonify(quot)
@pos_bp.route('/quotations/<int:quot_id>', methods=['PUT'])
@require_auth('pos.sell')
def update_quotation(quot_id):
"""Replace all items in an existing active quotation.
Body: { items: [...], customer_id, notes, valid_days, currency, exchange_rate }
"""
data = request.get_json() or {}
items = data.get('items', [])
if not items:
return jsonify({'error': 'No items provided'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if row[1] != 'active':
cur.close(); conn.close()
return jsonify({'error': f'Quotation is {row[1]}, cannot edit'}), 400
try:
enriched = _enrich_items(cur, items, data.get('customer_id'))
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
totals = calculate_totals(enriched)
valid_days = int(data.get('valid_days', 7))
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
from services.currency import get_exchange_rate
currency = data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'):
cur.close(); conn.close()
return jsonify({'error': f'Unsupported currency: {currency}'}), 400
exchange_rate = data.get('exchange_rate')
if currency != 'MXN' and exchange_rate is None:
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
try:
# Release old reservations before deleting items
from services.quote_reservation import (
release_quotation_reservation,
reserve_for_quotation,
get_quotation_items_for_reservation
)
old_items = get_quotation_items_for_reservation(conn, quot_id)
if old_items:
release_quotation_reservation(conn, quot_id, old_items, employee_id=g.employee_id)
# Delete old items
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
# Update header
cur.execute("""
UPDATE quotations
SET customer_id = %s, subtotal = %s, tax_total = %s, total = %s,
valid_until = %s, notes = %s, currency = %s, exchange_rate = %s,
employee_id = %s
WHERE id = %s
""", (
data.get('customer_id'), totals['subtotal'], totals['tax_total'],
totals['total'], valid_until, data.get('notes'),
currency, exchange_rate, g.employee_id, quot_id
))
# Insert new items
for item in totals['items']:
line_subtotal = round(
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
)
cur.execute("""
INSERT INTO quotation_items
(quotation_id, inventory_id, part_number, name, quantity,
unit_price, discount_pct, tax_rate, subtotal, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
quot_id, item['inventory_id'], item.get('part_number', ''),
item.get('name', ''), item['quantity'], item['unit_price'],
item['discount_pct'], item['tax_rate'], line_subtotal,
currency, exchange_rate
))
# Reserve stock for new items
new_items = get_quotation_items_for_reservation(conn, quot_id)
if new_items:
reserve_for_quotation(conn, quot_id, new_items, employee_id=g.employee_id)
log_action(conn, 'QUOTATION_UPDATE', 'quotation', quot_id,
new_value={'total': totals['total'], 'items_count': len(items)})
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation updated', 'id': quot_id, 'total': totals['total']})
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/quotations/<int:quot_id>', methods=['PATCH'])
@require_auth('pos.sell')
def patch_quotation(quot_id):
"""Update quotation header fields without touching items."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
fields = []
params = []
if 'customer_id' in data:
fields.append('customer_id = %s')
params.append(data['customer_id'])
if 'notes' in data:
fields.append('notes = %s')
params.append(data['notes'])
if 'valid_until' in data:
fields.append('valid_until = %s')
params.append(data['valid_until'])
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
fields.append('status = %s')
params.append(data['status'])
if not fields:
cur.close(); conn.close()
return jsonify({'message': 'No changes'}), 200
params.append(quot_id)
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation updated'})
@pos_bp.route('/quotations/<int:quot_id>/share', methods=['POST'])
@require_auth('pos.sell')
def share_quotation(quot_id):
"""Generate a public JWT token for viewing this quotation."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, valid_until, status FROM quotations WHERE id = %s", (quot_id,))
row = cur.fetchone()
cur.close(); conn.close()
if not row:
return jsonify({'error': 'Quotation not found'}), 404
if row[2] != 'active':
return jsonify({'error': 'Only active quotations can be shared'}), 400
valid_until = row[1] or (date.today() + timedelta(days=7))
if isinstance(valid_until, str):
valid_until = datetime.strptime(valid_until, '%Y-%m-%d').date()
payload = {
'type': 'public_quote',
'quot_id': quot_id,
'tenant_id': g.tenant_id,
'exp': datetime.combine(valid_until, datetime.max.time()),
}
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
public_url = request.host_url.rstrip('/') + f'/public/quote/{token}'
return jsonify({'token': token, 'url': public_url})
@pos_bp.route('/public/quote/<token>', methods=['GET'])
def public_quote(token):
"""Unauthenticated public view of a quotation."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
# Resolve tenant db
from tenant_db import get_tenant_conn
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
LEFT JOIN employees e ON q.employee_id = e.id
WHERE q.id = %s
""", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
'notes', 'customer_id', 'currency', 'exchange_rate', 'customer_name',
'customer_phone', 'customer_email', 'employee_name']
quot = dict(zip(cols, row))
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
if quot.get(k) is not None:
quot[k] = float(quot[k])
cur.execute("""
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (payload['quot_id'],))
items = []
for r in cur.fetchall():
items.append({
'part_number': r[0], 'name': r[1], 'quantity': r[2],
'unit_price': float(r[3]) if r[3] else 0,
'discount_pct': float(r[4]) if r[4] else 0,
'tax_rate': float(r[5]) if r[5] else 0,
'subtotal': float(r[6]) if r[6] else 0,
})
cur.close(); conn.close()
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
quot=quot, items=items, host=request.host_url.rstrip('/'),
token=token)
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
@pos_bp.route('/public/quote/<token>/accept', methods=['POST'])
def public_quote_accept(token):
"""Customer accepts a public quote."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if row[0] != 'active':
cur.close(); conn.close()
return jsonify({'error': 'Quotation is no longer active'}), 400
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
(payload['quot_id'],))
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET']) @pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
@require_auth('pos.view') @require_auth('pos.view')
def get_quotation_pdf(quot_id): def get_quotation_pdf(quot_id):
@@ -1004,6 +1370,19 @@ def convert_quotation(quot_id):
WHERE id = %s WHERE id = %s
""", (sale['id'], quot_id)) """, (sale['id'], quot_id))
# Convert reservation to actual sale
from services.quote_reservation import (
convert_quotation_reservation,
get_quotation_items_for_reservation
)
try:
res_items = get_quotation_items_for_reservation(conn, quot_id)
if res_items:
convert_quotation_reservation(conn, quot_id, res_items, sale_id=sale['id'], employee_id=g.employee_id)
except Exception as res_err:
import logging
logging.getLogger('pos').warning(f'Quote conversion reservation failed for #{quot_id}: {res_err}')
conn.commit() conn.commit()
cur.close(); conn.close() cur.close(); conn.close()
return jsonify(sale), 201 return jsonify(sale), 201
@@ -1034,11 +1413,76 @@ def cancel_quotation(quot_id):
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400 return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,)) cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
# Release reserved stock
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
res_items = get_quotation_items_for_reservation(conn, quot_id)
if res_items:
release_quotation_reservation(conn, quot_id, res_items, employee_id=g.employee_id)
except Exception as res_err:
import logging
logging.getLogger('pos').warning(f'Quote release on cancel failed for #{quot_id}: {res_err}')
conn.commit() conn.commit()
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'message': 'Quotation cancelled'}) return jsonify({'message': 'Quotation cancelled'})
@pos_bp.route('/internal/check-expired-quotations', methods=['POST'])
def check_expired_quotations():
"""Cron endpoint: mark active quotations as expired when valid_until < today.
Can be called internally by systemd timer or Celery beat.
Requires a secret header INTERNAL_API_KEY for safety.
Body (optional): { tenant_id: int } — if omitted, uses g.tenant_id.
"""
from config import INTERNAL_API_KEY
if INTERNAL_API_KEY and request.headers.get('X-Internal-Key') != INTERNAL_API_KEY:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json(silent=True) or {}
tenant_id = data.get('tenant_id') or getattr(g, 'tenant_id', None)
if not tenant_id:
return jsonify({'error': 'tenant_id required'}), 400
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute("""
UPDATE quotations
SET status = 'expired'
WHERE status = 'active'
AND valid_until < CURRENT_DATE
RETURNING id
""")
expired_ids = [r[0] for r in cur.fetchall()]
# Release reservations for expired quotes
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
for qid in expired_ids:
try:
res_items = get_quotation_items_for_reservation(conn, qid)
if res_items:
release_quotation_reservation(conn, qid, res_items)
except Exception as res_err:
import logging
logging.getLogger('pos').warning(f'Quote release on expiry failed for #{qid}: {res_err}')
conn.commit()
cur.close(); conn.close()
return jsonify({
'expired': len(expired_ids),
'ids': expired_ids,
'tenant_id': tenant_id,
})
# ─── Layaways (Apartados) ──────────────────────── # ─── Layaways (Apartados) ────────────────────────
@pos_bp.route('/layaways', methods=['POST']) @pos_bp.route('/layaways', methods=['POST'])
@@ -1510,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
@@ -1967,3 +2419,109 @@ def print_ticket(sale_id):
raw = generate_ticket(sale_data, business_info, width=width) raw = generate_ticket(sale_data, business_info, width=width)
return Response(raw, mimetype='application/octet-stream', return Response(raw, mimetype='application/octet-stream',
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'}) headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
# ─── Public Quote HTML Template ─────────────────────────────────────────────
PUBLIC_QUOTE_TEMPLATE = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cotizacion #{{ quot.id }}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.08);overflow:hidden}
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:28px 24px;text-align:center}
.header h1{font-size:22px;font-weight:700;margin-bottom:6px}
.header p{font-size:13px;opacity:.85}
.body{padding:24px}
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
.meta strong{color:#111;display:block;font-size:12px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
table{width:100%;border-collapse:collapse;font-size:14px;margin-bottom:16px}
th{text-align:left;padding:10px 8px;background:#f3f4f6;color:#374151;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
td{padding:12px 8px;border-bottom:1px solid #e5e7eb;vertical-align:top}
tr:last-child td{border-bottom:none}
.part{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#6b7280}
.qty{text-align:center}
.price{text-align:right;font-weight:600}
.totals{border-top:2px solid #e5e7eb;padding-top:16px;text-align:right;font-size:14px}
.totals div{margin-bottom:4px;color:#4b5563}
.totals .big{font-size:22px;font-weight:800;color:#111;margin-top:8px}
.actions{padding:0 24px 24px;text-align:center}
.btn{display:inline-block;width:100%;padding:14px 20px;border-radius:10px;border:none;font-size:16px;font-weight:700;cursor:pointer;transition:transform .1s}
.btn-primary{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
.btn-primary:hover{transform:translateY(-1px)}
.btn-primary:active{transform:translateY(0)}
.btn-disabled{background:#e5e7eb;color:#9ca3af;cursor:not-allowed}
.footer{text-align:center;padding:16px;font-size:12px;color:#9ca3af}
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
.badge-active{background:#d1fae5;color:#065f46}
.badge-expired{background:#fee2e2;color:#991b1b}
@media(min-width:480px){.meta{grid-template-columns:repeat(3,1fr)}.btn{width:auto;min-width:280px}}
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>Cotizacion #{{ quot.id }}</h1>
<p>{{ host }}</p>
</div>
<div class="body">
<div class="meta">
<div><strong>Cliente</strong>{{ quot.customer_name or 'Publico general' }}</div>
<div><strong>Fecha</strong>{{ quot.created_at[:10] if quot.created_at else '' }}</div>
<div><strong>Vigencia</strong>{{ quot.valid_until or '' }} <span class="badge badge-{{ 'active' if quot.status == 'active' else 'expired' }}">{{ quot.status }}</span></div>
</div>
<table>
<thead><tr><th>Descripcion</th><th class="qty">Cant</th><th class="price">P. Unit</th><th class="price">Subtotal</th></tr></thead>
<tbody>
{% for it in items %}
<tr>
<td>
<div style="font-weight:600">{{ it.name }}</div>
<div class="part">{{ it.part_number }}</div>
</td>
<td class="qty">{{ it.quantity }}</td>
<td class="price">${{ "{:,.2f}".format(it.unit_price) }}</td>
<td class="price">${{ "{:,.2f}".format(it.subtotal) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="totals">
<div>Subtotal: ${{ "{:,.2f}".format(quot.subtotal) }}</div>
<div>IVA: ${{ "{:,.2f}".format(quot.tax_total) }}</div>
<div class="big">Total: ${{ "{:,.2f}".format(quot.total) }}</div>
</div>
</div>
<div class="actions">
{% if quot.status == 'active' %}
<button class="btn btn-primary" id="acceptBtn" onclick="acceptQuote()">Aceptar cotizacion</button>
{% else %}
<button class="btn btn-disabled" disabled>Cotizacion no disponible</button>
{% endif %}
</div>
<div class="footer">
Precios sujetos a cambio sin previo aviso. Vigencia limitada.
</div>
</div>
<script>
function acceptQuote(){
var btn=document.getElementById('acceptBtn');
btn.disabled=true;btn.textContent='Procesando...';
fetch('/public/quote/{{ token }}/accept',{method:'POST'})
.then(function(r){return r.json();})
.then(function(d){
if(d.error){alert('Error: '+d.error);btn.disabled=false;btn.textContent='Aceptar cotizacion';}
else{btn.textContent='Cotizacion aceptada';btn.className='btn btn-disabled';alert(d.message);}
})
.catch(function(){alert('Error de red');btn.disabled=false;btn.textContent='Aceptar cotizacion';});
}
</script>
</body>
</html>
"""

106
pos/blueprints/public_bp.py Normal file
View File

@@ -0,0 +1,106 @@
"""Public blueprint — unauthenticated routes for shared content.
These routes live outside the /pos/api prefix so they can be accessed
by customers without login.
"""
import jwt
from flask import Blueprint, request, jsonify, render_template_string
from tenant_db import get_tenant_conn
from config import JWT_SECRET
from blueprints.pos_bp import PUBLIC_QUOTE_TEMPLATE
public_bp = Blueprint('public', __name__)
@public_bp.route('/public/quote/<token>', methods=['GET'])
def public_quote(token):
"""Unauthenticated public view of a quotation."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
q.status,
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
LEFT JOIN employees e ON q.employee_id = e.id
WHERE q.id = %s
""", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
'notes', 'customer_id', 'currency', 'exchange_rate', 'status',
'customer_name', 'customer_phone', 'customer_email', 'employee_name']
quot = dict(zip(cols, row))
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
if quot.get(k) is not None:
quot[k] = float(quot[k])
if quot.get('created_at'):
quot['created_at'] = str(quot['created_at'])
if quot.get('valid_until'):
quot['valid_until'] = str(quot['valid_until'])
cur.execute("""
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (payload['quot_id'],))
items = []
for r in cur.fetchall():
items.append({
'part_number': r[0], 'name': r[1], 'quantity': r[2],
'unit_price': float(r[3]) if r[3] else 0,
'discount_pct': float(r[4]) if r[4] else 0,
'tax_rate': float(r[5]) if r[5] else 0,
'subtotal': float(r[6]) if r[6] else 0,
})
cur.close(); conn.close()
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
quot=quot, items=items, host=request.host_url.rstrip('/'),
token=token)
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
@public_bp.route('/public/quote/<token>/accept', methods=['POST'])
def public_quote_accept(token):
"""Customer accepts a public quote."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if payload.get('type') != 'public_quote':
return jsonify({'error': 'Invalid token type'}), 400
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Quote expired'}), 410
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 400
conn = get_tenant_conn(payload['tenant_id'])
cur = conn.cursor()
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
if row[0] != 'active':
cur.close(); conn.close()
return jsonify({'error': 'Quotation is no longer active'}), 400
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
(payload['quot_id'],))
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})

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

@@ -0,0 +1,105 @@
"""Supplier Portal Blueprint — Demand insights for vendors.
Allows suppliers to view demand by zone, part type, and branch.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
from datetime import datetime, timedelta
from decimal import Decimal
import json
supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api/supplier-portal')
from middleware import require_auth
from tenant_db import get_tenant_conn
class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
return float(o)
return super().default(o)
@supplier_portal_bp.route('/demand', methods=['GET'])
@require_auth()
def get_demand():
"""Aggregated demand by zone, part group, and time range."""
days = request.args.get('days', 30, type=int)
branch_id = request.args.get('branch_id', type=int)
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
since = datetime.utcnow() - timedelta(days=days)
try:
params = [since]
filters = "s.created_at >= %s"
if branch_id:
filters += " AND s.branch_id = %s"
params.append(branch_id)
cur.execute(
f"""SELECT b.name as branch_name,
COUNT(DISTINCT s.id) as orders,
SUM(si.quantity) as qty_requested,
COALESCE(SUM(si.subtotal), 0) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
LEFT JOIN branches b ON s.branch_id = b.id
WHERE {filters}
GROUP BY b.name
ORDER BY revenue DESC
LIMIT 100""", tuple(params)
)
rows = cur.fetchall()
return jsonify({
'since': since.isoformat(),
'days': days,
'demand': [
{'branch': row[0] or 'Sin sucursal',
'orders': row[1], 'quantity': row[2],
'revenue': float(row[3]) if row[3] is not None else 0}
for row in rows
]
})
finally:
cur.close()
conn.close()
@supplier_portal_bp.route('/top-parts', methods=['GET'])
@require_auth()
def get_top_parts():
"""Top moving parts for suppliers to restock."""
days = request.args.get('days', 30, type=int)
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
since = datetime.utcnow() - timedelta(days=days)
try:
cur.execute(
"""SELECT si.part_number, si.name,
SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
WHERE s.created_at >= %s
GROUP BY si.part_number, si.name
ORDER BY sold DESC
LIMIT 50""", (since,)
)
rows = cur.fetchall()
return jsonify({
'since': since.isoformat(),
'parts': [
{'part_number': row[0], 'name': row[1],
'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0}
for row in rows
]
})
finally:
cur.close()
conn.close()

View File

@@ -1,14 +1,14 @@
"""Blueprint for background task management (Celery).""" """Blueprint for background task management (Celery)."""
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from auth import require_auth from middleware import require_auth
from tasks import warm_vehicle_cache_task, generate_report_task from tasks import warm_vehicle_cache_task, generate_report_task
tasks_bp = Blueprint('tasks', __name__, url_prefix='/pos/api/tasks') tasks_bp = Blueprint('tasks', __name__, url_prefix='/pos/api/tasks')
@tasks_bp.route('/warm-cache', methods=['POST']) @tasks_bp.route('/warm-cache', methods=['POST'])
@require_auth @require_auth()
def enqueue_warm_cache(): def enqueue_warm_cache():
"""Enqueue vehicle cache warming task.""" """Enqueue vehicle cache warming task."""
task = warm_vehicle_cache_task.apply_async() task = warm_vehicle_cache_task.apply_async()
@@ -16,7 +16,7 @@ def enqueue_warm_cache():
@tasks_bp.route('/report', methods=['POST']) @tasks_bp.route('/report', methods=['POST'])
@require_auth @require_auth()
def enqueue_report(): def enqueue_report():
"""Enqueue report generation task.""" """Enqueue report generation task."""
data = request.get_json() or {} data = request.get_json() or {}

View File

@@ -13,15 +13,145 @@ Endpoints:
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, 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 _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn): 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):
"""Return list of MYE ids matching vehicle brand/model/year text."""
if not master_conn or not vehicle:
return []
brand = vehicle.get('brand', '').strip()
model = vehicle.get('model', '').strip()
year = str(vehicle.get('year', '')).strip()
if not brand and not model:
return []
cur = master_conn.cursor()
clauses = []
params = []
if brand:
clauses.append("b.name_brand ILIKE %s")
params.append(f'%{brand}%')
if model:
clauses.append("m.name_model ILIKE %s")
params.append(f'%{model}%')
if year and year.isdigit():
clauses.append("y.year_car = %s")
params.append(int(year))
if not clauses:
cur.close()
return []
cur.execute(f"""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE {' AND '.join(clauses)}
LIMIT 50
""", tuple(params))
rows = cur.fetchall()
cur.close()
return [r[0] for r in rows]
def _get_conversation_history(phone, tenant_conn, limit=4):
"""Fetch recent messages for *phone* to give the AI conversation context.
Includes both user and assistant messages, truncated to keep token count low.
The most recent message (the one currently being processed) is excluded.
"""
if not tenant_conn or not phone:
return []
try:
cur = tenant_conn.cursor()
cur.execute("""
SELECT direction, message_text
FROM whatsapp_messages
WHERE phone = %s
ORDER BY created_at DESC
LIMIT %s OFFSET 1
""", (phone, limit))
rows = cur.fetchall()
cur.close()
# Reverse so oldest-first (chronological) for the LLM
history = []
for direction, text in reversed(rows):
if not text:
continue
role = "assistant" if direction == "outgoing" else "user"
# Truncate assistant replies more aggressively (they contain JSON/tables)
max_len = 200 if role == "assistant" else 300
truncated = text[:max_len] + ('...' if len(text) > max_len else '')
history.append({"role": role, "content": truncated})
return history
except Exception as e:
print(f"[WA-AI] Failed to load conversation history: {e}")
return []
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=None):
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply. """Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
If *vehicle* is provided and we have a master_conn, we first look up the
MYE ids for that vehicle and JOIN through inventory_vehicle_compat so we
only show parts that are known to fit the user's car.
Returns: Returns:
(formatted_text, first_part_dict) — first_part_dict is used by the (formatted_text, first_part_dict) — first_part_dict is used by the
quotation system to know what to add when the user says "cotizar". quotation system to know what to add when the user says "cotizar".
@@ -31,101 +161,125 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
return None, None return None, None
try: try:
# Translate common English search terms to Spanish for local inventory
# (the AI sends search_query in English, but local inventory names
# are often in Spanish)
from services.translations import PART_TRANSLATIONS from services.translations import PART_TRANSLATIONS
search_terms = [search_query]
# Add the Spanish translation if we have one
for en, es in PART_TRANSLATIONS.items():
if en.upper() in search_query.upper():
search_terms.append(es)
break
# Build ILIKE conditions for all search terms # Split search_query by '|' into individual terms
conditions = [] raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
params = [] if not raw_terms:
for term in search_terms: raw_terms = [search_query] if search_query else []
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
like = f'%{term}%'
params.extend([like, like, like])
where_search = ' OR '.join(conditions) # Translate each term to Spanish if possible
search_terms = set()
for term in raw_terms:
search_terms.add(term)
# Check if any English translation matches
for en, es in PART_TRANSLATIONS.items():
if en.upper() == term.upper():
search_terms.add(es)
break
# Also check if the term contains an English word
if en.upper() in term.upper():
search_terms.add(term.upper().replace(en.upper(), es))
cur = tenant_conn.cursor() search_terms = list(search_terms)
cur.execute(f""" if not search_terms:
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3, return None, None
COALESCE(s.stock, 0) AS stock,
i.unit, i.location
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(quantity) AS stock
FROM inventory_operations
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.is_active = TRUE
AND ({where_search})
ORDER BY
COALESCE(s.stock, 0) > 0 DESC,
i.name
LIMIT 10
""", params)
rows = cur.fetchall() # Vehicle-aware filtering
cur.close() mye_ids = _resolve_mye_ids(vehicle, master_conn)
if not rows: def _do_search(use_compat=True):
return ('❌ No tenemos esa parte en inventario actualmente.\n' """Run inventory search. Returns list of rows."""
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None conditions = []
params = []
for term in search_terms:
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
like = f'%{term}%'
params.extend([like, like, like])
# Split into in-stock and out-of-stock where_search = ' OR '.join(conditions)
in_stock = [r for r in rows if r[6] > 0] compat_clause = ""
out_stock = [r for r in rows if r[6] <= 0] if use_compat and mye_ids:
compat_clause = f"AND i.id IN (SELECT inventory_id FROM inventory_vehicle_compat WHERE model_year_engine_id IN ({','.join(['%s']*len(mye_ids))}))"
params.extend(mye_ids)
cur = tenant_conn.cursor()
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
COALESCE(s.stock, 0) AS stock,
i.unit, i.location
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = TRUE
AND ({where_search})
{compat_clause}
ORDER BY
COALESCE(s.stock, 0) > 0 DESC,
i.name
LIMIT 10
""", params)
rows = cur.fetchall()
cur.close()
return rows
# 1. Try with vehicle compatibility filter
rows = _do_search(use_compat=True)
compat_filter_applied = bool(mye_ids)
# 2. If no results with compatibility, try WITHOUT filter
fallback_rows = []
if not rows and mye_ids:
fallback_rows = _do_search(use_compat=False)
if not rows and not fallback_rows:
# Nothing found in local inventory — let the AI's original response stand.
# The webhook will append a soft note instead of replacing the message.
return None, None
# Use fallback rows if primary search returned nothing
using_fallback = False
if not rows and fallback_rows:
rows = fallback_rows
using_fallback = True
in_stock = [r for r in rows if r[7] > 0]
out_stock = [r for r in rows if r[7] <= 0]
# Build the first-part dict for quotation tracking
# Use the first in-stock part, or first out-of-stock if none available
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None) best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
first_part = None first_part = None
if best: if best:
first_part = { first_part = {
'inventory_id': None, # we'd need the id — fetch it 'inventory_id': best[0],
'part_number': best[0], 'part_number': best[1],
'name': best[1], 'name': best[2],
'brand': best[2] or '', 'brand': best[3] or '',
'price': float(best[3]) if best[3] else 0, 'price': float(best[4]) if best[4] else 0,
'tax_rate': 0.16, 'tax_rate': 0.16,
'stock': best[6], 'stock': best[7],
'unit': best[7] or 'PZA', 'unit': best[8] or 'PZA',
} }
# Fetch the inventory ID for the quotation item FK
try:
cur2 = tenant_conn.cursor()
cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1",
(best[0],))
inv_row = cur2.fetchone()
if inv_row:
first_part['inventory_id'] = inv_row[0]
cur2.close()
except Exception:
pass
lines = [] lines = []
if using_fallback:
lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*")
lines.append("")
if in_stock: if in_stock:
lines.append('✅ *Tenemos en stock:*') lines.append('✅ *Tenemos en stock:*')
lines.append('') lines.append('')
for r in in_stock: for r in in_stock:
part_num, name, brand, p1, p2, p3, stock, unit, location = r inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else '' brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio' price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
lines.append(f'{brand_str} {name}') lines.append(f'{brand_str} {name}')
lines.append(f' #{part_num}{price_str} ({stock} {unit or "pzas"} disponibles)') lines.append(f' #{part_num}{price_str} ({stock} {unit or "pzas"} disponibles)')
lines.append('') lines.append('')
else: elif out_stock:
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*') lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
lines.append('') lines.append('')
for r in out_stock[:5]: for r in out_stock[:5]:
part_num, name, brand, p1, p2, p3, stock, unit, location = r inv_id, part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else '' brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else '' price_str = f'${float(p1):,.2f}' if p1 else ''
lines.append(f'{brand_str} {name} #{part_num} {price_str}') lines.append(f'{brand_str} {name} #{part_num} {price_str}')
@@ -143,42 +297,61 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
except Exception as e: except Exception as e:
print(f"[WA-AI] Enrichment error: {e}") print(f"[WA-AI] Enrichment error: {e}")
import traceback
traceback.print_exc()
return None, None
return None, None return None, None
@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 {}
@@ -189,208 +362,228 @@ 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
inventory_context = None master_conn = None
try: try:
tenant_conn = get_tenant_conn(tenant_id) tenant_conn = get_tenant_conn(tenant_id)
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
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
# 3. Dispatch by media kind + quotation commands # 3. Check session expiry (30 minutes)
reply = None current_state = session.get('state', 'idle')
reply_to = msg.get('jid') or msg['phone'] state_data = session.get('state_data', {})
media_kind = msg.get('media_kind', 'text') last_updated = session.get('updated_at')
clean_phone = msg.get('phone', '')
# ── Check for quotation commands FIRST (before AI) ── if last_updated and hasattr(last_updated, 'strftime'):
if media_kind == 'text' and msg.get('text'): # PostgreSQL returns datetime objects (often timezone-aware)
from services.wa_quotation import ( from datetime import timezone
detect_quote_intent, get_open_quotation, create_quotation, now = datetime.now(timezone.utc)
add_item_to_quotation, get_quotation_detail, format_quotation_wa, if last_updated.tzinfo is None:
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part, now = now.replace(tzinfo=None)
) elapsed = (now - last_updated).total_seconds()
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone)) if elapsed > 1800:
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open) current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
if intent == 'add': elif last_updated and isinstance(last_updated, str):
last_part = get_last_shown_part(clean_phone) from datetime import datetime as dt
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.'
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})
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'],
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, 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'], 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:
if search_q and reply: loop_guard += 1
try: reply, next_state, next_state_data = process_message(
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn) phone=phone,
if enrichment: text=text,
reply = reply + '\n\n' + enrichment current_state=next_state,
# Track the found part so "cotizar" can add it state_data=next_state_data,
if found_part: context=context,
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 # 6. Save new state
save_session(tenant_conn, phone, next_state, next_state_data)
# 7. Send reply
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
# 4. Clean up the connection traceback.print_exc()
if tenant_conn is not None: # Fallback: enviar mensaje de error genérico
try: try:
tenant_conn.close() 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: except Exception:
pass pass
finally:
if tenant_conn:
try:
tenant_conn.close()
except Exception:
pass
if master_conn:
try:
master_conn.close()
except Exception:
pass
return jsonify({'ok': True}) return jsonify({'ok': True})
@@ -403,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)
@@ -415,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

@@ -0,0 +1,86 @@
"""WhatsApp Business API (Meta Cloud) Blueprint.
Replaces Baileys webhook for scalable production messaging.
Stubs ready for Meta Cloud API credentials.
"""
from flask import Blueprint, request, jsonify, g
from functools import wraps
import uuid
from datetime import datetime
whatsapp_cloud_bp = Blueprint('whatsapp_cloud', __name__, url_prefix='/pos/api/whatsapp-cloud')
from middleware import require_auth
_mock_messages = {}
@whatsapp_cloud_bp.route('/webhook', methods=['GET', 'POST'])
def webhook():
"""Meta Cloud API webhook verification and message reception."""
if request.method == 'GET':
# Verification challenge
mode = request.args.get('hub.mode')
token = request.args.get('hub.verify_token')
challenge = request.args.get('hub.challenge')
# In production: verify token against configured VERIFY_TOKEN
if mode == 'subscribe' and challenge:
return challenge, 200
return jsonify({'error': 'Verification failed'}), 403
# POST — incoming messages
data = request.get_json() or {}
# In production: process entries, messages, statuses
return jsonify({'received': True, 'entries': len(data.get('entry', []))}), 200
@whatsapp_cloud_bp.route('/messages', methods=['POST'])
@require_auth()
def send_message():
"""Send a message via Meta Cloud API."""
data = request.get_json() or {}
to = data.get('to')
body = data.get('body')
template = data.get('template')
if not to or (not body and not template):
return jsonify({'error': 'to and body/template are required'}), 400
msg_id = str(uuid.uuid4())
_mock_messages[msg_id] = {
'id': msg_id,
'to': to,
'body': body,
'template': template,
'status': 'sent',
'sent_at': datetime.utcnow().isoformat(),
}
return jsonify(_mock_messages[msg_id]), 201
@whatsapp_cloud_bp.route('/templates', methods=['GET'])
@require_auth()
def list_templates():
"""List approved message templates."""
return jsonify({
'templates': [
{'name': 'order_ready', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
{'name': 'payment_reminder', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
{'name': 'welcome_message', 'language': 'es_MX', 'category': 'MARKETING', 'status': 'PENDING'},
]
})
@whatsapp_cloud_bp.route('/status', methods=['GET'])
@require_auth()
def get_status():
"""Check Meta Cloud API connection status."""
return jsonify({
'connected': False,
'phone_number_id': None,
'business_account_id': None,
'message_limit': None,
'note': 'Configure WHATSAPP_CLOUD_ACCESS_TOKEN and PHONE_NUMBER_ID to connect',
})

View File

@@ -3,7 +3,7 @@
"appName": "Nexus POS", "appName": "Nexus POS",
"webDir": "www", "webDir": "www",
"server": { "server": {
"url": "https://nexus.consultoria-as.com/pos", "url": "https://pos.nexusautoparts.com.mx/pos",
"cleartext": true "cleartext": true
}, },
"plugins": { "plugins": {

View File

@@ -43,12 +43,21 @@ if not OPENROUTER_API_KEY:
RuntimeWarning RuntimeWarning
) )
# ─── Hermes Agent ──────────────────────────────────────────────────────────
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://192.168.10.71:8642/v1")
HERMES_API_KEY = os.environ.get("HERMES_API_KEY")
if not HERMES_API_KEY:
warnings.warn(
"HERMES_API_KEY not set. Hermes Agent integration will fall back to OpenRouter.",
RuntimeWarning
)
# ─── SMTP ────────────────────────────────────────────────────────────────── # ─── SMTP ──────────────────────────────────────────────────────────────────
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com') SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587')) SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
SMTP_USER = os.environ.get('SMTP_USER', '') SMTP_USER = os.environ.get('SMTP_USER', '')
SMTP_PASS = os.environ.get('SMTP_PASS', '') SMTP_PASS = os.environ.get('SMTP_PASS', '')
SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com') SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com.mx')
# ─── WhatsApp Bridge ─────────────────────────────────────────────────────── # ─── WhatsApp Bridge ───────────────────────────────────────────────────────
WHATSAPP_BRIDGE_URL = os.environ.get('WHATSAPP_BRIDGE_URL', 'http://localhost:21465') WHATSAPP_BRIDGE_URL = os.environ.get('WHATSAPP_BRIDGE_URL', 'http://localhost:21465')
@@ -75,3 +84,12 @@ MEILI_ENABLED = os.environ.get('MEILI_ENABLED', 'true').lower() == 'true'
# ─── Catalog OEM Access ──────────────────────────────────────────────────── # ─── Catalog OEM Access ────────────────────────────────────────────────────
CATALOG_OEM_ENABLED = os.environ.get('CATALOG_OEM_ENABLED', 'false').lower() == 'true' CATALOG_OEM_ENABLED = os.environ.get('CATALOG_OEM_ENABLED', 'false').lower() == 'true'
# ─── QWEN AI Fitment (private cloud server) ────────────────────────────────
QWEN_API_URL = os.environ.get('QWEN_API_URL', '')
QWEN_API_KEY = os.environ.get('QWEN_API_KEY', '')
QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen3.6')
# ─── Internal Cron / Job Security ──────────────────────────────────────────
INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', '')

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';

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