Compare commits

...

102 Commits

Author SHA1 Message Date
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
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
219 changed files with 33530 additions and 2296 deletions

View File

@@ -57,6 +57,13 @@ 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
# ═══════════════════════════════════════════════════════════════════════════

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

@@ -195,7 +195,9 @@ Ver [docs/INSTALACION.md](docs/INSTALACION.md) para instrucciones detalladas.
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Arquitectura del sistema |
| [docs/DATABASE.md](docs/DATABASE.md) | Esquema de base de datos |
---
**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"
)
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)
SQLITE_PATH = os.path.join(
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>
</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>
<!-- Main Content -->
@@ -660,6 +668,35 @@
</div>
</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>
</div>

View File

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

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
(function(){
var t = localStorage.getItem('nexus-theme') || 'industrial';
@@ -32,7 +32,6 @@
<span id="themeIcon">&#9790;</span>
</button>
<a href="/catalog" class="btn btn-primary">Ver Catalogo</a>
<a href="/pos/login" class="btn btn-secondary">Acceder POS</a>
</div>
</div>
</header>
@@ -42,13 +41,12 @@
<canvas id="heroCanvas"></canvas>
<div class="hero-content">
<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">
<span id="typewriterText"></span><span class="typewriter-cursor"></span>
</div>
<div class="hero-buttons nx-reveal">
<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 class="hero-stats nx-stagger">
<div class="stat-card nx-reveal">
@@ -80,59 +78,46 @@
<section class="product">
<div class="container">
<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-card product-card--orange nx-reveal">
<h3>Ventas & POS</h3>
<h3>Catalogo Completo + POS + Inventario</h3>
<ul>
<li>Punto de venta completo con F-keys y escaner</li>
<li>Caja registradora multi-caja, cortes X/Z</li>
<li>Cotizaciones, apartados, devoluciones</li>
<li>Clientes con credito y 3 niveles de precio</li>
<li>Facturacion CFDI 4.0 (Ingreso, Egreso, Pago)</li>
<li>Impresion termica ESC/POS</li>
<li>Contabilidad con polizas automaticas</li>
<li>Reportes: ventas, ABC, cortes, utilidad</li>
<li>Catalogo completo: 1.5M+ partes OEM y 304K+ aftermarket</li>
<li>Punto de venta completo con escaner y teclas rapidas</li>
<li>Inventario append-only con toma fisica y alertas de stock</li>
<li>Navegacion por vehiculo: Marca > Modelo > Ano > Motor</li>
<li>Decodificador VIN + busqueda por placas MX</li>
<li>Facturacion CFDI 4.0 integrada</li>
</ul>
</div>
<div class="product-card product-card--cyan nx-reveal">
<h3>Catalogo & Inventario</h3>
<h3>Agente AI para WhatsApp</h3>
<ul>
<li>Catalogo TecDoc: 1.5M+ partes OEM</li>
<li>304K+ partes aftermarket con cross-refs</li>
<li>Navegacion: Ano > Marca > Modelo > Motor</li>
<li>VIN decoder + busqueda por placas MX</li>
<li>Inventario append-only, toma fisica</li>
<li>Imagenes de productos con upload masivo</li>
<li>Traduccion automatica EN > ES (326 partes)</li>
<li>Marketplace B2B: bodegas ↔ talleres</li>
<li>Atiende consultas de autopartes 24/7 automaticamente</li>
<li>Genera cotizaciones inteligentes desde la conversacion</li>
<li>Reconoce piezas por foto con Vision AI</li>
<li>Transcripcion de notas de voz a texto</li>
<li>Envia catalogos y cotizaciones directo al cliente</li>
<li>Reduce llamadas y aumenta conversiones</li>
</ul>
</div>
<div class="product-card product-card--green nx-reveal">
<h3>IA & Plataforma</h3>
<h3>Vinculacion con Mercado Libre</h3>
<ul>
<li>Chatbot IA: diagnostico, cotizacion inteligente</li>
<li>Entrada por voz (Web Speech API)</li>
<li>Reconocimiento de partes por foto (Vision AI)</li>
<li>WhatsApp Business integrado (envio de cotizaciones)</li>
<li>Gestion de flotillas y mantenimiento</li>
<li>PWA + App Android, modo kiosko</li>
<li>Offline-first con sync automatico</li>
<li>2 temas, 2 idiomas (ES/EN), 2 monedas (MXN/USD)</li>
<li>Publica tu inventario en Mercado Libre en minutos</li>
<li>Sincronizacion automatica de stock y precios</li>
<li>Descarga ordenes y conviertelas en ventas del POS</li>
<li>Gestiona listados, preguntas y ventas desde un solo lugar</li>
<li>Empieza a vender en linea sin complicaciones</li>
<li>Mas canales, mas ventas, mismo inventario</li>
</ul>
</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>
</section>
@@ -154,12 +139,12 @@
<div class="step nx-reveal">
<div class="step-number">2</div>
<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 class="step nx-reveal">
<div class="step-number">3</div>
<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>
@@ -178,7 +163,7 @@
<div class="diff-grid nx-stagger">
<div class="diff-card nx-reveal">
<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>
</div>
<div class="diff-card nx-reveal">
@@ -193,13 +178,13 @@
</div>
<div class="diff-card nx-reveal">
<div class="diff-icon">&#128640;</div>
<h4>Marketplace B2B</h4>
<p>Conecta bodegas con talleres. Mas ventas, menos llamadas.</p>
<h4>Venta en Linea</h4>
<p>Conecta tu inventario con Mercado Libre y vende 24/7.</p>
</div>
<div class="diff-card nx-reveal">
<div class="diff-icon">&#128421;</div>
<h4>Hardware incluido</h4>
<p>Rack 3D con servidor. Renta todo por $2,000/mes.</p>
<h4>Hardware opcional</h4>
<p>Mini rack 3D con servidor. Disponible como add-on.</p>
</div>
<div class="diff-card nx-reveal">
<div class="diff-icon">&#127760;</div>
@@ -229,41 +214,46 @@
<section class="pricing">
<div class="container">
<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-card nx-reveal">
<h4>Basico</h4>
<div class="pricing-price">$999</div>
<div class="pricing-period">MXN / mes — solo software</div>
<h4>POS Basico</h4>
<div class="pricing-price">$650</div>
<div class="pricing-period">MXN / mes</div>
<ul>
<li>POS + Inventario</li>
<li>Catalogo TecDoc</li>
<li>CFDI 4.0</li>
<li>Punto de venta completo</li>
<li>Inventario y catalogo de partes</li>
<li>Facturacion CFDI 4.0</li>
<li>Reportes basicos</li>
</ul>
</div>
<div class="pricing-card featured nx-reveal">
<h4>Pro</h4>
<div class="pricing-price">$2,000</div>
<div class="pricing-period">MXN / mes — hardware incluido</div>
<h4>Sistema Completo</h4>
<div class="pricing-price">$1,660</div>
<div class="pricing-period">MXN / mes</div>
<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>Chatbot IA + WhatsApp</li>
<li>Marketplace B2B</li>
<li>&#128421; Mini PC + rack 3D + red incluidos</li>
<li>Multi-sucursal y flotillas</li>
</ul>
</div>
<div class="pricing-card nx-reveal">
<h4>Enterprise</h4>
<div class="pricing-price">$3,999</div>
<div class="pricing-period">MXN / mes — hardware incluido</div>
</div>
<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);">
<p><strong>Paga anual y ahorra 2 meses.</strong> Aplica a meses sin intereses (MSI).</p>
</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>
<li>Todo Pro +</li>
<li>Flotillas + Multi-bodega</li>
<li>API dedicada</li>
<li>Soporte prioritario</li>
<li>&#128421; Hardware dedicado por sucursal</li>
<li>Mini PC con POS preinstalado</li>
<li>Switch + Access Point + UPS</li>
<li>Rack 3D personalizado</li>
<li>Solo conectar y empezar a vender</li>
</ul>
</div>
</div>
@@ -283,12 +273,12 @@
<div class="contact-card nx-reveal">
<div class="contact-icon">&#9993;</div>
<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 class="contact-card nx-reveal">
<div class="contact-icon">&#128241;</div>
<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 class="contact-card nx-reveal">
<div class="contact-icon">&#128205;</div>

View File

@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
from config import DB_URL
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
sys.path.insert(0, os.path.join(_base, '..', 'pos'))
@@ -4628,6 +4629,76 @@ def part_aftermarket(part_id):
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)
# ============================================================================

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

82
docs/GLOBAL_INVOICE.md Normal file
View File

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

52
docs/MULTI_BRANCH.md Normal file
View File

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

25
manager/.env.example Normal file
View File

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

203
manager/README.md Normal file
View File

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

99
manager/app.py Normal file
View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

57
manager/config.py Normal file
View File

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

5
manager/requirements.txt Normal file
View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
manager/wsgi.py Normal file
View File

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

View File

@@ -1,100 +1,63 @@
# 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 {
server 127.0.0.1:5001;
}
upstream nexus_dashboard {
server 127.0.0.1:5000;
}
upstream nexus_quart {
server 127.0.0.1:5002;
}
# Gzip compression
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;
# Main site (no subdomain)
# ─── Landing page / Dashboard (primary domain) ───
server {
listen 80;
server_name nexusautoparts.com www.nexusautoparts.com;
server_name nexusautoparts.com.mx www.nexusautoparts.com.mx;
# Static asset caching
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;
}
client_max_body_size 10M;
# Auto-serve minified JS/CSS when available (transparent to templates)
location ~* ^(.+)\.js$ {
try_files $1.min.js $uri =404;
expires 6M;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
}
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;
}
# Gzip compression
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_main;
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_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_read_timeout 300s;
}
}
# POS subdomains (wildcard)
# ─── POS (dedicated subdomain) ───
server {
listen 80;
server_name ~^(?<tenant>.+)\.nexusautoparts\.com$;
server_name pos.nexusautoparts.com.mx;
# Security headers
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
client_max_body_size 10M;
# Static asset caching
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;
}
# Gzip compression
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 / {
# 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;
proxy_set_header X-Tenant-Subdomain $tenant;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
expires 6M;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
}
# Async catalog search via Quart+asyncpg (non-blocking I/O)
@@ -104,21 +67,68 @@ server {
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_set_header X-Tenant-Subdomain $tenant;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_buffering off;
}
# Rate limit login endpoint
location /pos/api/auth/login {
limit_req zone=pos_login burst=5 nodelay;
location = / {
return 302 /pos/login;
}
location / {
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;
proxy_set_header X-Tenant-Subdomain $tenant;
proxy_read_timeout 300s;
}
}
# ─── Dashboard admin (alternative access) ───
server {
listen 80;
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 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;
}
}
# ─── 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;
}
}

View File

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

View File

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

View File

@@ -32,6 +32,9 @@ def create_app():
from blueprints.pos_bp import 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
app.register_blueprint(customers_bp)
@@ -56,6 +59,12 @@ def create_app():
from blueprints.marketplace_bp import 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
app.register_blueprint(peer_bp)
@@ -104,6 +113,12 @@ def create_app():
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
@app.route('/pos/health')
def health():
@@ -122,6 +137,10 @@ def create_app():
tenant_name=getattr(g, 'tenant_name', 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')
def pos_catalog():
return render_template('catalog.html')
@@ -174,6 +193,18 @@ def create_app():
def pos_marketplace():
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>')
def pos_static(filename):
return send_from_directory('static', filename)

View File

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

View File

@@ -35,6 +35,25 @@ def _oem_blocked():
return None
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):
"""Helper: open master + tenant connections, call fn, close both.
fn receives (master_conn, tenant_conn, branch_id).
@@ -71,6 +90,34 @@ def _master_only(fn):
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) ───
@catalog_bp.route('/brands', methods=['GET'])
@@ -79,10 +126,11 @@ def brands():
from services.catalog_modes import normalize_mode
year_id = request.args.get('year_id', type=int)
mode = normalize_mode(request.args.get('mode'))
def _do(master):
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
def _do(master, tenant, branch_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_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/models', methods=['GET'])
@@ -92,10 +140,11 @@ def models():
year_id = request.args.get('year_id', type=int)
if not brand_id:
return jsonify({'error': 'brand_id required'}), 400
def _do(master):
data = catalog_service.get_models(master, brand_id, year_id=year_id)
def _do(master, tenant, branch_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 _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/years', methods=['GET'])
@@ -104,10 +153,11 @@ def years():
model_id = request.args.get('model_id', type=int)
if not model_id:
return jsonify({'error': 'model_id required'}), 400
def _do(master):
data = catalog_service.get_years(master, model_id)
def _do(master, tenant, branch_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 _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/years-all', methods=['GET'])
@@ -130,10 +180,11 @@ def engines():
year_id = request.args.get('year_id', type=int)
if not model_id or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400
def _do(master):
data = catalog_service.get_engines(master, model_id, year_id)
def _do(master, tenant, branch_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 _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/categories', methods=['GET'])
@@ -150,13 +201,14 @@ def categories():
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
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':
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
else:
data = catalog_service.get_categories(master, mye_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
data = catalog_service.get_categories(master, mye_id, allowed_brands)
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
return _with_conns(_do)
@catalog_bp.route('/groups', methods=['GET'])
@@ -174,17 +226,17 @@ def groups():
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
def _do(master, tenant, branch_id):
if mode == 'local':
if not category_slug:
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:
if not category_id:
return jsonify({'error': 'category_id required for oem mode'}), 400
data = catalog_service.get_groups(master, mye_id, category_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
return _with_conns(_do)
# ─── Parts with stock enrichment (master + tenant) ───
@@ -205,19 +257,19 @@ def part_types():
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
def _do(master, tenant, branch_id):
if mode == 'local':
if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
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:
if not group_id:
return jsonify({'error': 'group_id required for oem mode'}), 400
data = catalog_service.get_part_types(master, mye_id, group_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
@@ -261,8 +313,8 @@ def shop_supplies_parts():
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
part_type_slug = request.args.get('part_type_slug')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 30, type=int)
page = max(1, request.args.get('page', 1, type=int) or 1)
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:
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
def _do(master, tenant, branch_id):
@@ -298,8 +350,8 @@ def parts():
nexpart_subgroup = request.args.get('nexpart_subgroup')
nexpart_part_type = request.args.get('nexpart_part_type')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 30, type=int)
page = max(1, request.args.get('page', 1, type=int) or 1)
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
@@ -317,19 +369,34 @@ def parts():
return blocked
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:
result = catalog_service.get_parts_for_nexpart_triple(
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':
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:
result = catalog_service.get_parts(
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 _with_conns(_do)
@@ -337,9 +404,8 @@ def parts():
@catalog_bp.route('/part/<int:part_id>', methods=['GET'])
@require_auth('catalog.view')
def part_detail(part_id):
blocked = _oem_blocked()
if blocked:
return blocked
# Part detail is available in both local and OEM modes
# — it reads from the master parts DB and enriches with local stock.
def _do(master, tenant, branch_id):
result = catalog_service.get_part_detail(master, part_id, tenant, branch_id)
if not result:
@@ -351,16 +417,19 @@ def part_detail(part_id):
@catalog_bp.route('/search', methods=['GET'])
@require_auth('catalog.view')
def search():
blocked = _oem_blocked()
if blocked:
return blocked
# Search is available in both local and OEM modes
# — it reads from the master parts DB and enriches with local stock.
q = request.args.get('q', '').strip()
if not q or len(q) < 2:
return jsonify({'data': []})
limit = request.args.get('limit', 50, type=int)
mye_id = request.args.get('mye_id', type=int)
def _do(master, tenant, branch_id):
data = catalog_service.smart_search(master, q, tenant, branch_id, limit)
return jsonify({'data': data})
allowed_brands = _get_allowed_brands(tenant) if tenant else None
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)
@@ -592,3 +661,472 @@ def _match_vin_to_catalog(master_conn, vin_info):
return None
finally:
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():
conn = get_tenant_conn(g.tenant_id)
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 = []
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()
conn.close()
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'])
@require_auth('config.edit')
def create_branch():
@@ -47,10 +83,23 @@ def create_branch():
conn = get_tenant_conn(g.tenant_id)
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("""
INSERT INTO branches (name, address, phone)
VALUES (%s, %s, %s) RETURNING id
""", (data['name'], data.get('address'), data.get('phone')))
INSERT INTO branches (
name, address, phone, is_main,
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]
conn.commit()
cur.close()
@@ -58,6 +107,49 @@ def create_branch():
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'])
@require_auth('config.view')
def list_employees():
@@ -409,3 +501,247 @@ def upgrade_billing():
if 'error' in result:
return jsonify(result), 400
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
cur.execute(f"""
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.branch_id
FROM customers c
@@ -64,11 +65,12 @@ def list_customers():
for r in cur.fetchall():
customers.append({
'id': r[0], 'name': r[1], 'rfc': r[2], 'razon_social': r[3],
'phone': r[4], 'email': r[5], 'price_tier': r[6],
'credit_limit': float(r[7]) if r[7] else 0,
'credit_balance': float(r[8]) if r[8] else 0,
'vehicle_info': r[9],
'branch_id': r[10],
'phone': r[4], 'email': r[5], 'address': r[6], 'cp': r[7],
'price_tier': r[8],
'credit_limit': float(r[9]) if r[9] else 0,
'credit_balance': float(r[10]) if r[10] else 0,
'vehicle_info': r[11],
'branch_id': r[12],
})
cur.close()
@@ -91,7 +93,7 @@ def get_customer(customer_id):
cur.execute("""
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
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
""", (customer_id,))
row = cur.fetchone()
@@ -103,7 +105,7 @@ def get_customer(customer_id):
customer = dict(zip(cols, row))
# 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:
customer[k] = float(customer[k])
@@ -213,7 +215,7 @@ def update_customer(customer_id):
# Build dynamic update
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
'vehicle_info', 'is_active', 'branch_id']
'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
sets = []
vals = []
for field in allowed:

View File

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

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
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
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 (
enqueue_cfdi, process_queue, retry_failed,
cancel_cfdi, get_queue_status,
)
from services import facturapi_service
from services.audit import log_action
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
def _get_tenant_config(cur):
"""Load tenant CFDI configuration from tenant_config table.
def _get_issuer_config(cur, branch_id=None):
"""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 = {}
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
for row in cur.fetchall():
config[row[0]] = row[1]
return {
result = {
'rfc': config.get('tenant_rfc', ''),
'razon_social': config.get('tenant_razon_social', ''),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
'cp': config.get('tenant_cp', '00000'),
'serie': config.get('cfdi_serie', 'A'),
'horux_api_url': config.get('cfdi_horux_api_url', ''),
'horux_api_key': config.get('cfdi_horux_api_key', ''),
'facturapi_key': config.get('cfdi_facturapi_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):
"""Load a sale with its items for CFDI generation."""
@@ -134,14 +156,14 @@ def generate_invoice():
cur = conn.cursor()
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)
if not sale:
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':
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]})'
}), 409
# Build XML
# Build Facturapi payload
if cfdi_type == 'ingreso':
xml = build_ingreso_xml(sale, tenant_config, customer)
payload = build_ingreso_payload(sale, tenant_config, customer)
elif cfdi_type == 'egreso':
original_uuid = data.get('original_uuid')
if not original_uuid:
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:
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
# 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'],
new_value={'sale_id': sale_id, 'type': cfdi_type,
@@ -225,10 +247,10 @@ def get_queue_item(cfdi_id):
cur = conn.cursor()
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.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
""", (cfdi_id,))
row = cur.fetchone()
@@ -239,13 +261,14 @@ def get_queue_item(cfdi_id):
item = {
'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],
'retry_count': row[7], 'provisional_folio': row[8],
'error_message': row[9], 'cancel_motive': row[10],
'cancel_replacement_uuid': row[11],
'created_at': str(row[12]) if row[12] else None,
'stamped_at': str(row[13]) if row[13] else None,
'external_id': row[14],
}
cur.close()
@@ -261,20 +284,17 @@ def trigger_process_queue():
cur = conn.cursor()
try:
tenant_config = _get_tenant_config(cur)
horux_url = tenant_config.get('horux_api_url')
horux_key = tenant_config.get('horux_api_key')
if not horux_url or not horux_key:
tenant_config = _get_issuer_config(cur)
if not tenant_config.get('facturapi_key'):
cur.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_count = retry_failed(conn)
# Process the queue
result = process_queue(conn, horux_url, horux_key)
result = process_queue(conn, tenant_config)
result['retries_reset'] = reset_count
cur.close()
@@ -316,11 +336,10 @@ def cancel_invoice(cfdi_id):
cur = conn.cursor()
try:
tenant_config = _get_tenant_config(cur)
tenant_config = _get_issuer_config(cur)
result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid,
tenant_config.get('horux_api_url'),
tenant_config.get('horux_api_key'),
tenant_config=tenant_config,
)
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
@@ -362,7 +381,7 @@ def get_sale_pdf(sale_id):
cur.close(); conn.close()
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'))
# Check if there's a stamped CFDI
@@ -397,3 +416,249 @@ def get_sale_pdf(sale_id):
'customer': customer,
'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)
@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
# ═══════════════════════════════════════════════════════════════════════════

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 jwt
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 tenant_db import get_tenant_conn
from services.pos_engine import (
process_sale, cancel_sale, calculate_totals,
get_price_for_customer, get_margin_info
)
from services.inventory_engine import get_stock
from services.audit import log_action
from config import JWT_SECRET
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
cur.execute("""
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
""", (inv_ids,))
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,
'discount_pct': discount_pct,
'tax_rate': tax_rate,
'branch_id': inv[8],
})
return enriched
@@ -101,6 +103,19 @@ def create_sale():
data = request.get_json() or {}
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:
sale = process_sale(conn, data)
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'])
@require_auth('pos.view')
def get_sale(sale_id):
@@ -485,6 +577,16 @@ def create_quotation():
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,
new_value={'total': totals['total'], 'items_count': len(items)})
@@ -766,6 +868,270 @@ def get_quotation(quot_id):
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'])
@require_auth('pos.view')
def get_quotation_pdf(quot_id):
@@ -1004,6 +1370,19 @@ def convert_quotation(quot_id):
WHERE id = %s
""", (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()
cur.close(); conn.close()
return jsonify(sale), 201
@@ -1034,11 +1413,76 @@ def cancel_quotation(quot_id):
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
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()
cur.close(); conn.close()
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) ────────────────────────
@pos_bp.route('/layaways', methods=['POST'])
@@ -1510,6 +1954,14 @@ def complete_layaway(layaway_id):
new_value={'sale_id': sale['id'], 'total': total})
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()
return jsonify(sale), 201
@@ -1967,3 +2419,109 @@ def print_ticket(sale_id):
raw = generate_ticket(sale_data, business_info, width=width)
return Response(raw, mimetype='application/octet-stream',
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

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

@@ -13,15 +13,145 @@ Endpoints:
from flask import Blueprint, request, jsonify, g
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 config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
from datetime import datetime
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.
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:
(formatted_text, first_part_dict) — first_part_dict is used by the
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
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
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
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 search_query by '|' into individual terms
raw_terms = [t.strip() for t in (search_query or '').split('|') if t.strip()]
if not raw_terms:
raw_terms = [search_query] if search_query else []
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()
cur.execute(f"""
SELECT 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 (
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)
search_terms = list(search_terms)
if not search_terms:
return None, None
rows = cur.fetchall()
cur.close()
# Vehicle-aware filtering
mye_ids = _resolve_mye_ids(vehicle, master_conn)
if not rows:
return ('❌ No tenemos esa parte en inventario actualmente.\n'
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
def _do_search(use_compat=True):
"""Run inventory search. Returns list of rows."""
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
in_stock = [r for r in rows if r[6] > 0]
out_stock = [r for r in rows if r[6] <= 0]
where_search = ' OR '.join(conditions)
compat_clause = ""
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)
first_part = None
if best:
first_part = {
'inventory_id': None, # we'd need the id — fetch it
'part_number': best[0],
'name': best[1],
'brand': best[2] or '',
'price': float(best[3]) if best[3] else 0,
'inventory_id': best[0],
'part_number': best[1],
'name': best[2],
'brand': best[3] or '',
'price': float(best[4]) if best[4] else 0,
'tax_rate': 0.16,
'stock': best[6],
'unit': best[7] or 'PZA',
'stock': best[7],
'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 = []
if using_fallback:
lines.append("⚠️ *No encontré partes verificadas para tu vehículo, pero sí tengo estas opciones generales:*")
lines.append("")
if in_stock:
lines.append('✅ *Tenemos en stock:*')
lines.append('')
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 ''
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
lines.append(f'{brand_str} {name}')
lines.append(f' #{part_num}{price_str} ({stock} {unit or "pzas"} disponibles)')
lines.append('')
else:
elif out_stock:
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
lines.append('')
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 ''
price_str = f'${float(p1):,.2f}' if p1 else ''
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:
print(f"[WA-AI] Enrichment error: {e}")
import traceback
traceback.print_exc()
return None, None
return None, None
@whatsapp_bp.route('/status', methods=['GET'])
@require_auth()
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'])
@require_auth()
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'])
@require_auth()
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'])
@require_auth()
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'])
def webhook():
"""Receive messages from Baileys bridge (public, no auth).
Flow:
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.
Nuevo flujo: máquina de estados estructurada.
"""
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'):
return jsonify({'ok': True})
# Reuse one tenant connection for the whole webhook path — we need it
# for persistence AND for the inventory-context lookup.
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
tenant_id = 11
phone = msg['phone']
reply_to = msg.get('sender_pn') or msg.get('jid') or phone
text = msg.get('text', '')
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
inventory_context = None
master_conn = None
try:
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.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
VALUES (%s, 'incoming', %s, %s, %s)
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()
cur.close()
# 2. Build inventory context once per webhook call so the chatbot
# can say things like "tengo 5 Bosch BP-123 por $450".
try:
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}")
# 2. Load session state
from services.wa_state_machine import get_session, save_session, process_message, StateContext
session = get_session(tenant_conn, phone)
# 3. Dispatch by media kind + quotation commands
reply = None
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
# 3. Check session expiry (30 minutes)
current_state = session.get('state', 'idle')
state_data = session.get('state_data', {})
last_updated = session.get('updated_at')
# ── Check for quotation commands FIRST (before AI) ──
if media_kind == 'text' and msg.get('text'):
from services.wa_quotation import (
detect_quote_intent, get_open_quotation, create_quotation,
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
)
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
if intent == 'add':
last_part = get_last_shown_part(clean_phone)
if not last_part:
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
elif tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if not qid:
qid = create_quotation(tenant_conn, clean_phone)
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
detail = get_quotation_detail(tenant_conn, qid)
item_count = len(detail['items']) if detail else 0
reply = (
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
)
elif intent == 'send':
if tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if qid:
detail = get_quotation_detail(tenant_conn, qid)
reply = format_quotation_wa(detail)
if not reply:
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
else:
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
elif intent == 'clear':
if tenant_conn:
clear_quotation(tenant_conn, clean_phone)
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
elif intent == 'confirm':
if tenant_conn:
qid = confirm_quotation(tenant_conn, clean_phone)
if qid:
reply = (
f'✅ *Pedido confirmado!*\n\n'
f'Tu cotización #{qid} fue registrada.\n'
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
f'¡Gracias por tu compra! 🙏'
)
else:
reply = '⚠️ No tienes una cotización abierta para confirmar.'
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.
if last_updated and hasattr(last_updated, 'strftime'):
# PostgreSQL returns datetime objects (often timezone-aware)
from datetime import timezone
now = datetime.now(timezone.utc)
if last_updated.tzinfo is None:
now = now.replace(tzinfo=None)
elapsed = (now - last_updated).total_seconds()
if elapsed > 1800:
current_state = 'idle'
state_data = {'customer_id': state_data.get('customer_id')}
elif last_updated and isinstance(last_updated, str):
from datetime import datetime as dt
try:
from services.whisper_local import transcribe_audio_base64
transcript = transcribe_audio_base64(
msg['media_base64'],
mimetype=msg.get('media_mimetype') or 'audio/ogg',
parsed = dt.fromisoformat(last_updated.replace('Z', '+00:00'))
elapsed = (dt.now(dt.now().astimezone().tzinfo) - parsed).total_seconds()
if elapsed > 1800:
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:
transcript = None
print("[WA-AI] whisper_local not installed — voice notes skipped")
except Exception as e:
transcript = None
print(f"[WA-AI] Whisper transcription failed: {e}")
tenant_conn.commit()
cur_fu.close()
except Exception as fu_err:
print(f"[WA-SM] Follow-up send failed: {fu_err}")
if transcript:
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
from services.ai_chat import chat
ai_resp = chat(transcript, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Prefix the reply so the sender knows we understood the voice note
if reply:
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
else:
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
'Puedes escribirme el mensaje?')
# 4. Build context
context = StateContext(
tenant_conn=tenant_conn,
master_conn=master_conn,
wa_config=wa_config,
tenant_id=tenant_id,
phone=phone,
media_kind=media_kind,
media_base64=msg.get('media_base64'),
push_name=msg.get('push_name'),
)
elif msg.get('text'):
# Plain text message — standard chatbot flow
from services.ai_chat import chat
ai_resp = chat(msg['text'], inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# 5. Process through state machine
reply, next_state, next_state_data = process_message(
phone=phone,
text=text,
current_state=current_state,
state_data=state_data,
context=context,
)
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
if search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
# 5b. Si el estado transicionó sin mensaje, procesar el siguiente inmediatamente
# (algunos estados solo hacen transiciones y delegan el mensaje al siguiente estado)
loop_guard = 0
while reply is None and loop_guard < 5:
loop_guard += 1
reply, next_state, next_state_data = process_message(
phone=phone,
text=text,
current_state=next_state,
state_data=next_state_data,
context=context,
)
# 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:
result = whatsapp_service.send_message(reply_to, reply)
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
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
if tenant_conn:
try:
cur2 = tenant_conn.cursor()
cur2.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (msg['phone'], reply))
tenant_conn.commit()
cur2.close()
except Exception as db_err:
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
# Log outgoing
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (phone, reply))
tenant_conn.commit()
cur.close()
except Exception as e:
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
# 4. Clean up the connection
if tenant_conn is not None:
print(f"[WA-SM] Webhook error: {e}")
import traceback
traceback.print_exc()
# Fallback: enviar mensaje de error genérico
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:
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})
@@ -403,11 +596,17 @@ def send():
if not phone or not message:
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
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
@@ -415,9 +614,10 @@ def send():
""", (phone, message))
conn.commit()
cur.close()
conn.close()
except Exception:
pass
finally:
conn.close()
return jsonify(result)

View File

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

View File

@@ -43,12 +43,21 @@ if not OPENROUTER_API_KEY:
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_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
SMTP_USER = os.environ.get('SMTP_USER', '')
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_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_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.
# Ideal for I/O-bound Flask apps with DB queries.
# 4 workers × 4 threads = 16 concurrent requests.
workers = 4
workers = 8
threads = 4
worker_class = "gthread"
worker_connections = 1000

View File

@@ -29,7 +29,7 @@ def require_auth(*required_permissions):
except jwt.InvalidTokenError:
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
g.tenant_id = payload['tenant_id']

View File

@@ -55,7 +55,7 @@ def _lookup_tenant_by_subdomain(subdomain):
conn = get_master_conn()
cur = conn.cursor()
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,)
)
row = cur.fetchone()

View File

@@ -14,9 +14,11 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
MIGRATIONS = {
'v1.0': 'v1.0_initial.sql',
'v1.1': 'v1.1_pos_tables.sql',
'v1.2': 'v1.2_subdomain.sql',
'v1.3': 'v1.3_fleet.sql',
'v1.4': 'v1.4_whatsapp.sql',
'v1.5': 'v1.5_returns.sql',
'v1.6': 'v1.6_marketplace.sql',
'v1.7': 'v1.7_plates.sql',
'v1.8': 'v1.8_performance_indexes.sql',
'v1.9': 'v1.9_redis_cache.sql',
@@ -33,6 +35,20 @@ MIGRATIONS = {
'v3.0': 'v3.0_public_api.sql',
'v3.1': 'v3.1_inventory_vehicle_compat.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}")
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)
cur = conn.cursor()
try:
with open(filepath) as f:
cur.execute(f.read())
cur.execute(sql)
conn.commit()
return True
except Exception as e:

View File

@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
-- Barcode sequence
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
-- 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.
--
-- Notes:
-- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation).
-- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists.
-- - Run with statement_timeout = 0; this may take hours on first creation.
-- NOTE: This migration targets the vehicle_database, not tenant databases.
-- The runner skips files marked with ': SKIP' on the first line.
-- To apply manually on the vehicle database, run:
--
-- psql <vehicle_db> -f pos/migrations/v3.3_materialized_view.sql
--
-- (Remove the ': SKIP' line above before manual execution.)
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 INDEX idx_pvp_brand ON part_vehicle_preview(name_brand);
-- Grant select to application roles if needed
-- GRANT SELECT ON part_vehicle_preview TO nexus_app;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
## Architecture
The Capacitor app loads the POS from the remote server at
`https://nexus.consultoria-as.com/pos`. This means:
`https://pos.nexusautoparts.com.mx/pos`. This means:
- The app requires internet on first load.
- The PWA service worker handles offline caching after that.
- No HTML/JS/CSS is bundled into the native binary.

View File

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

View File

@@ -3,9 +3,15 @@
import requests
import json
from config import OPENROUTER_API_KEY
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL)
HERMES_CHAT_URL = (HERMES_API_URL.rstrip('/') + '/chat/completions') if HERMES_API_URL else None
QWEN_ENABLED = bool(QWEN_API_KEY and QWEN_API_URL)
QWEN_CHAT_URL = (QWEN_API_URL.rstrip('/') + '/chat/completions') if QWEN_API_URL else None
# ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago.
# El modelo DEBE terminar en ":free" para garantizar costo $0.
@@ -24,11 +30,100 @@ FALLBACK_MODELS = [
"meta-llama/llama-3.3-70b-instruct:free", # Meta — último fallback
]
# Hermes Agent model (OpenAI-compatible API server)
HERMES_MODEL = "hermes-agent"
def _validate_model(model_id):
"""Ensure only free models are used. Raises if model is not free."""
"""Ensure only free models are used. Raises if model is not free.
Skips validation for Hermes Agent and QWEN models (self-hosted / private API).
"""
if model_id == HERMES_MODEL:
return
if model_id == QWEN_MODEL:
return
if not model_id.endswith(':free'):
raise ValueError(f"BLOQUEADO: Solo se permiten modelos gratuitos (:free). Modelo '{model_id}' no es gratuito.")
def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temperature=0.3, timeout=25):
"""Generic OpenAI-compatible chat completion POST.
Returns the parsed response dict on success, None on failure.
"""
try:
resp = requests.post(
url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model_id,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
timeout=timeout,
)
if resp.status_code == 429:
print(f"[AI] Rate limited on {model_id} ({url})")
return None
if resp.status_code >= 400:
print(f"[AI] HTTP {resp.status_code} on {model_id} ({url}): {resp.text[:200]}")
return None
data = resp.json()
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content") or ""
content = content.strip()
finish = choice.get("finish_reason", "")
if not content:
print(f"[AI] Empty response from {model_id} (finish={finish})")
return None
return {"content": content, "finish_reason": finish, "model": model_id}
except Exception as e:
print(f"[AI] Error with {model_id} ({url}): {e}")
return None
SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
REGLAS DE VENTA AVANZADAS:
1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
3. MANEJO DE OBJECIONES:
- "Esta caro""Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
- "Voy a checar en otro lado""Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
- "Lo necesito para hoy" / "Urgente""Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
- "No se si sea esa""No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
- "Solo estoy cotizando""Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
- "¿Te lo aparto?"
- "¿Lo mando a tu taller o lo pasas a recoger?"
- "¿Con esto quedas o necesitas algo mas?"
- "¿Te armo el paquete completo? Sale mejor que por separado."
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
TRADUCCIONES search_query (EN INGLES):
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
FORMATO:
- search_query EN INGLES. NUNCA null si pide algo.
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
- Termina SIEMPRE con una pregunta de cierre.
"""
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
IMPORTANTE: Responde SIEMPRE en formato JSON valido con esta estructura:
@@ -131,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
GROUP BY i.brand
ORDER BY cnt DESC
LIMIT 15
LIMIT 10
""", params)
brands = cur.fetchall()
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
# Top categories with counts
cur.execute(f"""
SELECT c.name, COUNT(*) as cnt
FROM inventory i
JOIN part_categories c ON c.id = i.category_id
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
GROUP BY c.name
ORDER BY cnt DESC
LIMIT 10
""", params)
categories = cur.fetchall()
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
# Products with low stock (<=3)
cur.execute(f"""
SELECT COUNT(*) FROM inventory i
@@ -148,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
"CONTEXTO DEL INVENTARIO:",
f"Este negocio tiene {total} productos en inventario.",
]
if category_list:
lines.append(f"Categorias principales: {category_list}")
if brand_list:
lines.append(f"Marcas disponibles: {brand_list}")
lines.append(f"Marcas top: {brand_list}")
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.")
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
return "\n".join(lines)
except Exception:
@@ -161,6 +271,7 @@ def get_inventory_context(tenant_conn, branch_id=None):
VISION_MODEL = "google/gemma-3-27b-it:free"
HERMES_VISION_MODEL = "hermes-agent"
VISION_SYSTEM_PROMPT = """Eres un experto en identificación de autopartes. El usuario te envía una foto de una parte automotriz.
Tu trabajo es:
@@ -219,54 +330,41 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
]
messages.append({"role": "user", "content": user_content})
import time
max_retries = 3
for attempt in range(max_retries):
# Vision backends: QWEN only, fallback to OpenRouter if key present
backends = []
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
last_error = None
for url, key, model_id in backends:
_validate_model(model_id)
result = _post_chat_completion(url, key, model_id, messages, max_tokens=500, temperature=0.3, timeout=30)
if result is None:
last_error = "api_error"
continue
content = result["content"]
try:
resp = requests.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": VISION_MODEL,
"messages": messages,
"max_tokens": 500,
"temperature": 0.3,
},
timeout=30,
)
if resp.status_code == 429:
wait = (attempt + 1) * 5
if attempt < max_retries - 1:
time.sleep(wait)
continue
return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
try:
stripped = content.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
json_str = "\n".join(lines[1:-1])
parsed = json.loads(json_str)
else:
parsed = json.loads(stripped)
stripped = content.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
json_str = "\n".join(lines[1:-1])
parsed = json.loads(json_str)
return parsed
except (json.JSONDecodeError, IndexError):
return {"message": content, "search_query": None, "vehicle": None}
except Exception as e:
if attempt < max_retries - 1:
continue
return {
"message": f"Error al analizar imagen: {str(e)}",
"search_query": None,
"vehicle": None,
}
else:
parsed = json.loads(stripped)
return parsed
except (json.JSONDecodeError, IndexError):
return {"message": content, "search_query": None, "vehicle": None}
if last_error == "api_error":
return {"message": "El asistente esta ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
return {
"message": f"Error al analizar imagen: {last_error}",
"search_query": None,
"vehicle": None,
}
def classify_part(part_number):
@@ -287,47 +385,32 @@ def classify_part(part_number):
{"role": "user", "content": prompt}
]
import time
max_retries = 3
for attempt in range(max_retries):
# Backends: QWEN only, fallback to OpenRouter if key present
backends = []
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
for url, key, model_id in backends:
_validate_model(model_id)
result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15)
if result is None:
continue
content = result["content"]
try:
resp = requests.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"max_tokens": 300,
"temperature": 0.2,
},
timeout=15,
)
if resp.status_code == 429:
wait = (attempt + 1) * 5
if attempt < max_retries - 1:
time.sleep(wait)
continue
return {"name": None, "brand": None, "vehicle": None, "category": None}
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
stripped = content.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
json_str = "\n".join(lines[1:-1])
parsed = json.loads(json_str)
return parsed
else:
parsed = json.loads(stripped)
return parsed
return parsed
except Exception:
if attempt < max_retries - 1:
continue
return {"name": None, "brand": None, "vehicle": None, "category": None}
continue
return {"name": None, "brand": None, "vehicle": None, "category": None}
# ═══════════════════════════════════════════════════════════════════════════
@@ -491,74 +574,77 @@ def chat(user_message, conversation_history=None, inventory_context=None):
last_error = None
# Try each model in the fallback chain on 429 (rate limit)
for model_id in FALLBACK_MODELS:
_validate_model(model_id) # Block paid models
try:
resp = requests.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": model_id,
"messages": messages,
"max_tokens": 800,
"temperature": 0.3,
},
timeout=25,
)
if resp.status_code == 429:
# Build backend list: QWEN first, then OpenRouter fallback
backends = []
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
if OPENROUTER_API_KEY:
for m in FALLBACK_MODELS:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
for url, key, model_id, timeout_sec, sys_prompt, max_tok in backends:
_validate_model(model_id)
# Use backend-specific system prompt and max_tokens
sys_content = sys_prompt
if inventory_context:
sys_content = sys_prompt + "\n\n" + inventory_context
msgs = [{"role": "system", "content": sys_content}]
if conversation_history:
msgs.extend(conversation_history)
msgs.append({"role": "user", "content": user_message})
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
max_retries = 3 if url == QWEN_CHAT_URL else 1
result = None
for attempt in range(1, max_retries + 1):
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
if result is not None:
break
if attempt < max_retries:
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
_time_chat.sleep(2)
if result is None:
if url == QWEN_CHAT_URL:
print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
last_error = "qwen_failed"
else:
print(f"[AI] Rate limited on {model_id}, trying next model...")
last_error = "rate_limit"
continue
if resp.status_code >= 400:
print(f"[AI] HTTP {resp.status_code} on {model_id}: {resp.text[:200]}")
last_error = f"http_{resp.status_code}"
continue
data = resp.json()
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "").strip()
finish = choice.get("finish_reason", "")
if not content:
print(f"[AI] Empty response from {model_id} (finish={finish})")
last_error = "empty_response"
continue
print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)")
# Try to parse JSON response
try:
stripped = content.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
json_str = "\n".join(lines[1:-1])
parsed = json.loads(json_str)
else:
parsed = json.loads(stripped)
# Successful JSON response — cache it
if cache_key:
_cache_set(cache_key, parsed)
return parsed
except (json.JSONDecodeError, IndexError):
fallback = {"message": content, "search_query": None, "vehicle": None}
# Cache the fallback too — the model gave us a real answer,
# it just wasn't JSON. Next hit saves the API call.
if cache_key:
_cache_set(cache_key, fallback)
return fallback
except Exception as e:
print(f"[AI] Error with {model_id}: {e}")
last_error = str(e)
continue
content = result["content"]
finish = result["finish_reason"]
print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)")
# Try to parse JSON response
try:
stripped = content.strip()
if stripped.startswith("```"):
lines = stripped.split("\n")
json_str = "\n".join(lines[1:-1])
parsed = json.loads(json_str)
else:
parsed = json.loads(stripped)
# Successful JSON response — cache it
if cache_key:
_cache_set(cache_key, parsed)
return parsed
except (json.JSONDecodeError, IndexError):
fallback = {"message": content, "search_query": None, "vehicle": None}
# Cache the fallback too — the model gave us a real answer,
# it just wasn't JSON. Next hit saves the API call.
if cache_key:
_cache_set(cache_key, fallback)
return fallback
# All models exhausted — DON'T cache errors, we want retries next time
if last_error == "rate_limit":
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
if last_error == "qwen_failed":
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
return {
"message": f"Error de conexion: {last_error}",
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",
"search_query": None,
"vehicle": None,
}

View File

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

View File

@@ -30,28 +30,41 @@ OEM_BRANDS_NA = (
)
# ─── Local mode — brands actually stocked by Mexican bodegas ────────────────
# Popular Mexican market passenger cars + light trucks. Edit as needed.
# All brands with vehicles >= 1980 relevant to Mexico, USA and Canada.
# Covers passenger cars, light trucks, and common commercial vehicles.
LOCAL_BODEGA_BRANDS = (
'NISSAN', # Tsuru, Sentra, Versa, March, Tiida, Navara
'VW', # Jetta, Pointer, Vento, Gol, Polo, Beetle
'CHEVROLET', # Aveo, Chevy, Spark, Beat, Sonic, Sail
'FORD', # Fiesta, Focus, EcoSport, Ranger, Figo
'TOYOTA', # Corolla, Yaris, Hilux, Avanza, Tacoma
'HONDA', # Civic, City, CR-V, Fit, HR-V
'DODGE', # Attitude, Neon, Journey
'CHRYSLER',
'RAM', # Pickups
'HYUNDAI', # Accent, Grand i10, Tucson, Elantra
'KIA', # Rio, Forte, Sportage, Sorento
'MAZDA', # 2, 3, CX-5, CX-30
'MITSUBISHI', # Lancer, L200, Outlander
'RENAULT', # Logan, Sandero, Duster, Stepway
'SEAT', # Ibiza, Leon, Arona
'FIAT', # Uno, Palio, Mobi
'SUZUKI', # Swift, Vitara, Ignis, Ertiga
'JEEP', # Compass, Wrangler, Grand Cherokee, Renegade
'GMC', # Sierra, Terrain
'BUICK', # Encore, Enclave (GM)
# ─── Americanas ───
'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER', 'DODGE', 'FORD', 'GMC',
'HUMMER', 'JEEP', 'LINCOLN', 'MERCURY', 'OLDSMOBILE', 'PONTIAC', 'RAM',
'SATURN', 'TESLA', 'RIVIAN', 'LUCID', 'POLARIS',
# ─── Japonesas ───
'ACURA', 'DAIHATSU', 'HONDA', 'INFINITI', 'ISUZU', 'LEXUS', 'MAZDA',
'MITSUBISHI', 'NISSAN', 'SCION', 'SUBARU', 'SUZUKI', 'TOYOTA',
# ─── Coreanas ───
'GENESIS', 'HYUNDAI', 'KIA', 'KG MOBILITY',
# ─── Alemanas ───
'ALPINA', 'AUDI', 'BMW', 'BRABUS', 'MAYBACH', 'MERCEDES-BENZ', 'MINI',
'OPEL', 'PORSCHE', 'SMART', 'VW',
# ─── Inglesas / UK ───
'ASTON MARTIN', 'BENTLEY', 'JAGUAR', 'LAND ROVER', 'LOTUS', 'MG',
'MORGAN', 'ROLLS-ROYCE',
# ─── Italianas ───
'ABARTH', 'ALFA ROMEO', 'FERRARI', 'FIAT', 'LAMBORGHINI', 'MASERATI',
# ─── Francesas ───
'ALPINE', 'CITROËN', 'DS', 'PEUGEOT', 'RENAULT',
# ─── Suecas ───
'SAAB', 'VOLVO',
# ─── Española ───
'SEAT',
# ─── Chinas con presencia en MX ───
'BAIC', 'BYD', 'CHANGAN', 'CHERY', 'DFSK', 'GEELY', 'GREAT WALL',
'HAVAL', 'JAC', 'JAECOO', 'JETOUR', 'JETTA', 'JMC', 'MAXUS', 'MG',
'OMODA',
# ─── Indias ───
'MAHINDRA', 'TATA',
# ─── Camiones / comerciales ───
'DAEWOO', 'FARGO', 'FREIGHTLINER', 'INNOCENTI', 'INTERNATIONAL',
'IVECO', 'LANCIA', 'MAN', 'SKODA',
)

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,58 +12,200 @@ Features:
from typing import List, Dict, Optional
def get_compat_source(tenant_id):
"""Return the configured compatibility source: 'tecdoc', 'qwen', or 'both'.
Reads from tenant_config table. Defaults to 'both'.
"""
from tenant_db import get_tenant_conn
try:
conn = get_tenant_conn(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 in ('tecdoc', 'qwen', 'both'):
return source
except Exception:
pass
return 'both'
def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, part_number,
brand=None, name=None):
"""Find vehicle compatibility for an inventory item by part_number.
Searches:
1. parts.oem_part_number (exact, case-insensitive, spaces stripped)
2. aftermarket_parts.part_number
3. part_cross_references.cross_ref_number
Searches (in order of precision):
1. parts.oem_part_number exact normalized
2. aftermarket_parts.part_number exact normalized
3. part_cross_references.cross_reference_number exact normalized
4. parts.oem_part_number partial (ILIKE) — strips separators too
5. aftermarket_parts.part_number partial (ILIKE)
6. part_cross_references.cross_reference_number partial (ILIKE)
7. parts.name_part / parts.name_es by inventory item name (ILIKE)
8. parts.name_part / parts.name_es by inventory item name (tsvector)
If a brand hint is provided, results are preferentially filtered to
vehicles of that brand.
Returns:
dict: {'matched': bool, 'matches': [...], 'myes': [...]}
"""
cur = master_conn.cursor()
clean_pn = part_number.replace(' ', '').upper() if part_number else ''
oem_ids = set()
# 1. Direct OEM match
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
""", (clean_pn,))
oem_ids = [r[0] for r in cur.fetchall()]
# Normalization helpers
clean_pn = (part_number or '').replace(' ', '').upper()
no_seps = clean_pn.replace('-', '').replace('/', '').replace('.', '').replace('_', '')
name_q = (name or '').strip()
brand_hint = (brand or '').strip().upper()
# 2. Aftermarket match → get oem_part_id
if not oem_ids:
# ── 1. Exact normalized ──────────────────────────────────────────────
if clean_pn:
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
""", (clean_pn,))
for r in cur.fetchall():
oem_ids.add(r[0])
if clean_pn and not oem_ids:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE REPLACE(UPPER(part_number), ' ', '') = %s
""", (clean_pn,))
oem_ids = [r[0] for r in cur.fetchall() if r[0]]
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# 3. Cross-reference match
if not oem_ids:
if clean_pn and not oem_ids:
cur.execute("""
SELECT DISTINCT part_id FROM part_cross_references
WHERE REPLACE(UPPER(cross_ref_number), ' ', '') = %s
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') = %s
""", (clean_pn,))
oem_ids = [r[0] for r in cur.fetchall() if r[0]]
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# ── 2. Partial / separator-stripped ──────────────────────────────────
if clean_pn and len(clean_pn) >= 3 and len(oem_ids) < 10:
# OEM part number partial
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(UPPER(oem_part_number), ' ', '') LIKE %s
AND id_part NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
oem_ids.add(r[0])
# Aftermarket partial
if len(oem_ids) < 10:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE REPLACE(UPPER(part_number), ' ', '') LIKE %s
AND oem_part_id NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# Cross-reference partial
if len(oem_ids) < 10:
cur.execute("""
SELECT DISTINCT part_id FROM part_cross_references
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') LIKE %s
AND part_id NOT IN (SELECT unnest(%s::int[]))
LIMIT 20
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
# Separator-stripped fallback (e.g. KYB-343412 → KYB343412)
if len(no_seps) >= 4 and len(oem_ids) < 10:
cur.execute("""
SELECT id_part FROM parts
WHERE REPLACE(REPLACE(REPLACE(REPLACE(UPPER(oem_part_number), ' ', ''), '-', ''), '/', ''), '.', '') = %s
AND id_part NOT IN (SELECT unnest(%s::int[]))
LIMIT 10
""", (no_seps, list(oem_ids) or [0]))
for r in cur.fetchall():
oem_ids.add(r[0])
# ── 3. Search by item name (when part_number yields nothing) ─────────
if not oem_ids and name_q and len(name_q) >= 3:
# Name-based ILIKE search on parts
cur.execute("""
SELECT id_part FROM parts
WHERE UPPER(name_part) LIKE %s OR UPPER(COALESCE(name_es, '')) LIKE %s
LIMIT 20
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
for r in cur.fetchall():
oem_ids.add(r[0])
# Name-based ILIKE search on aftermarket_parts
if len(oem_ids) < 20:
cur.execute("""
SELECT DISTINCT oem_part_id FROM aftermarket_parts
WHERE UPPER(COALESCE(name_aftermarket_parts, '')) LIKE %s
OR UPPER(COALESCE(name_es, '')) LIKE %s
LIMIT 20
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
for r in cur.fetchall():
if r[0]:
oem_ids.add(r[0])
if not oem_ids:
cur.close()
return {'matched': False, 'matches': [], 'myes': []}
# Get MYEs for these part IDs
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
# ── Resolve MYEs ─────────────────────────────────────────────────────
oem_ids = list(oem_ids)
if brand_hint and brand_hint != 'GENERAL':
# Brand-aware: only MYEs for vehicles of the hinted brand
cur.execute("""
SELECT DISTINCT vp.model_year_engine_id
FROM vehicle_parts vp
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
WHERE vp.part_id = ANY(%s)
AND b.name_brand = %s
LIMIT 500
""", (oem_ids, brand_hint))
mye_ids = [r[0] for r in cur.fetchall()]
# Fallback: if brand filter yields nothing, the brand hint may be an
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
# OEM vehicle brand. Search without brand filter.
if not mye_ids:
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
else:
# No brand hint — return all MYEs for these parts
cur.execute("""
SELECT DISTINCT model_year_engine_id
FROM vehicle_parts
WHERE part_id = ANY(%s)
LIMIT 500
""", (oem_ids,))
mye_ids = [r[0] for r in cur.fetchall()]
cur.close()
# Insert into tenant table
# ── Insert into tenant table ─────────────────────────────────────────
inserted = 0
cur2 = tenant_conn.cursor()
for mye_id in mye_ids:
@@ -79,7 +221,7 @@ def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, par
cur2.close()
return {
'matched': True,
'matched': len(mye_ids) > 0,
'matches': oem_ids,
'myes': mye_ids,
'inserted': inserted,
@@ -114,6 +256,20 @@ def remove_compatibility(tenant_conn, inventory_id, model_year_engine_id):
return deleted
def remove_compatibility_by_id(tenant_conn, compat_id):
"""Remove a compatibility by its primary key (works for both MYE-linked
and text-only QWEN records)."""
cur = tenant_conn.cursor()
cur.execute("""
DELETE FROM inventory_vehicle_compat
WHERE id = %s
""", (compat_id,))
deleted = cur.rowcount
tenant_conn.commit()
cur.close()
return deleted
def remove_all_compatibility(tenant_conn, inventory_id):
cur = tenant_conn.cursor()
cur.execute("""
@@ -126,120 +282,245 @@ def remove_all_compatibility(tenant_conn, inventory_id):
def get_compatibility(tenant_conn, master_conn, inventory_id):
"""Get all vehicle compatibilities for an inventory item with vehicle details."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT model_year_engine_id, source, confidence, created_at
"""Get all MYE compatibilities for an inventory item.
Queries inventory_vehicle_compat from the tenant DB, then resolves
vehicle details (brand/model/year/engine) from the master DB.
Vehicles with model_year_engine_id IS NULL are text-only QWEN records
(master DB lacks the vehicle) and are returned using their stored text.
"""
# 1. Get all rows from tenant
cur_t = tenant_conn.cursor()
cur_t.execute("""
SELECT id, model_year_engine_id, make, model, year, engine, engine_code,
source, confidence, created_at
FROM inventory_vehicle_compat
WHERE inventory_id = %s
ORDER BY created_at DESC
ORDER BY COALESCE(make, ''), COALESCE(model, ''), COALESCE(year, 0)
""", (inventory_id,))
rows = cur.fetchall()
cur.close()
rows = cur_t.fetchall()
cur_t.close()
if not rows:
return []
mye_ids = [r[0] for r in rows]
# 2. Resolve MYE-linked vehicles from master DB
mye_ids = [r[0] for r in rows if r[0] is not None]
details = {}
if mye_ids:
cur_m = master_conn.cursor()
cur_m.execute("""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
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
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.id_mye = ANY(%s)
ORDER BY b.name_brand, m.name_model, y.year_car
""", (mye_ids,))
details = {r[0]: r for r in cur_m.fetchall()}
cur_m.close()
# Fetch vehicle details from master
cur2 = master_conn.cursor()
cur2.execute("""
# 3. Merge
result = []
for (compat_id, mye_id, make, model, year, engine, engine_code,
source, confidence, created_at) in rows:
if mye_id is not None and mye_id in details:
d = details[mye_id]
result.append({
'id': compat_id,
'model_year_engine_id': mye_id,
'brand': d[1],
'model': d[2],
'year': d[3],
'engine': d[4],
'engine_code': '',
'source': source,
'confidence': float(confidence),
'created_at': str(created_at),
})
else:
# Text-only QWEN record
result.append({
'id': compat_id,
'model_year_engine_id': None,
'brand': make or '',
'model': model or '',
'year': year,
'engine': engine or '',
'engine_code': engine_code or '',
'source': source,
'confidence': float(confidence),
'created_at': str(created_at),
})
return result
def search_mye(master_conn, brand_id=None, model_id=None, year_id=None, engine_id=None):
"""Search MYE IDs by vehicle criteria."""
cur = master_conn.cursor()
clauses = []
params = []
if brand_id:
clauses.append("mye.model_id IN (SELECT id_model FROM models WHERE brand_id = %s)")
params.append(brand_id)
if model_id:
clauses.append("mye.model_id = %s")
params.append(model_id)
if year_id:
clauses.append("mye.year_id = %s")
params.append(year_id)
if engine_id:
clauses.append("mye.engine_id = %s")
params.append(engine_id)
where = " AND ".join(clauses) if clauses else "TRUE"
cur.execute(f"""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
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
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.id_mye = ANY(%s)
""", (mye_ids,))
vehicle_map = {}
for r in cur2.fetchall():
vehicle_map[r[0]] = {
'brand': r[1], 'model': r[2], 'year': r[3], 'engine': r[4],
WHERE {where}
ORDER BY b.name_brand, m.name_model, y.year_car
LIMIT 100
""", tuple(params))
rows = cur.fetchall()
cur.close()
return [
{
'id_mye': r[0],
'brand': r[1],
'model': r[2],
'year': r[3],
'engine': r[4],
}
cur2.close()
for r in rows
]
results = []
for mye_id, source, confidence, created_at in rows:
v = vehicle_map.get(mye_id, {})
results.append({
'model_year_engine_id': mye_id,
'brand': v.get('brand', ''),
'model': v.get('model', ''),
'year': v.get('year', ''),
'engine': v.get('engine', ''),
'source': source,
'confidence': float(confidence) if confidence else 1.0,
'created_at': str(created_at),
})
return results
def batch_add_compatibilities(tenant_conn, inventory_id, mye_ids, source='manual'):
"""Add multiple MYE compatibilities at once."""
cur = tenant_conn.cursor()
inserted = 0
for mye_id in mye_ids:
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence)
VALUES (%s, %s, %s, 1.0)
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
""", (inventory_id, mye_id, source))
if cur.rowcount > 0:
inserted += 1
tenant_conn.commit()
cur.close()
return inserted
def save_qwen_fitment(tenant_conn, inventory_id, fitment_result):
"""Save QWEN fitment results into inventory_vehicle_compat.
Supports both TecDoc-linked vehicles (mye_id present) and text-only
QWEN vehicles (mye_id=None) when the master DB lacks the vehicle.
Args:
tenant_conn: Connection to tenant DB.
inventory_id: The inventory item ID.
fitment_result: Dict from get_vehicle_fitment() with 'vehicles' list.
Returns:
int: Number of compatibilities inserted.
"""
vehicles = fitment_result.get('vehicles', [])
if not vehicles:
return 0
inserted = 0
cur = tenant_conn.cursor()
for v in vehicles:
mye_id = v.get('mye_id')
if mye_id is not None and mye_id:
# TecDoc-linked vehicle
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, created_at)
VALUES (%s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (inventory_id, mye_id, fitment_result.get('confidence', 0)))
else:
# Text-only QWEN vehicle (master DB doesn't have this vehicle)
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code, source, confidence, created_at)
VALUES (%s, NULL, %s, %s, %s, %s, %s, 'qwen_ai', %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (
inventory_id,
v.get('make', '') or '',
v.get('model', '') or '',
v.get('year', 0) or 0,
v.get('engine', '') or '',
v.get('engine_code', '') or '',
fitment_result.get('confidence', 0),
))
if cur.rowcount > 0:
inserted += 1
tenant_conn.commit()
cur.close()
return inserted
def get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id=None):
"""Get local inventory items compatible with a specific vehicle.
"""Return local inventory items compatible with a given vehicle (MYE).
Used by catalog_service to inject local items into Local mode browsing.
Args:
tenant_conn: Connection to tenant DB.
master_conn: Connection to master DB (kept for API consistency).
mye_id: model_year_engine_id.
branch_id: Optional branch filter for stock.
Returns:
List of tuples: (id, part_number, name, brand, price_1, price_2, price_3,
image_url, description, stock)
"""
cur = tenant_conn.cursor()
branch_filter = ""
params = [mye_id]
if branch_id:
branch_filter = "AND i.branch_id = %s"
params.append(branch_id)
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
i.image_url, i.description, COALESCE(s.stock, 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE ivc.model_year_engine_id = %s {branch_filter}
AND i.is_active = true
ORDER BY i.name
""", params)
# Stock for specific branch
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand,
i.price_1, i.price_2, i.price_3,
i.image_url, i.description,
COALESCE(s.stock, 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s
ON s.inventory_id = i.id AND s.branch_id = %s
WHERE ivc.model_year_engine_id = %s
AND i.is_active = true
ORDER BY i.name
""", (branch_id, mye_id))
else:
# Total stock across all branches
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand,
i.price_1, i.price_2, i.price_3,
i.image_url, i.description,
COALESCE(SUM(s.stock), 0) as stock
FROM inventory i
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE ivc.model_year_engine_id = %s
AND i.is_active = true
GROUP BY i.id, i.part_number, i.name, i.brand,
i.price_1, i.price_2, i.price_3,
i.image_url, i.description
ORDER BY i.name
""", (mye_id,))
rows = cur.fetchall()
cur.close()
return rows
def search_mye(master_conn, brand_id=None, model_id=None, year_id=None, engine_id=None):
"""Search model_year_engine records. Returns list of MYE IDs."""
cur = master_conn.cursor()
where = ["true"]
params = []
if brand_id:
where.append("m.brand_id = %s")
params.append(brand_id)
if model_id:
where.append("m.id_model = %s")
params.append(model_id)
if year_id:
where.append("mye.year_id = %s")
params.append(year_id)
if engine_id:
where.append("mye.engine_id = %s")
params.append(engine_id)
cur.execute(f"""
SELECT mye.id_mye, b.name_brand, m.name_model, y.year_car, e.name_engine
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
JOIN engines e ON e.id_engine = mye.engine_id
WHERE {' AND '.join(where)}
ORDER BY b.name_brand, m.name_model, y.year_car, e.name_engine
LIMIT 500
""", params)
results = []
for r in cur.fetchall():
results.append({
'id_mye': r[0], 'brand': r[1], 'model': r[2],
'year': r[3], 'engine': r[4],
})
cur.close()
return results

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,123 @@
"""Quotation stock reservation engine.
Uses inventory_operations with operation types:
QUOTE_RESERVE — negative quantity, reserves stock when quote is created
QUOTE_RELEASE — positive quantity, restores stock when quote is cancelled/expired
QUOTE_CONVERT — neutral (just a marker), actual sale uses SALE operation
The trigger update_stock_summary() recalculates inventory_stock_summary
by summing ALL operations, so reservations automatically affect visible stock.
"""
from services.inventory_engine import record_operation
def reserve_for_quotation(conn, quotation_id, items, employee_id=None):
"""Reserve stock for each item in a new quotation.
Args:
conn: tenant DB connection (not committed by this function).
quotation_id: the quotations.id.
items: list of dicts with inventory_id, quantity, branch_id (optional).
employee_id: optional, passed explicitly when g.employee_id is unavailable.
Returns:
list of operation IDs.
"""
op_ids = []
for item in items:
inv_id = item.get('inventory_id')
qty = item.get('quantity', 0)
branch_id = item.get('branch_id')
if not inv_id or qty <= 0:
continue
op_id = record_operation(
conn, inv_id, branch_id, 'QUOTE_RESERVE',
quantity=-qty,
reference_id=quotation_id,
reference_type='quotation',
notes=f'Reserva cotizacion #{quotation_id}'
)
op_ids.append(op_id)
return op_ids
def release_quotation_reservation(conn, quotation_id, items, employee_id=None):
"""Release previously reserved stock (cancel, expire, or convert).
Args:
conn: tenant DB connection.
quotation_id: the quotations.id.
items: list of dicts with inventory_id, quantity, branch_id.
employee_id: optional.
Returns:
list of operation IDs.
"""
op_ids = []
for item in items:
inv_id = item.get('inventory_id')
qty = item.get('quantity', 0)
branch_id = item.get('branch_id')
if not inv_id or qty <= 0:
continue
op_id = record_operation(
conn, inv_id, branch_id, 'QUOTE_RELEASE',
quantity=qty,
reference_id=quotation_id,
reference_type='quotation',
notes=f'Liberacion cotizacion #{quotation_id}'
)
op_ids.append(op_id)
return op_ids
def convert_quotation_reservation(conn, quotation_id, items, sale_id=None, employee_id=None):
"""Convert reservation to actual sale.
Flow:
1. Release the reservation (QUOTE_RELEASE +qty)
2. Record the actual sale (SALE -qty)
Args:
conn: tenant DB connection.
quotation_id: the quotations.id.
items: list of dicts with inventory_id, quantity, branch_id.
sale_id: the resulting sales.id (for reference).
employee_id: optional.
Returns:
list of operation IDs.
"""
op_ids = release_quotation_reservation(conn, quotation_id, items, employee_id)
for item in items:
inv_id = item.get('inventory_id')
qty = item.get('quantity', 0)
branch_id = item.get('branch_id')
if not inv_id or qty <= 0:
continue
op_id = record_operation(
conn, inv_id, branch_id, 'SALE',
quantity=-qty,
reference_id=sale_id or quotation_id,
reference_type='sale' if sale_id else 'quotation',
notes=f'Venta convertida de cotizacion #{quotation_id}'
)
op_ids.append(op_id)
return op_ids
def get_quotation_items_for_reservation(conn, quotation_id):
"""Fetch items from a quotation joined with inventory to get branch_id.
Returns list of dicts: {inventory_id, quantity, branch_id}
"""
cur = conn.cursor()
cur.execute("""
SELECT qi.inventory_id, qi.quantity, i.branch_id
FROM quotation_items qi
JOIN inventory i ON i.id = qi.inventory_id
WHERE qi.quotation_id = %s
""", (quotation_id,))
rows = cur.fetchall()
cur.close()
return [
{'inventory_id': r[0], 'quantity': r[1], 'branch_id': r[2]}
for r in rows
]

View File

@@ -0,0 +1,341 @@
"""QWEN 3.6 Fitment Service — Vehicle compatibility lookup via AI.
Uses a private QWEN server (OpenAI-compatible API) to find compatible
vehicles for a given part number + name + brand, then validates them
against the master database (model_year_engine).
"""
import json
import re
import time
import requests
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
def get_vehicle_fitment(part_number, name, brand):
"""Ask QWEN for compatible vehicles and validate against master DB.
Returns: {
'vehicles': [{'make': 'Toyota', 'model': 'Corolla', 'year': 2015, 'engine': '1.8L', 'mye_id': 123}],
'confidence': 0.92,
'notes': ''
}
"""
if not QWEN_API_URL or not QWEN_API_KEY:
return {'vehicles': [], 'confidence': 0, 'notes': 'QWEN not configured'}
prompt = _build_prompt(part_number, name, brand)
content = ''
last_error = None
for attempt in range(3):
try:
response = requests.post(
f"{QWEN_API_URL}/chat/completions",
headers={
'Authorization': f'Bearer {QWEN_API_KEY}',
'Content-Type': 'application/json',
},
json={
'model': QWEN_MODEL,
'messages': [
{'role': 'system', 'content': 'Eres un experto en autopartes mexicanas y del mercado aftermarket norteamericano. Devuelve SIEMPRE JSON valido sin markdown.'},
{'role': 'user', 'content': prompt}
],
'temperature': 0.2,
'max_tokens': 8192,
},
timeout=120,
)
response.raise_for_status()
raw = response.json()
finish_reason = None
if raw.get('choices') and len(raw['choices']) > 0:
choice = raw['choices'][0]
msg = choice.get('message', {})
finish_reason = choice.get('finish_reason')
if msg:
content = msg.get('content') or ''
if not content:
# Fallback for reasoning models that return output in reasoning_content
content = msg.get('reasoning_content') or ''
if content:
break
except requests.RequestException as exc:
last_error = exc
# Retry on request failure
if attempt < 2:
time.sleep(1)
if not content:
err_msg = f'QWEN request failed: {last_error}' if last_error else 'Empty response from QWEN after 3 attempts'
if finish_reason == 'length':
err_msg += ' (response truncated by token limit — consider reducing prompt or increasing max_tokens)'
return {'vehicles': [], 'confidence': 0, 'notes': err_msg}
# Parse JSON from QWEN response (sometimes wrapped in markdown)
parsed = _extract_json(content)
if not parsed:
return {'vehicles': [], 'confidence': 0, 'notes': 'Invalid JSON from QWEN: ' + str(content)[:200]}
# Extract vehicles list from various possible structures
vehicles_raw = _extract_vehicles(parsed)
confidence = parsed.get('confidence', 0) or parsed.get('confianza', 0)
notes = parsed.get('notes', '') or parsed.get('notas', '') or parsed.get('advertencia', '')
# Validate vehicles against master DB
validated = _validate_vehicles(vehicles_raw)
return {
'vehicles': validated,
'confidence': confidence,
'notes': notes,
}
def _build_prompt(part_number, name, brand):
brand_str = brand or 'desconocida'
return f"""Dado el siguiente repuesto automotriz para el mercado mexicano y aftermarket norteamericano:
- Numero de parte: {part_number}
- Nombre/descripcion: {name}
- Marca del fabricante: {brand_str}
Devuelve UNICAMENTE un JSON valido (sin markdown, sin backticks, sin texto adicional) con esta estructura:
{{"vehicles":[{{"make":"Toyota","model":"Corolla","year_range":"2014-2019","engine":"1.8L","engine_code":"2ZR-FE","notes":""}}],"confidence":0.92,"notes":""}}
REGLAS OBLIGATORIAS:
1. "make" = marca del vehiculo.
2. "model" = modelo exacto (incluye variante si aplica).
3. USA "year_range" = string "YYYY-YYYY" cuando el MISMO modelo/motor abarca multiples anos consecutivos. Esto ahorra tokens y permite mas resultados.
4. USA "year" = int SOLO cuando sea un ano aislado sin rangos adyacentes.
5. "engine" = descripcion corta del motor (ej: "1.8L", "V6 3.5L"). Si no lo conoces, usa "".
6. "engine_code" = codigo exacto SI LO CONOCES. Si no, usa "".
7. "notes" = string vacio "" para ahorrar tokens, salvo que haya una advertencia critica.
8. Devuelve TODOS los vehiculos compatibles que conozcas. Minimo 1, maximo 200. Para piezas genericas (filtros de aceite, bujias, balatas, amortiguadores) incluye TODOS los modelos aplicables.
9. "confidence" entre 0.0 y 1.0. Valores >0.85 solo si estas muy seguro.
10. Incluye marcas y modelos populares en Mexico cuando apliquen.
11. Si la pieza es universal, indicalo en "notes".
"""
def _extract_json(text):
"""Extract JSON object from text, handling markdown fences."""
if text is None:
return None
text = text.strip()
if text.startswith('```'):
text = re.sub(r'^```(?:json)?\s*', '', text)
text = re.sub(r'\s*```$', '', text)
match = re.search(r'\{.*\}', text, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
return None
def _extract_vehicles(parsed):
"""Try multiple possible keys for the vehicle list."""
for key in ['vehicles', 'vehiculos_compatibles', 'vehiculos', 'compatible_vehicles', 'results']:
if key in parsed and isinstance(parsed[key], list):
return parsed[key]
# Deep search for any list containing objects with marca/modelo or make/model
for val in parsed.values():
if isinstance(val, list) and len(val) > 0:
first = val[0]
if isinstance(first, dict):
if any(k in first for k in ['make', 'model', 'marca', 'modelo']):
return val
return []
def _normalize_vehicle(v):
"""Normalize vehicle dict from QWEN to standard keys.
Supports:
- year: int or str (single year)
- year_range: str like "2003-2008" or "2003-2008"
- legacy: year as range string
"""
make = v.get('make') or v.get('marca') or ''
model = v.get('model') or v.get('modelo') or ''
engine = v.get('engine') or v.get('motor') or ''
engine_code = v.get('engine_code') or v.get('codigo_motor') or v.get('motor_code') or ''
years = []
# Prefer explicit year_range
year_range = v.get('year_range') or v.get('rango_ano') or ''
if isinstance(year_range, str):
m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_range)
if m:
start, end = int(m.group(1)), int(m.group(2))
years = list(range(start, end + 1))
# Fallback to year (int or str)
if not years:
year_raw = v.get('year') or v.get('ano') or v.get('año') or v.get('years') or v.get('anos') or ''
if isinstance(year_raw, int):
years = [year_raw]
elif isinstance(year_raw, str):
m = re.match(r'(\d{4})\s*[-]\s*(\d{4})', year_raw)
if m:
start, end = int(m.group(1)), int(m.group(2))
years = list(range(start, end + 1))
else:
m2 = re.match(r'(\d{4})', year_raw)
if m2:
years = [int(m2.group(1))]
return make, model, years, engine, engine_code
def _extract_displacement(engine):
"""Extract numeric displacement (L) from engine string, e.g. '1.8L 16V' -> 1.8."""
if not engine or engine.lower() == 'desconocido':
return None
# Match patterns like 1.8L, 2.0L, 3.5L, 1.6, etc.
match = re.search(r'(\d+\.?\d*)\s*[Ll]', engine)
if match:
try:
return float(match.group(1))
except ValueError:
return None
return None
def _validate_vehicles(vehicles):
"""Look up each vehicle in master DB and enrich with mye_id.
Validation strategy (in order of preference):
1. Exact engine_code match (most precise)
2. Displacement-based match (e.g. all 1.8L engines for that make/model/year)
3. Broad make/model/year match (all engines for that make/model/year)
If the master DB does not contain the vehicle (e.g. North-American models
missing from TecDoc), the vehicle is returned with mye_id=None so it can
be stored as a text-only QWEN record.
"""
from tenant_db import get_master_conn
try:
master = get_master_conn()
cur = master.cursor()
except Exception:
# Master DB unreachable — return all vehicles as unmatched text
return [
{'make': v.get('make') or v.get('marca') or '',
'model': v.get('model') or v.get('modelo') or '',
'year': v.get('year') or v.get('ano') or v.get('año') or 0,
'engine': v.get('engine') or v.get('motor') or '',
'engine_code': v.get('engine_code') or v.get('codigo_motor') or '',
'mye_id': None}
for v in vehicles
]
validated = []
seen_mye = set()
seen_text = set() # (make, model, year) for text-only dedup
for v in vehicles:
make, model, years, engine, engine_code = _normalize_vehicle(v)
if not make or not model or not years:
continue
for year in years:
matched_myes = []
# Strategy 1: engine_code match (most precise)
if engine_code:
cur.execute("""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON mye.model_id = m.id_model
JOIN brands b ON m.brand_id = b.id_brand
JOIN years y ON mye.year_id = y.id_year
JOIN engines e ON mye.engine_id = e.id_engine
WHERE b.name_brand ILIKE %s
AND m.name_model ILIKE %s
AND y.year_car = %s
AND e.engine_code ILIKE %s
""", (make, f'%{model}%', year, f'%{engine_code}%'))
matched_myes = [r[0] for r in cur.fetchall()]
# Strategy 2: displacement-based match
if not matched_myes:
disp = _extract_displacement(engine)
if disp is not None:
disp_pattern = f'{disp:.1f}L'
cur.execute("""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON mye.model_id = m.id_model
JOIN brands b ON m.brand_id = b.id_brand
JOIN years y ON mye.year_id = y.id_year
JOIN engines e ON mye.engine_id = e.id_engine
WHERE b.name_brand ILIKE %s
AND m.name_model ILIKE %s
AND y.year_car = %s
AND e.name_engine ILIKE %s
""", (make, f'%{model}%', year, f'%{disp_pattern}%'))
matched_myes = [r[0] for r in cur.fetchall()]
# Strategy 3: exact engine string match (legacy)
if not matched_myes and engine and engine.lower() != 'desconocido':
cur.execute("""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON mye.model_id = m.id_model
JOIN brands b ON m.brand_id = b.id_brand
JOIN years y ON mye.year_id = y.id_year
JOIN engines e ON mye.engine_id = e.id_engine
WHERE b.name_brand ILIKE %s
AND m.name_model ILIKE %s
AND y.year_car = %s
AND e.name_engine ILIKE %s
""", (make, f'%{model}%', year, engine))
matched_myes = [r[0] for r in cur.fetchall()]
# Strategy 4: broad make/model/year fallback (all engines)
if not matched_myes:
cur.execute("""
SELECT mye.id_mye
FROM model_year_engine mye
JOIN models m ON mye.model_id = m.id_model
JOIN brands b ON m.brand_id = b.id_brand
JOIN years y ON mye.year_id = y.id_year
WHERE b.name_brand ILIKE %s
AND m.name_model ILIKE %s
AND y.year_car = %s
""", (make, f'%{model}%', year))
matched_myes = [r[0] for r in cur.fetchall()]
# Deduplicate and add to results
if matched_myes:
for mye_id in matched_myes:
if mye_id not in seen_mye:
seen_mye.add(mye_id)
validated.append({
'make': make,
'model': model,
'year': year,
'engine': engine,
'engine_code': engine_code,
'mye_id': mye_id,
})
else:
# No match in master DB — store as text-only QWEN record
text_key = (make.upper(), model.upper(), year)
if text_key not in seen_text:
seen_text.add(text_key)
validated.append({
'make': make,
'model': model,
'year': year,
'engine': engine,
'engine_code': engine_code,
'mye_id': None,
})
cur.close()
master.close()
return validated

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

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

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