Compare commits

..

72 Commits

Author SHA1 Message Date
74118a3247 Merge branch 'desarrollo_hector' into main
Some checks are pending
CI / lint-and-test (3.11) (push) Waiting to run
CI / lint-and-test (3.13) (push) Waiting to run
2026-06-22 22:51:05 +00:00
14219e7117 feat: migración PZ La Casita, fix motor N/A/RUEDA, cache-buster catálogo y variant_ids 2026-06-22 22:33:59 +00:00
6b80add102 Mejora, ahora los intercambios estan paginados para que no sea una lista larga, muestran de 15 en 15 los intermcambios 2026-06-18 12:11:39 -06:00
ad04572305 correccion del cambio modo oscuro involuntario 2026-06-17 14:52:25 -06:00
ee7e1d49e5 Merge branch 'main' into desarrollo_hector 2026-06-17 14:20:48 -06:00
49bbc37117 Merge branch 'main' into desarrollo_hector 2026-06-15 12:56:54 -06:00
f5711ae22f fix(catalog): unifica modelos duplicados por variante de carroceria/generacion
- catalog_service.get_models ahora agrupa variantes (p. ej. AVEO Saloon,
  AVEO Hatchback) bajo un unico display_name y devuelve variant_ids.
- Se elige el id_model mas bajo como canonico para presentacion.
- /catalog/years y /catalog/engines aceptan model_id como lista separada
  por comas para consultar todos los MYEs de las variantes agrupadas.
- catalog.js usa variant_ids al cargar años/motores y en el selector
  desplegable (incluyendo carga desde VIN).
2026-06-15 18:24:58 +00:00
85ecf52561 feat(customers): habilitar edición de clientes desde la lista
- Hace clickeables las filas de la tabla para seleccionar un cliente y
  mostrar su panel de detalle (donde ya existe el botón Editar).
- Agrega botón de acción rápida con icono de lápiz en cada fila para
  abrir directamente el modal de edición.
- Extrae openEditModal y agrega editCustomer(id) para cargar el cliente
  vía API y abrir el modal sin depender de la selección previa.
- Actualiza colspan del estado vacío de 9 a 11 por la nueva columna.
2026-06-15 18:10:23 +00:00
584b87f82c fix(catalog): descarga de plantilla de precios proveedor con token
El enlace <a> a /pos/api/supplier-catalog/prices/template fallaba con 401
porque la navegación normal no envía el header Authorization. Se reemplaza
por un botón que descarga el blob vía fetch con Bearer token y dispara la
descarga del cliente. También se corrige clase btn-primary -> btn--primary.
2026-06-15 18:01:42 +00:00
b635e44302 style(workshop): alinea UI del taller con el resto del POS
- Usa app-shell/main, page-header con eyebrow, summary-strip y cards con iconos.
- Reemplaza badges personalizados por .badge del design system.
- Unifica tablas con .data-table y .table-wrapper.
- Estandariza modales con .modal-overlay/.modal y formularios con .form-grid.
- Actualiza workshop.js para usar clases del sistema y toggle is-open en modales.
- Corrige tokens rotos (--text-sm, --color-warn, etc.) y usa variables del tema.
2026-06-15 07:17:28 +00:00
e201dce290 feat(pos/workshop): add 80mm thermal ticket printing for service orders
- Add generate_service_order_ticket() in thermal_printer.py with ESC/POS commands for 58mm and 80mm printers.

- Add POST /pos/api/service-orders/:id/print endpoint returning raw bytes or JSON for browser rendering.

- Extend printer.js with printServiceOrder() using WebUSB/Web Serial.

- Add Imprimir orden button in workshop.js detail modal.

- Update FASES_IMPLEMENTADAS.md.
2026-06-15 06:18:33 +00:00
ce66212223 feat(pos/workshop): add lightweight workshop/taller module
- Add DB migration v4.4_workshop.sql (sale_id, service_catalog,
  reserved_quantity, SO_RESERVE/SO_RELEASE operation types).
- Extend service_order_engine with inventory reservation, release,
  convert-to-sale, mechanic assignment, and service catalog CRUD.
- Extend service_order_bp with /reserve, /convert-to-sale,
  /assign-mechanic, and /service-catalog endpoints.
- Create workshop Kanban UI: workshop.html, workshop.js, workshop.css.
- Add /pos/workshop route and sidebar navigation (sidebar.js + inline
  templates).
- Add 11 unit tests with mocked cursors.
- Update FASES_IMPLEMENTADAS.md with FASE 9 documentation.

Tests: 92 passing (61 console + 20 Facturapi + 11 workshop).
2026-06-15 05:34:35 +00:00
d67887284d feat(pos/facturapi): finalize Horux-to-Facturapi migration
- Normalize Facturapi key/org_id resolution (supports both cfdi_ prefixed
  tenant_config keys and short names used by invoicing_bp).
- Add CSD upload end-to-end (backend + frontend).
- Add helper scripts: setup_facturapi_orgs.py and check_facturapi_tenants.py.
- Add 20 unit tests with mocks (pos/tests/test_facturapi_service.py).
- Add CI workflow for lint + console tests on Python 3.11/3.13.
- Add pyproject.toml and requirements-dev.txt with ruff/pytest config.
- Update FASES_IMPLEMENTADAS.md with FASE 8 documentation.

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

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

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

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

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

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

View File

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

67
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.13"]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r pos/requirements.txt
pip install -r requirements-dev.txt
- name: Determine changed Python files
id: changed
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
BASE="HEAD~1"
fi
FILES=$(git diff --name-only --diff-filter=ACMRT "$BASE" HEAD | grep '\.py$' || true)
echo "files=$FILES" >> "$GITHUB_OUTPUT"
echo "Changed Python files:"
echo "$FILES"
- name: Lint changed files with ruff
run: |
FILES="${{ steps.changed.outputs.files }}"
if [ -z "$FILES" ]; then
echo "No Python files changed. Skipping lint."
exit 0
fi
ruff check $FILES
ruff format --check $FILES
- name: Run console unit tests
run: |
python -m pytest console/tests/test_core.py console/tests/test_utils.py -v
# Playwright E2E tests require the full stack (PostgreSQL, Redis, etc.).
# Enable this job once a test environment is available in CI.
# - name: Run E2E tests
# run: |
# npm ci
# npx playwright install --with-deps chromium
# npx playwright test

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
# Nexus POS — Resumen de Fases Implementadas # Nexus POS — Resumen de Fases Implementadas
**Fecha:** 2026-04-29 **Fecha:** 2026-06-15
**Versión DB:** v3.2 **Versión DB:** v4.4
**Tests:** 73/73 pasando (pytest) **Tests:** 92/92 pasando (pytest: 61 consola + 20 Facturapi + 11 Taller; POS requieren PostgreSQL)
**Commit:** `d678872` (HEAD + cambios sin commitear)
--- ---
@@ -199,6 +200,123 @@ METABASE_URL=http://localhost:3000
| — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` | | — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` |
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` | | — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` | | — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
| — | **Migración CFDI de Horux a Facturapi** | 2026-06-14 | `8796cad` |
| — | **Setup/estado masivo de organizaciones Facturapi** | 2026-06-15 | — |
| — | **Módulo de Taller (Workshop Lite)** | 2026-06-15 | — |
## 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 |
---
## FASE 8: Migración CFDI a Facturapi
**Commit:** `8796cad` (2026-06-14)
**Migración DB:** `v4.3_facturapi.sql`
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Timbrado vía Facturapi** | `facturapi_service.py`, `cfdi_facturapi_builder.py`, `cfdi_queue.py` | Payloads JSON para Facturapi en lugar de XML unsigned; timbrado, descarga XML/PDF, cancelación SAT |
| **Organizaciones Facturapi** | `invoicing_bp.py` | `POST /pos/api/invoicing/facturapi/setup` crea/liga organización; `GET /pos/api/invoicing/facturapi/status` muestra estado del PAC |
| **Subida de CSD** | `invoicing_bp.py`, `invoicing.html`, `invoicing.js` | Upload de `.cer` y `.key` con contraseña directo a Facturapi |
| **Migración de datos** | `v4.3_facturapi.sql`, `scripts/apply_facturapi_to_all_tenants.py` | Renombra `xml_unsigned``payload_unsigned`, agrega `external_id`, inserta keys de config |
| **Setup masivo** | `scripts/setup_facturapi_orgs.py` | Crea organizaciones Facturapi para todos los tenants activos usando `FACTURAPI_USER_KEY` |
| **Status masivo** | `scripts/check_facturapi_tenants.py` | Reporte tabular/JSON/CSV del estado de configuración Facturapi por tenant |
| **Tests unitarios** | `pos/tests/test_facturapi_service.py` | 20 tests con mocks; sin llamadas a red ni PostgreSQL |
| **CI** | `.github/workflows/ci.yml` | Lint con ruff sobre archivos cambiados + tests de consola en Python 3.11 y 3.13 |
### Variables de entorno
```bash
# Modo automático (recomendado para multi-tenant)
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
# Modo manual por tenant (sobreescribe lo anterior)
# Se almacena en tenant_config: cfdi_facturapi_key, cfdi_facturapi_org_id
```
### Uso
```bash
# 1. Aplicar migración y key a todos los tenants
export FACTURAPI_SECRET_KEY=sk_user_xxx
python3 scripts/apply_facturapi_to_all_tenants.py
# 2. Crear organizaciones Facturapi
export FACTURAPI_USER_KEY=sk_user_xxx
python3 scripts/setup_facturapi_orgs.py
# 3. Ver estado
python3 scripts/check_facturapi_tenants.py
```
---
## FASE 9: Módulo de Taller (Workshop Lite)
**Commit:** (en progreso)
**Migración DB:** `v4.4_workshop.sql`
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Migración DB** | `v4.4_workshop.sql` | `service_orders.sale_id`, tabla `service_catalog`, columna `reserved_quantity`, tipos `SO_RESERVE`/`SO_RELEASE` en `inventory_operations` |
| **Reserva de inventario** | `service_order_engine.py` | `reserve_item()` y `release_item()` para apartar/liberar refacciones del stock de la sucursal |
| **Conversión a venta** | `service_order_engine.py` | `convert_to_sale()` crea una venta en `sales` con refacciones + mano de obra, descuenta inventario y guarda `sale_id` |
| **Catálogo de servicios** | `service_order_engine.py`, `service_order_bp.py` | Conceptos reutilizables de mano de obra (ej. "Cambio de aceite") |
| **Endpoints taller** | `service_order_bp.py` | `POST /:id/items/:item_id/reserve`, `POST /:id/convert-to-sale`, `PUT /:id/assign-mechanic`, CRUD `/service-catalog` |
| **Interfaz Kanban** | `workshop.html`, `workshop.js`, `workshop.css` | Vista por columnas, tarjetas de orden, modal de detalle, cambio de estado, agregar refacciones/mano de obra |
| **Impresión de orden** | `thermal_printer.py`, `service_order_bp.py`, `printer.js`, `workshop.js` | Ticket ESC/POS optimizado para impresoras térmicas 80 mm (58 mm compatible) |
| **Navegación** | `sidebar.js`, plantillas inline | Entrada "Taller" en el menú de gestión |
| **Tests** | `pos/tests/test_service_order_integration.py` | 11 tests con cursores mocks; validan reserva, liberación, conversión a venta y catálogo |
### Flujo de uso
1. El paquetero crea la orden desde `/pos/workshop` (cliente, vehículo, mecánico, falla).
2. El mecánico diagnostica y agrega refacciones y mano deobra.
3. Se reservan las refacciones del inventario de la sucursal.
4. Cuando el vehículo está listo, se convierte la orden en venta.
5. Desde facturación se timbra el CFDI de la venta generada.
--- ---
@@ -215,7 +333,7 @@ METABASE_URL=http://localhost:3000
| 1 | **WhatsApp Business API (Meta Cloud) real** | Migrar de Baileys a Meta Cloud API. Requiere verificación de cuenta Meta, Business Manager, número de teléfono verificado. | 2-3 semanas | Stub creado (`whatsapp_cloud_bp.py`) | | 1 | **WhatsApp Business API (Meta Cloud) real** | Migrar de Baileys a Meta Cloud API. Requiere verificación de cuenta Meta, Business Manager, número de teléfono verificado. | 2-3 semanas | Stub creado (`whatsapp_cloud_bp.py`) |
| 2 | **BNPL real** | Integrar APLAZO/Kueski/Clip con credenciales de sandbox/producción. | 2 semanas | Stub creado (`bnpl_bp.py`) | | 2 | **BNPL real** | Integrar APLAZO/Kueski/Clip con credenciales de sandbox/producción. | 2 semanas | Stub creado (`bnpl_bp.py`) |
| 3 | **ERP Sync real** | Conectar Aspel/CONTPAQi/SAP/Odoo vía API o archivos de intercambio. | 2-3 semanas | Stub creado (`erp_bp.py`) | | 3 | **ERP Sync real** | Conectar Aspel/CONTPAQi/SAP/Odoo vía API o archivos de intercambio. | 2-3 semanas | Stub creado (`erp_bp.py`) |
| 4 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas | No iniciado | | 4 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas | **Parcialmente listo** — ver Fase 7.4 |
### 🟡 Medio — Diferenciadores ### 🟡 Medio — Diferenciadores

82
docs/GLOBAL_INVOICE.md Normal file
View File

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

52
docs/MULTI_BRANCH.md Normal file
View File

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

View File

@@ -58,3 +58,24 @@ def delete_tenant(tenant_id):
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 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

View File

@@ -311,6 +311,56 @@ def get_tenant_login_url(subdomain):
return f"https://{subdomain}.{domain}/pos/login" 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(): def get_dashboard_stats():
"""Global stats for the manager dashboard.""" """Global stats for the manager dashboard."""
conn = get_master_conn() conn = get_master_conn()

View File

@@ -661,3 +661,42 @@ body {
.sidebar-brand span, .nav-item span, .user-info span { display: none; } .sidebar-brand span, .nav-item span, .user-info span { display: none; }
.stats-grid { grid-template-columns: 1fr; } .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

@@ -188,6 +188,7 @@ async function loadDemos() {
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></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>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
<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="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="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> <button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
@@ -254,6 +255,7 @@ async function loadTenants(withStats = false) {
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td> <td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
<td>${formatDate(t.created_at)}</td> <td>${formatDate(t.created_at)}</td>
<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="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="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> <button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
@@ -475,5 +477,60 @@ function copyText(text) {
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success")); 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 ────────────────────────────────────────────────────────────────── // ─── Init ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", initAuth); document.addEventListener("DOMContentLoaded", initAuth);

View File

@@ -28,6 +28,10 @@ Environment=NEXUS_SERVER_HOST=127.0.0.1
# ─── Security (CHANGE THIS) ──────────────────────────────────────────────── # ─── Security (CHANGE THIS) ────────────────────────────────────────────────
Environment=MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string 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) ─────────────────────────────────── # ─── Redis (optional, health check only) ───────────────────────────────────
Environment=REDIS_URL=redis://127.0.0.1:6379/0 Environment=REDIS_URL=redis://127.0.0.1:6379/0

View File

@@ -316,6 +316,63 @@
</div> </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 --> <!-- Toast -->
<div id="toast-container"></div> <div id="toast-container"></div>

View File

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

View File

@@ -1,6 +1,7 @@
from flask import Flask from flask import Flask
from json_provider import OrjsonProvider from json_provider import OrjsonProvider
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.json = OrjsonProvider(app) app.json = OrjsonProvider(app)
@@ -59,6 +60,12 @@ def create_app():
from blueprints.marketplace_bp import marketplace_bp from blueprints.marketplace_bp import marketplace_bp
app.register_blueprint(marketplace_bp) app.register_blueprint(marketplace_bp)
from blueprints.marketplace_external_bp import marketplace_ext_bp
app.register_blueprint(marketplace_ext_bp)
from blueprints.dropshipping_bp import dropship_bp
app.register_blueprint(dropship_bp)
from blueprints.peer_bp import peer_bp from blueprints.peer_bp import peer_bp
app.register_blueprint(peer_bp) app.register_blueprint(peer_bp)
@@ -107,6 +114,9 @@ def create_app():
from blueprints.supplier_portal_bp import supplier_portal_bp from blueprints.supplier_portal_bp import supplier_portal_bp
app.register_blueprint(supplier_portal_bp) app.register_blueprint(supplier_portal_bp)
from blueprints.supplier_catalog_bp import supplier_catalog_bp
app.register_blueprint(supplier_catalog_bp)
from blueprints.internal_bp import internal_bp from blueprints.internal_bp import internal_bp
app.register_blueprint(internal_bp) app.register_blueprint(internal_bp)
@@ -115,7 +125,7 @@ def create_app():
def health(): def health():
return {'status': 'ok'} return {'status': 'ok'}
from flask import render_template, send_from_directory, jsonify, g from flask import g, jsonify, render_template, send_from_directory
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():
@@ -128,6 +138,10 @@ def create_app():
tenant_name=getattr(g, 'tenant_name', None), tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None)) tenant_subdomain=getattr(g, 'tenant_subdomain', None))
@app.route('/pos/supplier-catalog')
def supplier_catalog_page():
return render_template('supplier_catalog.html')
@app.route('/pos/catalog') @app.route('/pos/catalog')
def pos_catalog(): def pos_catalog():
return render_template('catalog.html') return render_template('catalog.html')
@@ -168,6 +182,10 @@ def create_app():
def pos_fleet(): def pos_fleet():
return render_template('fleet.html') return render_template('fleet.html')
@app.route('/pos/workshop')
def pos_workshop():
return render_template('workshop.html')
@app.route('/pos/quotations') @app.route('/pos/quotations')
def pos_quotations(): def pos_quotations():
return render_template('quotations.html') return render_template('quotations.html')
@@ -180,6 +198,18 @@ def create_app():
def pos_marketplace(): def pos_marketplace():
return render_template('marketplace.html') return render_template('marketplace.html')
@app.route('/pos/marketplace-external')
def pos_marketplace_external():
return render_template('marketplace_external.html')
@app.route('/pos/marketplace-external/callback')
def pos_marketplace_external_callback():
return render_template('marketplace_external.html')
@app.route('/pos/historical-sales')
def pos_historical_sales():
return render_template('historical_sales.html')
@app.route('/pos/static/<path:filename>') @app.route('/pos/static/<path:filename>')
def pos_static(filename): def pos_static(filename):
return send_from_directory('static', filename) return send_from_directory('static', filename)

View File

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

View File

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

View File

@@ -13,15 +13,51 @@ config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
def list_branches(): def list_branches():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id") cur.execute("""
SELECT id, name, address, phone, is_active, is_main,
rfc, razon_social, regimen_fiscal, cp,
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
FROM branches ORDER BY id
""")
branches = [] branches = []
for r in cur.fetchall(): for r in cur.fetchall():
branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]}) branches.append({
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
'is_active': r[4], 'is_main': r[5],
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
})
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'data': branches}) return jsonify({'data': branches})
@config_bp.route('/branches/<int:branch_id>', methods=['GET'])
@require_auth('config.view')
def get_branch(branch_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT id, name, address, phone, is_active, is_main,
rfc, razon_social, regimen_fiscal, cp,
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
FROM branches WHERE id = %s
""", (branch_id,))
r = cur.fetchone()
cur.close()
conn.close()
if not r:
return jsonify({'error': 'Branch not found'}), 404
return jsonify({
'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3],
'is_active': r[4], 'is_main': r[5],
'rfc': r[6], 'razon_social': r[7], 'regimen_fiscal': r[8],
'cp': r[9], 'direccion_fiscal': r[10], 'serie_cfdi': r[11],
'folio_inicio': r[12], 'folio_actual': r[13], 'email': r[14],
})
@config_bp.route('/branches', methods=['POST']) @config_bp.route('/branches', methods=['POST'])
@require_auth('config.edit') @require_auth('config.edit')
def create_branch(): def create_branch():
@@ -47,10 +83,23 @@ def create_branch():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
# If setting as main, clear any existing main
if data.get('is_main'):
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true")
cur.execute(""" cur.execute("""
INSERT INTO branches (name, address, phone) INSERT INTO branches (
VALUES (%s, %s, %s) RETURNING id name, address, phone, is_main,
""", (data['name'], data.get('address'), data.get('phone'))) rfc, razon_social, regimen_fiscal, cp,
direccion_fiscal, serie_cfdi, folio_inicio, folio_actual, email
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id
""", (
data['name'], data.get('address'), data.get('phone'), bool(data.get('is_main')),
data.get('rfc'), data.get('razon_social'), data.get('regimen_fiscal'), data.get('cp'),
data.get('direccion_fiscal'), data.get('serie_cfdi'), data.get('folio_inicio'), data.get('folio_actual'), data.get('email'),
))
branch_id = cur.fetchone()[0] branch_id = cur.fetchone()[0]
conn.commit() conn.commit()
cur.close() cur.close()
@@ -58,6 +107,49 @@ def create_branch():
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201 return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
@config_bp.route('/branches/<int:branch_id>', methods=['PUT'])
@require_auth('config.edit')
def update_branch(branch_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Branch not found'}), 404
# If setting as main, clear any existing main
if data.get('is_main'):
cur.execute("UPDATE branches SET is_main = false WHERE is_main = true AND id <> %s", (branch_id,))
updates = []
params = []
field_map = {
'name': 'name', 'address': 'address', 'phone': 'phone',
'is_active': 'is_active', 'is_main': 'is_main',
'rfc': 'rfc', 'razon_social': 'razon_social',
'regimen_fiscal': 'regimen_fiscal', 'cp': 'cp',
'direccion_fiscal': 'direccion_fiscal', 'serie_cfdi': 'serie_cfdi',
'folio_inicio': 'folio_inicio', 'folio_actual': 'folio_actual', 'email': 'email',
}
for json_key, col in field_map.items():
if json_key in data:
updates.append(f"{col} = %s")
params.append(data[json_key])
if not updates:
cur.close(); conn.close()
return jsonify({'error': 'Nothing to update'}), 400
params.append(branch_id)
cur.execute(f"UPDATE branches SET {', '.join(updates)} WHERE id = %s", params)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Branch updated'})
@config_bp.route('/employees', methods=['GET']) @config_bp.route('/employees', methods=['GET'])
@require_auth('config.view') @require_auth('config.view')
def list_employees(): def list_employees():
@@ -469,21 +561,12 @@ _ALLOWED_PART_BRANDS = [
@config_bp.route('/available-brands', methods=['GET']) @config_bp.route('/available-brands', methods=['GET'])
@require_auth() @require_auth()
def get_available_brands(): def get_available_brands():
"""Return whitelisted aftermarket manufacturer names from master DB.""" """Return the whitelisted part manufacturer names.
from tenant_db import get_master_conn
conn = get_master_conn() The master DB manufacturers/aftermarket_parts tables were removed with
cur = conn.cursor() TecDoc, so we return the curated whitelist directly.
cur.execute(""" """
SELECT DISTINCT m.name_manufacture brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()})
FROM manufacturers m
JOIN aftermarket_parts ap ON ap.manufacturer_id = m.id_manufacture
WHERE m.name_manufacture IS NOT NULL AND m.name_manufacture != ''
AND LOWER(m.name_manufacture) = ANY(%s)
ORDER BY m.name_manufacture ASC
""", ([b.lower() for b in _ALLOWED_PART_BRANDS],))
brands = [r[0] for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'brands': brands}) return jsonify({'brands': brands})
@@ -579,6 +662,60 @@ def update_whatsapp_config():
return jsonify({'message': 'WhatsApp configuration updated'}) 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']) @config_bp.route('/onboarding-status', methods=['GET'])
@require_auth('pos.view') @require_auth('pos.view')
def get_onboarding_status(): def get_onboarding_status():

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,89 +5,133 @@ All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
This blueprint is the HTTP layer that validates input and returns JSON. This blueprint is the HTTP layer that validates input and returns JSON.
""" """
import json import base64
from flask import Blueprint, request, jsonify, g from datetime import datetime
from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn from services import facturapi_service
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
from services.cfdi_queue import (
enqueue_cfdi, process_queue, retry_failed,
cancel_cfdi, get_queue_status,
)
from services.audit import log_action from services.audit import log_action
from services.cfdi_facturapi_builder import (
build_egreso_payload,
build_ingreso_payload,
)
from services.cfdi_queue import (
cancel_cfdi,
enqueue_cfdi,
get_queue_status,
process_queue,
retry_failed,
)
from tenant_db import get_tenant_conn
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') invoicing_bp = Blueprint("invoicing", __name__, url_prefix="/pos/api/invoicing")
def _get_tenant_config(cur): def _get_issuer_config(cur, branch_id=None):
"""Load tenant CFDI configuration from tenant_config table. """Load CFDI issuer configuration.
Falls back to sensible defaults if config is incomplete. If branch_id is provided and the branch has fiscal data, use it.
Otherwise fall back to tenant-level config.
""" """
# Tenant-level defaults
config = {} config = {}
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'") cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
for row in cur.fetchall(): for row in cur.fetchall():
config[row[0]] = row[1] config[row[0]] = row[1]
return { result = {
'rfc': config.get('tenant_rfc', ''), "rfc": config.get("tenant_rfc", ""),
'razon_social': config.get('tenant_razon_social', ''), "razon_social": config.get("tenant_razon_social", ""),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), "regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
'cp': config.get('tenant_cp', '00000'), "cp": config.get("tenant_cp", "00000"),
'serie': config.get('cfdi_serie', 'A'), "serie": config.get("cfdi_serie", "A"),
'horux_api_url': config.get('cfdi_horux_api_url', ''), "facturapi_key": config.get("cfdi_facturapi_key", ""),
'horux_api_key': config.get('cfdi_horux_api_key', ''), "facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
} }
# Branch-level override
if branch_id:
cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s
""",
(branch_id,),
)
row = cur.fetchone()
if row and row[0]:
result["rfc"] = row[0] or result["rfc"]
result["razon_social"] = row[1] or result["razon_social"]
result["regimen_fiscal"] = row[2] or result["regimen_fiscal"]
result["cp"] = row[3] or result["cp"]
result["serie"] = row[4] or result["serie"]
return result
def _get_sale_with_items(cur, sale_id): def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation.""" """Load a sale with its items for CFDI generation."""
cur.execute(""" cur.execute(
"""
SELECT id, branch_id, customer_id, employee_id, sale_type, SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total, payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales WHERE id = %s FROM sales WHERE id = %s
""", (sale_id,)) """,
(sale_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None return None
sale = { sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2], "id": row[0],
'employee_id': row[3], 'sale_type': row[4], "branch_id": row[1],
'payment_method': row[5], "customer_id": row[2],
'subtotal': float(row[6]) if row[6] else 0, "employee_id": row[3],
'discount_total': float(row[7]) if row[7] else 0, "sale_type": row[4],
'tax_total': float(row[8]) if row[8] else 0, "payment_method": row[5],
'total': float(row[9]) if row[9] else 0, "subtotal": float(row[6]) if row[6] else 0,
'metodo_pago_sat': row[10] or 'PUE', "discount_total": float(row[7]) if row[7] else 0,
'forma_pago_sat': row[11] or '01', "tax_total": float(row[8]) if row[8] else 0,
'status': row[12], "total": float(row[9]) if row[9] else 0,
'created_at': str(row[13]), "metodo_pago_sat": row[10] or "PUE",
"forma_pago_sat": row[11] or "01",
"status": row[12],
"created_at": str(row[13]),
} }
cur.execute(""" cur.execute(
"""
SELECT id, inventory_id, part_number, name, quantity, unit_price, SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad subtotal, clave_prod_serv, clave_unidad
FROM sale_items WHERE sale_id = %s ORDER BY id FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,)) """,
(sale_id,),
)
sale['items'] = [] sale["items"] = []
for r in cur.fetchall(): for r in cur.fetchall():
sale['items'].append({ sale["items"].append(
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], {
'name': r[3], 'quantity': r[4], "id": r[0],
'unit_price': float(r[5]) if r[5] else 0, "inventory_id": r[1],
'unit_cost': float(r[6]) if r[6] else 0, "part_number": r[2],
'discount_pct': float(r[7]) if r[7] else 0, "name": r[3],
'discount_amount': float(r[8]) if r[8] else 0, "quantity": r[4],
'tax_rate': float(r[9]) if r[9] else 0.16, "unit_price": float(r[5]) if r[5] else 0,
'tax_amount': float(r[10]) if r[10] else 0, "unit_cost": float(r[6]) if r[6] else 0,
'subtotal': float(r[11]) if r[11] else 0, "discount_pct": float(r[7]) if r[7] else 0,
'clave_prod_serv': r[12] or '25174800', "discount_amount": float(r[8]) if r[8] else 0,
'clave_unidad': r[13] or 'H87', "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",
}
)
return sale return sale
@@ -96,24 +140,32 @@ def _get_customer(cur, customer_id):
"""Load customer data for CFDI receptor.""" """Load customer data for CFDI receptor."""
if not customer_id: if not customer_id:
return None return None
cur.execute(""" cur.execute(
"""
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
FROM customers WHERE id = %s FROM customers WHERE id = %s
""", (customer_id,)) """,
(customer_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None return None
return { return {
'id': row[0], 'name': row[1], 'rfc': row[2], "id": row[0],
'razon_social': row[3], 'regimen_fiscal': row[4], "name": row[1],
'uso_cfdi': row[5] or 'G03', 'cp': row[6], "rfc": row[2],
"razon_social": row[3],
"regimen_fiscal": row[4],
"uso_cfdi": row[5] or "G03",
"cp": row[6],
} }
# ─── Generate CFDI ───────────────────────────────── # ─── Generate CFDI ─────────────────────────────────
@invoicing_bp.route('/invoice', methods=['POST'])
@require_auth('invoicing.create') @invoicing_bp.route("/invoice", methods=["POST"])
@require_auth("invoicing.create")
def generate_invoice(): def generate_invoice():
"""Generate a CFDI for a sale and enqueue for timbrado. """Generate a CFDI for a sale and enqueue for timbrado.
@@ -124,57 +176,66 @@ def generate_invoice():
} }
""" """
data = request.get_json() or {} data = request.get_json() or {}
sale_id = data.get('sale_id') sale_id = data.get("sale_id")
cfdi_type = data.get('type', 'ingreso') cfdi_type = data.get("type", "ingreso")
if not sale_id: if not sale_id:
return jsonify({'error': 'sale_id is required'}), 400 return jsonify({"error": "sale_id is required"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
try: try:
tenant_config = _get_tenant_config(cur)
if not tenant_config['rfc']:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
sale = _get_sale_with_items(cur, sale_id) sale = _get_sale_with_items(cur, sale_id)
if not sale: if not sale:
return jsonify({'error': 'Sale not found'}), 404 return jsonify({"error": "Sale not found"}), 404
if sale['status'] == 'cancelled': tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400 if not tenant_config["rfc"]:
return jsonify({"error": "Tenant RFC not configured. Set tenant_rfc in config."}), 400
customer = _get_customer(cur, sale.get('customer_id')) if sale["status"] == "cancelled":
return jsonify({"error": "Cannot invoice a cancelled sale"}), 400
customer = _get_customer(cur, sale.get("customer_id"))
# Check if this sale already has a stamped CFDI # Check if this sale already has a stamped CFDI
cur.execute(""" cur.execute(
"""
SELECT id, status FROM cfdi_queue SELECT id, status FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed') WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
""", (sale_id, cfdi_type)) """,
(sale_id, cfdi_type),
)
existing = cur.fetchone() existing = cur.fetchone()
if existing: if existing:
return jsonify({ return jsonify(
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' {
}), 409 "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': if cfdi_type == "ingreso":
xml = build_ingreso_xml(sale, tenant_config, customer) payload = build_ingreso_payload(sale, tenant_config, customer)
elif cfdi_type == 'egreso': elif cfdi_type == "egreso":
original_uuid = data.get('original_uuid') original_uuid = data.get("original_uuid")
if not original_uuid: if not original_uuid:
return jsonify({'error': 'original_uuid required for egreso'}), 400 return jsonify({"error": "original_uuid required for egreso"}), 400
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid) payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
else: else:
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 return jsonify({"error": f"Invalid CFDI type: {cfdi_type}"}), 400
# Enqueue # Enqueue
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml) result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'], log_action(
new_value={'sale_id': sale_id, 'type': cfdi_type, conn,
'folio': result['provisional_folio']}) "CFDI_GENERATED",
"cfdi_queue",
result["id"],
new_value={"sale_id": sale_id, "type": cfdi_type, "folio": result["provisional_folio"]},
)
conn.commit() conn.commit()
cur.close() cur.close()
@@ -185,18 +246,19 @@ def generate_invoice():
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── Queue Management ────────────────────────────── # ─── Queue Management ──────────────────────────────
@invoicing_bp.route('/queue', methods=['GET'])
@require_auth('invoicing.view') @invoicing_bp.route("/queue", methods=["GET"])
@require_auth("invoicing.view")
def list_queue(): def list_queue():
"""List CFDI queue items. """List CFDI queue items.
@@ -205,11 +267,11 @@ def list_queue():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
filters = { filters = {
'status': request.args.get('status'), "status": request.args.get("status"),
'sale_id': request.args.get('sale_id'), "sale_id": request.args.get("sale_id"),
'type': request.args.get('type'), "type": request.args.get("type"),
'page': request.args.get('page', 1), "page": request.args.get("page", 1),
'per_page': request.args.get('per_page', 50), "per_page": request.args.get("per_page", 50),
} }
result = get_queue_status(conn, filters) result = get_queue_status(conn, filters)
@@ -217,35 +279,46 @@ def list_queue():
return jsonify(result) return jsonify(result)
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET']) @invoicing_bp.route("/queue/<int:cfdi_id>", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def get_queue_item(cfdi_id): def get_queue_item(cfdi_id):
"""Get CFDI queue item detail (includes XML).""" """Get CFDI queue item detail (includes XML)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed, """
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
q.error_message, q.cancel_motive, q.cancel_replacement_uuid, q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
q.created_at, q.stamped_at q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q WHERE q.id = %s FROM cfdi_queue q WHERE q.id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'CFDI queue item not found'}), 404 conn.close()
return jsonify({"error": "CFDI queue item not found"}), 404
item = { item = {
'id': row[0], 'sale_id': row[1], 'type': row[2], "id": row[0],
'xml_unsigned': row[3], 'xml_signed': row[4], "sale_id": row[1],
'uuid_fiscal': row[5], 'status': row[6], "type": row[2],
'retry_count': row[7], 'provisional_folio': row[8], "payload_unsigned": row[3],
'error_message': row[9], 'cancel_motive': row[10], "xml_signed": row[4],
'cancel_replacement_uuid': row[11], "uuid_fiscal": row[5],
'created_at': str(row[12]) if row[12] else None, "status": row[6],
'stamped_at': str(row[13]) if row[13] else None, "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() cur.close()
@@ -253,29 +326,26 @@ def get_queue_item(cfdi_id):
return jsonify(item) return jsonify(item)
@invoicing_bp.route('/queue/process', methods=['POST']) @invoicing_bp.route("/queue/process", methods=["POST"])
@require_auth('invoicing.create') @require_auth("invoicing.create")
def trigger_process_queue(): def trigger_process_queue():
"""Manually trigger processing of pending CFDI queue items.""" """Manually trigger processing of pending CFDI queue items."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
try: try:
tenant_config = _get_tenant_config(cur) tenant_config = _get_issuer_config(cur)
horux_url = tenant_config.get('horux_api_url') if not tenant_config.get("facturapi_key"):
horux_key = tenant_config.get('horux_api_key')
if not horux_url or not horux_key:
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': 'Horux API not configured'}), 400 return jsonify({"error": "Facturapi key not configured"}), 400
# Reset eligible failed items first # Reset eligible failed items first
reset_count = retry_failed(conn) reset_count = retry_failed(conn)
# Process the queue # Process the queue
result = process_queue(conn, horux_url, horux_key) result = process_queue(conn, tenant_config)
result['retries_reset'] = reset_count result["retries_reset"] = reset_count
cur.close() cur.close()
conn.close() conn.close()
@@ -285,13 +355,14 @@ def trigger_process_queue():
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── Cancel CFDI ──────────────────────────────────── # ─── Cancel CFDI ────────────────────────────────────
@invoicing_bp.route('/cancel/<int:cfdi_id>', methods=['POST'])
@require_auth('invoicing.delete') @invoicing_bp.route("/cancel/<int:cfdi_id>", methods=["POST"])
@require_auth("invoicing.delete")
def cancel_invoice(cfdi_id): def cancel_invoice(cfdi_id):
"""Cancel a CFDI with SAT motive code. """Cancel a CFDI with SAT motive code.
@@ -302,29 +373,36 @@ def cancel_invoice(cfdi_id):
Only owner and admin can cancel CFDIs. Only owner and admin can cancel CFDIs.
""" """
if g.employee_role not in ('owner', 'admin'): if g.employee_role not in ("owner", "admin"):
return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403 return jsonify({"error": "Only owner or admin can cancel CFDIs"}), 403
data = request.get_json() or {} data = request.get_json() or {}
motive = data.get('motive') motive = data.get("motive")
replacement_uuid = data.get('replacement_uuid') replacement_uuid = data.get("replacement_uuid")
if not motive: if not motive:
return jsonify({'error': 'motive is required'}), 400 return jsonify({"error": "motive is required"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
try: try:
tenant_config = _get_tenant_config(cur) tenant_config = _get_issuer_config(cur)
result = cancel_cfdi( result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid, conn,
tenant_config.get('horux_api_url'), cfdi_id,
tenant_config.get('horux_api_key'), motive,
replacement_uuid,
tenant_config=tenant_config,
) )
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, log_action(
new_value={'motive': motive, 'replacement_uuid': replacement_uuid}) conn,
"CFDI_CANCELLED",
"cfdi_queue",
cfdi_id,
new_value={"motive": motive, "replacement_uuid": replacement_uuid},
)
conn.commit() conn.commit()
cur.close() cur.close()
@@ -335,18 +413,19 @@ def cancel_invoice(cfdi_id):
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── PDF Generation ───────────────────────────────── # ─── PDF Generation ─────────────────────────────────
@invoicing_bp.route('/<int:sale_id>/pdf', methods=['GET'])
@require_auth('invoicing.view') @invoicing_bp.route("/<int:sale_id>/pdf", methods=["GET"])
@require_auth("invoicing.view")
def get_sale_pdf(sale_id): def get_sale_pdf(sale_id):
"""Generate a PDF representation of the sale/CFDI. """Generate a PDF representation of the sale/CFDI.
@@ -359,41 +438,381 @@ def get_sale_pdf(sale_id):
sale = _get_sale_with_items(cur, sale_id) sale = _get_sale_with_items(cur, sale_id)
if not sale: if not sale:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'Sale not found'}), 404 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')) customer = _get_customer(cur, sale.get("customer_id"))
# Check if there's a stamped CFDI # Check if there's a stamped CFDI
cur.execute(""" cur.execute(
"""
SELECT uuid_fiscal, provisional_folio, status, stamped_at SELECT uuid_fiscal, provisional_folio, status, stamped_at
FROM cfdi_queue FROM cfdi_queue
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped' WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
ORDER BY stamped_at DESC LIMIT 1 ORDER BY stamped_at DESC LIMIT 1
""", (sale_id,)) """,
(sale_id,),
)
cfdi_row = cur.fetchone() cfdi_row = cur.fetchone()
cfdi_info = None cfdi_info = None
if cfdi_row: if cfdi_row:
cfdi_info = { cfdi_info = {
'uuid_fiscal': cfdi_row[0], "uuid_fiscal": cfdi_row[0],
'provisional_folio': cfdi_row[1], "provisional_folio": cfdi_row[1],
'status': cfdi_row[2], "status": cfdi_row[2],
'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None, "stamped_at": str(cfdi_row[3]) if cfdi_row[3] else None,
} }
cur.close() cur.close()
conn.close() conn.close()
return jsonify({ return jsonify(
'sale': sale, {
'tenant': { "sale": sale,
'rfc': tenant_config.get('rfc', ''), "tenant": {
'razon_social': tenant_config.get('razon_social', ''), "rfc": tenant_config.get("rfc", ""),
'regimen_fiscal': tenant_config.get('regimen_fiscal', ''), "razon_social": tenant_config.get("razon_social", ""),
'cp': tenant_config.get('cp', ''), "regimen_fiscal": tenant_config.get("regimen_fiscal", ""),
}, "cp": tenant_config.get("cp", ""),
'customer': customer, },
'cfdi': cfdi_info, "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}"'},
)
@invoicing_bp.route("/facturapi/csd", methods=["POST"])
@require_auth("invoicing.create")
def facturapi_upload_csd():
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
Multipart form with:
- certificate: .cer file
- private_key: .key file
- password: CSD password
"""
if "certificate" not in request.files or "private_key" not in request.files:
return jsonify({"error": "certificate and private_key files are required"}), 400
password = (request.form.get("password") or "").strip()
if not password:
return jsonify({"error": "password is required"}), 400
cer_file = request.files["certificate"]
key_file = request.files["private_key"]
if not cer_file.filename or not key_file.filename:
return jsonify({"error": "certificate and private_key files are required"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
cer_b64 = base64.b64encode(cer_file.read()).decode("ascii")
key_b64 = base64.b64encode(key_file.read()).decode("ascii")
result = facturapi_service.upload_csd(tenant_config, cer_b64, key_b64, password)
log_action(
conn,
"FACTURAPI_CSD_UPLOAD",
"tenant_config",
None,
new_value={"org_id": tenant_config.get("facturapi_org_id")},
)
conn.commit()
cur.close()
conn.close()
return jsonify(
{
"success": True,
"message": "CSD uploaded successfully",
"certificate": result.get("certificate"),
}
)
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 500

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,31 @@
Prefix: /pos/api/service-orders Prefix: /pos/api/service-orders
""" """
from flask import Blueprint, request, jsonify, g from flask import Blueprint, g, jsonify, request
from middleware import require_auth from middleware import require_auth
from tenant_db import get_tenant_conn
from services.service_order_engine import ( from services.service_order_engine import (
create_service_order, get_service_order, list_service_orders, add_item,
update_status, add_item, update_item, remove_item, add_labor,
add_labor, update_labor, remove_labor, assign_mechanic,
update_service_order, get_kanban_summary, convert_to_sale,
create_service_catalog_item,
create_service_order,
delete_service_catalog_item,
get_kanban_summary,
get_service_order,
list_service_catalog,
list_service_orders,
release_item,
remove_item,
remove_labor,
reserve_item,
update_item,
update_labor,
update_service_catalog_item,
update_service_order,
update_status,
) )
from tenant_db import get_tenant_conn
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders') service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
@@ -202,3 +218,212 @@ def kanban_summary():
return jsonify(summary) return jsonify(summary)
finally: finally:
conn.close() conn.close()
# ─── Inventory reservation ────────────────────────
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_auth()
def reserve_order_item(so_id, item_id):
"""Reserve inventory for a service order item."""
conn = get_tenant_conn(g.tenant_id)
try:
result = reserve_item(conn, item_id, branch_id=g.branch_id, employee_id=g.employee_id)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
@require_auth()
def release_order_item(so_id, item_id):
"""Release a previous inventory reservation."""
conn = get_tenant_conn(g.tenant_id)
try:
result = release_item(conn, item_id, employee_id=g.employee_id)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Convert to sale ──────────────────────────────
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
@require_auth('pos.sell')
def convert_order_to_sale(so_id):
"""Convert a service order into a POS sale.
Body: {
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
sale_type: 'cash' | 'credit' | 'mixed',
register_id: int (optional),
amount_paid: float (optional),
payment_details: [...] (optional),
notes: str (optional)
}
"""
data = request.get_json() or {}
sale_payload = {
'payment_method': data.get('payment_method', 'efectivo'),
'sale_type': data.get('sale_type', 'cash'),
'register_id': data.get('register_id'),
'amount_paid': data.get('amount_paid'),
'payment_details': data.get('payment_details', []),
'notes': data.get('notes'),
}
conn = get_tenant_conn(g.tenant_id)
try:
result = convert_to_sale(
conn, so_id, sale_payload, employee_id=g.employee_id
)
return jsonify(result), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Mechanic assignment ──────────────────────────
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
@require_auth()
def assign_mechanic_endpoint(so_id):
"""Assign a mechanic/technician to a service order."""
data = request.get_json() or {}
employee_id = data.get('employee_id')
if not employee_id:
return jsonify({'error': 'employee_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = assign_mechanic(conn, so_id, employee_id)
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 400
finally:
conn.close()
# ─── Service catalog (reusable labor) ─────────────
@service_order_bp.route('/service-catalog', methods=['GET'])
@require_auth()
def list_catalog():
"""List reusable labor/service concepts."""
active_only = request.args.get('active_only', 'true').lower() != 'false'
conn = get_tenant_conn(g.tenant_id)
try:
items = list_service_catalog(conn, active_only=active_only)
return jsonify({'data': items})
finally:
conn.close()
@service_order_bp.route('/service-catalog', methods=['POST'])
@require_auth()
def create_catalog_item():
"""Create a reusable labor concept."""
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'name is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = create_service_catalog_item(conn, g.tenant_id, data)
return jsonify(result), 201
finally:
conn.close()
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
@require_auth()
def update_catalog_item(item_id):
"""Update a reusable labor concept."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_service_catalog_item(conn, item_id, data)
if not ok:
return jsonify({'error': 'No fields to update'}), 400
return jsonify({'message': 'Catalog item updated'})
finally:
conn.close()
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
@require_auth()
def delete_catalog_item(item_id):
"""Soft-delete a reusable labor concept."""
conn = get_tenant_conn(g.tenant_id)
try:
delete_service_catalog_item(conn, item_id)
return jsonify({'message': 'Catalog item deactivated'})
finally:
conn.close()
# ─── Thermal printing ─────────────────────────────
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_auth()
def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order.
Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80}
- escpos_raw: returns raw ESC/POS bytes (application/octet-stream)
- browser: returns the data dict as JSON for browser-side rendering
"""
from flask import Response
from services.thermal_printer import generate_service_order_ticket
body = request.get_json(silent=True) or {}
printer_type = body.get('printer_type', 'escpos_raw')
width = int(body.get('width', 80))
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
order = get_service_order(conn, so_id)
if not order:
cur.close()
conn.close()
return jsonify({'error': 'Service order not found'}), 404
# Fetch business info from config
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try:
cur.execute(
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
)
for rw in cur.fetchall():
if rw[0] == 'business_name':
business_info['name'] = rw[1]
else:
business_info[rw[0]] = rw[1]
except Exception:
pass
cur.close()
conn.close()
if printer_type == 'browser':
return jsonify(order)
raw = generate_service_order_ticket(order, business_info, width=width)
return Response(
raw,
mimetype='application/octet-stream',
headers={
'Content-Disposition': f'attachment; filename=orden_{order.get("order_number", so_id)}.bin'
},
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
import os import os
import sys import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tenant_db import get_master_conn, get_tenant_conn_by_dbname from tenant_db import get_master_conn, get_tenant_conn_by_dbname
@@ -12,27 +13,45 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
# Migration registry: version -> filename # Migration registry: version -> filename
MIGRATIONS = { MIGRATIONS = {
'v1.0': 'v1.0_initial.sql', "v1.0": "v1.0_initial.sql",
'v1.1': 'v1.1_pos_tables.sql', "v1.1": "v1.1_pos_tables.sql",
'v1.3': 'v1.3_fleet.sql', "v1.2": "v1.2_subdomain.sql",
'v1.4': 'v1.4_whatsapp.sql', "v1.3": "v1.3_fleet.sql",
'v1.5': 'v1.5_returns.sql', "v1.4": "v1.4_whatsapp.sql",
'v1.7': 'v1.7_plates.sql', "v1.5": "v1.5_returns.sql",
'v1.8': 'v1.8_performance_indexes.sql', "v1.6": "v1.6_marketplace.sql",
'v1.9': 'v1.9_redis_cache.sql', "v1.7": "v1.7_plates.sql",
'v2.0': 'v2.0_multi_currency.sql', "v1.8": "v1.8_performance_indexes.sql",
'v2.1': 'v2.1_suppliers.sql', "v1.9": "v1.9_redis_cache.sql",
'v2.2': 'v2.2_alerts_warranty.sql', "v2.0": "v2.0_multi_currency.sql",
'v2.3': 'v2.3_metabase.sql', "v2.1": "v2.1_suppliers.sql",
'v2.4': 'v2.4_crm_enhanced.sql', "v2.2": "v2.2_alerts_warranty.sql",
'v2.5': 'v2.5_service_orders.sql', "v2.3": "v2.3_metabase.sql",
'v2.6': 'v2.6_bnpl_erp.sql', "v2.4": "v2.4_crm_enhanced.sql",
'v2.7': 'v2.7_notifications.sql', "v2.5": "v2.5_service_orders.sql",
'v2.8': 'v2.8_savings.sql', "v2.6": "v2.6_bnpl_erp.sql",
'v2.9': 'v2.9_logistics.sql', "v2.7": "v2.7_notifications.sql",
'v3.0': 'v3.0_public_api.sql', "v2.8": "v2.8_savings.sql",
'v3.1': 'v3.1_inventory_vehicle_compat.sql', "v2.9": "v2.9_logistics.sql",
'v3.2': 'v3.2_db_performance.sql', "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",
"v4.4": "v4.4_workshop.sql",
"v4.5": "v4.5_customer_max_discount.sql",
} }
@@ -61,11 +80,19 @@ def apply_migration(db_name, version):
print(f" ERROR: Migration file not found: {filepath}") print(f" ERROR: Migration file not found: {filepath}")
return False return False
with open(filepath) as f:
sql = f.read()
# Skip migrations marked for manual/non-tenant execution
first_line = sql.splitlines()[0].strip() if sql.strip() else ""
if first_line.startswith(": SKIP") or first_line.startswith("-- : SKIP"):
print(" SKIP (manual/non-tenant migration)")
return True
conn = get_tenant_conn_by_dbname(db_name) conn = get_tenant_conn_by_dbname(db_name)
cur = conn.cursor() cur = conn.cursor()
try: try:
with open(filepath) as f: cur.execute(sql)
cur.execute(f.read())
conn.commit() conn.commit()
return True return True
except Exception as e: except Exception as e:
@@ -92,16 +119,19 @@ def run_migrations():
if version <= current_version: if version <= current_version:
continue continue
print(f" Applying {version}...", end=' ') print(f" Applying {version}...", end=" ")
if apply_migration(db_name, version): if apply_migration(db_name, version):
# Update version in master # Update version in master
master_conn = get_master_conn() master_conn = get_master_conn()
master_cur = master_conn.cursor() master_cur = master_conn.cursor()
master_cur.execute(""" master_cur.execute(
"""
INSERT INTO tenant_schema_version (tenant_id, version) INSERT INTO tenant_schema_version (tenant_id, version)
VALUES (%s, %s) VALUES (%s, %s)
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW() ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
""", (tenant_id, version, version)) """,
(tenant_id, version, version),
)
master_conn.commit() master_conn.commit()
master_cur.close() master_cur.close()
master_conn.close() master_conn.close()
@@ -113,5 +143,5 @@ def run_migrations():
print("\nDone.") print("\nDone.")
if __name__ == '__main__': if __name__ == "__main__":
run_migrations() run_migrations()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
-- v4.4 Workshop Lite
-- Extends service orders with inventory reservation, sale linking and a labor catalog.
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. SERVICE_ORDERS: link to the sale generated from the order
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS sale_id INTEGER REFERENCES sales(id);
COMMENT ON COLUMN service_orders.sale_id IS 'Sale/invoice generated from this service order';
CREATE INDEX IF NOT EXISTS idx_service_orders_sale_id ON service_orders(sale_id);
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. SERVICE_CATALOG: reusable labor/work concepts for mechanics
-- ═════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS service_catalog (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
suggested_hours NUMERIC(6,2) DEFAULT 0,
suggested_rate NUMERIC(12,2) DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_service_catalog_tenant ON service_catalog(tenant_id);
CREATE INDEX IF NOT EXISTS idx_service_catalog_active ON service_catalog(is_active);
COMMENT ON TABLE service_catalog IS 'Reusable labor concepts for workshop service orders';
-- Trigger to auto-update updated_at on service_catalog
CREATE OR REPLACE FUNCTION update_service_catalog_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_service_catalog_updated_at ON service_catalog;
CREATE TRIGGER trg_service_catalog_updated_at
BEFORE UPDATE ON service_catalog
FOR EACH ROW
EXECUTE FUNCTION update_service_catalog_updated_at();
-- ═════════════════════════════════════════════════════════════════════════════
-- 3. SERVICE_ORDER_ITEMS: track reserved quantity separately
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_order_items
ADD COLUMN IF NOT EXISTS reserved_quantity NUMERIC(10,2) DEFAULT 0;
COMMENT ON COLUMN service_order_items.reserved_quantity IS 'Quantity currently reserved from inventory';
-- ═════════════════════════════════════════════════════════════════════════════
-- 4. INVENTORY_OPERATIONS: new operation types for service orders
-- ═════════════════════════════════════════════════════════════════════════════
-- operation_type is VARCHAR(20) without a constraint, so no ALTER is needed.
-- New types used by the workshop module:
-- SO_RESERVE : negative quantity, reserves stock when item is added to SO
-- SO_RELEASE : positive quantity, releases a previous reservation
-- SO_CONSUME : negative quantity, final deduction when SO is converted to sale
COMMENT ON COLUMN inventory_operations.operation_type IS
'SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL, QUOTE_RESERVE, QUOTE_RELEASE, SO_RESERVE, SO_RELEASE, SO_CONSUME';

View File

@@ -0,0 +1,5 @@
-- /home/Autopartes/pos/migrations/v4.5_customer_max_discount.sql
-- Tenant DB schema v4.5 — add per-customer maximum discount percentage.
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS max_discount_pct NUMERIC(5,2) DEFAULT 0;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,238 @@
# /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 datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
# 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)
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
if "T" not in str(payment_date):
payment_date = f"{payment_date}T12:00:00"
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
payload = {
"type": "P",
"customer": customer_payload,
"complements": [
{
"type": "pago",
"data": {
"payment_form": forma_pago,
"payment_date": payment_date,
"amount": _fmt2(amount),
"related_documents": [
{
"uuid": original_uuid,
"amount": _fmt2(amount),
"taxes": [
{
"type": "IVA",
"rate": 0.16,
"factor": "Tasa",
"base": _fmt2(base),
}
],
}
],
},
}
],
}
return payload
def build_global_invoice_payload(sales, tenant_config, year, month):
"""Build Facturapi payload for a monthly global invoice."""
tenant_cp = tenant_config.get("cp", "00000")
total_subtotal = Decimal("0")
total_discount = Decimal("0")
total_tax = Decimal("0")
total_total = Decimal("0")
all_items = []
for sale in sales:
total_subtotal += _to_dec(sale.get("subtotal", 0))
total_discount += _to_dec(sale.get("discount_total", 0))
total_tax += _to_dec(sale.get("tax_total", 0))
total_total += _to_dec(sale.get("total", 0))
all_items.extend(_build_items(sale.get("items", [])))
payload = {
"customer": {
"tax_id": RFC_PUBLICO_GENERAL,
"legal_name": "PUBLICO EN GENERAL",
"tax_system": "616",
"address": {"zip": tenant_cp},
},
"items": all_items,
"use": "S01",
"payment_form": "01",
"payment_method": "PUE",
"currency": "MXN",
"series": tenant_config.get("serie", "FG"),
"folio_number": int(f"{year}{month:02d}"),
"global": {
"periodicity": "04", # Mensual
"months": f"{month:02d}",
"year": year,
},
}
return payload

View File

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

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,404 @@
# /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 base64
import logging
import os
from decimal import Decimal
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) from e
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) -> str | None:
for key in ("facturapi_secret_key", "facturapi_key", "cfdi_facturapi_key"):
val = (tenant_config.get(key) or "").strip()
if val:
return val
return None
def _get_org_id(tenant_config: dict) -> str | None:
for key in ("facturapi_org_id", "cfdi_facturapi_org_id"):
val = (tenant_config.get(key) or "").strip()
if val:
return val
return None
def _get_user_key() -> str | None:
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 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 = _get_org_id(tenant_config)
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
for key in ("facturapi_key", "cfdi_facturapi_key"):
tenant_key = (tenant_config.get(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) -> dict | None:
"""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 = _get_org_id(tenant_config)
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: str | None = None) -> dict:
"""Cancel an invoice in Facturapi.
Motive codes:
01: errores con relacion (requires replacement_uuid)
02: errores sin relacion
03: no se llevo a cabo la operacion
04: operacion nominativa relacionada en factura global
"""
api_key = get_api_key(tenant_config)
params = {"motive": motive}
if replacement_uuid:
params["replacement"] = replacement_uuid
return _request("DELETE", f"/invoices/{invoice_id}", api_key, params=params, timeout=60)
def download_xml(tenant_config: dict, invoice_id: str) -> bytes:
api_key = get_api_key(tenant_config)
return _download("GET", f"/invoices/{invoice_id}/xml", api_key)
def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
api_key = get_api_key(tenant_config)
return _download("GET", f"/invoices/{invoice_id}/pdf", api_key)
# ─── Helpers ─────────────────────────────────────────────────────────────────
def is_lco_rejection(message: str) -> bool:
"""Detect SAT LCO rejection (CSD not yet propagated)."""
if not message:
return False
msg = message.lower()
return any(
pattern in msg
for pattern in [
"lco",
"no se encontro el rfc",
"rfc no registrado",
"lista de contribuyentes obligados",
"csd no registrado",
]
)
def to_cents(amount) -> int:
"""Convert Decimal/float/None to integer cents for Facturapi."""
if amount is None:
return 0
return int(Decimal(str(amount)).quantize(Decimal("0.01")) * 100)

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -3,8 +3,11 @@
States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered States: received -> diagnosis -> waiting_parts -> repair -> quality_check -> ready -> delivered
""" """
import contextlib
from datetime import datetime from datetime import datetime
from services import inventory_engine
VALID_TRANSITIONS = { VALID_TRANSITIONS = {
'received': ['diagnosis', 'cancelled'], 'received': ['diagnosis', 'cancelled'],
'diagnosis': ['waiting_parts', 'repair', 'cancelled'], 'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
@@ -30,10 +33,8 @@ def _generate_order_number(conn):
row = cur.fetchone() row = cur.fetchone()
last_num = 0 last_num = 0
if row and row[0]: if row and row[0]:
try: with contextlib.suppress(ValueError):
last_num = int(row[0].split('-')[-1]) last_num = int(row[0].split('-')[-1])
except ValueError:
pass
new_num = last_num + 1 new_num = last_num + 1
cur.close() cur.close()
return f"{prefix}{new_num:04d}" return f"{prefix}{new_num:04d}"
@@ -422,7 +423,7 @@ def get_kanban_summary(conn, branch_id=None):
GROUP BY status GROUP BY status
""", params) """, params)
summary = {status: 0 for status in VALID_TRANSITIONS.keys() if status != 'cancelled'} summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
for r in cur.fetchall(): for r in cur.fetchall():
summary[r[0]] = r[1] summary[r[0]] = r[1]
@@ -438,3 +439,413 @@ def get_kanban_summary(conn, branch_id=None):
cur.close() cur.close()
summary['overdue'] = overdue summary['overdue'] = overdue
return summary return summary
# ─── Workshop inventory integration ─────────────────────────────────────────
def reserve_item(conn, so_item_id, branch_id, employee_id=None):
"""Reserve inventory for a service order item.
Records a negative SO_RESERVE operation and updates reserved_quantity.
Raises ValueError if stock is insufficient.
"""
cur = conn.cursor()
cur.execute(
"""
SELECT soi.service_order_id, soi.inventory_id, soi.quantity, soi.status,
so.order_number
FROM service_order_items soi
JOIN service_orders so ON so.id = soi.service_order_id
WHERE soi.id = %s
""",
(so_item_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Service order item not found")
so_id, inventory_id, quantity, status, order_number = row
if status == "cancelled":
cur.close()
raise ValueError("Cannot reserve a cancelled item")
if not inventory_id:
cur.close()
raise ValueError("Item has no inventory linked")
qty = int(quantity)
available = inventory_engine.get_stock(conn, inventory_id, branch_id)
if available < qty:
cur.close()
raise ValueError(f"Insufficient stock. Available: {available}, requested: {qty}")
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SO_RESERVE",
-qty,
reference_id=so_id,
reference_type="service_order_item",
notes=f"Reserva orden {order_number}",
employee_id=employee_id,
)
cur.execute(
"UPDATE service_order_items SET reserved_quantity = %s WHERE id = %s",
(qty, so_item_id),
)
conn.commit()
cur.close()
return {"reserved": qty}
def release_item(conn, so_item_id, employee_id=None):
"""Release a previous reservation for a service order item.
Records a positive SO_RELEASE operation and resets reserved_quantity.
"""
cur = conn.cursor()
cur.execute(
"""
SELECT soi.service_order_id, soi.inventory_id, soi.reserved_quantity,
so.branch_id, so.order_number
FROM service_order_items soi
JOIN service_orders so ON so.id = soi.service_order_id
WHERE soi.id = %s
""",
(so_item_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Service order item not found")
so_id, inventory_id, reserved_qty, branch_id, order_number = row
if not inventory_id or not reserved_qty:
cur.close()
return {"released": 0}
qty = int(reserved_qty)
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SO_RELEASE",
qty,
reference_id=so_id,
reference_type="service_order_item",
notes=f"Liberacion reserva orden {order_number}",
employee_id=employee_id,
)
cur.execute(
"UPDATE service_order_items SET reserved_quantity = 0 WHERE id = %s",
(so_item_id,),
)
conn.commit()
cur.close()
return {"released": qty}
def _consume_item_inventory(conn, so_item, sale_id, order_number, branch_id, employee_id=None):
"""Release reservation and record final SALE for a service order item."""
inventory_id = so_item.get("inventory_id")
reserved_qty = so_item.get("reserved_quantity", 0)
qty = int(so_item.get("quantity", 0))
if not inventory_id or qty <= 0:
return
if reserved_qty:
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SO_RELEASE",
int(reserved_qty),
reference_id=so_item.get("service_order_id"),
reference_type="service_order",
notes=f"Liberacion para venta orden {order_number}",
employee_id=employee_id,
)
inventory_engine.record_operation(
conn,
inventory_id,
branch_id,
"SALE",
-qty,
reference_id=sale_id,
reference_type="sale",
notes=f"Venta desde orden {order_number}",
employee_id=employee_id,
)
def convert_to_sale(conn, so_id, sale_data, employee_id=None):
"""Convert a service order into a POS sale.
sale_data keys:
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto'
sale_type: 'cash' | 'credit' | 'mixed'
register_id: int (optional)
amount_paid: float (optional)
payment_details: list (optional)
notes: str (optional)
Returns dict with sale_id, total, items_count.
"""
cur = conn.cursor()
so = get_service_order(conn, so_id)
if not so:
cur.close()
raise ValueError("Service order not found")
if so["status"] == "cancelled":
cur.close()
raise ValueError("Cannot convert a cancelled service order")
if so.get("sale_id"):
cur.close()
raise ValueError("Service order already converted to sale")
branch_id = so["branch_id"]
customer_id = so["customer_id"]
# Build sale items from SO parts and labor
sale_items = []
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
qty = int(item.get("quantity", 1))
unit_price = float(item.get("unit_price") or 0)
unit_cost = float(item.get("unit_cost") or 0)
sale_items.append(
{
"inventory_id": item.get("inventory_id"),
"part_number": item.get("part_number") or "PART",
"name": item.get("name") or "Refaccion",
"quantity": qty,
"unit_price": unit_price,
"unit_cost": unit_cost,
"tax_rate": 0.16,
}
)
for labor in so.get("labor", []):
if labor.get("status") == "cancelled":
continue
sale_items.append(
{
"inventory_id": None,
"part_number": "SERV",
"name": labor.get("description") or "Mano de obra",
"quantity": 1,
"unit_price": float(labor.get("total_cost") or 0),
"unit_cost": 0,
"tax_rate": 0.16,
}
)
if not sale_items:
cur.close()
raise ValueError("No items or labor to invoice")
# Calculate totals
subtotal = 0.0
tax_total = 0.0
for item in sale_items:
item_subtotal = item["quantity"] * item["unit_price"]
item_tax = item_subtotal * item["tax_rate"]
item["subtotal"] = item_subtotal
item["tax_amount"] = item_tax
subtotal += item_subtotal
tax_total += item_tax
total = subtotal + tax_total
payment_method = sale_data.get("payment_method", "efectivo")
sale_type = sale_data.get("sale_type", "cash")
register_id = sale_data.get("register_id")
amount_paid = float(sale_data.get("amount_paid", total if sale_type == "cash" else 0))
change_given = max(amount_paid - total, 0) if sale_type == "cash" and payment_method == "efectivo" else 0
notes = sale_data.get("notes") or f"Orden de servicio {so['order_number']}"
metodo_pago_sat = "PPD" if sale_type == "credit" else "PUE"
forma_pago_map = {"efectivo": "01", "transferencia": "03", "tarjeta": "04", "mixto": "99"}
forma_pago_sat = forma_pago_map.get(payment_method, "99")
cur.execute(
"""
INSERT INTO sales
(branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s)
RETURNING id, created_at
""",
(
branch_id,
customer_id,
employee_id,
register_id,
sale_type,
payment_method,
subtotal,
0,
tax_total,
total,
amount_paid,
change_given,
metodo_pago_sat,
forma_pago_sat,
notes,
),
)
sale_id, _created_at = cur.fetchone()
# Insert sale_items
for item in sale_items:
cur.execute(
"""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
sale_id,
item["inventory_id"],
item["part_number"],
item["name"],
item["quantity"],
item["unit_price"],
item["unit_cost"],
item["tax_rate"],
item["tax_amount"],
item["subtotal"],
),
)
# Consume inventory for parts
for item in so.get("items", []):
if item.get("status") == "cancelled":
continue
_consume_item_inventory(
conn, item, sale_id, so["order_number"], branch_id, employee_id
)
# Link order to sale
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
conn.commit()
cur.close()
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
def assign_mechanic(conn, so_id, employee_id):
"""Assign a mechanic/technician to a service order."""
cur = conn.cursor()
cur.execute("SELECT id FROM service_orders WHERE id = %s", (so_id,))
if not cur.fetchone():
cur.close()
raise ValueError("Service order not found")
cur.execute(
"UPDATE service_orders SET employee_id = %s WHERE id = %s",
(employee_id, so_id),
)
conn.commit()
cur.close()
return {"employee_id": employee_id}
# ─── Service catalog (reusable labor concepts) ───────────────────────────────
def list_service_catalog(conn, active_only=True):
"""List reusable labor/service concepts."""
cur = conn.cursor()
where = "WHERE is_active = true" if active_only else ""
cur.execute(
f"""
SELECT id, tenant_id, name, description, suggested_hours, suggested_rate,
is_active, created_at, updated_at
FROM service_catalog
{where}
ORDER BY name
"""
)
items = []
for r in cur.fetchall():
items.append(
{
"id": r[0],
"tenant_id": r[1],
"name": r[2],
"description": r[3],
"suggested_hours": float(r[4]) if r[4] else 0,
"suggested_rate": float(r[5]) if r[5] else 0,
"is_active": r[6],
"created_at": str(r[7]) if r[7] else None,
"updated_at": str(r[8]) if r[8] else None,
}
)
cur.close()
return items
def create_service_catalog_item(conn, tenant_id, data):
"""Create a reusable labor concept."""
cur = conn.cursor()
cur.execute(
"""
INSERT INTO service_catalog
(tenant_id, name, description, suggested_hours, suggested_rate, is_active)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
tenant_id,
data.get("name"),
data.get("description"),
data.get("suggested_hours", 0),
data.get("suggested_rate", 0),
data.get("is_active", True),
),
)
item_id = cur.fetchone()[0]
conn.commit()
cur.close()
return {"id": item_id}
def update_service_catalog_item(conn, item_id, data):
"""Update a reusable labor concept."""
cur = conn.cursor()
allowed = ["name", "description", "suggested_hours", "suggested_rate", "is_active"]
sets = []
vals = []
for field in allowed:
if field in data:
sets.append(f"{field} = %s")
vals.append(data[field])
if not sets:
cur.close()
return False
vals.append(item_id)
cur.execute(f"UPDATE service_catalog SET {', '.join(sets)} WHERE id = %s", vals)
conn.commit()
cur.close()
return True
def delete_service_catalog_item(conn, item_id):
"""Soft-delete a reusable labor concept by setting is_active = false."""
cur = conn.cursor()
cur.execute(
"UPDATE service_catalog SET is_active = false WHERE id = %s", (item_id,)
)
conn.commit()
cur.close()
return True

View File

@@ -204,3 +204,140 @@ def _total_line(label, amount, width):
"""Format a totals line like 'Subtotal: $1,234.56'.""" """Format a totals line like 'Subtotal: $1,234.56'."""
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}' val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
return _format_line(label, val, width) + '\n' return _format_line(label, val, width) + '\n'
def generate_service_order_ticket(so_data, business_info, width=80):
"""Generate ESC/POS bytes for a workshop service order ticket.
Args:
so_data: dict with service order info:
order_number, status, customer_name, vehicle_plate, vehicle_make,
vehicle_model, mileage_in, fuel_level, reception_notes,
employee_name, created_at, items[{name, part_number, quantity,
unit_price}], labor[{description, hours, hourly_rate, total_cost}],
estimated_cost, total
business_info: dict with name, rfc, address
width: 58 or 80 (mm)
Returns: bytes ready to send to printer
"""
chars = 32 if width == 58 else 48
buf = bytearray()
buf += INIT
# Header
buf += ALIGN_CENTER
buf += LARGE_SIZE
buf += (business_info.get("name", "NEXUS POS") + "\n").encode("cp437", errors="replace")
buf += NORMAL_SIZE
if business_info.get("rfc"):
buf += (business_info["rfc"] + "\n").encode("cp437", errors="replace")
if business_info.get("address"):
buf += (business_info["address"] + "\n").encode("cp437", errors="replace")
buf += b"\n"
# Title
buf += BOLD_ON + DOUBLE_HEIGHT
buf += "ORDEN DE SERVICIO\n".encode("cp437", errors="replace")
buf += NORMAL_SIZE + BOLD_OFF
buf += b"\n"
# Order info
buf += ALIGN_LEFT
buf += BOLD_ON
buf += f"Folio: {so_data.get('order_number', 'N/A')}\n".encode("cp437", errors="replace")
buf += BOLD_OFF
buf += f"Estado: {so_data.get('status', '')}\n".encode("cp437", errors="replace")
buf += f"Fecha: {str(so_data.get('created_at', ''))[:19]}\n".encode("cp437", errors="replace")
if so_data.get("employee_name"):
buf += f"Mecanico: {so_data['employee_name']}\n".encode("cp437", errors="replace")
buf += ("-" * chars + "\n").encode()
# Customer / vehicle
if so_data.get("customer_name"):
buf += BOLD_ON
buf += f"Cliente: {so_data['customer_name']}\n".encode("cp437", errors="replace")
buf += BOLD_OFF
vehicle = " ".join(
str(v) for v in [
so_data.get("vehicle_plate", ""),
so_data.get("vehicle_make", ""),
so_data.get("vehicle_model", ""),
] if v
).strip()
if vehicle:
buf += f"Vehiculo: {vehicle}\n".encode("cp437", errors="replace")
if so_data.get("mileage_in"):
buf += f"Kilometraje: {so_data['mileage_in']}\n".encode("cp437", errors="replace")
if so_data.get("fuel_level"):
buf += f"Gasolina: {so_data['fuel_level']}\n".encode("cp437", errors="replace")
buf += ("-" * chars + "\n").encode()
# Reception notes
if so_data.get("reception_notes"):
buf += BOLD_ON
buf += "Falla / Observaciones:\n".encode("cp437", errors="replace")
buf += BOLD_OFF
for line in str(so_data["reception_notes"]).splitlines():
buf += (line[:chars] + "\n").encode("cp437", errors="replace")
buf += ("-" * chars + "\n").encode()
# Parts
items = so_data.get("items", [])
if items:
buf += BOLD_ON
buf += "REFACCIONES\n".encode("cp437", errors="replace")
buf += BOLD_OFF
for item in items:
name = item.get("name", "")[:chars - 10]
part_no = item.get("part_number", "")
qty = item.get("quantity", 1)
unit_price = item.get("unit_price", 0)
line_total = qty * unit_price
buf += f"{qty}x {name}\n".encode("cp437", errors="replace")
if part_no:
buf += f" #{part_no}\n".encode("cp437", errors="replace")
buf += ALIGN_RIGHT
buf += f"${line_total:,.2f}\n".encode("cp437", errors="replace")
buf += ALIGN_LEFT
buf += ("-" * chars + "\n").encode()
# Labor
labor_items = so_data.get("labor", [])
if labor_items:
buf += BOLD_ON
buf += "MANO DE OBRA\n".encode("cp437", errors="replace")
buf += BOLD_OFF
for labor in labor_items:
desc = labor.get("description", "")[:chars - 10]
hours = labor.get("hours", 0)
rate = labor.get("hourly_rate", 0)
total = labor.get("total_cost", hours * rate)
buf += f"{desc}\n".encode("cp437", errors="replace")
buf += f" {hours} hrs x ${rate:,.2f}\n".encode("cp437", errors="replace")
buf += ALIGN_RIGHT
buf += f"${total:,.2f}\n".encode("cp437", errors="replace")
buf += ALIGN_LEFT
buf += ("-" * chars + "\n").encode()
# Totals
buf += ALIGN_RIGHT
if items or labor_items:
total = so_data.get("total") or sum(
i.get("quantity", 1) * i.get("unit_price", 0) for i in items
) + sum(labor.get("total_cost", 0) for labor in labor_items)
buf += BOLD_ON + DOUBLE_HEIGHT
buf += _total_line("TOTAL ESTIMADO:", total, chars).encode("cp437", errors="replace")
buf += NORMAL_SIZE + BOLD_OFF
if so_data.get("estimated_cost"):
buf += _total_line("Costo estimado:", so_data["estimated_cost"], chars).encode("cp437", errors="replace")
# Footer
buf += b"\n"
buf += ALIGN_CENTER
buf += "No es comprobante fiscal\n".encode("cp437", errors="replace")
buf += "Nexus Autoparts POS\n".encode("cp437", errors="replace")
buf += b"\n\n\n"
buf += PARTIAL_CUT
return bytes(buf)

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -88,9 +88,19 @@ def process_incoming(webhook_data):
key = data.get('key', {}) key = data.get('key', {})
message = data.get('message', {}) message = data.get('message', {})
# remoteJid can be phone@s.whatsapp.net or LID@lid # remoteJid can be phone@s.whatsapp.net or LID:instance@lid
remote_jid = key.get('remoteJid', '') remote_jid = key.get('remoteJid', '')
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '') # Strip JID suffixes and LID instance suffix (:12)
phone = remote_jid.split('@')[0].split(':')[0] if remote_jid else ''
# DEBUG
import json
print(f"[WA-DEBUG] key fields: {json.dumps({k: v for k, v in key.items() if k in ('remoteJid', 'senderPn', 'fromMe', 'id')})}")
# senderPn contains the real phone number when remoteJid is a privacy LID
sender_pn = key.get('senderPn', '')
if sender_pn:
sender_pn = sender_pn.replace('@s.whatsapp.net', '')
# The bridge now classifies and passes these extra fields. Fall back to # The bridge now classifies and passes these extra fields. Fall back to
# the old parsing if they're missing (older bridge version). # the old parsing if they're missing (older bridge version).
@@ -105,6 +115,7 @@ def process_incoming(webhook_data):
# - For 'text' messages → conversation or extendedTextMessage # - For 'text' messages → conversation or extendedTextMessage
# - For 'image'/'video' → the caption (may be empty) # - For 'image'/'video' → the caption (may be empty)
# - For 'audio' → empty (filled in later by Whisper transcription) # - For 'audio' → empty (filled in later by Whisper transcription)
# - For 'location' → synthetic text with coordinates
if media_kind == 'text': if media_kind == 'text':
text = ( text = (
message.get('conversation', '') message.get('conversation', '')
@@ -114,9 +125,14 @@ def process_incoming(webhook_data):
else: else:
text = media_caption text = media_caption
# Location fields (from bridge classification)
latitude = data.get('latitude')
longitude = data.get('longitude')
return { return {
'phone': phone, 'phone': phone,
'jid': remote_jid, 'jid': remote_jid,
'sender_pn': sender_pn,
'text': text, 'text': text,
'from_me': key.get('fromMe', False), 'from_me': key.get('fromMe', False),
'message_id': key.get('id', ''), 'message_id': key.get('id', ''),
@@ -125,4 +141,20 @@ def process_incoming(webhook_data):
'media_mimetype': media_mimetype, 'media_mimetype': media_mimetype,
'is_voice_note': is_voice_note, 'is_voice_note': is_voice_note,
'push_name': push_name, 'push_name': push_name,
'latitude': latitude,
'longitude': longitude,
} }
def send_image(phone, caption, base64_image, bridge_url=None):
"""Send an image message via the Baileys bridge."""
url = _get_url(bridge_url)
try:
return requests.post(
f'{url}/send-image',
headers=HEADERS,
json={'phone': phone, 'caption': caption, 'base64': base64_image},
timeout=15
).json()
except Exception as e:
return {'error': str(e)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,6 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
color: var(--color-text-primary); color: var(--color-text-primary);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;

View File

@@ -20,8 +20,6 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
color: var(--color-text-primary); color: var(--color-text-primary);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;

View File

@@ -23,8 +23,6 @@
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
} }
/* Modern theme dot-grid on body */ /* Modern theme dot-grid on body */
@@ -814,6 +812,18 @@
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.chart-canvas-wrap {
position: relative;
height: 220px;
width: 100%;
}
.chart-canvas-wrap canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.rank-list { .rank-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -23,8 +23,6 @@
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
} }
/* Modern theme dot-grid on body */ /* Modern theme dot-grid on body */

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,8 +19,6 @@
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-normal) var(--ease-in-out),
color var(--duration-normal) var(--ease-in-out);
overflow: hidden; overflow: hidden;
} }
@@ -732,6 +730,20 @@
font-size: var(--text-caption); font-size: var(--text-caption);
} }
.btn--meli {
background: #FFE600;
color: #2D3277;
border-color: transparent;
font-weight: 700;
}
.btn--meli:hover {
background: #e6cf00;
color: #1a1f5c;
}
.btn--meli svg {
stroke: currentColor;
}
/* ========================================================================= /* =========================================================================
DATA TABLE DATA TABLE
========================================================================= */ ========================================================================= */
@@ -1261,7 +1273,7 @@
.inv-field label { .inv-field label {
font-size: var(--text-caption); font-size: var(--text-caption);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--color-text-muted); color: var(--color-text-secondary);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
} }
@@ -1282,6 +1294,23 @@
box-shadow: 0 0 0 2px var(--color-primary-muted); box-shadow: 0 0 0 2px var(--color-primary-muted);
} }
.inv-field select {
padding: var(--space-2) var(--space-3);
background: var(--color-surface-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-body-sm);
width: 100%;
}
.inv-field select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted);
}
.count-row { .count-row {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
@@ -1301,3 +1330,129 @@
/* History table inside modal */ /* History table inside modal */
.inv-modal .data-table { width: 100%; } .inv-modal .data-table { width: 100%; }
/* ─── Virtual Scroll fixes ───────────────────────────────────────────── */
.vs-container {
will-change: transform;
contain: layout paint;
-webkit-overflow-scrolling: touch;
}
.vs-container table {
will-change: transform;
}
.vs-container tbody tr {
content-visibility: auto;
contain-intrinsic-size: auto 48px;
}
/* ─── MercadoLibre Publish Modal Enhancements ────────────────────────── */
.meli-preview-card {
display: grid;
grid-template-columns: 56px 1fr auto auto auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
background: var(--color-surface-1);
}
.meli-preview-card img {
width: 56px; height: 56px; object-fit: cover; border-radius: var(--radius-sm);
background: var(--color-surface-2);
}
.meli-preview-card .meli-title-input {
width: 100%;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
padding: 4px 8px;
font-size: var(--text-caption);
}
.meli-preview-card .meli-num-input {
width: 80px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
padding: 4px 8px;
font-size: var(--text-caption);
text-align: right;
}
.meli-check { font-size: var(--text-caption); display: flex; align-items: center; gap: 4px; }
.meli-check.ok { color: var(--color-success); }
.meli-check.fail { color: var(--color-error); }
.meli-checks-row {
display: flex; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-1);
}
.meli-attrs-section {
margin-top: var(--space-3);
padding: var(--space-3);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-1);
}
.meli-attrs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
margin-top: var(--space-2);
}
.meli-img-upload {
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
text-align: center;
color: var(--color-text-muted);
font-size: var(--text-caption);
cursor: pointer;
transition: border-color var(--transition-fast);
}
.meli-img-upload:hover { border-color: var(--color-primary); }
.meli-img-upload input { display: none; }
/* ─── MercadoLibre Category Autocomplete ─────────────────────────────── */
.meli-cat-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 200;
background: var(--color-surface-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 240px;
overflow-y: auto;
margin-top: 4px;
}
.meli-cat-item {
padding: 10px 14px;
cursor: pointer;
font-size: var(--text-body-sm);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
transition: background var(--transition-fast);
display: flex;
justify-content: space-between;
align-items: center;
}
.meli-cat-item:last-child { border-bottom: none; }
.meli-cat-item:hover,
.meli-cat-item.is-active {
background: var(--color-surface-2);
}
.meli-cat-item .cat-id {
font-size: var(--text-caption);
color: var(--color-text-muted);
margin-left: 8px;
font-family: var(--font-mono);
}
.meli-cat-loading,
.meli-cat-empty {
padding: 12px 14px;
font-size: var(--text-caption);
color: var(--color-text-muted);
text-align: center;
}

View File

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

View File

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

View File

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

View File

@@ -19,8 +19,6 @@
font-size: var(--text-body); font-size: var(--text-body);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-slow) var(--ease-in-out),
color var(--duration-slow) var(--ease-in-out);
min-height: 100vh; min-height: 100vh;
} }
@@ -58,8 +56,6 @@
background-color: var(--color-bg-elevated); background-color: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: background-color var(--duration-slow) var(--ease-in-out),
border-color var(--duration-slow) var(--ease-in-out);
} }
.theme-bar__label { .theme-bar__label {
@@ -657,8 +653,6 @@
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
background: var(--color-surface-1); background: var(--color-surface-1);
transition: background-color var(--duration-slow) var(--ease-in-out),
border-color var(--duration-slow) var(--ease-in-out);
} }
.footer-version { .footer-version {

View File

@@ -19,8 +19,6 @@
font-size: var(--text-body); font-size: var(--text-body);
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-base); background-color: var(--color-bg-base);
transition: background-color var(--duration-slow) var(--ease-in-out),
color var(--duration-slow) var(--ease-in-out);
min-height: 100vh; min-height: 100vh;
} }
@@ -58,8 +56,6 @@
background-color: var(--color-bg-elevated); background-color: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: background-color var(--duration-slow) var(--ease-in-out),
border-color var(--duration-slow) var(--ease-in-out);
} }
.theme-bar__label { .theme-bar__label {
@@ -657,8 +653,6 @@
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
background: var(--color-surface-1); background: var(--color-surface-1);
transition: background-color var(--duration-slow) var(--ease-in-out),
border-color var(--duration-slow) var(--ease-in-out);
} }
.footer-version { .footer-version {

View File

@@ -18,14 +18,22 @@ body {
SIDEBAR — Glass treatment SIDEBAR — Glass treatment
========================================================================== */ ========================================================================== */
/* Prevent flash/stun while sidebar.js replaces static sidebar markup */
.sidebar, .sidebar,
.pos-sidebar { .pos-sidebar {
opacity: 0;
transition: opacity 0.15s ease;
background: var(--glass-bg-strong) !important; background: var(--glass-bg-strong) !important;
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border) !important; border-right: 1px solid var(--glass-border) !important;
} }
body.sidebar-ready .sidebar,
body.sidebar-ready .pos-sidebar {
opacity: 1;
}
.sidebar__logo { .sidebar__logo {
position: relative; position: relative;
} }

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