Compare commits

...

93 Commits

Author SHA1 Message Date
f2d4558b80 Make dashboard/home link role-aware: counter -> inventory, cashier -> POS
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 19:57:01 +00:00
c374cccd77 Set default landing page for counter role to Inventory
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 17:44:00 +00:00
a4f90cd4a3 Remove Cobrar button from Notas de Remision list
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 17:25:24 +00:00
667cd3b1eb Fix ticket print not opening (variable order) and add error handling
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 17:14:13 +00:00
ee5075ea2e Increase ticket font sizes and render at real printable width
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 17:10:33 +00:00
4363829c26 Lower ticket virtual print resolution for testing
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 16:44:12 +00:00
95a01c8958 Improve ticket legibility: larger fonts, spacing, indentation and thermal emphasized mode
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 16:22:26 +00:00
8e6c1f2887 Add ticket paper width selector and improve thermal cut/print layout
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 09:22:58 +00:00
f0c605f911 Fix: only sync ML stock when both marketplace_listings and meli_sync_queue exist
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 04:50:01 +00:00
4a4e53602b Guard against missing marketplace tables when recording inventory operations
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-18 04:47:46 +00:00
6d07eab5c0 Add workshop module toggle in Modules & Integrations settings
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-16 08:13:56 +00:00
c90012ca37 Add allow_negative_stock toggle for sales, remissions, quotes, layaways and service orders
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-15 18:02:43 +00:00
116d4452da Add allow_zero_price_sales toggle: config UI, POS enforcement and backend validation
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-15 17:50:02 +00:00
2734a484f6 Add script to clone inventory between tenant DBs
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-15 17:41:26 +00:00
1cce81b75f Facturapi: fix legal payload (name/legal_name/tax_system, address, no tax_id), reuse local org, status via user key; add fiscal address fields; embed Carta Manifiesto iframe
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-13 06:35:36 +00:00
b2f23ef3cf fix(facturapi): configure legal data after org creation/reuse to enable Live mode
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-09 07:25:07 +00:00
9b88b8ba4c chore(systemd): add FACTURAPI_USER_KEY to nexus-pos.service
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-08 08:07:46 +00:00
fa992b7a4a fix(invoicing): return clear error on Facturapi config issues instead of 500
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-08 07:18:50 +00:00
a8e748e08f fix(invoicing): wire emisor config save/load and align fiscal config keys
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-06 23:49:23 +00:00
07dad0801e fix(sw): bump SW version to 41; fix page guard fallback to avoid loops
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-03 08:27:11 +00:00
0bd5ee8ee8 fix(permissions): honor module permissions for cashier/counter in sidebar and page guard
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-03 08:10:22 +00:00
e270e848d7 fix(config): use cfg-tab-btn style for permissions tabs
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-03 08:05:27 +00:00
6e85497e42 feat(permissions): granular module permissions + workshop permissions matrix
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-03 07:55:17 +00:00
4718a09f15 fix(inventory): use category_id in bulk import and resolve category names
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 23:19:33 +00:00
1ff4d80277 fix(pos): print tickets via iframe with measured height and configurable paper width
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 23:11:55 +00:00
31b533f9f9 fix(inventory): support cp1252/latin-1 CSV encodings in bulk import
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 22:49:39 +00:00
982315bdaf feat(permissions): add dashboard.view permission for cashier/counter/workshop
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 22:37:28 +00:00
1b51fe65b5 fix(pos): hide Sistema button for non-admin roles and allow login/logout in page guard
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 22:27:50 +00:00
302d54eb0a fix(config): re-sync employee permissions on role change; add sync script
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 19:37:19 +00:00
53ec0cc3e5 fix(inventory): read refreshed token dynamically in inventory.js
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 19:27:22 +00:00
944b9b51b3 fix(customers): allow cashiers to view customers and use refreshed token
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 14:49:47 +00:00
670db75fcd fix(workshop): show autorizada for workshop/mechanic and hide delivery statuses
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 14:45:25 +00:00
c226b1cf77 fix(workshop): restrict workshop/mechanic views to only 'autorizada' status
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-07-02 14:38:29 +00:00
f42910f4f6 feat: cashier/counter reports, service-order & remission flows, Rached migration utils
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Add "Mis cortes de caja" report for cashiers/counters with sales detail.
- Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint.
- Remove dashboard from cashier menu; add Reports to cashier/counter.
- Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission.
- Remission notes module (UI, CSS, courier, counter remissions).
- Customer hard-delete and vehicle/customer linkage in workshop.
- POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio.
- Inventory: CSV template with sku_secondary, alias import.
- Rached migration scripts and DB migrations.
- Version-bump cached JS/CSS query strings.

Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
2026-07-02 12:51:56 +00:00
483498cfcc fix: define global toast utility in app-init
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Added window.toast() in app-init.js so workshop/customers/inventory pages can use it.

- Bumped app-init.js cache version on all templates.
2026-06-30 20:13:32 +00:00
7dc34f68ab feat(workshop): make brand/model required, plate optional, remove VIN
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- create_vehicle endpoints now require make and model; plate is optional; VIN is no longer stored.

- Updated new-vehicle modal: removed VIN field, plate is optional, brand/model marked required.
2026-06-30 20:08:56 +00:00
73553a3771 feat(customers): hard-delete customers
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
DELETE /customers/<id> now removes the row.

- Deletes layaways and dependent items/payments.

- Nullifies customer_id in sales, service_orders, fleet_vehicles, plate_vehicles, quotations, returns, warranties, bnpl_transactions, customer_payments.

- Cascade-delete CRM tables are handled by FK constraints.
2026-06-30 20:04:11 +00:00
073c97406b feat(customers): show vehicles and delete customer
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- get_customer now returns fleet_vehicles assigned to the customer.

- Added DELETE /pos/api/customers/<id> soft-delete (is_active=false) with customers.delete permission.

- Added Vehicles section in customer detail panel.

- Added Eliminar button in quick actions (owner/admin/customers.delete).
2026-06-30 19:58:45 +00:00
66b8fe3d69 feat(workshop): vehicles must be assigned to a customer
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Added customer_id column to fleet_vehicles with FK to customers.

- Enforced customer_id on create_vehicle endpoints (workshop and fleet).

- New vehicle modal now requires a selected customer and stores customer_id.

- Migration v4.13 applied to all tenants.
2026-06-30 19:53:00 +00:00
1af66cb302 fix(workshop): ensure new-customer/new-vehicle modals open on top
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Raised z-index of newCustomerModal and newVehicleModal so they appear above the order modal.

- Added '+ Nuevo' customer button in the edit-order modal as well.
2026-06-30 19:49:42 +00:00
5c700e98c3 feat(workshop): add new customer from new order modal
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Added POST /pos/api/service-orders/customers endpoint for workshop.edit users.

- Added 'Nuevo cliente' modal reachable from the new service order form.

- After creation the customer is automatically selected in the order.
2026-06-30 19:45:41 +00:00
603046cb09 feat(inventory): edit items in inventory grid
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Added 'Editar' button in inventory rows.

- Reused create modal as create/edit with pre-filled data.

- Added fields: unit, max_stock, tax_rate, description, is_active.

- Added /inventory/categories/all endpoint for flat category selector.

- Price/cost inputs are disabled for users without config.edit_prices.
2026-06-30 19:43:03 +00:00
b6679c6d9c feat(workshop): add vehicle from new order modal too
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
The '+ Nuevo' vehicle button is now available in both the edit and new service order modals.
2026-06-30 19:36:05 +00:00
52cb01eb4e feat(workshop): add vehicle from service order
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Added POST /pos/api/service-orders/vehicles endpoint for workshop.edit users.

- Added 'Nuevo vehiculo' modal in edit order with plate, VIN, make, model, year, color and owner.

- After creation the vehicle is automatically selected in the order.
2026-06-30 19:33:07 +00:00
ff600ed7df feat(workshop): remove quality_check and add edit order modal
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Removed 'Control de calidad' from service order pipeline (VALID_TRANSITIONS, status labels, Kanban columns).

- Added editable fields for customer, vehicle, branch, mechanic, priority, mileage, fuel level, estimated completion/cost and reception notes.

- Added customer/vehicle autocomplete in the edit order modal.
2026-06-30 19:30:51 +00:00
b539c92daf feat(i18n): force Spanish as the only language across all tenants
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- i18n.js now locks currentLang to 'es' and overwrites pos_lang in localStorage.

- Disabled setLang language switching.

- Removed ES/EN toggle from sidebar.

- Bumped i18n.js and sidebar.js cache version on all templates.
2026-06-30 18:13:43 +00:00
37b7f94f54 fix(config): saveAll crash when saving modules
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
saveModules() used event.target, which is undefined when called from saveAll.

Replaced with direct checkbox lookups and null checks.
2026-06-30 18:04:22 +00:00
7852838b21 feat(config): hard-delete branches for admins
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
DELETE /branches/<id> now removes the row, nullifies FK references, and deletes branch-specific stock/count records.

Main branch remains protected.
2026-06-30 18:00:26 +00:00
7e3205b03a feat(config): allow admin/owner to delete branches
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Added DELETE /pos/api/config/branches/<id> that soft-deletes the branch (is_active=false).

- Restricted to owner/admin; main branch cannot be deleted.

- Added 'Eliminar' button in the branches grid for admin/owner.
2026-06-30 17:57:29 +00:00
e9cfb2e756 feat(workshop): allow admin/owner to delete service orders
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Soft-delete service orders via is_deleted flag.

- Added DELETE /pos/api/service-orders/<id> restricted to owner/admin.

- Updated list, detail, summary, status and assignment queries to exclude deleted orders.

- Added 'Eliminar orden' button in the detail modal for administrators.
2026-06-30 17:51:09 +00:00
2863cf8e9f feat(workshop): restrict status flow, add mechanic notes, inventory part picker
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Service orders can no longer move backwards through the workshop pipeline.

- Added editable Reception / Diagnosis / Repair / Delivery notes in the detail modal.

- Added inventory-aware autocomplete when attaching parts to a service order.
2026-06-30 17:43:19 +00:00
bd5f733d6f feat(config): hard-delete employees instead of soft-deactivating
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Updates DELETE /employees/<id> to nullify FK references, remove dependent rows (permissions/sessions), and delete the employee record. Owners remain protected.
2026-06-30 17:33:41 +00:00
8b378c3476 feat(config): agrega opcion para eliminar empleados
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Backend: nuevo endpoint DELETE /employees/<id> que desactiva (soft-delete).
  No permite eliminar cuentas de dueno.
- Frontend: boton Eliminar en la tabla de empleados con confirmacion.
- Exponer deleteEmployee en el modulo Config.

Tests: 35 passed
2026-06-30 17:28:48 +00:00
76dfa2adff fix(login): elimina fallback legacy y exige employee_id + PIN correcto
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- auth_bp.py: ahora login requiere employee_id. Si el PIN no coincide con
  el empleado seleccionado, devuelve 401. Ya no escanea todos los empleados
  ni permite entrar como otro usuario con el mismo PIN.

Esto evita que al elegir un usuario se entre como mecanico solo porque
comparten PIN.
2026-06-30 17:23:39 +00:00
1ca056ad0f fix(login): deshabilita auto-redirect y redirige segun rol tras login
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- login.html ya no redirige automaticamente si hay token; esto permite
  cambiar de usuario en dispositivos compartidos.
- Limpia token/empleado expirados.
- Tras login exitoso, redirige a /pos/workshop para taller/mecanico y a
  /pos/catalog para los demas roles.

Esto facilita probar el login con distintos usuarios en La Casita.
2026-06-30 17:17:56 +00:00
8ce6c398d4 fix(login): mueve login a /pos/login2 para evitar cache viejo
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- /pos/login redirige a /pos/login2 (no-store).
- /pos/login2 sirve la plantilla actualizada que envia employee_id.
- nginx redirige / a /pos/login2.

Esto evita que navegadores/PWA con cache antigua sigan usando la version
que no enviaba el empleado seleccionado.
2026-06-30 17:15:42 +00:00
50f2c21da7 fix(login): fuerza recarga de login redirigiendo a URL versionada
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- /pos/login ahora redirige a /pos/login?v=34 para evitar que el navegador
  use una copia antigua de la pagina de login.
- nginx redirige / a /pos/login?v=34.

Esto asegura que todos los clientes obtengan el login que envia employee_id.
2026-06-30 17:13:06 +00:00
06b1de84dc fix(app): importa make_response para headers de login
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-30 17:08:40 +00:00
2af305d184 fix(login): evita cache del login y fuerza validacion por empleado
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- app.py: login.html ahora se sirve con Cache-Control no-store.
- auth_bp.py: login valida PIN contra el employee_id enviado por el frontend.
- login.html: envia employee_id y setea cookie pos_role al iniciar sesion.

Se requiere limpiar cache del navegador en la pagina de login para que
 aplique el nuevo formulario.
2026-06-30 17:08:05 +00:00
93eebe0662 fix(login): el login ahora respeta el empleado seleccionado
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- auth_bp.py: login acepta employee_id y verifica el PIN solo de ese empleado.
  Fallback legacy se mantiene para clientes antiguos.
- login.html: guarda el id del empleado seleccionado y lo envia en la peticion.
- Se agrega cookie pos_role en el login inline del HTML.

Esto corrige el problema donde seleccionar cualquier usuario accedia siempre
al mismo empleado (el primero con el mismo PIN).

Tests: 35 passed
2026-06-30 17:02:40 +00:00
a37e60eb2a fix(auth): restringe workshop/mecanico solo por servidor y cookie
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Elimina el script inline que redirigia a todos los usuarios.
- auth_bp.py: la respuesta de login ahora setea cookie pos_role.
- app.py: before_request redirige a /pos/workshop solo cuando la cookie
  indica rol workshop/mechanic (no depende de JS cacheado).
- login.js/login.min.js/app-init.js: sincronizan/limpian la cookie pos_role
  junto con el token en login y logout.

Tests: 35 passed
2026-06-30 16:57:11 +00:00
688ea8c70c fix(auth): redirige a taller y limpia SW para usuarios workshop/mechanic
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Agrega script inline en todas las plantillas (excepto login/workshop) que lee
  el rol del JWT y redirige a /pos/workshop si es workshop/mechanic.
- Desregistra el Service Worker antes de redirigir para forzar carga fresca
  de assets y evitar que menus antiguos permanezcan por cache.
- Actualiza labels de rol en auth_bp.py para taller y mecanico.

Tests: 35 passed
2026-06-30 16:46:30 +00:00
4d971dd1a4 chore(cache): bump static assets y service worker a v33
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Fuerza recarga del sidebar.js, app-init.js y workshop.js actualizados
- Soluciona que usuarios workshop/mechanic veian todos los menus por cache anterior
2026-06-30 16:41:54 +00:00
09e5bffe54 feat(migration): limpia permisos de usuarios workshop/mechanic en todos los tenants
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Agrega migracion v4.11_clean_workshop_mechanic_permissions.sql
- Aplica DELETE de permisos no relacionados con taller y reinserta los 3 permisos base
- Ejecutada en todas las bases de datos de tenant activas
2026-06-30 16:39:10 +00:00
c2db68b184 fix(roles): restringe usuarios Taller y Mecanico a solo la seccion de Taller y oculta precios
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- sidebar.js: itemAllowed incluye mechanic junto a workshop -> solo muestra Taller
- app-init.js: redirige a /pos/workshop si el rol es workshop/mechanic
- config_bp.py: remueve customers.view y fleet.view de los permisos por defecto de workshop/mechanic
- workshop.js: hidePrices ahora tambien aplica para mechanic; oculta columnas/inputs de precio
- app-init.js/config.js: agrega etiquetas de rol para workshop y mechanic
- workshop.html: marca columnas de precio con price-col y oculta input catRate

Tests: 35 passed
2026-06-30 16:36:38 +00:00
c58f622486 fix(config): agrega roles Taller y Mecanico en creacion de empleados
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Agrega opciones workshop y mechanic al select de rol en config.html
- Actualiza ROLE_LABELS y ROLE_BADGE en config.js
- Valida roles mechanic y workshop en config_bp.py con permisos por defecto
- Añade clases CSS badge--orange y badge--teal

Tests: 35 passed
2026-06-30 16:31:45 +00:00
c1e481fb79 feat(config): agrega personalización de ticket con logo y opciones
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Nuevos endpoints /pos/api/config/receipt (GET/PUT)
- Sección en config.html para subir logo, datos del negocio y toggles visuales
- pos.js carga la config y renderiza el ticket usando logo, RFC, dirección,
  teléfono, mensajes y opciones de desglose/empleado/pago configurables
- Logo se redimensiona a 300px de ancho y se guarda como base64 JPEG en tenant_config

Tests: 35 passed
2026-06-30 08:35:10 +00:00
70a600cc66 hotfix: corrige 500 en /customers y /historical-sales
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- customers_bp: last_purchase no existe como columna; se calcula via subquery a sales.
- pos_bp: historical_sales no existe en tenants sin importacion; endpoint devuelve lista vacia en lugar de 500.
- Servicio reiniciado y validado.

Tests: 35 passed
2026-06-30 08:20:04 +00:00
2bdeb2973a fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
- Arregla @require_auth, permisos, race conditions, locks de caja/stock
- Elimina N+1 en layaway, flotilla, dashboard y global_invoice
- Asegura folios atomicos para CFDI, ordenes de servicio y polizas
- Protege client_secret de MercadoLibre en backend
- Conecta botones/filtros de config, customers, accounting e invoicing
- Mejora accesibilidad (labels/aria-label) y estados de carga/vacio
- Limpia accounting.js obsoleto y consolida accounting.v9.js
- Actualiza cache busting a v32 y Service Worker a v32
- Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md

Tests: 35 passed
2026-06-29 23:54:58 +00:00
59a4893e84 fix: reporte de Clientes usa /aging-summary agrupado por cliente
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-26 00:36:50 +00:00
66d1268bfb fix: separa Cuentas por Cobrar (ventas a crédito) y Cuentas por Pagar (órdenes de compra)
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-26 00:32:46 +00:00
9a55e15142 fix: botón Historial en clientes abre modal con historial completo de compras
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-26 00:28:18 +00:00
2ed672c3c3 fix: agrega helper esc en accounting.js y placeholder para pagos a proveedor
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-26 00:21:18 +00:00
6b00e45f5d feat: detalle de cuentas por cobrar con opción de cancelar ticket
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-26 00:17:25 +00:00
0da4bcd9d9 feat: agrega botón Venta a Crédito en barra de cliente del POS
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-26 00:10:18 +00:00
eced393b66 fix: precios PZ se importan sin IVA (dividiendo entre 1.16)
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-25 23:52:52 +00:00
af82b028d8 feat: importa stock positivo del respaldo PZ a tenant La Casita
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-25 23:45:30 +00:00
d3e1660cb1 fix: formatea fechas de historial de inventario a hora local del navegador
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-25 23:29:25 +00:00
98e037c09a fix: agrega migración v4.6 con tablas categories y tier_discounts para tenant
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
2026-06-25 23:14:31 +00:00
74118a3247 Merge branch 'desarrollo_hector' into main
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
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
71f3b1cdec se hacen modificaciones de catalogo a peticion de observaciones de carlos 24052026 2026-05-24 21:13:11 -07:00
135 changed files with 16396 additions and 1696 deletions

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

4
.gitignore vendored
View File

@@ -91,3 +91,7 @@ backups/
# Local tools (AWS CLI)
tools/
# Rached migration session artifacts (tokens / captures / samples)
rached_*.json
rached_*.txt

View File

@@ -0,0 +1,140 @@
# Auditoría y Mejoras del Sistema Nexus POS — 15 de junio de 2026
## Resumen ejecutivo
Se realizó una revisión completa del sistema Nexus POS enfocada en corregir errores críticos y mayores, eliminar código muerto, cerrar brechas de seguridad menores y estandarizar la experiencia de usuario. También se aplicaron optimizaciones de rendimiento en consultas frecuentes y se mejoró la accesibilidad de los formularios.
- **Rama/base:** `main`
- **Tenant de pruebas:** `tenant_refaccionaria_la_casita` (`tenant_id = 33`)
- **Servicio:** `nexus-pos.service` (puerto 5001)
- **Estado final:** operativo, tests pasando
---
## 1. Problemas críticos corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| `@require_auth` sin paréntesis en `tasks_bp` | Se cambió al decorador correcto | `pos/blueprints/tasks_bp.py` |
| Permiso inexistente `accounting.read` | Se reemplazó por `accounting.view` | `pos/blueprints/accounting_bp.py` |
| `historical_sales.html` no cargaba `api.js` | Se incluyó el helper compartido | `pos/templates/historical_sales.html`, `pos/static/js/api.js` |
| `featureProximamente` no estaba disponible en `customers.js` | Se expuso la función en el módulo | `pos/static/js/customers.js` |
| `pos_engine` usaba `threading` sin importar y la caja sin `FOR UPDATE` | Se agregó `import threading` y lock pesimista de caja | `pos/services/pos_engine.py` |
| Race condition en stock de ventas | Se agregó `SELECT ... FOR UPDATE` sobre `inventory_stock` | `pos/services/pos_engine.py` |
---
## 2. Problemas mayores corregidos
| Problema | Solución | Archivos principales |
|----------|----------|----------------------|
| N+1 en `complete_layaway` | Se cargan todos los items en una sola consulta | `pos/services/pos_engine.py` |
| Race conditions en folios CFDI y números de póliza | Se usó `pg_advisory_xact_lock(hashtext(prefix))` | `pos/services/cfdi_queue.py`, `pos/services/accounting_engine.py` |
| N+1 en flotilla (schedules/history) | Se crearon endpoints bulk `/vehicles/schedules` y `/vehicles/history` | `pos/blueprints/fleet_bp.py`, `pos/static/js/fleet.js` |
| N+1 en dashboard (ventas recientes) y `global_invoice` | Se consolidaron consultas | `pos/blueprints/dashboard_stats_bp.py`, `pos/services/global_invoice.py` |
| `client_secret` de MercadoLibre expuesto en frontend | Se movió el flujo OAuth a backend con endpoint `/connect/init` | `pos/blueprints/marketplace_external_bp.py`, `pos/static/js/marketplace_external.js` |
| Botones/filtros sin handler en config, customers y accounting | Se conectaron eventos y handlers faltantes | `pos/static/js/config.js`, `pos/static/js/customers.js`, `pos/static/js/accounting.v9.js`, `pos/templates/*.html` |
| Botones/filtros sin handler en invoicing | Se implementaron filtros, export CSV y acciones | `pos/static/js/invoicing.js`, `pos/templates/invoicing.html` |
---
## 3. Mejoras de rendimiento
- **Locks atómicos:** los folios de órdenes de servicio, CFDI y pólizas contables ahora se generan bajo `pg_advisory_xact_lock` para evitar duplicados bajo concurrencia.
- **Consultas bulk:** flotilla y dashboard redujeron drásticamente el número de queries al backend.
- **Virtual scroll:** se mantiene en tablas grandes (clientes, programas de mantenimiento, historial) para renderizado eficiente.
- **Cache busting:** se actualizaron los query strings estáticos a `?v=32` y el Service Worker a `v32` para forzar la actualización de assets en navegadores y PWA.
---
## 4. Mejoras de UX y accesibilidad
- **Formularios accesibles:** se agregaron `aria-label`, `id` y `<label>` explícitos en:
- POS: pago mixto, toggle de costo/margen, campos de vehículo del modal de cliente.
- Clientes: búsqueda y filtros.
- Catálogo: búsqueda y filtro de niveles.
- Facturación: búsquedas de facturas, notas de crédito y complementos de pago.
- **Estados de carga consistentes:** se añadió `renderLoadingState()` en `pos-utils.js` con estilo en `pos-ui.css`, y se aplicó en:
- Clientes (`customers.js`)
- Facturación (`invoicing.js`)
- Flotilla (`fleet.js`): vehículos, programas, historial y alertas.
- **Empty states normalizados:** los errores y listas vacías ahora usan `renderEmptyState()` en lugar de mensajes inline inconsistentes.
---
## 5. Limpieza de código
- Se eliminaron las versiones obsoletas `pos/static/js/accounting.js` y `pos/static/js/accounting.min.js`.
- Se consolidó la lógica contable en `pos/static/js/accounting.v9.js`.
- Se removieron `console.log`/`console.error` de depuración de `accounting.v9.js`.
- Se limpió encabezado comentado de `accounting.v9.js`.
---
## 6. Archivos añadidos
```text
pos/migrations/v4.10_fleet_permissions.sql
pos/migrations/v4.7_workshop_business.sql
pos/migrations/v4.8_workshop_permissions.sql
pos/migrations/v4.9_workshop_customers_view.sql
pos/static/js/accounting.v9.js
pos/static/js/api.js
scripts/allocate_existing_customer_payments.py
```
---
## 7. Estadísticas del cambio
```text
54 archivos modificados
+2485 / -1300 líneas aproximadas
```
---
## 8. Validación
```bash
# Sintaxis de JavaScript
node --check pos/static/js/pos-utils.js
node --check pos/static/js/customers.js
node --check pos/static/js/invoicing.js
node --check pos/static/js/fleet.js
node --check pos/static/js/accounting.v9.js
node --check pos/static/js/pos.js
node --check pos/static/js/catalog.js
# Tests de backend
python3 -m pytest pos/tests/test_service_order_integration.py \
pos/tests/test_bulk_import.py \
pos/tests/test_facturapi_service.py -q
# Resultado: 35 passed
```
Servicio `nexus-pos.service` verificado como **activo**.
---
## 9. Instrucciones de despliegue
1. Aplicar migraciones SQL nuevas según el orden numérico:
- `v4.7_workshop_business.sql`
- `v4.8_workshop_permissions.sql`
- `v4.9_workshop_customers_view.sql`
- `v4.10_fleet_permissions.sql`
2. Reiniciar el servicio para cargar cambios de backend:
```bash
sudo systemctl restart nexus-pos.service
```
3. Refrescar la PWA en los navegadores/clientes para que `sw.js` actualice el cache a `v32`.
---
## 10. Notas para el equipo
- Los permisos de flotilla (`fleet.view/create/edit/delete`) ya están sembrados para `owner`/`admin` y `view` para `accountant`/`workshop`.
- El Service Worker se sirve correctamente en `/pos/sw.js` desde `static/pwa/sw.js`.
- El helper global `api()` fue creado para páginas standalone como `historical_sales.html`.
- No se realizaron mutaciones de git no solicitadas; el push a Gitea se hace como parte de esta entrega.

View File

@@ -1,9 +1,9 @@
# Nexus POS — Resumen de Fases Implementadas
**Fecha:** 2026-06-11
**Versión DB:** v4.1
**Tests:** 73/73 pasando (pytest)
**Commit:** `2b73c2c`
**Fecha:** 2026-06-15
**Versión DB:** v4.4
**Tests:** 92/92 pasando (pytest: 61 consola + 20 Facturapi + 11 Taller; POS requieren PostgreSQL)
**Commit:** `d678872` (HEAD + cambios sin commitear)
---
@@ -200,6 +200,9 @@ METABASE_URL=http://localhost:3000
| — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` |
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
| — | **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
@@ -247,6 +250,76 @@ METABASE_URL=http://localhost:3000
---
## 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.
---
## Mejoras Pendientes (Roadmap Actualizado)
### 🔴 Crítico — Deuda Técnica

View File

@@ -74,7 +74,7 @@ server {
}
location = / {
return 302 /pos/login;
return 302 /pos/login2;
}
location / {

View File

@@ -1,6 +1,7 @@
from flask import Flask
from flask import Flask, request, redirect, make_response
from json_provider import OrjsonProvider
def create_app():
app = Flask(__name__)
app.json = OrjsonProvider(app)
@@ -9,12 +10,20 @@ def create_app():
from middleware_tenant import resolve_tenant
app.before_request(resolve_tenant)
# NOTE: Page-level routing guards are handled client-side by app-init.js
# using the employee's current permissions; API endpoints enforce their own
# permission checks via @require_auth.
# ─── PWA: Service Worker must be served from /pos/ scope ──────
@app.route('/pos/sw.js')
def pos_sw():
from flask import send_from_directory
return send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript')
from flask import send_from_directory, make_response
response = make_response(send_from_directory('static/pwa', 'sw.js',
mimetype='application/javascript'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Register blueprints
from blueprints.auth_bp import auth_bp
@@ -124,18 +133,30 @@ def create_app():
def health():
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')
def favicon():
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
@app.route('/pos/login')
def pos_login_legacy():
# Redirect to the new login path to bypass any stale browser/SW cache
# of the old /pos/login page that did not send employee_id.
response = make_response(redirect('/pos/login2'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
@app.route('/pos/login2')
def pos_login():
return render_template('login.html',
response = make_response(render_template('login.html',
tenant_id=getattr(g, 'tenant_id', None),
tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
tenant_subdomain=getattr(g, 'tenant_subdomain', None)))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/supplier-catalog')
def supplier_catalog_page():
@@ -167,7 +188,11 @@ def create_app():
@app.route('/pos/dashboard')
def pos_dashboard():
return render_template('dashboard.html')
response = make_response(render_template('dashboard.html'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/config')
def pos_config():
@@ -181,6 +206,10 @@ def create_app():
def pos_fleet():
return render_template('fleet.html')
@app.route('/pos/workshop')
def pos_workshop():
return render_template('workshop.html')
@app.route('/pos/quotations')
def pos_quotations():
return render_template('quotations.html')
@@ -205,6 +234,14 @@ def create_app():
def pos_historical_sales():
return render_template('historical_sales.html')
@app.route('/pos/remission-notes')
def pos_remission_notes():
response = make_response(render_template('remission_notes.html'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/pos/static/<path:filename>')
def pos_static(filename):
return send_from_directory('static', filename)

View File

@@ -7,8 +7,10 @@ NUMERIC(14,2) in the database.
"""
import json
from datetime import date, datetime
from flask import Blueprint, request, jsonify, g
import csv
import io
from datetime import date, datetime, timedelta
from flask import Blueprint, request, jsonify, g, Response
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.accounting_engine import create_manual_entry
@@ -565,21 +567,140 @@ def balance_sheet():
@accounting_bp.route('/aging', methods=['GET'])
@require_auth('accounting.view')
def aging_report():
"""Antiguedad de saldos (accounts receivable aging).
"""Antiguedad de saldos.
Groups outstanding credit sales by age:
- Corriente (not yet due)
- 1-30 dias
- 31-60 dias
- 61-90 dias
- 90+ dias
Returns individual credit sales with outstanding balance when type=receivable
(default), or purchase orders payable to suppliers when type=payable.
"""
report_type = request.args.get('type', 'receivable')
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
rows = []
if report_type == 'payable':
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.id, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected_date = r[4]
status = r[5]
vendor_name = r[7]
paid = 0 # TODO: sum supplier payments when that table is added
balance = round(total - paid, 2)
if balance <= 0:
continue
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
if days_overdue > 0:
po_status = 'overdue'
label = 'Vencida'
elif paid > 0:
po_status = 'partial'
label = 'Parcial'
else:
po_status = 'pending'
label = 'Pendiente'
rows.append({
'po_id': po_id,
'invoice': invoice,
'vendor_name': vendor_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': expected_date.isoformat() if expected_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': po_status,
'status_label': label,
})
else:
# Accounts receivable: credit sales to customers
cur.execute("""
SELECT s.id, s.total, s.created_at, s.status,
c.id, c.name, c.rfc,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
for r in cur.fetchall():
sale_id = r[0]
total = float(r[1]) if r[1] else 0
created_at = r[2]
status = r[3]
customer_name = r[5]
payments_total = float(r[7]) if r[7] else 0
paid = payments_total
balance = round(total - paid, 2)
if balance <= 0:
continue
due_date = created_at + timedelta(days=30) if created_at else None
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
if days_overdue > 0:
sale_status = 'overdue'
label = 'Vencida'
elif paid > 0:
sale_status = 'partial'
label = 'Parcial'
else:
sale_status = 'pending'
label = 'Vigente'
rows.append({
'sale_id': sale_id,
'invoice': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': sale_status,
'status_label': label,
})
totals = {
'count': len(rows),
'total': round(sum(r['total'] for r in rows), 2),
'paid': round(sum(r['paid'] for r in rows), 2),
'balance': round(sum(r['balance'] for r in rows), 2),
}
cur.close()
conn.close()
return jsonify({'data': rows, 'totals': totals})
@accounting_bp.route('/aging-summary', methods=['GET'])
@require_auth('accounting.view')
def aging_summary():
"""Customer-level accounts receivable summary (used by reports)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
s.id as sale_id, s.total, s.created_at,
s.id as sale_id,
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
s.created_at,
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
FROM customers c
JOIN sales s ON s.customer_id = c.id
@@ -592,6 +713,11 @@ def aging_report():
customers = {}
for r in cur.fetchall():
cust_id = r[0]
balance = round(float(r[6]) if r[6] else 0, 2)
if balance <= 0:
continue
days = r[8] or 0
if cust_id not in customers:
customers[cust_id] = {
'id': r[0], 'name': r[1], 'rfc': r[2],
@@ -601,29 +727,25 @@ def aging_report():
'total': 0,
}
amount = float(r[6]) if r[6] else 0
days = r[8] or 0
if days <= 0:
customers[cust_id]['corriente'] += amount
customers[cust_id]['corriente'] += balance
elif days <= 30:
customers[cust_id]['d1_30'] += amount
customers[cust_id]['d1_30'] += balance
elif days <= 60:
customers[cust_id]['d31_60'] += amount
customers[cust_id]['d31_60'] += balance
elif days <= 90:
customers[cust_id]['d61_90'] += amount
customers[cust_id]['d61_90'] += balance
else:
customers[cust_id]['d90_plus'] += amount
customers[cust_id]['d90_plus'] += balance
customers[cust_id]['total'] += amount
customers[cust_id]['total'] += balance
result = list(customers.values())
# Round all amounts
for c in result:
c['credit_balance'] = round(c['total'], 2)
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
c[key] = round(c[key], 2)
# Totals row
totals = {
'corriente': round(sum(c['corriente'] for c in result), 2),
'd1_30': round(sum(c['d1_30'] for c in result), 2),
@@ -631,6 +753,7 @@ def aging_report():
'd61_90': round(sum(c['d61_90'] for c in result), 2),
'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
'total': round(sum(c['total'] for c in result), 2),
'count': len(result),
}
cur.close()
@@ -638,6 +761,161 @@ def aging_report():
return jsonify({'data': result, 'totals': totals})
@accounting_bp.route('/aging/export', methods=['GET'])
@require_auth('accounting.view')
def export_aging():
"""Export receivables or payables to CSV/PDF."""
report_type = request.args.get('type', 'receivable')
fmt = request.args.get('format', 'csv').lower()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
if report_type == 'payable':
# Payables always CSV for now
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected = r[4]
status = r[5]
vendor = r[6]
paid = 0
balance = round(total - paid, 2)
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
writer.writerow([
invoice, vendor,
created_at.strftime('%Y-%m-%d') if created_at else '',
expected.strftime('%Y-%m-%d') if expected else '',
total, paid, balance, days_overdue, status_label
])
cur.close(); conn.close()
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
)
# Receivables: CSV or PDF
cur.execute("""
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
c.name,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
rows = []
for r in cur.fetchall():
sale_id = r[0]
subtotal = float(r[1]) if r[1] else 0
tax = float(r[2]) if r[2] else 0
total = float(r[3]) if r[3] else 0
created_at = r[4]
customer_name = r[5]
payments_total = float(r[6]) if r[6] else 0
balance = round(total - payments_total, 2)
if balance <= 0:
continue
rows.append({
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
'customer': customer_name,
'subtotal': subtotal,
'tax': tax,
'total': total,
'balance': balance,
})
cur.close(); conn.close()
if fmt == 'pdf':
from fpdf import FPDF
class ReceivablesPDF(FPDF):
def header(self):
self.set_font('Arial', 'B', 14)
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
self.set_font('Arial', '', 10)
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
self.ln(4)
def footer(self):
self.set_y(-15)
self.set_font('Arial', 'I', 8)
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
pdf = ReceivablesPDF('L', 'mm', 'A4')
pdf.add_page()
pdf.set_font('Arial', 'B', 10)
pdf.set_fill_color(230, 230, 230)
col_widths = [30, 95, 35, 30, 35, 35]
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
for i, h in enumerate(headers):
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
pdf.ln()
pdf.set_font('Arial', '', 9)
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
for row in rows:
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
totals['subtotal'] += row['subtotal']
totals['tax'] += row['tax']
totals['total'] += row['total']
totals['balance'] += row['balance']
pdf.set_font('Arial', 'B', 9)
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
pdf_bytes = bytes(pdf.output(dest='S'))
return Response(
pdf_bytes,
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
)
# CSV fallback
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
for row in rows:
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
)
# ─── Fiscal Periods ────────────────────────────────
@accounting_bp.route('/periods', methods=['GET'])
@@ -744,7 +1022,7 @@ def close_period():
@accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.read')
@require_auth('accounting.view')
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)

View File

@@ -5,9 +5,10 @@ import jwt
import bcrypt
import time
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify, g
from flask import Blueprint, request, jsonify, g, make_response
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
from tenant_db import get_tenant_conn, get_master_conn
from middleware import require_auth
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
@@ -59,6 +60,7 @@ def login_pin():
# Subdomain-resolved tenant takes priority over body param
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
pin = data.get('pin', '')
employee_id = data.get('employee_id')
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
# Optional: branch_id from the device for PIN search optimization
device_branch_id = data.get('branch_id')
@@ -89,39 +91,26 @@ def login_pin():
matched_employee = None
if device_branch_id:
# Try branch employees first (fast path for known devices)
cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.is_active = true AND e.pin IS NOT NULL AND e.branch_id = %s
""", (device_branch_id,))
for emp in cur.fetchall():
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
}
break
if not employee_id:
_record_attempt(device_id, False)
cur.close()
conn.close()
return jsonify({'error': 'Empleado no seleccionado'}), 400
if not matched_employee:
# Fallback: check ALL active employees (covers owners, admins, roaming staff)
cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.is_active = true AND e.pin IS NOT NULL
""")
employees = cur.fetchall()
for emp in employees:
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
}
break
# Verify the PIN only for the selected employee.
cur.execute("""
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL
""", (employee_id,))
emp = cur.fetchone()
if emp:
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
matched_employee = {
'id': emp_id, 'name': emp_name, 'role': emp_role,
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
}
if not matched_employee:
_record_attempt(device_id, False)
@@ -157,10 +146,78 @@ def login_pin():
}
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
return jsonify({
response = make_response(jsonify({
'token': token,
'employee': matched_employee,
'permissions': permissions
}))
# Cookie used by server-side route guards; JS can also read it for quick checks.
response.set_cookie(
'pos_role', matched_employee['role'],
path='/pos', samesite='Lax', httponly=False
)
return response
@auth_bp.route('/refresh', methods=['POST'])
@require_auth()
def refresh_token():
"""Reissue the JWT with the employee's current permissions from the DB.
This lets permission changes take effect without forcing a full re-login.
The original expiration time is preserved.
"""
auth_header = request.headers.get('Authorization', '')
try:
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 401
tenant_id = payload.get('tenant_id')
employee_id = payload.get('employee_id')
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute(
"""
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
FROM employees e
WHERE e.id = %s AND e.is_active = true
""",
(employee_id,)
)
emp = cur.fetchone()
if not emp:
cur.close(); conn.close()
return jsonify({'error': 'Employee not found or inactive'}), 404
cur.execute(
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
(emp[0],)
)
permissions = [r[0] for r in cur.fetchall()]
cur.close(); conn.close()
new_payload = {
'tenant_id': tenant_id,
'employee_id': emp[0],
'name': emp[1],
'role': emp[2],
'branch_id': emp[3],
'max_discount_pct': float(emp[4]) if emp[4] else 0,
'permissions': permissions,
'device_id': payload.get('device_id', 'unknown'),
'type': 'pos_access',
'exp': payload.get('exp'),
'iat': datetime.now(timezone.utc),
}
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
return jsonify({
'token': token,
'employee': {
'id': emp[0], 'name': emp[1], 'role': emp[2],
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
},
'permissions': permissions
})
@@ -197,7 +254,7 @@ def list_login_employees(tenant_id=None):
name = row[1]
parts = name.split()
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
employees.append({
'id': row[0],
'name': name,

View File

@@ -3,16 +3,26 @@
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.audit import log_action
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
# Roles expected to operate a cash register even without the explicit pos.sell permission.
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
def _can_operate_register():
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
@cashregister_bp.route('/open', methods=['POST'])
@require_auth('pos.sell')
@require_auth()
def open_register():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Open a cash register session.
Body: {register_number: int, opening_amount: float}
@@ -84,7 +94,7 @@ def open_register():
@cashregister_bp.route('/current', methods=['GET'])
@require_auth('pos.sell')
@require_auth()
def current_register():
"""Get the current open register for this employee."""
conn = get_tenant_conn(g.tenant_id)
@@ -115,8 +125,10 @@ def current_register():
@cashregister_bp.route('/movement', methods=['POST'])
@require_auth('pos.sell')
@require_auth()
def cash_movement():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Record a cash in/out movement with mandatory reason.
Body: {type: 'in'|'out', amount: float, reason: str}
@@ -273,8 +285,10 @@ def _compute_register_summary(conn, register_id):
@cashregister_bp.route('/cut-x', methods=['GET'])
@require_auth('pos.sell')
@require_auth()
def cut_x():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Partial cut (corte X): read-only summary without closing the register."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -301,8 +315,10 @@ def cut_x():
@cashregister_bp.route('/cut-z', methods=['POST'])
@require_auth('pos.sell')
@require_auth()
def cut_z():
if not _can_operate_register():
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
"""Final cut (corte Z): close the register.
Body: {closing_amount: float} (the amount physically counted in the register)
@@ -379,12 +395,18 @@ def cut_z():
@cashregister_bp.route('/history', methods=['GET'])
@require_auth('pos.view')
@require_auth()
def register_history():
"""List closed registers with summary.
Query params: date_from, date_to, employee_id, page, per_page
Permission rules:
- owner/admin and users with pos.view can query any employee.
- Cashiers/counters without pos.view can only query their own registers.
"""
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -402,6 +424,9 @@ def register_history():
date_to = request.args.get('date_to')
employee_id = request.args.get('employee_id')
if not can_view_all:
employee_id = g.employee_id
if date_from:
where_clauses.append("cr.closed_at >= %s")
params.append(date_from)
@@ -576,3 +601,89 @@ def daily_summary():
'movements_out': movements['out'],
'registers': registers,
})
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
@require_auth()
def register_sales(register_id):
"""List the sales associated with a specific cash register (cash cut).
Returns sales details and a summary by payment method. Accessible to
owners/admins, users with pos.view, or the employee who operated the register.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
(register_id,)
)
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Register not found'}), 404
register_employee_id, register_branch_id = row
can_view = (
g.employee_role in ('owner', 'admin') or
has_permission('pos.view') or
register_employee_id == g.employee_id
)
if not can_view:
cur.close(); conn.close()
return jsonify({'error': 'Missing permissions: pos.view'}), 403
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
cur.close(); conn.close()
return jsonify({'error': 'Register belongs to another branch'}), 403
where = "s.register_id = %s"
params = [register_id]
if g.branch_id:
where += " AND s.branch_id = %s"
params.append(g.branch_id)
cur.execute(f"""
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
s.created_at, c.name as customer_name, e.name as employee_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN employees e ON s.employee_id = e.id
WHERE {where}
ORDER BY s.created_at DESC
""", params)
sales = []
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
for r in cur.fetchall():
sale = {
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
'subtotal': float(r[3]) if r[3] else 0,
'discount_total': float(r[4]) if r[4] else 0,
'tax_total': float(r[5]) if r[5] else 0,
'total': float(r[6]) if r[6] else 0,
'amount_paid': float(r[7]) if r[7] else 0,
'change_given': float(r[8]) if r[8] else 0,
'status': r[9], 'created_at': str(r[10]),
'customer_name': r[11], 'employee_name': r[12]
}
sales.append(sale)
if sale['status'] == 'completed':
summary['total'] += sale['total']
summary['count'] += 1
m = sale['payment_method'] or 'Otro'
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
cur.close(); conn.close()
return jsonify({
'register_id': register_id,
'sales': sales,
'summary': {
'total': round(summary['total'], 2),
'count': summary['count'],
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
}
})

View File

@@ -150,12 +150,18 @@ def models():
@catalog_bp.route('/years', methods=['GET'])
@require_auth('catalog.view')
def years():
model_id = request.args.get('model_id', type=int)
if not model_id:
model_id_param = request.args.get('model_id', '')
if not model_id_param:
return jsonify({'error': 'model_id required'}), 400
try:
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_id, mye_ids=mye_ids)
data = catalog_service.get_years(master, model_ids, mye_ids=mye_ids)
return jsonify({'data': data})
return _with_conns(_do)
@@ -176,13 +182,19 @@ def years_all():
@catalog_bp.route('/engines', methods=['GET'])
@require_auth('catalog.view')
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)
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
try:
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_id, year_id, mye_ids=mye_ids)
data = catalog_service.get_engines(master, model_ids, year_id, mye_ids=mye_ids)
return jsonify({'data': data})
return _with_conns(_do)

View File

@@ -1,6 +1,7 @@
# /home/Autopartes/pos/blueprints/config_bp.py
"""Config blueprint: tenant configuration, branches, theming."""
import json
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
@@ -8,6 +9,188 @@ from tenant_db import get_tenant_conn
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
# Default permission set per role. Can be overridden per tenant via role_permissions config.
_DEFAULT_ROLE_PERMISSIONS = {
'owner': [], # owner bypasses permission checks
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices',
'workshop.view', 'workshop.edit',
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create',
'workshop.view', 'workshop.edit', 'workshop.add_items',
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
'counter': ['pos.remission', 'pos.view',
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view',
'fleet.view'],
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
'mechanic': ['workshop.view'],
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
'customers.view', 'customers.create'],
}
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
_WORKSHOP_STATUSES = [
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
'por_entregar', 'entregado', 'por_enviar', 'enviado',
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
]
# Workshop-specific actions configurable per role.
_WORKSHOP_ACTIONS = [
{'key': 'create_order', 'label': 'Crear órdenes'},
{'key': 'edit_order', 'label': 'Editar órdenes'},
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
{'key': 'add_items', 'label': 'Agregar artículos'},
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
{'key': 'change_status', 'label': 'Cambiar estatus'},
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
{'key': 'view_prices', 'label': 'Ver precios/costos'},
{'key': 'view_notes', 'label': 'Ver bitácora'},
]
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
_DEFAULT_WORKSHOP_PERMISSIONS = {
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
'workshop': {
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
'actions': ['change_status', 'add_labor', 'view_notes']
},
'mechanic': {
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
'actions': ['change_status', 'add_labor', 'view_notes']
},
}
_AVAILABLE_PERMISSIONS = [
{'module': 'Dashboard', 'permissions': [
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
]},
{'module': 'Punto de Venta', 'permissions': [
{'key': 'pos.sell', 'label': 'Realizar ventas'},
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
{'key': 'pos.remission', 'label': 'Notas de remisión'},
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
]},
{'module': 'Inventario', 'permissions': [
{'key': 'inventory.view', 'label': 'Ver inventario'},
{'key': 'inventory.create', 'label': 'Crear artículos'},
{'key': 'inventory.edit', 'label': 'Editar artículos'},
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
]},
{'module': 'Catálogo', 'permissions': [
{'key': 'catalog.view', 'label': 'Ver catálogo'},
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
]},
{'module': 'Clientes', 'permissions': [
{'key': 'customers.view', 'label': 'Ver clientes'},
{'key': 'customers.create', 'label': 'Crear clientes'},
{'key': 'customers.edit', 'label': 'Editar clientes'},
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
]},
{'module': 'Taller', 'permissions': [
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
]},
{'module': 'Facturación', 'permissions': [
{'key': 'invoicing.view', 'label': 'Ver facturas'},
{'key': 'invoicing.create', 'label': 'Crear facturas'},
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
]},
{'module': 'Configuración', 'permissions': [
{'key': 'config.view', 'label': 'Ver configuración'},
{'key': 'config.edit', 'label': 'Editar configuración'},
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
]},
{'module': 'Contabilidad', 'permissions': [
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
]},
{'module': 'Reportes', 'permissions': [
{'key': 'reports.view', 'label': 'Ver reportes'},
{'key': 'reports.financial', 'label': 'Reportes financieros'},
]},
{'module': 'Flotillas', 'permissions': [
{'key': 'fleet.view', 'label': 'Ver flotillas'},
{'key': 'fleet.create', 'label': 'Crear flotillas'},
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
]},
]
def _get_role_permissions(conn, role):
"""Return configured permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return list(configured.get(role, []))
except (ValueError, TypeError):
pass
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
def _get_workshop_permissions(conn, role):
"""Return configured workshop permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return configured[role]
except (ValueError, TypeError):
pass
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
def _get_all_workshop_permissions(conn):
"""Return effective workshop permissions for every known role."""
result = {}
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
result[role] = _get_workshop_permissions(conn, role)
return result
@config_bp.route('/branches', methods=['GET'])
@require_auth()
def list_branches():
@@ -150,8 +333,58 @@ def update_branch(branch_id):
return jsonify({'ok': True, 'message': 'Branch updated'})
@config_bp.route('/branches/<int:branch_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_branch(branch_id):
"""Hard-delete a branch. Only owner/admin can delete; main branch cannot be deleted.
Related records keep their data but lose the branch reference; stock and count
rows tied exclusively to the branch are removed.
"""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar sucursales'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT is_main FROM branches WHERE id = %s", (branch_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Branch not found'}), 404
if row[0]:
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar la sucursal principal'}), 403
# Remove branch-specific stock and count rows first.
cur.execute("DELETE FROM inventory_stock WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM inventory_stock_summary WHERE branch_id = %s", (branch_id,))
cur.execute("DELETE FROM physical_counts WHERE branch_id = %s", (branch_id,))
# Nullify every other FK reference back to branches.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.branches'::regclass
AND con.contype = 'f'
""")
for tbl, col in cur.fetchall():
if tbl in ('inventory_stock', 'inventory_stock_summary', 'physical_counts'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (branch_id,))
cur.execute("DELETE FROM branches WHERE id = %s", (branch_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Sucursal eliminada'})
@config_bp.route('/employees', methods=['GET'])
@require_auth('config.view')
@require_auth()
def list_employees():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -200,7 +433,7 @@ def create_employee():
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
@@ -215,26 +448,8 @@ def create_employee():
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
emp_id = cur.fetchone()[0]
# Set default permissions by role
role_permissions = {
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
'catalog.view', 'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view'],
}
for perm in role_permissions.get(data['role'], []):
# Set default permissions by role (configurable per tenant)
for perm in _get_role_permissions(conn, data['role']):
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
@@ -250,6 +465,105 @@ def create_employee():
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
@config_bp.route('/role-permissions', methods=['GET'])
@require_auth()
def get_role_permissions_config():
"""Return the configured permissions for each role and the available permission list."""
conn = get_tenant_conn(g.tenant_id)
try:
configured = {}
for role in _DEFAULT_ROLE_PERMISSIONS:
configured[role] = _get_role_permissions(conn, role)
return jsonify({
'roles': configured,
'available': _AVAILABLE_PERMISSIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions', methods=['PUT'])
@require_auth('config.edit')
def save_role_permissions_config():
"""Save the permission mapping per role. Only owner/admin can edit."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, perms in data['roles'].items():
if role not in _DEFAULT_ROLE_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(perms, list):
return jsonify({'error': f'permissions for {role} must be a list'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
# Apply the new permissions to existing employees of each affected role
for role, perms in data['roles'].items():
cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,))
cur.execute("SELECT id FROM employees WHERE role = %s", (role,))
emp_ids = [r[0] for r in cur.fetchall()]
for emp_id in emp_ids:
for perm in perms:
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/role-permissions/workshop', methods=['GET'])
@require_auth('config.view')
def get_workshop_permissions_config():
"""Return the configured workshop permissions (statuses + actions) per role."""
conn = get_tenant_conn(g.tenant_id)
try:
return jsonify({
'roles': _get_all_workshop_permissions(conn),
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
'actions': _WORKSHOP_ACTIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
@require_auth('config.edit')
def save_workshop_permissions_config():
"""Save the workshop permission mapping per role."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, cfg in data['roles'].items():
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(cfg, dict):
return jsonify({'error': f'config for {role} must be an object'}), 400
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
@require_auth('config.edit')
def update_employee(emp_id):
@@ -295,6 +609,15 @@ def update_employee(emp_id):
params.append(emp_id)
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
# If the role changed, re-sync permissions to match the new role defaults/config.
if 'role' in data:
cur.execute("DELETE FROM employee_permissions WHERE employee_id = %s", (emp_id,))
for perm in _get_role_permissions(conn, data['role']):
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
from services.audit import log_action
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
new_value={k: v for k, v in data.items() if k != 'pin'})
@@ -305,6 +628,53 @@ def update_employee(emp_id):
return jsonify({'ok': True, 'message': 'Employee updated'})
@config_bp.route('/employees/<int:emp_id>', methods=['DELETE'])
@require_auth('config.edit')
def delete_employee(emp_id):
"""Hard-delete an employee. Foreign-key references are nulled and
dependent rows (permissions/sessions) are removed. Owners cannot be
deleted via UI to prevent locking out the tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT role FROM employees WHERE id = %s", (emp_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Employee not found'}), 404
if row[0] == 'owner':
cur.close(); conn.close()
return jsonify({'error': 'No se puede eliminar una cuenta de dueno'}), 403
# Resolve every foreign-key column that points back to employees.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.employees'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
if tbl in ('employee_permissions', 'employee_sessions', 'notification_preferences'):
cur.execute(f"DELETE FROM \"{tbl}\" WHERE \"{col}\" = %s", (emp_id,))
else:
cur.execute(f"UPDATE \"{tbl}\" SET \"{col}\" = NULL WHERE \"{col}\" = %s", (emp_id,))
cur.execute("DELETE FROM employees WHERE id = %s", (emp_id,))
from services.audit import log_action
log_action(conn, 'EMPLOYEE_DELETE', 'employee', emp_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Empleado eliminado'})
@config_bp.route('/currency', methods=['GET'])
@require_auth()
def get_currency():
@@ -389,7 +759,14 @@ def get_business():
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
'rfc': cfg.get('tenant_rfc', ''),
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
'cp': cfg.get('tenant_cp', ''),
'direccion': cfg.get('tenant_direccion', ''),
'numero_exterior': cfg.get('tenant_numero_exterior', ''),
'numero_interior': cfg.get('tenant_numero_interior', ''),
'colonia': cfg.get('tenant_colonia', ''),
'ciudad': cfg.get('tenant_ciudad', ''),
'municipio': cfg.get('tenant_municipio', ''),
'estado': cfg.get('tenant_estado', ''),
'telefono': cfg.get('tenant_telefono', ''),
'email': cfg.get('tenant_email', ''),
})
@@ -405,14 +782,22 @@ def update_business():
'nombre': 'tenant_nombre',
'rfc': 'tenant_rfc',
'regimen_fiscal': 'tenant_regimen_fiscal',
'cp': 'tenant_cp',
'direccion': 'tenant_direccion',
'numero_exterior': 'tenant_numero_exterior',
'numero_interior': 'tenant_numero_interior',
'colonia': 'tenant_colonia',
'ciudad': 'tenant_ciudad',
'municipio': 'tenant_municipio',
'estado': 'tenant_estado',
'telefono': 'tenant_telefono',
'email': 'tenant_email',
# Tax params
# Tax params (also keep cfdi_* aliases in sync)
'tax_iva': 'tax_iva',
'tax_ieps': 'tax_ieps',
'invoice_serie': 'invoice_serie',
'invoice_folio': 'invoice_folio',
'cfdi_serie': 'cfdi_serie',
'default_currency': 'default_currency',
'default_payment_method': 'default_payment_method',
}
@@ -663,7 +1048,7 @@ def update_whatsapp_config():
@config_bp.route('/modules', methods=['GET'])
@require_auth('config.view')
@require_auth()
def get_modules():
"""Get enabled modules for this tenant."""
conn = get_tenant_conn(g.tenant_id)
@@ -681,6 +1066,7 @@ def get_modules():
'marketplace': _bool('module_marketplace'),
'meli': _bool('module_meli'),
'catalog': _bool('module_catalog'),
'workshop': _bool('module_workshop'),
})
@@ -697,6 +1083,7 @@ def update_modules():
'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',
'module_workshop': 'true' if data.get('workshop') else 'false',
}
for key, value in settings.items():
@@ -713,9 +1100,78 @@ def update_modules():
'whatsapp': data.get('whatsapp'),
'marketplace': data.get('marketplace'),
'meli': data.get('meli'),
'catalog': data.get('catalog'),
'workshop': data.get('workshop'),
}})
@config_bp.route('/counter-remission', methods=['GET'])
@require_auth()
def get_counter_remission_config():
"""Get counter remission note feature flag for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
row = cur.fetchone()
cur.close(); conn.close()
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
@config_bp.route('/counter-remission', methods=['PUT'])
@require_auth('config.edit')
def update_counter_remission_config():
"""Enable/disable counter remission notes for this tenant."""
data = request.get_json() or {}
enabled = 'true' if data.get('enabled') else 'false'
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('counter_remission_enabled', enabled))
conn.commit()
cur.close(); conn.close()
return jsonify({'enabled': enabled == 'true'})
@config_bp.route('/sales-settings', methods=['GET'])
@require_auth('pos.view')
def get_sales_settings():
"""Get sales-related settings (zero-price sales, negative stock)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')")
rows = {k: v for k, v in cur.fetchall()}
cur.close(); conn.close()
# Default allow_zero_price_sales to true to avoid breaking existing tenants.
allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes')
# Default allow_negative_stock to false (safer).
neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes')
return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg})
@config_bp.route('/sales-settings', methods=['PUT'])
@require_auth('config.edit')
def update_sales_settings():
"""Update sales-related settings."""
data = request.get_json() or {}
allow = 'true' if data.get('allow_zero_price_sales') else 'false'
neg = 'true' if data.get('allow_negative_stock') else 'false'
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('allow_zero_price_sales', allow))
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", ('allow_negative_stock', neg))
conn.commit()
cur.close(); conn.close()
return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'})
@config_bp.route('/onboarding-status', methods=['GET'])
@require_auth('pos.view')
def get_onboarding_status():
@@ -745,3 +1201,102 @@ def set_onboarding_status():
cur.close()
conn.close()
return jsonify({'completed': completed == 'true'})
# ─── Receipt / Ticket Customization ────────────────────────────────────────
RECEIPT_CONFIG_KEYS = [
'receipt_logo',
'receipt_store_name',
'receipt_tagline',
'receipt_rfc',
'receipt_address',
'receipt_phone',
'receipt_footer',
'receipt_thanks_message',
'receipt_show_logo',
'receipt_show_rfc',
'receipt_show_address',
'receipt_show_phone',
'receipt_show_iva_breakdown',
'receipt_show_payment_details',
'receipt_show_employee',
'receipt_paper_width',
]
@config_bp.route('/receipt', methods=['GET'])
@require_auth('pos.view')
def get_receipt_config():
"""Get receipt customization settings."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
(RECEIPT_CONFIG_KEYS,)
)
rows = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
conn.close()
def _bool(key, default=False):
v = rows.get(key, 'true' if default else 'false')
return str(v).lower() == 'true'
return jsonify({
'logo': rows.get('receipt_logo', ''),
'store_name': rows.get('receipt_store_name', ''),
'tagline': rows.get('receipt_tagline', ''),
'rfc': rows.get('receipt_rfc', ''),
'address': rows.get('receipt_address', ''),
'phone': rows.get('receipt_phone', ''),
'footer': rows.get('receipt_footer', ''),
'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'),
'show_logo': _bool('receipt_show_logo', True),
'show_rfc': _bool('receipt_show_rfc', True),
'show_address': _bool('receipt_show_address', False),
'show_phone': _bool('receipt_show_phone', False),
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
'show_payment_details': _bool('receipt_show_payment_details', True),
'show_employee': _bool('receipt_show_employee', False),
'paper_width': rows.get('receipt_paper_width', '80') or '80',
})
@config_bp.route('/receipt', methods=['PUT'])
@require_auth('config.edit')
def update_receipt_config():
"""Update receipt customization settings."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
settings = {
'receipt_logo': data.get('logo', ''),
'receipt_store_name': data.get('store_name', ''),
'receipt_tagline': data.get('tagline', ''),
'receipt_rfc': data.get('rfc', ''),
'receipt_address': data.get('address', ''),
'receipt_phone': data.get('phone', ''),
'receipt_footer': data.get('footer', ''),
'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'),
'receipt_show_logo': 'true' if data.get('show_logo') else 'false',
'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false',
'receipt_show_address': 'true' if data.get('show_address') else 'false',
'receipt_show_phone': 'true' if data.get('show_phone') else 'false',
'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false',
'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false',
'receipt_show_employee': 'true' if data.get('show_employee') else 'false',
'receipt_paper_width': str(data.get('paper_width', '80') or '80'),
}
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': 'Receipt configuration updated'})

View File

@@ -10,10 +10,20 @@ from services.audit import log_action
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
def _can_view_customers():
"""Cashiers, counter and workshop employees need customer access for POS/service flows."""
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
def _can_create_customer():
"""Cashiers, counter and workshop employees can create customers on the fly."""
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
# ─── Customer CRUD ─────────────────────────────
@customers_bp.route('', methods=['GET'])
@require_auth('customers.view')
@require_auth()
def list_customers():
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
@@ -23,6 +33,8 @@ def list_customers():
per_page: items per page (default 50, max 200)
branch_id: filter by branch (default: current user's branch)
"""
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -30,10 +42,24 @@ def list_customers():
per_page = min(int(request.args.get('per_page', 50)), 200)
search = request.args.get('q', '').strip()
branch_id = request.args.get('branch_id')
price_tier = request.args.get('price_tier', '').strip()
status = request.args.get('status', '').strip().lower()
where_clauses = ["c.is_active = true"]
where_clauses = []
params = []
if status == 'inactive':
where_clauses.append("c.is_active = false")
elif status == 'overdue':
where_clauses.append(
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
)
elif status == 'all':
pass # no is_active filter
else:
# Default to active customers for backwards compatibility
where_clauses.append("c.is_active = true")
if branch_id:
where_clauses.append("c.branch_id = %s")
params.append(int(branch_id))
@@ -42,8 +68,20 @@ def list_customers():
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
)
params.extend([f'%{search}%'] * 4)
if price_tier:
# Support numeric tier or Spanish labels
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
tier_val = tier_map.get(price_tier.lower())
if tier_val is None:
try:
tier_val = int(price_tier)
except ValueError:
tier_val = None
if tier_val in (1, 2, 3):
where_clauses.append("c.price_tier = %s")
params.append(tier_val)
where = " AND ".join(where_clauses)
where = " AND ".join(where_clauses) if where_clauses else "true"
# Count
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
@@ -54,7 +92,8 @@ def list_customers():
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
c.address, c.cp,
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
c.branch_id
c.branch_id, c.is_active, c.created_at,
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
FROM customers c
WHERE {where}
ORDER BY c.name
@@ -71,6 +110,9 @@ def list_customers():
'credit_balance': float(r[10]) if r[10] else 0,
'vehicle_info': r[11],
'branch_id': r[12],
'is_active': r[13],
'created_at': str(r[14]) if r[14] else None,
'last_purchase': str(r[15]) if r[15] else None,
})
cur.close()
@@ -84,8 +126,10 @@ def list_customers():
@customers_bp.route('/<int:customer_id>', methods=['GET'])
@require_auth('customers.view')
@require_auth()
def get_customer(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer details with credit info, vehicle history, and recent purchases."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -135,19 +179,71 @@ def get_customer(customer_id):
float(customer['credit_limit']) - float(customer['credit_balance']), 2
)
# Fleet vehicles assigned to this customer
cur.execute("""
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
FROM fleet_vehicles
WHERE customer_id = %s
ORDER BY is_active DESC, created_at DESC
""", (customer_id,))
customer['fleet_vehicles'] = []
for r in cur.fetchall():
customer['fleet_vehicles'].append({
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
})
cur.close()
conn.close()
return jsonify(customer)
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
@require_auth()
def get_customer_purchases(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Return full purchase history for a customer."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
e.name as employee_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
WHERE s.customer_id = %s
ORDER BY s.created_at DESC
""", (customer_id,))
purchases = []
for r in cur.fetchall():
purchases.append({
'id': r[0],
'total': float(r[1]) if r[1] else 0,
'payment_method': r[2],
'sale_type': r[3],
'status': r[4],
'created_at': str(r[5]),
'employee_name': r[6],
})
cur.close()
conn.close()
return jsonify({'data': purchases})
@customers_bp.route('', methods=['POST'])
@require_auth('customers.create')
@require_auth()
def create_customer():
"""Create a new customer.
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
phone, address, price_tier, credit_limit, vehicle_info}
"""
if not _can_create_customer():
return jsonify({'error': 'Missing permissions: customers.create'}), 403
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'name is required'}), 400
@@ -161,8 +257,9 @@ def create_customer():
cur.execute("""
INSERT INTO customers
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, vehicle_info)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
cp, email, phone, address, price_tier, credit_limit,
max_discount_pct, vehicle_info)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
branch_id, data['name'], data.get('rfc'), data.get('razon_social'),
@@ -170,6 +267,7 @@ def create_customer():
data.get('cp'), data.get('email'), data.get('phone'),
data.get('address'), data.get('price_tier', 1),
data.get('credit_limit', 0),
data.get('max_discount_pct', 0),
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
))
customer_id = cur.fetchone()[0]
@@ -238,9 +336,57 @@ def update_customer(customer_id):
return jsonify({'message': 'Customer updated'})
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
@require_auth('customers.delete')
def delete_customer(customer_id):
"""Hard-delete a customer.
Related records (sales, service orders, vehicles, etc.) keep their data but
lose the customer reference. Layaways for this customer are removed because
they require a customer_id.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
# Remove layaway dependencies first, then the layaways themselves
cur.execute("DELETE FROM layaway_items WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaway_payments WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
cur.execute("DELETE FROM layaways WHERE customer_id = %s", (customer_id,))
# Nullify every other FK reference back to customers.
cur.execute("""
SELECT c.relname::text AS tbl, a.attname::text AS col
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
WHERE con.confrelid = 'public.customers'::regclass
AND con.contype = 'f'
""")
refs = cur.fetchall()
for tbl, col in refs:
# Cascade-delete tables and already-handled layaways are skipped.
if tbl in ('customer_activities', 'customer_tag_assignments', 'loyalty_points', 'loyalty_redemptions', 'layaways'):
continue
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (customer_id,))
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
log_action(conn, 'CUSTOMER_DELETE', 'customer', customer_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Cliente eliminado'})
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
@require_auth('customers.view')
@require_auth()
def customer_statement(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Account statement: sales (invoices), payments, running balance.
Query params:
@@ -339,8 +485,10 @@ def customer_statement(customer_id):
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
@require_auth('customers.view')
@require_auth()
def customer_vehicles(customer_id):
if not _can_view_customers():
return jsonify({'error': 'Missing permissions: customers.view'}), 403
"""Get customer's vehicle list with last purchases per vehicle.
Vehicle info is stored as JSONB in customers.vehicle_info:
@@ -437,6 +585,32 @@ def record_customer_payment(customer_id):
UPDATE customers SET credit_balance = %s WHERE id = %s
""", (new_balance, customer_id))
# Allocate the customer payment to oldest unpaid credit sales so the
# aging report and per-sale balances reflect the remaining debt.
remaining = amount
cur.execute("""
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s
AND s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
ORDER BY s.created_at
""", (customer_id,))
for sale_id, balance in cur.fetchall():
if remaining <= 0:
break
pay = round(min(remaining, float(balance)), 2)
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, pay,
f'Abono cliente #{customer_id}'))
remaining = round(remaining - pay, 2)
# Record cash movement on register if cash payment
if register_id and payment_method == 'efectivo':
cur.execute("""

View File

@@ -118,3 +118,70 @@ def get_employee_stats():
finally:
cur.close()
conn.close()
@dashboard_stats_bp.route('/credit-alerts', methods=['GET'])
@require_auth()
def credit_alerts():
"""Credit sales that are overdue or due within the next 7 days."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
SELECT s.id,
c.name as customer_name,
s.total,
s.created_at,
s.created_at + INTERVAL '30 days' as due_date,
COALESCE(SUM(sp.amount), 0) as paid
FROM sales s
JOIN customers c ON c.id = s.customer_id
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, c.name, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days'
ORDER BY due_date
LIMIT 50
""")
rows = []
now = datetime.utcnow()
for r in cur.fetchall():
sale_id = r[0]
customer_name = r[1]
total = float(r[2]) if r[2] else 0
paid = float(r[5]) if r[5] else 0
balance = round(total - paid, 2)
created_at = r[3]
due_date = r[4]
days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None
if days_until_due is None:
continue
status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current')
label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente')
rows.append({
'sale_id': sale_id,
'folio': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'days_until_due': days_until_due,
'total': total,
'paid': paid,
'balance': balance,
'status': status,
'status_label': label,
})
return jsonify({
'data': rows,
'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'),
'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'),
})
finally:
cur.close()
conn.close()

View File

@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
# ─── Vehicles CRUD ─────────────────────────────
@fleet_bp.route('/vehicles', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def list_vehicles():
"""List fleet vehicles with pagination and search.
@@ -83,7 +83,7 @@ def list_vehicles():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def get_vehicle(vehicle_id):
"""Vehicle detail with maintenance schedules and recent logs."""
conn = get_tenant_conn(g.tenant_id)
@@ -155,16 +155,128 @@ def get_vehicle(vehicle_id):
return jsonify(vehicle)
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
@require_auth('fleet.view')
def list_all_schedules():
"""Return all active maintenance schedules joined with vehicle data.
Replaces the N+1 pattern of fetching schedules per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
where_clauses = ["v.is_active = true", "s.is_active = true"]
params = []
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
s.interval_months, s.last_done_at, s.last_done_km,
s.next_due_at, s.next_due_km, s.notes,
v.plate, v.make, v.model, v.current_mileage, v.color
FROM fleet_maintenance_schedules s
JOIN fleet_vehicles v ON v.id = s.vehicle_id
WHERE {where}
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
""", params)
schedules = []
for r in cur.fetchall():
schedules.append({
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
'interval_km': r[3], 'interval_months': r[4],
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
'notes': r[9],
'vehicle': {
'plate': r[10], 'make': r[11], 'model': r[12],
'current_mileage': r[13], 'color': r[14]
}
})
cur.close(); conn.close()
return jsonify({'data': schedules})
@fleet_bp.route('/vehicles/history', methods=['GET'])
@require_auth('fleet.view')
def list_all_history():
"""Return recent maintenance logs for all vehicles joined with vehicle data.
Replaces the N+1 pattern of fetching logs per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
limit = min(int(request.args.get('limit', 200)), 500)
where_clauses = ["v.is_active = true"]
params = [limit]
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
e.name as employee_name,
v.plate, v.make, v.model, v.color
FROM fleet_maintenance_logs l
JOIN fleet_vehicles v ON v.id = l.vehicle_id
LEFT JOIN employees e ON l.employee_id = e.id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT %s
""", params)
logs = []
for r in cur.fetchall():
logs.append({
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
'maintenance_type': r[3], 'mileage_at': r[4],
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
'employee_name': r[9],
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
})
cur.close(); conn.close()
return jsonify({'data': logs})
@fleet_bp.route('/vehicles', methods=['POST'])
@require_auth()
@require_auth('fleet.create')
def create_vehicle():
"""Create a fleet vehicle.
Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
Body: {customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
Brand and model are required; plate is optional; VIN is not used.
"""
data = request.get_json() or {}
if not data.get('plate') and not data.get('vin'):
return jsonify({'error': 'plate or vin is required'}), 400
if not data.get('make') or not data.get('model'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
branch_id = data.get('branch_id', g.branch_id)
@@ -172,17 +284,23 @@ def create_vehicle():
cur = conn.cursor()
try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute("""
INSERT INTO fleet_vehicles
(branch_id, plate, vin, make, model, year,
(branch_id, customer_id, plate, make, model, year,
current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
branch_id, data.get('plate'), data.get('vin'),
branch_id, data['customer_id'], data.get('plate'),
data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), data.get('owner_name'), data.get('notes'),
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'),
))
vehicle_id = cur.fetchone()[0]
@@ -201,7 +319,7 @@ def create_vehicle():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
@require_auth()
@require_auth('fleet.edit')
def update_vehicle(vehicle_id):
"""Update vehicle fields including mileage.
@@ -245,7 +363,7 @@ def update_vehicle(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
@require_auth()
@require_auth('fleet.delete')
def deactivate_vehicle(vehicle_id):
"""Soft-delete: set is_active = false."""
conn = get_tenant_conn(g.tenant_id)
@@ -266,7 +384,7 @@ def deactivate_vehicle(vehicle_id):
# ─── Maintenance Schedules ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def list_schedules(vehicle_id):
"""Maintenance schedules for a vehicle."""
conn = get_tenant_conn(g.tenant_id)
@@ -299,7 +417,7 @@ def list_schedules(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
@require_auth()
@require_auth('fleet.create')
def create_schedule(vehicle_id):
"""Create maintenance schedule for a vehicle.
@@ -344,7 +462,7 @@ def create_schedule(vehicle_id):
# ─── Maintenance Logs ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
@require_auth()
@require_auth('fleet.create')
def record_maintenance(vehicle_id):
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
@@ -427,7 +545,7 @@ def record_maintenance(vehicle_id):
# ─── Alerts ─────────────────────────────
@fleet_bp.route('/alerts', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def fleet_alerts():
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
conn = get_tenant_conn(g.tenant_id)
@@ -472,7 +590,7 @@ def fleet_alerts():
# ─── Stats ─────────────────────────────
@fleet_bp.route('/stats', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def fleet_stats():
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
conn = get_tenant_conn(g.tenant_id)

View File

@@ -103,8 +103,13 @@ def list_items():
# branch_id no longer filters inventory rows (shared catalog).
# It is used only to show per-branch stock.
if search:
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
# Search also matches alternate / alias SKUs.
where_clauses.append(
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
)
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
if category:
where_clauses.append("i.category_id = %s")
params.append(int(category))
@@ -421,7 +426,7 @@ def bulk_import_items():
'skip' ignores missing compat; 'reject' requires all compat.
Expected CSV columns (case-insensitive):
sku/part_number, name, brand, price, stock, cost,
location, description, category, make, model, year, engine, engine_code
sku_secondary, description, category, make, model, year, engine, engine_code
Optional compat columns: make, model, year, engine, engine_code
"""
from services.qwen_fitment import get_vehicle_fitment
@@ -445,8 +450,17 @@ def bulk_import_items():
try:
ext = os.path.splitext(file.filename)[1].lower()
if ext == '.csv':
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
reader = csv.DictReader(stream)
raw = file.stream.read()
decoded = None
for enc in ('utf-8-sig', 'cp1252', 'latin-1'):
try:
decoded = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if decoded is None:
return jsonify({'error': 'Unable to detect CSV encoding. Please save the file as UTF-8.'}), 400
reader = csv.DictReader(io.StringIO(decoded))
rows = list(reader)
elif ext in ('.xls', '.xlsx', '.xlsm'):
try:
@@ -490,6 +504,7 @@ def bulk_import_items():
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
'ubicacion': 'location', 'categoria': 'category',
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
'modelo': 'model', 'anio': 'year', 'ano': 'year',
'motor': 'engine', 'codigo_motor': 'engine_code',
@@ -520,6 +535,10 @@ def bulk_import_items():
db_name = db_name_row[0] if db_name_row else None
mcur.close(); mconn.close()
# Pre-fetch category name -> id mapping
cur.execute("SELECT id, name FROM categories")
category_map = {str(r[1]).strip().lower(): r[0] for r in cur.fetchall()}
for row_num, row in enumerate(rows, start=1):
part_number = str(row.get('part_number', '')).strip()
name = str(row.get('name', '')).strip()
@@ -548,7 +567,10 @@ def bulk_import_items():
cost = _to_decimal(row.get('cost'), 0)
location = str(row.get('location', '')).strip()
description = str(row.get('description', '')).strip()
category = str(row.get('category', '')).strip()
category_name = str(row.get('category', '')).strip()
category_id = category_map.get(category_name.lower()) if category_name else None
if category_name and category_id is None:
warnings.append(f'Row {row_num}: categoria "{category_name}" no encontrada')
# Check if item already exists (catalog is shared across branches)
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
@@ -566,10 +588,10 @@ def bulk_import_items():
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
location = COALESCE(NULLIF(%s,''), location),
description = COALESCE(NULLIF(%s,''), description),
category = COALESCE(NULLIF(%s,''), category)
category_id = COALESCE(%s, category_id)
WHERE id = %s
""",
(name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
)
was_inserted = False
# Record stock adjustment for existing item if stock > 0
@@ -582,11 +604,11 @@ def bulk_import_items():
cur.execute(
"""
INSERT INTO inventory
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA')
)
item_id = cur.fetchone()[0]
was_inserted = True
@@ -599,7 +621,20 @@ def bulk_import_items():
created_ids.append(item_id)
created += 1
# ---------- 2. Vehicle compatibility ----------
# ---------- 2. Secondary SKU alias ----------
sku_secondary = str(row.get('sku_secondary', '')).strip()
if sku_secondary:
cur.execute(
"""
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
VALUES (%s, %s, %s)
ON CONFLICT DO NOTHING
""",
(item_id, sku_secondary, 'Secundario')
)
conn.commit()
# ---------- 3. Vehicle compatibility ----------
make = str(row.get('make', '')).strip()
model = str(row.get('model', '')).strip()
year_str = str(row.get('year', '')).strip()
@@ -2271,6 +2306,22 @@ def list_inventory_subcategories(category_id):
conn.close()
@inventory_bp.route('/categories/all', methods=['GET'])
@require_auth()
def list_all_inventory_categories():
"""Return all active categories (flat, with parent_id) for selectors."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, parent_id FROM categories WHERE is_active = true ORDER BY name"
)
rows = cur.fetchall()
return jsonify({'categories': [{'id': r[0], 'name': r[1], 'parent_id': r[2]} for r in rows]})
finally:
conn.close()
# ─── Global Tier Discounts ───────────────────────
@inventory_bp.route('/tier-discounts', methods=['GET'])

View File

@@ -5,22 +5,28 @@ All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
This blueprint is the HTTP layer that validates input and returns JSON.
"""
import json
import base64
from datetime import datetime
from flask import Blueprint, request, jsonify, g
from flask import Blueprint, g, jsonify, request
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.cfdi_facturapi_builder import (
build_ingreso_payload, build_egreso_payload, build_pago_payload,
)
from services.cfdi_queue import (
enqueue_cfdi, process_queue, retry_failed,
cancel_cfdi, get_queue_status,
)
from services import facturapi_service
from services.audit import log_action
from services.facturapi_service import FacturapiError
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_issuer_config(cur, branch_id=None):
@@ -36,80 +42,104 @@ def _get_issuer_config(cur, branch_id=None):
config[row[0]] = row[1]
result = {
'rfc': config.get('tenant_rfc', ''),
'razon_social': config.get('tenant_razon_social', ''),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
'cp': config.get('tenant_cp', '00000'),
'serie': config.get('cfdi_serie', 'A'),
'facturapi_key': config.get('cfdi_facturapi_key', ''),
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''),
"rfc": config.get("tenant_rfc", ""),
"razon_social": config.get("tenant_razon_social", ""),
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"),
"cp": config.get("tenant_cp", "00000"),
"direccion": config.get("tenant_direccion", ""),
"exterior": config.get("tenant_numero_exterior", ""),
"interior": config.get("tenant_numero_interior", ""),
"colonia": config.get("tenant_colonia", ""),
"ciudad": config.get("tenant_ciudad", ""),
"municipio": config.get("tenant_municipio", ""),
"estado": config.get("tenant_estado", ""),
"serie": config.get("cfdi_serie") or config.get("invoice_serie", "A"),
"facturapi_key": config.get("cfdi_facturapi_key", ""),
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
}
# Branch-level override
if branch_id:
cur.execute("""
cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s
""", (branch_id,))
""",
(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']
result["rfc"] = row[0] or result["rfc"]
result["razon_social"] = row[1] or result["razon_social"]
result["regimen_fiscal"] = row[2] or result["regimen_fiscal"]
result["cp"] = row[3] or result["cp"]
result["serie"] = row[4] or result["serie"]
return result
def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation."""
cur.execute("""
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,))
""",
(sale_id,),
)
row = cur.fetchone()
if not row:
return None
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]),
"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]),
}
cur.execute("""
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,))
""",
(sale_id,),
)
sale['items'] = []
sale["items"] = []
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',
})
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",
}
)
return sale
@@ -118,24 +148,32 @@ def _get_customer(cur, customer_id):
"""Load customer data for CFDI receptor."""
if not customer_id:
return None
cur.execute("""
cur.execute(
"""
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
FROM customers WHERE id = %s
""", (customer_id,))
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return None
return {
'id': row[0], 'name': row[1], 'rfc': row[2],
'razon_social': row[3], 'regimen_fiscal': row[4],
'uso_cfdi': row[5] or 'G03', 'cp': row[6],
"id": row[0],
"name": row[1],
"rfc": row[2],
"razon_social": row[3],
"regimen_fiscal": row[4],
"uso_cfdi": row[5] or "G03",
"cp": row[6],
}
# ─── 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():
"""Generate a CFDI for a sale and enqueue for timbrado.
@@ -146,11 +184,11 @@ def generate_invoice():
}
"""
data = request.get_json() or {}
sale_id = data.get('sale_id')
cfdi_type = data.get('type', 'ingreso')
sale_id = data.get("sale_id")
cfdi_type = data.get("type", "ingreso")
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)
cur = conn.cursor()
@@ -158,45 +196,54 @@ def generate_invoice():
try:
sale = _get_sale_with_items(cur, sale_id)
if not sale:
return jsonify({'error': 'Sale not found'}), 404
return jsonify({"error": "Sale not found"}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
if not tenant_config['rfc']:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
if not tenant_config["rfc"]:
return jsonify({"error": "Tenant RFC not configured. Set tenant_rfc in config."}), 400
if sale['status'] == 'cancelled':
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
if sale["status"] == "cancelled":
return jsonify({"error": "Cannot invoice a cancelled sale"}), 400
customer = _get_customer(cur, sale.get('customer_id'))
customer = _get_customer(cur, sale.get("customer_id"))
# Check if this sale already has a stamped CFDI
cur.execute("""
cur.execute(
"""
SELECT id, status FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
""", (sale_id, cfdi_type))
""",
(sale_id, cfdi_type),
)
existing = cur.fetchone()
if existing:
return jsonify({
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
}), 409
return jsonify(
{
"error": f"Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})"
}
), 409
# Build Facturapi payload
if cfdi_type == 'ingreso':
if cfdi_type == "ingreso":
payload = build_ingreso_payload(sale, tenant_config, customer)
elif cfdi_type == 'egreso':
original_uuid = data.get('original_uuid')
elif cfdi_type == "egreso":
original_uuid = data.get("original_uuid")
if not original_uuid:
return jsonify({'error': 'original_uuid required for egreso'}), 400
return jsonify({"error": "original_uuid required for egreso"}), 400
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
else:
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
return jsonify({"error": f"Invalid CFDI type: {cfdi_type}"}), 400
# Enqueue
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
new_value={'sale_id': sale_id, 'type': cfdi_type,
'folio': result['provisional_folio']})
log_action(
conn,
"CFDI_GENERATED",
"cfdi_queue",
result["id"],
new_value={"sale_id": sale_id, "type": cfdi_type, "folio": result["provisional_folio"]},
)
conn.commit()
cur.close()
@@ -207,18 +254,19 @@ def generate_invoice():
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 400
return jsonify({"error": str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
return jsonify({"error": str(e)}), 500
# ─── 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():
"""List CFDI queue items.
@@ -227,11 +275,11 @@ def list_queue():
conn = get_tenant_conn(g.tenant_id)
filters = {
'status': request.args.get('status'),
'sale_id': request.args.get('sale_id'),
'type': request.args.get('type'),
'page': request.args.get('page', 1),
'per_page': request.args.get('per_page', 50),
"status": request.args.get("status"),
"sale_id": request.args.get("sale_id"),
"type": request.args.get("type"),
"page": request.args.get("page", 1),
"per_page": request.args.get("per_page", 50),
}
result = get_queue_status(conn, filters)
@@ -239,36 +287,46 @@ def list_queue():
return jsonify(result)
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET'])
@require_auth('invoicing.view')
@invoicing_bp.route("/queue/<int:cfdi_id>", methods=["GET"])
@require_auth("invoicing.view")
def get_queue_item(cfdi_id):
"""Get CFDI queue item detail (includes XML)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q WHERE q.id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'CFDI queue item not found'}), 404
cur.close()
conn.close()
return jsonify({"error": "CFDI queue item not found"}), 404
item = {
'id': row[0], 'sale_id': row[1], 'type': row[2],
'payload_unsigned': row[3], 'xml_signed': row[4],
'uuid_fiscal': row[5], 'status': row[6],
'retry_count': row[7], 'provisional_folio': row[8],
'error_message': row[9], 'cancel_motive': row[10],
'cancel_replacement_uuid': row[11],
'created_at': str(row[12]) if row[12] else None,
'stamped_at': str(row[13]) if row[13] else None,
'external_id': row[14],
"id": row[0],
"sale_id": row[1],
"type": row[2],
"payload_unsigned": row[3],
"xml_signed": row[4],
"uuid_fiscal": row[5],
"status": row[6],
"retry_count": row[7],
"provisional_folio": row[8],
"error_message": row[9],
"cancel_motive": row[10],
"cancel_replacement_uuid": row[11],
"created_at": str(row[12]) if row[12] else None,
"stamped_at": str(row[13]) if row[13] else None,
"external_id": row[14],
}
cur.close()
@@ -276,8 +334,8 @@ def get_queue_item(cfdi_id):
return jsonify(item)
@invoicing_bp.route('/queue/process', methods=['POST'])
@require_auth('invoicing.create')
@invoicing_bp.route("/queue/process", methods=["POST"])
@require_auth("invoicing.create")
def trigger_process_queue():
"""Manually trigger processing of pending CFDI queue items."""
conn = get_tenant_conn(g.tenant_id)
@@ -285,17 +343,17 @@ def trigger_process_queue():
try:
tenant_config = _get_issuer_config(cur)
if not tenant_config.get('facturapi_key'):
if not tenant_config.get("facturapi_key"):
cur.close()
conn.close()
return jsonify({'error': 'Facturapi key not configured'}), 400
return jsonify({"error": "Facturapi key not configured"}), 400
# Reset eligible failed items first
reset_count = retry_failed(conn)
# Process the queue
result = process_queue(conn, tenant_config)
result['retries_reset'] = reset_count
result["retries_reset"] = reset_count
cur.close()
conn.close()
@@ -305,13 +363,14 @@ def trigger_process_queue():
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
return jsonify({"error": str(e)}), 500
# ─── 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):
"""Cancel a CFDI with SAT motive code.
@@ -322,15 +381,15 @@ def cancel_invoice(cfdi_id):
Only owner and admin can cancel CFDIs.
"""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403
if g.employee_role not in ("owner", "admin"):
return jsonify({"error": "Only owner or admin can cancel CFDIs"}), 403
data = request.get_json() or {}
motive = data.get('motive')
replacement_uuid = data.get('replacement_uuid')
motive = data.get("motive")
replacement_uuid = data.get("replacement_uuid")
if not motive:
return jsonify({'error': 'motive is required'}), 400
return jsonify({"error": "motive is required"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -338,12 +397,20 @@ def cancel_invoice(cfdi_id):
try:
tenant_config = _get_issuer_config(cur)
result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid,
conn,
cfdi_id,
motive,
replacement_uuid,
tenant_config=tenant_config,
)
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
new_value={'motive': motive, 'replacement_uuid': replacement_uuid})
log_action(
conn,
"CFDI_CANCELLED",
"cfdi_queue",
cfdi_id,
new_value={"motive": motive, "replacement_uuid": replacement_uuid},
)
conn.commit()
cur.close()
@@ -354,18 +421,19 @@ def cancel_invoice(cfdi_id):
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 400
return jsonify({"error": str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
return jsonify({"error": str(e)}), 500
# ─── 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):
"""Generate a PDF representation of the sale/CFDI.
@@ -378,48 +446,54 @@ def get_sale_pdf(sale_id):
sale = _get_sale_with_items(cur, sale_id)
if not sale:
cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404
cur.close()
conn.close()
return jsonify({"error": "Sale not found"}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id'))
customer = _get_customer(cur, sale.get('customer_id'))
tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
customer = _get_customer(cur, sale.get("customer_id"))
# Check if there's a stamped CFDI
cur.execute("""
cur.execute(
"""
SELECT uuid_fiscal, provisional_folio, status, stamped_at
FROM cfdi_queue
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
ORDER BY stamped_at DESC LIMIT 1
""", (sale_id,))
""",
(sale_id,),
)
cfdi_row = cur.fetchone()
cfdi_info = None
if cfdi_row:
cfdi_info = {
'uuid_fiscal': cfdi_row[0],
'provisional_folio': cfdi_row[1],
'status': cfdi_row[2],
'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None,
"uuid_fiscal": cfdi_row[0],
"provisional_folio": cfdi_row[1],
"status": cfdi_row[2],
"stamped_at": str(cfdi_row[3]) if cfdi_row[3] else None,
}
cur.close()
conn.close()
return jsonify({
'sale': sale,
'tenant': {
'rfc': tenant_config.get('rfc', ''),
'razon_social': tenant_config.get('razon_social', ''),
'regimen_fiscal': tenant_config.get('regimen_fiscal', ''),
'cp': tenant_config.get('cp', ''),
},
'customer': customer,
'cfdi': cfdi_info,
})
return jsonify(
{
"sale": sale,
"tenant": {
"rfc": tenant_config.get("rfc", ""),
"razon_social": tenant_config.get("razon_social", ""),
"regimen_fiscal": tenant_config.get("regimen_fiscal", ""),
"cp": tenant_config.get("cp", ""),
},
"customer": customer,
"cfdi": cfdi_info,
}
)
@invoicing_bp.route('/stats', methods=['GET'])
@require_auth('invoicing.read')
@invoicing_bp.route("/stats", methods=["GET"])
@require_auth("invoicing.view")
def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id)
@@ -437,16 +511,18 @@ def api_invoicing_stats():
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,
})
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')
@invoicing_bp.route("/global-invoice", methods=["POST"])
@require_auth("invoicing.create")
def generate_global_invoice():
"""Generate a monthly global invoice for cash sales.
@@ -458,39 +534,45 @@ def generate_global_invoice():
"""
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')
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
return jsonify({"error": "month must be 1-12"}), 400
except (ValueError, TypeError):
return jsonify({'error': 'year and month must be integers'}), 400
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
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)
conn, tenant_config, year, month, branch_id=branch_id, employee_id=getattr(g, "employee_id", None)
)
if 'error' in result:
cur.close(); conn.close()
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']})
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()
@@ -498,56 +580,62 @@ def generate_global_invoice():
return jsonify(result), 201
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET'])
@require_auth('invoicing.view')
@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({"error": "Global invoice not found"}), 404
return jsonify(result)
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET'])
@require_auth('invoicing.view')
@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)
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],
})
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')
@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)
@@ -560,8 +648,8 @@ def facturapi_status():
return jsonify(status)
@invoicing_bp.route('/facturapi/setup', methods=['POST'])
@require_auth('invoicing.create')
@invoicing_bp.route("/facturapi/setup", methods=["POST"])
@require_auth("invoicing.create")
def facturapi_setup():
"""Create or link a Facturapi organization for this tenant.
@@ -573,92 +661,179 @@ def facturapi_setup():
try:
tenant_config = _get_issuer_config(cur)
if not tenant_config.get('rfc'):
return jsonify({'error': 'Tenant RFC not configured'}), 400
if not tenant_config.get("rfc"):
return jsonify({"error": "Tenant RFC not configured"}), 400
result = facturapi_service.create_organization(tenant_config)
cur.execute("""
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'],))
""",
(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'],))
if result.get("api_key"):
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']})
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.',
})
status_conn = get_tenant_conn(g.tenant_id)
try:
status_cur = status_conn.cursor()
status = facturapi_service.get_org_status(_get_issuer_config(status_cur))
status["org_id"] = result["org_id"]
if not result.get("legal_updated"):
status["error"] = status.get("error") or "Datos fiscales no pudieron configurarse automáticamente. Configúralos en el dashboard de Facturapi."
status_cur.close()
status_conn.close()
except Exception:
status_conn.close()
raise
return jsonify(status)
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({'error': str(e)}), 400
return jsonify({"error": str(e)}), 400
except FacturapiError 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
return jsonify({"error": str(e)}), 500
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
@require_auth('invoicing.view')
@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
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("""
cur.execute(
"""
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'CFDI not found'}), 404
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
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':
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'
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'
mime = "application/xml"
filename = f"cfdi_{uuid_fiscal or external_id}.xml"
except Exception as e:
return jsonify({'error': str(e)}), 500
return jsonify({"error": str(e)}), 500
from flask import Response
return Response(
content,
mimetype=mime,
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
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

@@ -24,6 +24,8 @@ Routes:
POST /pos/api/marketplace-ext/webhook/meli
"""
import urllib.parse
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn, get_master_conn
@@ -81,6 +83,49 @@ def get_config():
conn.close()
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
@require_auth()
def init_meli_connect():
"""Store client credentials server-side and return the MercadoLibre auth URL.
The frontend no longer keeps the client_secret in localStorage.
"""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
client_id = data.get("client_id", "").strip()
client_secret = data.get("client_secret", "").strip()
category = data.get("category", "").strip()
shipping = data.get("shipping", "").strip()
if not client_id or not client_secret:
return jsonify({"error": "client_id and client_secret required"}), 400
base = _get_public_base_url().rstrip("/")
redirect_uri = f"{base}/pos/marketplace-external/callback"
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.save_meli_config(conn, {
"meli_client_id": client_id,
"meli_client_secret": client_secret,
"meli_default_category_id": category,
"meli_shipping_mode": shipping,
})
finally:
conn.close()
auth_url = (
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
f"&client_id={urllib.parse.quote(client_id)}"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
"&scope=read+write+offline_access"
)
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
@marketplace_ext_bp.route("/connect", methods=["POST"])
@require_auth()
def connect_meli():
@@ -90,12 +135,21 @@ def connect_meli():
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
if not code:
return jsonify({"error": "code required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
client_id = data.get("client_id") or cfg.get("meli_client_id")
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
finally:
conn.close()
if not client_id or not client_secret:
return jsonify({"error": "ML credentials not configured"}), 400
try:
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)

View File

@@ -13,7 +13,8 @@ from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
from services.pos_engine import (
process_sale, cancel_sale, calculate_totals,
get_price_for_customer, get_margin_info
get_price_for_customer, get_margin_info,
create_remission_note, pay_pending_sale
)
from services.inventory_engine import get_stock
from services.audit import log_action
@@ -22,6 +23,53 @@ from config import JWT_SECRET
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
def _tenant_allows_negative_stock(conn):
"""Return True if the tenant explicitly allows selling below zero stock."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'")
row = cur.fetchone()
cur.close()
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
def _tenant_allows_zero_price(conn):
"""Return True if the tenant allows selling items at $0. Defaults to True."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
row = cur.fetchone()
cur.close()
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
def _validate_zero_price(conn, items):
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
if _tenant_allows_zero_price(conn):
return
for item in items:
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 1) or 1)
discount_pct = float(item.get('discount_pct', 0) or 0)
line_total = unit_price * quantity * (1 - discount_pct / 100)
if line_total <= 0:
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
def _validate_stock_availability(conn, items, branch_id):
"""Raise ValueError if stock is insufficient and the tenant forbids negative stock."""
if _tenant_allows_negative_stock(conn):
return
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1) or 1)
if not inv_id:
continue
available = get_stock(conn, inv_id, branch_id)
if available < qty:
name = item.get('name') or item.get('part_number') or inv_id
raise ValueError(f'Sin stock suficiente para {name}. Disponible: {available}, solicitado: {qty}')
def _enrich_items(cur, items, customer_id=None):
"""Look up inventory data for items that lack unit_price/tax_rate.
@@ -103,20 +151,11 @@ def create_sale():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
# Verify stock availability per item for the active branch
branch_id = data.get('branch_id', g.branch_id)
for item in data.get('items', []):
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if inv_id:
available = get_stock(conn, inv_id, branch_id)
if available < qty:
conn.close()
return jsonify({
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
}), 400
try:
_validate_stock_availability(conn, data.get('items', []), branch_id)
_validate_zero_price(conn, data.get('items', []))
sale = process_sale(conn, data)
conn.commit()
conn.close()
@@ -131,8 +170,80 @@ def create_sale():
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales/remission', methods=['POST'])
@require_auth('pos.remission')
def create_remission():
"""Create a counter remission note (pending payment, reserved stock)."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
branch_id = data.get('branch_id', g.branch_id)
try:
_validate_stock_availability(conn, data.get('items', []), branch_id)
_validate_zero_price(conn, data.get('items', []))
sale = create_remission_note(conn, {
'tenant_id': g.tenant_id,
'branch_id': branch_id,
'customer_id': data.get('customer_id'),
'items': data.get('items', []),
'notes': data.get('notes'),
'register_id': data.get('register_id'),
'currency': data.get('currency', 'MXN'),
'exchange_rate': data.get('exchange_rate'),
'courier_id': data.get('courier_id'),
})
conn.commit()
conn.close()
return jsonify(sale), 201
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales/<int:sale_id>/pay', methods=['POST'])
@require_auth('pos.sell')
def pay_sale(sale_id):
"""Pay a pending counter remission note.
Body: {
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
amount_paid: float,
payment_details: [{method, amount, reference}],
register_id: int,
reference: str
}
"""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
try:
sale = pay_pending_sale(conn, sale_id, {
'payment_method': data.get('payment_method', 'efectivo'),
'amount_paid': data.get('amount_paid', 0),
'payment_details': data.get('payment_details', []),
'register_id': data.get('register_id'),
'reference': data.get('reference', ''),
})
conn.commit()
conn.close()
return jsonify(sale), 200
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@pos_bp.route('/sales', methods=['GET'])
@require_auth('pos.view')
@require_auth()
def list_sales():
"""List sales with filters.
@@ -146,6 +257,13 @@ def list_sales():
page: int (default 1)
per_page: int (default 50, max 200)
"""
sale_type = request.args.get('sale_type')
is_remission = sale_type == 'counter_remission'
can_view_all = g.employee_role == 'owner' or has_permission('pos.view')
can_view_remissions = is_remission and (has_permission('pos.sell') or has_permission('pos.remission'))
if not (can_view_all or can_view_remissions):
return jsonify({'error': 'Missing permissions: pos.view'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -160,6 +278,10 @@ def list_sales():
employee_id = request.args.get('employee_id')
customer_id = request.args.get('customer_id')
status = request.args.get('status')
sale_type = request.args.get('sale_type')
q = request.args.get('q')
customer_q = request.args.get('customer')
courier_id = request.args.get('courier_id')
register_id = request.args.get('register_id')
if date_from:
@@ -177,6 +299,18 @@ def list_sales():
if status:
where_clauses.append("s.status = %s")
params.append(status)
if sale_type:
where_clauses.append("s.sale_type = %s")
params.append(sale_type)
if q:
where_clauses.append("(s.id::text ILIKE %s OR c.name ILIKE %s)")
params.extend([f'%{q}%', f'%{q}%'])
if customer_q:
where_clauses.append("c.name ILIKE %s")
params.append(f'%{customer_q}%')
if courier_id:
where_clauses.append("s.courier_id = %s")
params.append(int(courier_id))
if register_id:
where_clauses.append("s.register_id = %s")
params.append(int(register_id))
@@ -195,12 +329,14 @@ def list_sales():
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
s.sale_type, s.payment_method, s.subtotal, s.discount_total,
s.tax_total, s.total, s.amount_paid, s.change_given,
s.status, s.created_at,
s.status, s.created_at, s.courier_id,
e.name as employee_name,
c.name as customer_name
c.name as customer_name,
co.name as courier_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN couriers co ON s.courier_id = co.id
WHERE {where}
ORDER BY s.created_at DESC
LIMIT %s OFFSET %s
@@ -219,7 +355,9 @@ def list_sales():
'amount_paid': float(r[11]) if r[11] else 0,
'change_given': float(r[12]) if r[12] else 0,
'status': r[13], 'created_at': str(r[14]),
'employee_name': r[15], 'customer_name': r[16],
'courier_id': r[15],
'employee_name': r[16], 'customer_name': r[17],
'courier_name': r[18],
})
cur.close()
@@ -232,6 +370,75 @@ def list_sales():
})
@pos_bp.route('/sales/recent', methods=['GET'])
@require_auth('pos.view')
def recent_sales():
"""Return recent sales with their items in a single response.
Query params:
date_from: YYYY-MM-DD (defaults to today)
date_to: YYYY-MM-DD (defaults to today)
limit: int (default 10, max 50)
"""
from datetime import date
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
date_from = request.args.get('date_from') or str(date.today())
date_to = request.args.get('date_to') or date_from
limit = min(int(request.args.get('limit', 10)), 50)
where_clauses = [
"s.created_at >= %s",
"s.created_at < %s::date + interval '1 day'",
"s.status != 'cancelled'"
]
params = [date_from, date_to]
if g.branch_id:
where_clauses.append("s.branch_id = %s")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.customer_id, s.payment_method, s.total, s.status, s.created_at,
c.name as customer_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY s.created_at DESC
LIMIT %s
""", params + [limit])
sales = []
sale_ids = []
for r in cur.fetchall():
sale_ids.append(r[0])
sales.append({
'id': r[0], 'customer_id': r[1], 'payment_method': r[2],
'total': float(r[3]) if r[3] else 0, 'status': r[4],
'created_at': str(r[5]), 'customer_name': r[6],
'items': []
})
if sale_ids:
cur.execute("""
SELECT sale_id, name, quantity
FROM sale_items
WHERE sale_id = ANY(%s)
ORDER BY sale_id, id
""", (sale_ids,))
for r in cur.fetchall():
for sale in sales:
if sale['id'] == r[0]:
sale['items'].append({'name': r[1], 'quantity': r[2]})
break
cur.close(); conn.close()
return jsonify({'data': sales})
@pos_bp.route('/historical-sales', methods=['GET'])
@require_auth('pos.view')
def list_historical_sales():
@@ -269,6 +476,16 @@ def list_historical_sales():
where = " AND ".join(where_clauses)
# Defensive: the historical_sales table is created on demand by imports.
# If it does not exist yet, return an empty list instead of a 500.
cur.execute("SELECT to_regclass('historical_sales')")
if cur.fetchone()[0] is None:
cur.close(); conn.close()
return jsonify({
'data': [],
'pagination': {'page': page, 'per_page': per_page, 'total': 0, 'total_pages': 0}
})
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
total = cur.fetchone()[0]
@@ -310,9 +527,13 @@ def list_historical_sales():
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
@require_auth('pos.view')
@require_auth()
def get_sale(sale_id):
"""Get sale detail with items."""
# Allow POS users or accounting users to view receivable/sale detail.
if g.employee_role != 'owner' and not ({'pos.view', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -378,12 +599,17 @@ def get_sale(sale_id):
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
@require_auth()
def api_cancel_sale(sale_id):
"""Cancel a sale. Requires mandatory reason.
Body: {reason: str}
"""
# Allow POS sellers or accounting staff to cancel tickets from the
# receivables / accounting view.
if g.employee_role != 'owner' and not ({'pos.sell', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
data = request.get_json() or {}
reason = data.get('reason', '').strip()
@@ -520,6 +746,8 @@ def create_quotation():
# Enrich items with inventory data (price, tax, etc.)
try:
enriched = _enrich_items(cur, items, data.get('customer_id'))
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
@@ -682,9 +910,23 @@ def list_quotations():
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
@require_auth('pos.sell')
def delete_quotation(quot_id):
"""Delete a quotation and its items."""
"""Delete a quotation, release its stock reservations and remove its items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Release reserved stock before deleting items
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
# Continue with deletion even if release fails (e.g. no reservations)
pass
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
deleted = cur.rowcount
@@ -894,6 +1136,8 @@ def update_quotation(quot_id):
try:
enriched = _enrich_items(cur, items, data.get('customer_id'))
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
@@ -986,6 +1230,8 @@ def patch_quotation(quot_id):
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
old_status = row[1]
fields = []
params = []
if 'customer_id' in data:
@@ -997,9 +1243,10 @@ def patch_quotation(quot_id):
if 'valid_until' in data:
fields.append('valid_until = %s')
params.append(data['valid_until'])
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
new_status = data.get('status')
if new_status and new_status in ('active', 'cancelled', 'expired'):
fields.append('status = %s')
params.append(data['status'])
params.append(new_status)
if not fields:
cur.close(); conn.close()
@@ -1007,6 +1254,20 @@ def patch_quotation(quot_id):
params.append(quot_id)
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
# Release reservations when cancelling or expiring
if new_status in ('cancelled', 'expired') and old_status not in ('cancelled', 'expired', 'converted'):
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
pass
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation updated'})
@@ -1356,12 +1617,15 @@ def convert_quotation(quot_id):
'register_id': data.get('register_id'),
'amount_paid': data.get('amount_paid', 0),
'payment_details': data.get('payment_details', []),
'reference': data.get('reference', ''),
'notes': f'Convertida de cotizacion #{quot_id}',
'currency': quot_currency,
'exchange_rate': quot_rate,
}
try:
_validate_stock_availability(conn, items, g.branch_id)
_validate_zero_price(conn, items)
sale = process_sale(conn, sale_data)
# Mark quotation as converted
@@ -1520,6 +1784,8 @@ def create_layaway():
# Enrich items with inventory data
try:
enriched = _enrich_items(cur, items, customer_id)
_validate_stock_availability(conn, enriched, g.branch_id)
_validate_zero_price(conn, enriched)
except ValueError as e:
cur.close(); conn.close()
return jsonify({'error': str(e)}), 400
@@ -1911,10 +2177,14 @@ def complete_layaway(layaway_id):
# Create sale_items (no inventory deduction — already reserved)
sale_items = []
inv_ids = [item['inventory_id'] for item in totals_calc['items']]
cur.execute("""
SELECT id, part_number, name, cost FROM inventory WHERE id = ANY(%s)
""", (inv_ids,))
inv_map = {r[0]: (r[1], r[2], r[3]) for r in cur.fetchall()}
for item in totals_calc['items']:
cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
(item['inventory_id'],))
inv = cur.fetchone()
inv = inv_map.get(item['inventory_id'], ('', '', 0))
cur.execute("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
@@ -1923,9 +2193,9 @@ def complete_layaway(layaway_id):
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
sale_id, item['inventory_id'],
inv[0] if inv else '', inv[1] if inv else '',
inv[0] or '', inv[1] or '',
item['quantity'], item['unit_price'],
float(inv[2]) if inv and inv[2] else 0,
float(inv[2]) if inv[2] else 0,
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal']
))
@@ -2067,7 +2337,7 @@ def create_return():
try:
# Validate sale exists and is completed
cur.execute("""
SELECT id, customer_id, total, status, branch_id
SELECT id, customer_id, total, status, branch_id, sale_type
FROM sales WHERE id = %s
""", (sale_id,))
sale = cur.fetchone()
@@ -2078,6 +2348,7 @@ def create_return():
sale_customer_id = sale[1]
sale_branch_id = sale[4] or g.branch_id
sale_type = sale[5]
# Validate each return item against original sale items
total_refund = 0
@@ -2179,10 +2450,10 @@ def create_return():
new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
# Update customer credit if applicable
if sale_customer_id:
# Update customer credit if the original sale was on credit
if sale_customer_id and sale_type == 'credit':
cur.execute("""
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) + %s
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) - %s
WHERE id = %s
""", (total_refund, sale_customer_id))
@@ -2421,6 +2692,79 @@ def print_ticket(sale_id):
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
@pos_bp.route('/sales/<int:sale_id>/print-remission', methods=['POST'])
@require_auth()
def print_remission(sale_id):
"""Generate printable data for a counter remission note."""
from middleware import has_permission
if not (has_permission('pos.remission') or has_permission('pos.sell')):
return jsonify({'error': 'Missing permissions'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT s.*, e.name as employee_name, c.name as customer_name, co.name as courier_name
FROM sales s
LEFT JOIN employees e ON s.employee_id = e.id
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN couriers co ON s.courier_id = co.id
WHERE s.id = %s
""", (sale_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Sale not found'}), 404
cols = [desc[0] for desc in cur.description]
sale = dict(zip(cols, row))
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
if sale.get(k) is not None:
sale[k] = float(sale[k])
cur.execute("""
SELECT name, quantity, unit_price, subtotal
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
items = []
for r in cur.fetchall():
items.append({
'name': r[0], 'quantity': r[1],
'unit_price': float(r[2]) if r[2] else 0,
'subtotal': float(r[3]) if r[3] else 0,
})
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()
return jsonify({
'folio': f'NR-{sale["id"]}',
'date': str(sale.get('created_at', '')),
'employee': sale.get('employee_name', ''),
'customer': sale.get('customer_name', ''),
'courier': sale.get('courier_name', ''),
'items': items,
'subtotal': sale.get('subtotal', 0),
'discount_total': sale.get('discount_total', 0),
'tax_total': sale.get('tax_total', 0),
'total': sale.get('total', 0),
'status': sale.get('status', ''),
'business_name': business_info.get('name', ''),
'business_rfc': business_info.get('rfc', ''),
'business_address': business_info.get('address', ''),
})
# ─── Public Quote HTML Template ─────────────────────────────────────────────
PUBLIC_QUOTE_TEMPLATE = """

View File

@@ -3,26 +3,166 @@
Prefix: /pos/api/service-orders
"""
from flask import Blueprint, request, jsonify, g
import json
from functools import wraps
from flask import Blueprint, g, jsonify, request
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.service_order_engine import (
create_service_order, get_service_order, list_service_orders,
update_status, add_item, update_item, remove_item,
add_labor, update_labor, remove_labor,
update_service_order, get_kanban_summary,
add_item,
add_labor,
assign_mechanic,
convert_to_remission,
convert_to_sale,
create_service_catalog_item,
create_service_order,
delete_service_catalog_item,
delete_service_order,
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
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
def _tenant_allows_zero_price(conn):
"""Return True if the tenant allows selling items at $0. Defaults to True."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
row = cur.fetchone()
cur.close()
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
def _validate_zero_price(conn, items):
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
if _tenant_allows_zero_price(conn):
return
for item in items:
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 1) or 1)
discount_pct = float(item.get('discount_pct', 0) or 0)
line_total = unit_price * quantity * (1 - discount_pct / 100)
if line_total <= 0:
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
# Roles allowed to access the workshop module at all.
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
def _can_view_workshop():
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
def _get_ws_perms():
"""Return effective workshop permissions for the current user."""
conn = get_tenant_conn(g.tenant_id)
try:
return _get_workshop_permissions(conn, g.employee_role)
finally:
conn.close()
def _visible_statuses():
if g.employee_role in ('owner', 'admin'):
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
return _get_ws_perms().get('statuses', [])
def _can_do_action(action):
if g.employee_role in ('owner', 'admin'):
return True
return action in _get_ws_perms().get('actions', [])
def _redact_order_for_restricted(order):
"""Remove customer/commercial data when the role cannot view sensitive data."""
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
return order
sensitive = {
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
'final_cost', 'total_parts', 'total_labor', 'total',
}
def redact_value(v):
if isinstance(v, str):
return ''
if isinstance(v, (int, float)):
return 0
return None
redacted = {}
for k, v in order.items():
if k in sensitive:
redacted[k] = redact_value(v)
elif k == 'items' and isinstance(v, list):
redacted[k] = [_redact_item_for_restricted(it) for it in v]
else:
redacted[k] = v
return redacted
def _redact_item_for_restricted(item):
"""Hide sensitive item rows for restricted viewers."""
item = dict(item)
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
if k in item:
item[k] = None if not isinstance(item[k], (int, float)) else 0
return item
def require_workshop_view(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
return f(*args, **kwargs)
return decorated
def require_workshop_action(action):
def decorator(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
if not _can_do_action(action):
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
return f(*args, **kwargs)
return decorated
return decorator
@service_order_bp.route('', methods=['GET'])
@require_auth()
@require_workshop_view
def list_orders():
status = request.args.get('status')
priority = request.args.get('priority')
customer_id = request.args.get('customer_id', type=int)
employee_id = request.args.get('employee_id', type=int)
delivery_method = request.args.get('delivery_method')
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
q = request.args.get('q')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
@@ -31,15 +171,65 @@ def list_orders():
result = list_service_orders(
conn, status=status, branch_id=g.branch_id,
customer_id=customer_id, priority=priority,
employee_id=employee_id, page=page, per_page=per_page
employee_id=employee_id, delivery_method=delivery_method,
is_direct=is_direct, q=q, page=page, per_page=per_page
)
if g.employee_role not in ('owner', 'admin'):
visible = _visible_statuses()
result['data'] = [
_redact_order_for_restricted(o) for o in result.get('data', [])
if o.get('status') in visible
]
return jsonify(result)
finally:
conn.close()
@service_order_bp.route('/from-pos', methods=['POST'])
@require_auth('pos.sell')
def create_order_from_pos():
"""Create a service order directly from the POS cart (quotation)."""
data = request.get_json() or {}
items = data.get('items', [])
estimated_cost = sum(
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
for it in items
)
conn = get_tenant_conn(g.tenant_id)
try:
_validate_zero_price(conn, items)
result = create_service_order(conn, {
'tenant_id': g.tenant_id,
'branch_id': data.get('branch_id', g.branch_id),
'customer_id': data.get('customer_id'),
'vehicle_id': data.get('vehicle_id'),
'priority': data.get('priority', 'normal'),
'reception_notes': data.get('reception_notes'),
'estimated_cost': estimated_cost or None,
'estimated_completion': data.get('estimated_completion'),
'employee_id': data.get('employee_id'),
'mileage_in': data.get('mileage_in'),
'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
'requires_invoice': data.get('requires_invoice', False),
'workshop_name': data.get('workshop_name'),
'customer_address': data.get('customer_address'),
'customer_phone': data.get('customer_phone'),
'vehicle_description': data.get('vehicle_description'),
})
so_id = result['service_order_id']
for item in items:
add_item(conn, so_id, item)
order = get_service_order(conn, so_id)
return jsonify(order), 201
finally:
conn.close()
@service_order_bp.route('', methods=['POST'])
@require_auth()
@require_workshop_action('create_order')
def create_order():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -57,6 +247,14 @@ def create_order():
'mileage_in': data.get('mileage_in'),
'fuel_level': data.get('fuel_level'),
'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
'requires_invoice': data.get('requires_invoice', False),
'workshop_name': data.get('workshop_name'),
'customer_address': data.get('customer_address'),
'customer_phone': data.get('customer_phone'),
'vehicle_description': data.get('vehicle_description'),
})
return jsonify(result), 201
finally:
@@ -64,22 +262,29 @@ def create_order():
@service_order_bp.route('/<int:so_id>', methods=['GET'])
@require_auth()
@require_workshop_view
def get_order(so_id):
conn = get_tenant_conn(g.tenant_id)
try:
order = get_service_order(conn, so_id)
if not order:
return jsonify({'error': 'Service order not found'}), 404
return jsonify(order)
if order.get('status') not in _visible_statuses():
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
return jsonify(_redact_order_for_restricted(order))
finally:
conn.close()
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
@require_auth()
@require_workshop_action('edit_order')
def update_order(so_id):
data = request.get_json() or {}
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
if g.employee_role == 'mechanic':
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
if not data or any(k not in allowed for k in data.keys()):
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_service_order(conn, so_id, data)
@@ -90,13 +295,32 @@ def update_order(so_id):
conn.close()
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
@require_workshop_action('delete_order')
def delete_order(so_id):
"""Soft-delete a service order. Restricted to owner/admin."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
delete_service_order(conn, so_id)
return jsonify({'message': 'Orden eliminada'})
except ValueError as e:
return jsonify({'error': str(e)}), 404
finally:
conn.close()
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
@require_auth()
@require_workshop_action('change_status')
def change_status(so_id):
data = request.get_json() or {}
new_status = data.get('status')
if not new_status:
return jsonify({'error': 'status is required'}), 400
if new_status not in _visible_statuses():
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
result = update_status(
@@ -114,7 +338,7 @@ def change_status(so_id):
# ─── Items (Parts) ─────────────────────────────
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
@require_auth()
@require_workshop_action('add_items')
def add_order_item(so_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -126,7 +350,7 @@ def add_order_item(so_id):
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_auth()
@require_workshop_action('add_items')
def update_order_item(item_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -140,7 +364,7 @@ def update_order_item(item_id):
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_auth()
@require_workshop_action('add_items')
def delete_order_item(item_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -153,7 +377,7 @@ def delete_order_item(item_id):
# ─── Labor ─────────────────────────────
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
@require_auth()
@require_workshop_action('add_labor')
def add_order_labor(so_id):
data = request.get_json() or {}
if not data.get('description'):
@@ -167,7 +391,7 @@ def add_order_labor(so_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
@require_auth()
@require_workshop_action('add_labor')
def update_order_labor(labor_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -181,7 +405,7 @@ def update_order_labor(labor_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
@require_auth()
@require_workshop_action('add_labor')
def delete_order_labor(labor_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -194,7 +418,7 @@ def delete_order_labor(labor_id):
# ─── Kanban Summary ─────────────────────────────
@service_order_bp.route('/kanban/summary', methods=['GET'])
@require_auth()
@require_workshop_view
def kanban_summary():
conn = get_tenant_conn(g.tenant_id)
try:
@@ -202,3 +426,378 @@ def kanban_summary():
return jsonify(summary)
finally:
conn.close()
# ─── Inventory reservation ────────────────────────
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_workshop_action('add_items')
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_workshop_action('add_items')
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')
@require_workshop_action('convert_to_sale')
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()
# ─── Convert to remission note ────────────────────
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
@require_auth('pos.remission')
@require_workshop_action('convert_to_remission')
def convert_order_to_remission(so_id):
"""Convert a service order into a counter remission note (pending payment).
Body: {
register_id: int (optional),
notes: str (optional)
}
"""
data = request.get_json() or {}
sale_payload = {
'register_id': data.get('register_id'),
'notes': data.get('notes'),
}
conn = get_tenant_conn(g.tenant_id)
try:
result = convert_to_remission(
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_workshop_action('assign_mechanic')
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_order_bp.route('/customers', methods=['POST'])
@require_workshop_action('edit_order')
def create_customer_for_workshop():
"""Create a customer directly from the workshop flow."""
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'El nombre es obligatorio'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO customers
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, max_discount_pct, vehicle_info)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
data.get('branch_id', g.branch_id), data['name'], data.get('rfc'),
data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
data.get('cp'), data.get('email'), data.get('phone'), data.get('address'),
data.get('price_tier', 1), data.get('credit_limit', 0), data.get('max_discount_pct', 0),
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
))
customer_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/vehicles', methods=['POST'])
@require_workshop_action('edit_order')
def create_vehicle_for_workshop():
"""Create a fleet vehicle directly from the workshop flow.
Vehicles must be assigned to a customer. Brand and model are required;
plate is optional and VIN is not used.
"""
data = request.get_json() or {}
if not data.get('make') or not data.get('model'):
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
if not data.get('customer_id'):
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
cust = cur.fetchone()
if not cust:
cur.close(); conn.close()
return jsonify({'error': 'Cliente no encontrado'}), 404
cur.execute("""
INSERT INTO fleet_vehicles
(branch_id, customer_id, plate, make, model, year,
current_mileage, fuel_type, color, owner_name, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""", (
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'),
data.get('make'), data.get('model'), data.get('year'),
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
data.get('color'), cust[0], data.get('notes'),
))
vehicle_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201
except Exception as e:
conn.rollback()
cur.close(); conn.close()
return jsonify({'error': str(e)}), 500
@service_order_bp.route('/inventory-search', methods=['GET'])
@require_workshop_view
def inventory_search():
"""Search active inventory for attaching parts to a service order.
Does not require inventory.view so workshop users can pick parts.
"""
q = request.args.get('q', '').strip()
if not q:
return jsonify({'data': []})
branch_id = request.args.get('branch_id', g.branch_id)
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
like = f'%{q}%'
if branch_id:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock
WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (branch_id, like, like, like))
else:
cur.execute("""
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
COALESCE((SELECT stock FROM inventory_stock_summary
WHERE inventory_id = i.id), 0) AS stock,
i.cost, i.price_1
FROM inventory i
WHERE i.is_active = true
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
ORDER BY i.name
LIMIT 20
""", (like, like, like))
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4],
'stock': float(r[5]) if r[5] else 0,
'cost': float(r[6]) if r[6] else 0,
'price_1': float(r[7]) if r[7] else 0,
})
cur.close()
return jsonify({'data': items})
finally:
conn.close()
# ─── Service catalog (reusable labor) ─────────────
@service_order_bp.route('/service-catalog', methods=['GET'])
@require_workshop_view
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_workshop_action('edit_order')
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_workshop_action('edit_order')
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_workshop_action('edit_order')
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_workshop_action('edit_order')
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

@@ -28,7 +28,7 @@ def enqueue_report():
@tasks_bp.route('/<task_id>/status', methods=['GET'])
@require_auth
@require_auth()
def task_status(task_id):
"""Get status of a background task."""
from celery_app import celery

View File

@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
# Check permissions
if required_permissions:
missing = set(required_permissions) - g.permissions
# owner role bypasses all permission checks
if g.employee_role != 'owner' and missing:
# owner/admin roles bypass all permission checks
if g.employee_role not in ('owner', 'admin') and missing:
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
return f(*args, **kwargs)
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
def has_permission(permission):
"""Check if current user has a specific permission. Use inside a route."""
return g.employee_role == 'owner' or permission in g.permissions
return g.employee_role in ('owner', 'admin') or permission in g.permissions

View File

@@ -4,6 +4,7 @@
import os
import sys
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
@@ -12,43 +13,63 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
# Migration registry: version -> filename
MIGRATIONS = {
'v1.0': 'v1.0_initial.sql',
'v1.1': 'v1.1_pos_tables.sql',
'v1.2': 'v1.2_subdomain.sql',
'v1.3': 'v1.3_fleet.sql',
'v1.4': 'v1.4_whatsapp.sql',
'v1.5': 'v1.5_returns.sql',
'v1.6': 'v1.6_marketplace.sql',
'v1.7': 'v1.7_plates.sql',
'v1.8': 'v1.8_performance_indexes.sql',
'v1.9': 'v1.9_redis_cache.sql',
'v2.0': 'v2.0_multi_currency.sql',
'v2.1': 'v2.1_suppliers.sql',
'v2.2': 'v2.2_alerts_warranty.sql',
'v2.3': 'v2.3_metabase.sql',
'v2.4': 'v2.4_crm_enhanced.sql',
'v2.5': 'v2.5_service_orders.sql',
'v2.6': 'v2.6_bnpl_erp.sql',
'v2.7': 'v2.7_notifications.sql',
'v2.8': 'v2.8_savings.sql',
'v2.9': 'v2.9_logistics.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',
"v1.0": "v1.0_initial.sql",
"v1.1": "v1.1_pos_tables.sql",
"v1.2": "v1.2_subdomain.sql",
"v1.3": "v1.3_fleet.sql",
"v1.4": "v1.4_whatsapp.sql",
"v1.5": "v1.5_returns.sql",
"v1.6": "v1.6_marketplace.sql",
"v1.7": "v1.7_plates.sql",
"v1.8": "v1.8_performance_indexes.sql",
"v1.9": "v1.9_redis_cache.sql",
"v2.0": "v2.0_multi_currency.sql",
"v2.1": "v2.1_suppliers.sql",
"v2.2": "v2.2_alerts_warranty.sql",
"v2.3": "v2.3_metabase.sql",
"v2.4": "v2.4_crm_enhanced.sql",
"v2.5": "v2.5_service_orders.sql",
"v2.6": "v2.6_bnpl_erp.sql",
"v2.7": "v2.7_notifications.sql",
"v2.8": "v2.8_savings.sql",
"v2.9": "v2.9_logistics.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",
"v4.6": "v4.6_inventory_support.sql",
"v4.7": "v4.7_workshop_business.sql",
"v4.8": "v4.8_workshop_permissions.sql",
"v4.9": "v4.9_workshop_customers_view.sql",
"v4.10": "v4.10_fleet_permissions.sql",
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
"v4.12": "v4.12_service_order_soft_delete.sql",
"v4.13": "v4.13_fleet_vehicle_customer.sql",
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
"v4.15": "v4.15_counter_remission.sql",
"v4.16": "v4.16_remission_courier.sql",
"v4.17": "v4.17_service_order_invoice.sql",
"v4.18": "v4.18_rached_workshop.sql",
"v4.19": "v4.19_counter_inventory_create.sql",
"v4.20": "v4.20_cashier_sell_permissions.sql",
"v4.21": "v4.21_cashier_workshop_permissions.sql",
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
"v4.23": "v4.23_service_order_mechanic_name.sql",
}
@@ -81,9 +102,9 @@ def apply_migration(db_name, version):
sql = f.read()
# Skip migrations marked for manual/non-tenant execution
first_line = sql.splitlines()[0].strip() if sql.strip() else ''
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'):
print(f" SKIP (manual/non-tenant migration)")
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)
@@ -104,7 +125,10 @@ def apply_migration(db_name, version):
def run_migrations():
"""Apply pending migrations to all tenants."""
tenants = get_all_tenants()
sorted_versions = sorted(MIGRATIONS.keys())
def _version_key(v):
return tuple(int(x) for x in v.lstrip('v').split('.'))
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
print(f"Found {len(tenants)} active tenants")
print(f"Available migrations: {sorted_versions}")
@@ -112,20 +136,24 @@ def run_migrations():
for tenant_id, db_name, name, current_version in tenants:
print(f"\n[{name}] (db={db_name}, current={current_version})")
current_key = _version_key(current_version)
for version in sorted_versions:
if version <= current_version:
if _version_key(version) <= current_key:
continue
print(f" Applying {version}...", end=' ')
print(f" Applying {version}...", end=" ")
if apply_migration(db_name, version):
# Update version in master
master_conn = get_master_conn()
master_cur = master_conn.cursor()
master_cur.execute("""
master_cur.execute(
"""
INSERT INTO tenant_schema_version (tenant_id, version)
VALUES (%s, %s)
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
""", (tenant_id, version, version))
""",
(tenant_id, version, version),
)
master_conn.commit()
master_cur.close()
master_conn.close()
@@ -137,5 +165,5 @@ def run_migrations():
print("\nDone.")
if __name__ == '__main__':
if __name__ == "__main__":
run_migrations()

View File

@@ -0,0 +1,20 @@
-- v4.10: add fleet RBAC permissions for existing employees.
-- Owner and admin get full fleet access; accountant and workshop get read access.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('fleet.view'),
('fleet.create'),
('fleet.edit'),
('fleet.delete')
) AS p(perm)
WHERE e.role IN ('owner', 'admin')
ON CONFLICT (employee_id, permission) DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'fleet.view'
FROM employees e
WHERE e.role IN ('accountant', 'workshop')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -0,0 +1,24 @@
-- v4.11_clean_workshop_mechanic_permissions.sql
-- Restringe los permisos de los empleados con rol 'workshop' y 'mechanic'
-- para que solo tengan acceso a la seccion de Taller y no vean precios.
-- Ejecutar en cada base de datos de tenant.
-- 1. Eliminar cualquier permiso que no sea de taller para estos roles
DELETE FROM employee_permissions
WHERE employee_id IN (
SELECT id FROM employees WHERE role IN ('workshop', 'mechanic')
)
AND permission NOT IN ('workshop.view', 'workshop.edit', 'workshop.add_items');
-- 2. Asegurar que tengan los permisos base de taller
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(permission)
WHERE e.role IN ('workshop', 'mechanic')
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -0,0 +1,11 @@
-- v4.12_service_order_soft_delete.sql
-- Soft-delete support for service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_is_deleted
ON service_orders (is_deleted);
-- Exclude deleted orders from common queries by default via partial index helpers
-- (application filters explicitly).

View File

@@ -0,0 +1,20 @@
-- v4.13_fleet_vehicle_customer.sql
-- Link fleet vehicles to a customer.
ALTER TABLE fleet_vehicles
ADD COLUMN IF NOT EXISTS customer_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fleet_vehicles_customer_id_fkey'
) THEN
ALTER TABLE fleet_vehicles
ADD CONSTRAINT fleet_vehicles_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES customers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_customer_id
ON fleet_vehicles (customer_id);

View File

@@ -0,0 +1,10 @@
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
UPDATE service_orders
SET delivery_method = 'pickup'
WHERE delivery_method = 'courier';
UPDATE service_orders
SET courier_id = NULL
WHERE delivery_method IS NULL OR delivery_method != 'delivery';

View File

@@ -0,0 +1,18 @@
-- v4.15: Counter remission note support
-- Ensures employees with role 'counter' have the base permissions needed to
-- generate remission notes from the POS.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.permission
FROM employees e
CROSS JOIN (
VALUES
('pos.remission'),
('pos.view'),
('catalog.view'),
('inventory.view'),
('customers.view'),
('customers.create')
) AS p(permission)
WHERE e.role = 'counter'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -0,0 +1,18 @@
-- v4.16: Add courier assignment to counter remission notes.
ALTER TABLE sales
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'sales_courier_id_fkey'
) THEN
ALTER TABLE sales
ADD CONSTRAINT sales_courier_id_fkey
FOREIGN KEY (courier_id) REFERENCES couriers(id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);

View File

@@ -0,0 +1,6 @@
-- Service order invoice requirement flag
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
ON service_orders(requires_invoice);

View File

@@ -0,0 +1,32 @@
-- v4.18 Rached workshop fields
-- Extends service orders and items to match the Rached legacy workshop flow.
-- Applied to all tenants.
-- ═════════════════════════════════════════════════════════════════════════════
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
ADD COLUMN IF NOT EXISTS customer_address TEXT,
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
-- ═════════════════════════════════════════════════════════════════════════════
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE service_order_items
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
ADD COLUMN IF NOT EXISTS observations TEXT;
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);

View File

@@ -0,0 +1,11 @@
-- v4.19 — Grant inventory.create permission to existing counter employees
-- so they can create items and record purchase entries.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'inventory.create'
FROM employees e
WHERE e.role = 'counter'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
);

View File

@@ -0,0 +1,18 @@
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
-- The cashier default set already includes these permissions, but employees
-- created before the fix may be missing them.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('pos.sell'),
('pos.discount'),
('pos.cancel'),
('pos.view')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -0,0 +1,15 @@
-- v4.21 — Allow existing cashier employees to create and edit service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('workshop.view'),
('workshop.edit'),
('workshop.add_items')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -0,0 +1,15 @@
-- v4.22 -- Enable invoicing for existing cashier employees.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, p.perm
FROM employees e
CROSS JOIN (VALUES
('invoicing.view'),
('invoicing.create'),
('invoicing.cancel')
) AS p(perm)
WHERE e.role = 'cashier'
AND e.is_active = true
AND NOT EXISTS (
SELECT 1 FROM employee_permissions ep
WHERE ep.employee_id = e.id AND ep.permission = p.perm
);

View File

@@ -0,0 +1,2 @@
-- v4.23 -- Add free-text mechanic name for service orders.
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;

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

@@ -0,0 +1,20 @@
-- Tenant DB schema v4.6 — support tables for inventory UI
-- Product categories (hierarchical, optional)
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_categories_active ON categories(is_active);
-- Tier-based discount configuration for price tiers 2 and 3
CREATE TABLE IF NOT EXISTS tier_discounts (
tier_id INTEGER PRIMARY KEY,
tier_name VARCHAR(50) NOT NULL,
discount_pct NUMERIC(5,2) DEFAULT 0
);

View File

@@ -0,0 +1,11 @@
-- v4.6 Workshop business fields
-- Adds delivery method, courier assignment and direct-order flag to service orders.
ALTER TABLE service_orders
ADD COLUMN IF NOT EXISTS delivery_method VARCHAR(30),
ADD COLUMN IF NOT EXISTS courier_id INTEGER REFERENCES couriers(id),
ADD COLUMN IF NOT EXISTS is_direct BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_service_orders_delivery_method ON service_orders(delivery_method);
CREATE INDEX IF NOT EXISTS idx_service_orders_courier_id ON service_orders(courier_id);
CREATE INDEX IF NOT EXISTS idx_service_orders_is_direct ON service_orders(is_direct);

View File

@@ -0,0 +1,15 @@
-- v4.8 Workshop permissions seed
-- Grants workshop permissions to existing admin employees so they keep access
-- after the new role-based restrictions are enforced.
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.view'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;
INSERT INTO employee_permissions (employee_id, permission)
SELECT id, 'workshop.edit'
FROM employees
WHERE role = 'admin'
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,7 @@
-- v4.9: Grant customers.view permission to existing workshop employees
-- so they can load the customer list when creating service orders.
INSERT INTO employee_permissions (employee_id, permission)
SELECT e.id, 'customers.view'
FROM employees e
WHERE e.role = 'workshop'
ON CONFLICT (employee_id, permission) DO NOTHING;

View File

@@ -46,18 +46,22 @@ def _get_account_id(cur, code):
def _get_account_ids(cur, codes):
"""Look up multiple account IDs by code. Returns dict {code: id}."""
result = {}
for code in codes:
result[code] = _get_account_id(cur, code)
return result
cur.execute(
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true",
(list(codes),)
)
rows = {row[0]: row[1] for row in cur.fetchall()}
missing = set(codes) - set(rows)
if missing:
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
return rows
def get_next_entry_number(conn):
"""Get the next sequential journal entry number.
Uses a simple MAX+1 approach. For high-concurrency environments this
could be replaced with a sequence, but for single-tenant refaccionarias
the transaction-level lock from the INSERT is sufficient.
Uses a transaction-level advisory lock to prevent duplicate numbers
when multiple journal entries are created concurrently.
Args:
conn: psycopg2 connection to tenant DB
@@ -66,6 +70,7 @@ def get_next_entry_number(conn):
int: next entry number (starts at 1)
"""
cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
number = cur.fetchone()[0]
cur.close()

View File

@@ -285,20 +285,24 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
# Filter to North America models only, add clean display name, deduplicate
filtered = [r for r in rows if is_na_model(brand_name, r[1])]
# Group by (display_name, raw name) so distinct body-style variants
# (e.g. AVEO vs AVEO SALOON) remain selectable.
seen = set()
results = []
# Group by display_name so body-style/generation variants
# (e.g. AVEO Saloon, AVEO Hatchback) are shown as a single model.
groups = {}
for r in filtered:
display = _clean_model_name(r[1])
key = (display, r[1])
if key not in seen:
seen.add(key)
results.append({
'id_model': r[0],
'name_model': r[1],
'display_name': display,
})
groups.setdefault(display, []).append(r)
results = []
for display, variants in groups.items():
# Sort by raw model id ascending; first becomes the canonical id.
variants.sort(key=lambda x: x[0])
canonical = variants[0]
results.append({
'id_model': canonical[0],
'name_model': canonical[1],
'display_name': display,
'variant_ids': [v[0] for v in variants],
})
# Sort by display_name
results.sort(key=lambda x: x['display_name'])
@@ -306,34 +310,37 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
def get_years(master_conn, model_id, mye_ids=None):
"""Get distinct years for a model via MYE (fast, no vehicle_parts scan). Ordered DESC."""
"""Get distinct years for a model (or list of model variants) via MYE.
Ordered DESC."""
cur = master_conn.cursor()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
if mye_ids:
cur.execute("""
SELECT DISTINCT y.id_year, y.year_car
FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = %s AND mye.id_mye = ANY(%s)
WHERE mye.model_id = ANY(%s) AND mye.id_mye = ANY(%s)
ORDER BY y.year_car DESC
""", (model_id, mye_ids))
""", (list(model_ids), mye_ids))
else:
cur.execute("""
SELECT DISTINCT y.id_year, y.year_car
FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = %s
WHERE mye.model_id = ANY(%s)
ORDER BY y.year_car DESC
""", (model_id,))
""", (list(model_ids),))
rows = cur.fetchall()
cur.close()
return [{'id_year': r[0], 'year_car': r[1]} for r in rows]
def get_engines(master_conn, model_id, year_id, mye_ids=None):
"""Get MYE entries (engine + trim) for a model+year combo."""
"""Get MYE entries (engine + trim) for a model (or list of variants) + year combo."""
cur = master_conn.cursor()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
mye_filter = ""
params = [model_id, year_id]
params = [list(model_ids), year_id]
if mye_ids:
mye_filter = " AND mye.id_mye = ANY(%s)"
params.append(mye_ids)
@@ -341,12 +348,29 @@ def get_engines(master_conn, model_id, year_id, mye_ids=None):
SELECT mye.id_mye, e.name_engine, mye.trim_level
FROM model_year_engine mye
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.model_id = %s AND mye.year_id = %s{mye_filter}
ORDER BY e.name_engine, mye.trim_level
WHERE mye.model_id = ANY(%s) AND mye.year_id = %s{mye_filter}
ORDER BY e.name_engine, mye.trim_level, mye.id_mye
""", tuple(params))
rows = cur.fetchall()
cur.close()
return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows]
def _clean_engine_name(name):
if not name or name.strip().upper() in ('N/A', 'RUEDA', ''):
return 'Sin especificar'
return name.strip()
# Deduplicate identical (name, trim) entries so the user doesn't see
# multiple indistinguishable "Sin especificar" options.
seen = set()
results = []
for id_mye, name_engine, trim_level in rows:
clean_name = _clean_engine_name(name_engine)
key = (clean_name, trim_level or '')
if key in seen:
continue
seen.add(key)
results.append({'id_mye': id_mye, 'name_engine': clean_name, 'trim_level': key[1]})
return results
def get_categories(master_conn, mye_id, allowed_brands=None):

View File

@@ -9,8 +9,8 @@ generates those payloads for:
- Factura global mensual
"""
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
# SAT defaults
RFC_PUBLICO_GENERAL = "XAXX010101000"
@@ -148,9 +148,7 @@ 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["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
payload["payment_method"] = "PUE"
return payload
@@ -162,15 +160,12 @@ def build_pago_payload(payment, tenant_config, customer, original_uuid):
amount = _to_dec(payment.get("amount", 0))
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
iva = (amount - base).quantize(TWO, ROUND_HALF_UP)
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
if "T" not in str(payment_date):
payment_date = f"{payment_date}T12:00:00"
forma_pago = FORMA_PAGO_MAP.get(
(payment.get("payment_method") or "").lower().strip(), "01"
)
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
payload = {
"type": "P",

View File

@@ -17,7 +17,7 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
import json
import logging
from datetime import datetime, timedelta
from datetime import datetime
from services import facturapi_service
@@ -29,12 +29,17 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001."""
"""Generate a provisional folio like PRE-00001.
Uses a transaction-level advisory lock to avoid duplicate provisional
folios when multiple CFDIs are enqueued concurrently.
"""
cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0]
cur.close()
return f'PRE-{seq:05d}'
return f"PRE-{seq:05d}"
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
@@ -54,22 +59,25 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
cur.execute("""
cur.execute(
"""
INSERT INTO cfdi_queue
(sale_id, type, payload_unsigned, status, provisional_folio)
VALUES (%s, %s, %s, 'pending', %s)
RETURNING id, created_at
""", (sale_id, cfdi_type, payload_json, provisional_folio))
""",
(sale_id, cfdi_type, payload_json, provisional_folio),
)
cfdi_id, created_at = cur.fetchone()
cur.close()
return {
'id': cfdi_id,
'sale_id': sale_id,
'type': cfdi_type,
'status': 'pending',
'provisional_folio': provisional_folio,
'created_at': str(created_at),
"id": cfdi_id,
"sale_id": sale_id,
"type": cfdi_type,
"status": "pending",
"provisional_folio": provisional_folio,
"created_at": str(created_at),
}
@@ -90,34 +98,41 @@ def process_queue(conn, tenant_config, dry_run=False):
"""
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT id, sale_id, type, payload_unsigned, retry_count
FROM cfdi_queue
WHERE status IN ('pending', 'failed')
AND retry_count < %s
ORDER BY created_at ASC
LIMIT 50
""", (MAX_RETRIES,))
FOR UPDATE SKIP LOCKED
""",
(MAX_RETRIES,),
)
items = cur.fetchall()
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
results = {"processed": 0, "stamped": 0, "failed": 0, "details": []}
api_key = tenant_config.get('facturapi_key')
api_key = tenant_config.get("facturapi_key")
if not api_key:
cur.close()
raise ValueError("Facturapi key not configured for tenant")
for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items:
results['processed'] += 1
for cfdi_id, _sale_id, _cfdi_type, payload_unsigned, _retry_count in items:
results["processed"] += 1
# Update status to 'sending'
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
conn.commit()
try:
payload = json.loads(payload_unsigned or '{}')
payload = json.loads(payload_unsigned or "{}")
if not payload:
raise ValueError("Empty payload in queue item")
@@ -127,18 +142,19 @@ def process_queue(conn, tenant_config, dry_run=False):
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')
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)
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 = ''
xml_signed_str = ""
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'stamped',
xml_signed = %s,
@@ -147,30 +163,37 @@ def process_queue(conn, tenant_config, dry_run=False):
stamped_at = NOW(),
error_message = NULL
WHERE id = %s
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
""",
(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,
})
results["stamped"] += 1
results["details"].append(
{
"id": cfdi_id,
"status": "stamped",
"uuid": uuid_fiscal,
"external_id": invoice_id,
}
)
except Exception as e:
error_msg = f'{type(e).__name__}: {str(e)[:500]}'
cur.execute("""
error_msg = f"{type(e).__name__}: {str(e)[:500]}"
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
""",
(error_msg, cfdi_id),
)
conn.commit()
results['failed'] += 1
results['details'].append({
'id': cfdi_id, 'status': 'failed', 'error': error_msg
})
results["failed"] += 1
results["details"].append({"id": cfdi_id, "status": "failed", "error": error_msg})
cur.close()
return results
@@ -184,30 +207,33 @@ def retry_failed(conn):
"""
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT id, retry_count, created_at
FROM cfdi_queue
WHERE status = 'failed' AND retry_count < %s
ORDER BY created_at ASC
""", (MAX_RETRIES,))
""",
(MAX_RETRIES,),
)
items = cur.fetchall()
reset_count = 0
now = datetime.utcnow()
for cfdi_id, retry_count, created_at in items:
if retry_count < len(BACKOFF_INTERVALS):
wait_seconds = BACKOFF_INTERVALS[retry_count]
else:
wait_seconds = BACKOFF_INTERVALS[-1]
wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1]
# Use created_at as approximation for last attempt.
# In production, track last_attempt_at separately.
elapsed = (now - created_at).total_seconds()
if elapsed >= wait_seconds:
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
reset_count += 1
conn.commit()
@@ -215,8 +241,7 @@ def retry_failed(conn):
return reset_count
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
tenant_config=None):
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None):
"""Cancel a stamped CFDI via Facturapi.
SAT cancellation motives:
@@ -238,38 +263,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
Raises:
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}")
if motive == '01' and not replacement_uuid:
if motive == "01" and not replacement_uuid:
raise ValueError("Motive 01 requires a replacement UUID")
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
row = cur.fetchone()
if not row:
raise ValueError(f"CFDI queue item {cfdi_id} not found")
_, uuid_fiscal, external_id, current_status = row
if current_status == 'cancelled':
if current_status == "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
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'cancelled', cancel_motive = %s
WHERE id = %s
""", (motive, cfdi_id))
""",
(motive, cfdi_id),
)
conn.commit()
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)"}
if not tenant_config or not tenant_config.get('facturapi_key'):
if not tenant_config or not tenant_config.get("facturapi_key"):
cur.close()
raise ValueError("Facturapi key not configured for tenant")
@@ -279,36 +310,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
try:
facturapi_service.cancel_invoice(
tenant_config, external_id, motive,
tenant_config,
external_id,
motive,
replacement_uuid=replacement_uuid,
)
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'cancelled',
cancel_motive = %s,
cancel_replacement_uuid = %s,
error_message = NULL
WHERE id = %s
""", (motive, replacement_uuid, cfdi_id))
""",
(motive, replacement_uuid, cfdi_id),
)
conn.commit()
cur.close()
return {
'id': cfdi_id,
'status': 'cancelled',
'message': f'Cancelled with SAT (motive {motive})',
"id": cfdi_id,
"status": "cancelled",
"message": f"Cancelled with SAT (motive {motive})",
}
except Exception as e:
error_msg = f'Cancel failed: {str(e)[:500]}'
cur.execute("""
error_msg = f"Cancel failed: {str(e)[:500]}"
cur.execute(
"""
UPDATE cfdi_queue
SET error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
""",
(error_msg, cfdi_id),
)
conn.commit()
cur.close()
raise ValueError(error_msg)
raise ValueError(error_msg) from e
def get_queue_status(conn, filters=None):
@@ -316,57 +355,79 @@ def get_queue_status(conn, filters=None):
filters = filters or {}
cur = conn.cursor()
page = int(filters.get('page', 1))
per_page = min(int(filters.get('per_page', 50)), 200)
page = int(filters.get("page", 1))
per_page = min(int(filters.get("per_page", 50)), 200)
where_clauses = ["1=1"]
params = []
if filters.get('status'):
if filters.get("status"):
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")
params.append(int(filters['sale_id']))
params.append(int(filters["sale_id"]))
if filters.get('type'):
where_clauses.append("q.type = %s")
params.append(filters['type'])
if filters.get("type"):
where_clauses.append("LOWER(q.type) = LOWER(%s)")
params.append(filters["type"])
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
cur.execute(
f"""
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
q.retry_count, q.provisional_folio, q.error_message,
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
q.cancel_motive, q.created_at, q.stamped_at, q.external_id,
c.name as customer_name, c.rfc,
s.subtotal, s.tax_total, s.total, s.payment_method
FROM cfdi_queue q
LEFT JOIN sales s ON q.sale_id = s.id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY q.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
""",
params + [per_page, (page - 1) * per_page],
)
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'sale_id': r[1], 'type': r[2],
'uuid_fiscal': r[3], '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],
})
items.append(
{
"id": r[0],
"sale_id": r[1],
"type": r[2],
"uuid_fiscal": r[3],
"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],
"customer_name": r[12],
"rfc": r[13],
"subtotal": float(r[14]) if r[14] else 0,
"tax_total": float(r[15]) if r[15] else 0,
"total": float(r[16]) if r[16] else 0,
"payment_method": r[17],
}
)
cur.close()
total_pages = (total + per_page - 1) // per_page
return {
'data': items,
'pagination': {
'page': page, 'per_page': per_page,
'total': total, 'total_pages': total_pages,
}
"data": items,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
},
}

View File

@@ -12,11 +12,10 @@ Authentication modes:
Reference: https://docs.facturapi.io/
"""
import os
import base64
import logging
import os
from decimal import Decimal
from typing import Optional
import requests
@@ -35,8 +34,8 @@ class FacturapiError(Exception):
# ─── HTTP helpers ───────────────────────────────────────────────────────────
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None,
extra_headers=None, timeout=60):
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"}
@@ -54,7 +53,7 @@ def _request(method: str, endpoint: str, api_key: str, json_payload=None, params
timeout=timeout,
)
except requests.RequestException as e:
raise FacturapiError(f"Connection error: {e}", status_code=0)
raise FacturapiError(f"Connection error: {e}", status_code=0) from e
if not resp.ok:
raise FacturapiError(
@@ -88,15 +87,24 @@ def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60)
# ─── Tenant config helpers ──────────────────────────────────────────────────
def _get_secret_key(tenant_config: dict) -> Optional[str]:
for key in ("facturapi_key", "facturapi_secret_key"):
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_user_key() -> Optional[str]:
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
@@ -117,42 +125,11 @@ def get_api_key(tenant_config: dict) -> str:
user = _get_user_key()
if user:
return user
raise FacturapiError(
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
)
raise FacturapiError("Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key")
# ─── Organizations ──────────────────────────────────────────────────────────
def create_organization(tenant_config: dict) -> dict:
"""Create a new Facturapi organization for the tenant.
Requires FACTURAPI_USER_KEY.
Returns dict with id, api_key.
"""
user_key = _get_user_key()
if not user_key:
raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations")
payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))}
legal = tenant_config.get("legal_name") or tenant_config.get("razon_social")
if legal:
payload["legal"] = {"name": legal}
if tenant_config.get("rfc"):
payload["legal"] = payload.get("legal", {})
payload["legal"]["tax_id"] = tenant_config["rfc"]
org = _request("POST", "/organizations", user_key, json_payload=payload)
org_id = org.get("id")
# Generate live secret key
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={})
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key}
def get_organization(org_id: str, api_key: str) -> dict:
return _request("GET", f"/organizations/{org_id}", api_key)
@@ -164,7 +141,7 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
cer_b64 and key_b64 are base64-encoded strings.
"""
api_key = get_api_key(tenant_config)
org_id = tenant_config.get("facturapi_org_id")
org_id = _get_org_id(tenant_config)
if not org_id:
raise FacturapiError("No Facturapi organization configured for tenant")
@@ -196,15 +173,14 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
user_key = _get_user_key()
if user_key:
return user_key
tenant_key = (tenant_config.get("facturapi_key") or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
raise FacturapiError(
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required"
)
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) -> Optional[dict]:
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).
@@ -230,7 +206,7 @@ def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]:
def create_organization(tenant_config: dict) -> dict:
"""Create a new Facturapi organization for the tenant and return live key.
"""Create or reuse a Facturapi organization, configure legal data and return a live key.
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
Uses tenant RFC/razon_social if available.
@@ -240,26 +216,121 @@ def create_organization(tenant_config: dict) -> dict:
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:
# 1) Reuse the organization already stored locally, if it still exists in Facturapi.
local_org_id = _get_org_id(tenant_config)
if local_org_id:
try:
get_organization(local_org_id, user_key)
org_id = local_org_id
except FacturapiError:
local_org_id = None
# 2) Try to find an existing organization by RFC (legacy / Horux-style lookup).
if not local_org_id and rfc:
existing = find_organization_by_rfc(tenant_config)
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")
elif not local_org_id:
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")
# Configure fiscal/legal data (required before Live mode can be used)
try:
update_organization_legal(tenant_config, org_id)
except FacturapiError:
# If legal update fails, still return org info so the caller can surface it,
# but do not generate a live key because it would be unusable.
return {"org_id": org_id, "api_key": None, "legal_updated": False}
# Generate live secret key
key_resp = _request(
"PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60
)
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}
return {"org_id": org_id, "api_key": live_key, "legal_updated": True}
def _build_legal_payload(tenant_config: dict) -> dict:
"""Build Facturapi /organizations/{id}/legal payload from tenant config.
Facturapi expects `name`, `legal_name`, `tax_system` and an `address` object
with at least `street` and `exterior` non-empty. `tax_id` is not accepted
by this endpoint (it is set from the CSD or from the organization profile).
"""
rfc = (tenant_config.get("rfc") or "").upper().strip()
legal_name = (tenant_config.get("razon_social") or "").strip()
tax_system = (tenant_config.get("regimen_fiscal") or "").strip() or "601"
zip_code = (tenant_config.get("cp") or "").strip() or "00000"
if not rfc or not legal_name:
raise FacturapiError("RFC y Razón Social son obligatorios para configurar la organización en Facturapi")
name = legal_name or rfc or "Nexus"
street = (tenant_config.get("direccion") or "").strip() or "No especificada"
exterior = (
(tenant_config.get("exterior") or "").strip()
or (tenant_config.get("numero_exterior") or "").strip()
or "S/N"
)
address = {
"zip": zip_code,
"street": street,
"exterior": exterior,
}
optional_address_fields = {
"interior": (tenant_config.get("interior") or tenant_config.get("numero_interior") or "").strip(),
"neighborhood": (tenant_config.get("colonia") or "").strip(),
"city": (tenant_config.get("ciudad") or "").strip(),
"municipality": (tenant_config.get("municipio") or "").strip(),
"state": (tenant_config.get("estado") or "").strip(),
}
for key, value in optional_address_fields.items():
if value:
address[key] = value
return {
"name": name,
"legal_name": legal_name,
"tax_system": tax_system,
"address": address,
}
def update_organization_legal(tenant_config: dict, org_id: str) -> dict:
"""Update the legal/fiscal data of a Facturapi organization.
Uses the user key (admin) because the org may not have a live key yet.
"""
user_key = _get_user_key_for_tenant(tenant_config)
payload = _build_legal_payload(tenant_config)
return _request("PUT", f"/organizations/{org_id}/legal", user_key, json_payload=payload, timeout=60)
def _get_status_key(tenant_config: dict) -> str | None:
"""Return the best key for read-only organization status checks.
Prefer the Facturapi user key because the live secret key cannot read
organization metadata until the org is production-ready.
"""
user = _get_user_key()
if user:
return user
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
return _get_secret_key(tenant_config)
def get_org_status(tenant_config: dict) -> dict:
@@ -275,14 +346,14 @@ def get_org_status(tenant_config: dict) -> dict:
"error": None,
}
try:
api_key = get_api_key(tenant_config)
result["has_key"] = True
except FacturapiError as e:
result["error"] = str(e)
has_secret = bool(_get_secret_key(tenant_config))
has_user = bool(_get_user_key_for_tenant(tenant_config))
if not has_secret and not has_user:
result["error"] = "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
return result
result["has_key"] = True
org_id = tenant_config.get("facturapi_org_id")
org_id = _get_org_id(tenant_config)
if not org_id:
result["error"] = "No Facturapi organization configured"
return result
@@ -290,25 +361,43 @@ def get_org_status(tenant_config: dict) -> dict:
result["has_org_id"] = True
result["org_id"] = org_id
try:
api_key = _get_status_key(tenant_config)
if not api_key:
result["error"] = "No Facturapi key available"
return result
def _fetch():
org = get_organization(org_id, api_key)
legal = org.get("legal", {})
cert = org.get("certificate", {})
result.update({
return {
"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", []),
})
}
try:
result.update(_fetch())
except FacturapiError as e:
result["error"] = str(e)
# If the org reports "not configured for Live" (401) and we have a user key,
# try to backfill legal data and retry.
if e.status_code == 401 and _get_user_key_for_tenant(tenant_config):
try:
update_organization_legal(tenant_config, org_id)
result.update(_fetch())
except FacturapiError as e2:
result["error"] = str(e2)
else:
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.
@@ -364,6 +453,7 @@ def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
# ─── Invoices ───────────────────────────────────────────────────────────────
def create_invoice(tenant_config: dict, payload: dict) -> dict:
"""Create and stamp an invoice in Facturapi.
@@ -373,8 +463,7 @@ def create_invoice(tenant_config: dict, payload: dict) -> dict:
return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str,
replacement_uuid: Optional[str] = None) -> dict:
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, replacement_uuid: str | None = None) -> dict:
"""Cancel an invoice in Facturapi.
Motive codes:
@@ -402,6 +491,7 @@ def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
# ─── Helpers ─────────────────────────────────────────────────────────────────
def is_lco_rejection(message: str) -> bool:
"""Detect SAT LCO rejection (CSD not yet propagated)."""
if not message:

View File

@@ -58,19 +58,18 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
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
# Load sale details with items in two bulk queries (O(1) round-trips)
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 = ANY(%s)
ORDER BY created_at ASC
""", (sale_ids,))
sales = {}
for row in cur.fetchall():
sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
'employee_id': row[3], 'sale_type': row[4],
@@ -85,33 +84,37 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
'created_at': str(row[13]),
'items': [],
}
sales[row[0]] = sale
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,))
cur.execute("""
SELECT id, sale_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 = ANY(%s)
ORDER BY sale_id, id
""", (sale_ids,))
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)
for r in cur.fetchall():
sale = sales.get(r[1])
if not sale:
continue
sale['items'].append({
'id': r[0], 'inventory_id': r[2], 'part_number': r[3],
'name': r[4], 'quantity': r[5],
'unit_price': float(r[6]) if r[6] else 0,
'unit_cost': float(r[7]) if r[7] else 0,
'discount_pct': float(r[8]) if r[8] else 0,
'discount_amount': float(r[9]) if r[9] else 0,
'tax_rate': float(r[10]) if r[10] else 0.16,
'tax_amount': float(r[11]) if r[11] else 0,
'subtotal': float(r[12]) if r[12] else 0,
'clave_prod_serv': r[13] or '25174800',
'clave_unidad': r[14] or 'H87',
})
cur.close()
return sales
return list(sales.values())
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,

View File

@@ -122,16 +122,23 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
))
op_id = cur.fetchone()[0]
# Queue ML stock sync if this product has an active ML listing
# Queue ML stock sync if this product has an active ML listing.
# Skip gracefully if the marketplace tables do not exist in this tenant.
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))
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('marketplace_listings', 'meli_sync_queue')
""")
if cur.fetchone()[0] >= 2:
cur.execute("""
INSERT INTO meli_sync_queue (inventory_id, action, status)
SELECT %s, 'stock_update', 'pending'
WHERE EXISTS (
SELECT 1 FROM marketplace_listings
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
)
ON CONFLICT DO NOTHING
""", (inventory_id, inventory_id))
cur.close()
return op_id
@@ -230,6 +237,54 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
return op_id
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
cost_at_time=None, remaining_stock=None):
"""Reserve stock for a counter remission note (negative quantity).
The reserved quantity is deducted from available stock until the note is
paid or cancelled.
"""
op_id = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
notes='Reserva por nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
try:
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
if remaining <= 0:
cur = conn.cursor()
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
inv_row = cur.fetchone()
cur.close()
if inv_row:
from services.push_service import notify_owner
notify_owner(
conn,
'Stock en Cero',
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
'/pos'
)
except Exception:
pass
return op_id
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Release a previous reservation (positive quantity)."""
result = record_operation(
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
reference_id=sale_id, reference_type='sale',
notes=notes or 'Liberacion de reserva de nota de remision'
)
invalidate_stock(inventory_id, branch_id)
invalidate_stock(inventory_id, None)
return result
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
"""Record a customer return (positive quantity)."""
result = record_operation(
@@ -374,7 +429,7 @@ def get_movement_history(conn, inventory_id, limit=50):
history.append({
'id': r[0], 'type': r[1], 'quantity': r[2],
'cost': float(r[3]) if r[3] else None,
'notes': r[4], 'date': str(r[5]),
'notes': r[4], 'date': r[5].isoformat() if r[5] else None,
'employee': r[6], 'branch_id': r[7]
})
cur.close()

View File

@@ -11,10 +11,13 @@ Tax: 16% IVA per item (from item.tax_rate field).
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
import threading
from flask import g
from services.audit import log_action
from services.inventory_engine import (
record_sale as inventory_record_sale,
record_reservation as inventory_record_reservation,
release_reservation as inventory_release_reservation,
record_operation,
get_stock,
get_stock_bulk,
@@ -223,9 +226,9 @@ def process_sale(conn, sale_data):
if not items:
raise ValueError("No items in sale")
# Validate register is open
# Validate register is open and lock it to prevent concurrent close/sale races
if register_id:
cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,))
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
reg = cur.fetchone()
if not reg or reg[0] != 'open':
raise ValueError("Cash register is not open")
@@ -249,6 +252,17 @@ def process_sale(conn, sale_data):
# Batch stock check
stock_map = get_stock_bulk(conn, branch_id)
# Lock per-branch stock rows and refresh stock map to prevent overselling
# on concurrent sales of the same items.
if branch_id:
cur.execute("""
SELECT inventory_id, stock
FROM inventory_stock
WHERE branch_id = %s AND inventory_id = ANY(%s)
FOR UPDATE
""", (branch_id, inv_ids))
stock_map = {r[0]: r[1] for r in cur.fetchall()}
# Validate and enrich items
enriched_items = []
for item in items:
@@ -301,24 +315,34 @@ def process_sale(conn, sale_data):
credit_limit = float(cust[0] or 0)
credit_balance = float(cust[1] or 0)
credit_available = credit_limit - credit_balance
if totals['total'] > credit_available and credit_limit > 0:
if totals['total'] > credit_available:
raise ValueError(
f"Insufficient credit. Available: ${credit_available:.2f}, "
f"Required: ${totals['total']:.2f}"
)
# Pending payment sale (e.g. "Pendiente")
is_pending = payment_method == 'pendiente'
if is_pending:
amount_paid = 0.0
sale_type = 'cash'
# Calculate change
change_given = 0.0
if sale_type == 'cash' and payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
# SAT payment method codes
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
forma_pago_map = {
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
}
forma_pago_sat = forma_pago_map.get(payment_method, '99')
# Determine sale status
status = 'pending_payment' if is_pending else 'completed'
# Create sale record (with currency)
cur.execute("""
INSERT INTO sales
@@ -326,14 +350,14 @@ def process_sale(conn, sale_data):
payment_method, subtotal, discount_total, tax_total, total,
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
status, device_id, notes, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id, sale_type,
payment_method, totals['subtotal'], totals['discount_total'],
totals['tax_total'], totals['total'], amount_paid, change_given,
metodo_pago_sat, forma_pago_sat,
_safe_g('device_id'), notes,
status, _safe_g('device_id'), notes,
currency, exchange_rate
))
sale_id, created_at = cur.fetchone()
@@ -392,23 +416,26 @@ def process_sale(conn, sale_data):
'subtotal': item['subtotal'],
})
# Record payment on cash register (cash movements for efectivo)
if register_id and payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
# Record payment on cash register (skip pending sales and zero-amount rows)
if not is_pending:
if register_id and payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
if amt <= 0:
continue
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
elif register_id and amount_paid > 0:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
elif register_id:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
# Update customer credit balance if credit sale
if sale_type == 'credit' and customer_id:
@@ -418,6 +445,16 @@ def process_sale(conn, sale_data):
WHERE id = %s
""", (totals['total'], customer_id))
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
# Audit log
log_action(conn, 'SALE', 'sale', sale_id,
new_value={
@@ -494,6 +531,8 @@ def process_sale(conn, sale_data):
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': sale_type,
@@ -514,6 +553,342 @@ def process_sale(conn, sale_data):
}
def create_remission_note(conn, data):
"""Create a counter remission note: reserve stock, no payment, pending status.
data: {
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int | null,
notes: str,
branch_id: int,
register_id: int | null (optional)
}
"""
cur = conn.cursor()
items = data.get('items', [])
customer_id = data.get('customer_id')
notes = data.get('notes')
branch_id = data.get('branch_id') or _safe_g('branch_id')
register_id = data.get('register_id')
employee_id = _safe_g('employee_id')
currency = data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'):
raise ValueError("Unsupported currency")
exchange_rate = float(data.get('exchange_rate') or 1.0)
if currency != 'MXN' and not exchange_rate:
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
if not branch_id:
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
row = cur.fetchone()
branch_id = row[0] if row else None
if not branch_id:
raise ValueError("No hay sucursal activa disponible")
if not items:
raise ValueError("No items in remission note")
inv_ids = [item.get('inventory_id') for item in items]
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id, retail_price
FROM inventory
WHERE id = ANY(%s) AND is_active = true
ORDER BY id
FOR UPDATE
""", (inv_ids,))
inv_rows = {r[0]: r for r in cur.fetchall()}
stock_map = get_stock_bulk(conn, branch_id)
if branch_id:
cur.execute("""
SELECT inventory_id, stock
FROM inventory_stock
WHERE branch_id = %s AND inventory_id = ANY(%s)
FOR UPDATE
""", (branch_id, inv_ids))
stock_map = {r[0]: r[1] for r in cur.fetchall()}
enriched_items = []
for item in items:
inv_id = item.get('inventory_id')
qty = int(item.get('quantity', 1))
if qty <= 0:
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
inv = inv_rows.get(inv_id)
if not inv:
raise ValueError(f"Inventory item {inv_id} not found or inactive")
current_stock = stock_map.get(inv_id, 0)
unit_price = float(item.get('unit_price', inv[4]))
discount_pct = float(item.get('discount_pct', 0))
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
unit_cost = float(inv[3]) if inv[3] else 0
enriched_items.append({
'inventory_id': inv_id,
'part_number': inv[1],
'name': inv[2],
'quantity': qty,
'unit_price': unit_price,
'unit_cost': unit_cost,
'discount_pct': discount_pct,
'tax_rate': tax_rate,
'branch_id': inv[8],
'stock_before': current_stock,
})
totals = calculate_totals(enriched_items)
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, status, device_id, notes, currency, exchange_rate,
courier_id)
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
%s, %s, %s, %s, 0, 0, 'pending_payment',
%s, %s, %s, %s, %s)
RETURNING id, created_at
""", (
branch_id, customer_id, employee_id, register_id,
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
_safe_g('device_id'), notes, currency, exchange_rate,
data.get('courier_id')
))
sale_id, created_at = cur.fetchone()
sale_items_data = []
for item in totals['items']:
inv = inv_rows.get(item['inventory_id'])
retail_price = inv[9] if inv else None
sale_items_data.append((
sale_id, item['inventory_id'], item['part_number'], item['name'],
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal'],
retail_price, currency, exchange_rate
))
cur.executemany("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
unit_price, unit_cost, discount_pct, discount_amount,
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", sale_items_data)
sale_items = []
for item in totals['items']:
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
remaining_after = stock_before - item['quantity']
inventory_record_reservation(
conn,
item['inventory_id'],
item.get('branch_id', branch_id),
item['quantity'],
sale_id=sale_id,
cost_at_time=item.get('unit_cost'),
remaining_stock=remaining_after
)
sale_items.append({
'inventory_id': item['inventory_id'],
'part_number': item['part_number'],
'name': item['name'],
'quantity': item['quantity'],
'unit_price': item['unit_price'],
'unit_cost': item.get('unit_cost', 0),
'discount_pct': item['discount_pct'],
'discount_amount': item['discount_amount'],
'tax_rate': item['tax_rate'],
'tax_amount': item['tax_amount'],
'subtotal': item['subtotal'],
})
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
new_value={
'total': totals['total'],
'items_count': len(sale_items),
'customer_id': customer_id,
})
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
cur.close()
return {
'id': sale_id,
'branch_id': branch_id,
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
'employee_id': employee_id,
'register_id': register_id,
'sale_type': 'counter_remission',
'payment_method': 'remission',
'subtotal': totals['subtotal'],
'discount_total': totals['discount_total'],
'tax_total': totals['tax_total'],
'total': totals['total'],
'amount_paid': 0.0,
'change_given': 0.0,
'status': 'pending_payment',
'courier_id': data.get('courier_id'),
'items': sale_items,
'created_at': str(created_at),
'currency': currency,
'exchange_rate': exchange_rate,
}
def pay_pending_sale(conn, sale_id, data):
"""Pay a pending counter remission note and convert it into a completed sale.
data: {
payment_method: str,
amount_paid: float,
payment_details: [{method, amount, reference}],
register_id: int,
reference: str
}
"""
cur = conn.cursor()
cur.execute("""
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate
FROM sales WHERE id = %s
FOR UPDATE
""", (sale_id,))
row = cur.fetchone()
if not row:
raise ValueError("Sale not found")
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
currency, exchange_rate) = row
# Fetch customer info for ticket/receipt
customer_name = None
customer_rfc = None
if customer_id:
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
cust_row = cur.fetchone()
if cust_row:
customer_name = cust_row[0]
customer_rfc = cust_row[1]
if status != 'pending_payment':
raise ValueError("La nota no esta pendiente de pago")
payment_method = data.get('payment_method', 'efectivo')
sale_type = 'cash'
amount_paid = float(data.get('amount_paid', 0))
payment_details = data.get('payment_details', [])
register_id = data.get('register_id')
reference = data.get('reference', '')
if register_id:
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
reg = cur.fetchone()
if not reg or reg[0] != 'open':
raise ValueError("Cash register is not open")
totals = {'total': float(total)}
change_given = 0.0
if payment_method == 'efectivo':
change_given = round(max(amount_paid - totals['total'], 0), 2)
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
forma_pago_sat = forma_pago_map.get(payment_method, '99')
cur.execute("""
UPDATE sales
SET status = 'completed',
sale_type = %s,
payment_method = %s,
amount_paid = %s,
change_given = %s,
register_id = COALESCE(%s, register_id),
metodo_pago_sat = 'PUE',
forma_pago_sat = %s
WHERE id = %s
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
if payment_details:
for pd in payment_details:
method = pd.get('method', payment_method)
amt = float(pd.get('amount', 0))
ref = pd.get('reference', '')
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
else:
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
# Release reservation and record actual sale movement
cur.execute("""
SELECT inventory_id, quantity, unit_cost
FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,))
for inv_id, qty, cost in cur.fetchall():
inventory_release_reservation(
conn, inv_id, branch_id, qty, sale_id=sale_id,
notes='Pago de nota de remision'
)
inventory_record_sale(
conn, inv_id, branch_id, qty,
sale_id=sale_id, cost_at_time=float(cost) if cost else None
)
# Accounting (non-blocking)
try:
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
cur.execute("""
SELECT COALESCE(SUM(unit_cost * quantity), 0)
FROM sale_items WHERE sale_id = %s
""", (sale_id,))
cost_total = float(cur.fetchone()[0] or 0)
record_sale_entry(conn, {
'id': sale_id,
'sale_type': sale_type,
'total': total_mxn,
'tax_total': tax_mxn,
'subtotal': sub_mxn,
'cost_total': cost_total,
'payment_method': payment_method,
})
except Exception:
pass
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
old_value={'status': 'pending_payment', 'total': totals['total']},
new_value={'status': 'completed', 'payment_method': payment_method})
cur.close()
return {
'id': sale_id,
'status': 'completed',
'payment_method': payment_method,
'amount_paid': amount_paid,
'change_given': change_given,
'total': totals['total'],
'customer_id': customer_id,
'customer_name': customer_name,
'customer_rfc': customer_rfc,
}
def cancel_sale(conn, sale_id, reason):
"""Cancel a sale: validate permissions, reverse inventory, update credit.
@@ -555,15 +930,15 @@ def cancel_sale(conn, sale_id, reason):
if s_status == 'cancelled':
raise ValueError("Sale is already cancelled")
# Permission check: cashiers can only cancel own sales within 30 min
# Permission check: non-admin employees can only cancel their own docs within 30 min
role = _safe_g('employee_role', 'cashier')
emp_id = _safe_g('employee_id')
if role == 'cashier':
if role not in ('owner', 'admin'):
if s_emp_id != emp_id:
raise ValueError("Cashiers can only cancel their own sales")
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
if datetime.utcnow() - s_created > timedelta(minutes=30):
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
# Get sale items for inventory reversal
cur.execute("""
@@ -572,14 +947,23 @@ def cancel_sale(conn, sale_id, reason):
""", (sale_id,))
sale_items = cur.fetchall()
# Reverse inventory: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion venta #{sale_id}: {reason}"
)
if s_status == 'pending_payment':
# Pending remission: release reservation
for inv_id, qty, cost in sale_items:
inventory_release_reservation(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
)
else:
# Completed sale: create RETURN operations (positive quantity)
from services.inventory_engine import record_return
for inv_id, qty, cost in sale_items:
record_return(
conn, inv_id, s_branch, qty,
sale_id=sale_id,
notes=f"Cancelacion venta #{sale_id}: {reason}"
)
# Update sale status
cur.execute("""
@@ -615,7 +999,7 @@ def cancel_sale(conn, sale_id, reason):
# Audit log
log_action(conn, 'CANCEL', 'sale', sale_id,
old_value={'status': 'completed', 'total': float(s_total)},
old_value={'status': s_status, 'total': float(s_total)},
new_value={'status': 'cancelled', 'reason': reason})
# Push notification to owner/admin (best-effort, non-blocking)

File diff suppressed because it is too large Load Diff

View File

@@ -247,7 +247,8 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
'accounting.view', 'accounting.create', 'accounting.close',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices'
'config.view', 'config.edit', 'config.edit_prices',
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'
]
tenant_cur.executemany(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)",

View File

@@ -16,9 +16,13 @@ ALIGN_CENTER = ESC + b'a' + b'\x01'
ALIGN_RIGHT = ESC + b'a' + b'\x02'
BOLD_ON = ESC + b'E' + b'\x01'
BOLD_OFF = ESC + b'E' + b'\x00'
EMPH_ON = ESC + b'G' + b'\x01' # Double-strike / emphasized
EMPH_OFF = ESC + b'G' + b'\x00'
DOUBLE_HEIGHT = ESC + b'!' + b'\x10'
NORMAL_SIZE = ESC + b'!' + b'\x00'
LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height
LINE_SPACING_140 = ESC + b'3' + b'\x28' # ~1.4x line spacing
LINE_SPACING_DEFAULT = ESC + b'2'
def generate_ticket(sale_data, business_info, width=80):
@@ -34,6 +38,8 @@ def generate_ticket(sale_data, business_info, width=80):
chars = 32 if width == 58 else 48 # characters per line
buf = bytearray()
buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header: business name (centered, bold, large)
buf += ALIGN_CENTER
@@ -100,7 +106,10 @@ def generate_ticket(sale_data, business_info, width=80):
buf += 'Gracias por su compra!\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n'
buf += PARTIAL_CUT
buf += LINE_SPACING_DEFAULT
buf += EMPH_OFF
buf += FEED + b'\x05' # Feed 5 lines before cutting
buf += CUT # Full cut
return bytes(buf)
@@ -119,6 +128,8 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
chars = 32 if width == 58 else 48
buf = bytearray()
buf += INIT
buf += EMPH_ON
buf += LINE_SPACING_140
# Header
buf += ALIGN_CENTER
@@ -187,7 +198,10 @@ def generate_quotation_ticket(quote_data, business_info, width=80):
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n'
buf += PARTIAL_CUT
buf += LINE_SPACING_DEFAULT
buf += EMPH_OFF
buf += FEED + b'\x05'
buf += CUT
return bytes(buf)
@@ -204,3 +218,145 @@ def _total_line(label, amount, width):
"""Format a totals line like 'Subtotal: $1,234.56'."""
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
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
buf += EMPH_ON
buf += LINE_SPACING_140
# 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 += LINE_SPACING_DEFAULT
buf += EMPH_OFF
buf += FEED + b'\x05'
buf += CUT
return bytes(buf)

View File

@@ -1243,3 +1243,45 @@
.summary-strip { grid-template-columns: 1fr; }
.finance-grid { grid-template-columns: 1fr; }
}
/* =========================================================================
MODAL DETALLE CUENTA POR COBRAR
========================================================================= */
.modal-overlay {
position: fixed; inset: 0;
background: var(--overlay-backdrop);
display: flex; align-items: center; justify-content: center;
z-index: var(--z-modal);
opacity: 0; pointer-events: none;
transition: var(--transition-normal);
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal-pago {
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); box-shadow: var(--shadow-xl);
width: 560px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
transform: translateY(20px); transition: var(--transition-normal);
}
.modal-overlay.open .modal-pago { transform: translateY(0); }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
font-family: var(--font-heading); font-size: var(--text-h4);
font-weight: var(--heading-weight-primary); color: var(--color-text-primary);
}
.modal-close {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid var(--color-border);
border-radius: var(--radius-md); cursor: pointer;
color: var(--color-text-muted); font-size: 18px; transition: var(--transition-fast);
}
.modal-close:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
.modal-footer {
display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3);
padding: var(--space-4) var(--space-6); border-top: 1px solid var(--color-border);
}

View File

@@ -88,7 +88,8 @@
.breadcrumb__link:hover { color: var(--color-primary); }
.breadcrumb__sep { color: var(--color-text-disabled); }
.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); }
/* ── Catalog mode toggle (OEM / Local) ── */
@@ -362,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 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: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__mfr { font-size: var(--text-caption); color: var(--color-text-muted); }
.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 */
.detail-footer {
padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border);

View File

@@ -1186,6 +1186,8 @@
.badge--green { background: rgba(34,197,94,0.15); color: var(--color-success); }
.badge--yellow { background: rgba(234,179,8,0.15); color: #eab308; }
.badge--purple { background: rgba(168,85,247,0.15); color: #a855f7; }
.badge--orange { background: rgba(249,115,22,0.15); color: #f97316; }
.badge--teal { background: rgba(20,184,166,0.15); color: #14b8a6; }
/* Toast notification */
.cfg-toast {

View File

@@ -1209,6 +1209,16 @@
color: #000;
}
.action-btn--danger {
color: var(--color-error);
border-color: var(--color-error);
}
.action-btn--danger:hover {
background-color: var(--color-error);
color: #fff;
}
.action-btn__icon {
width: 20px;
height: 20px;

View File

@@ -117,6 +117,16 @@
.empty-state__action {
margin-top: var(--space-2, 0.5rem);
}
.empty-state__icon svg {
display: block;
}
.empty-state__icon[aria-hidden="true"],
.empty-state__icon .empty-state__spinner {
animation: empty-spin 1s linear infinite;
}
@keyframes empty-spin {
to { transform: rotate(360deg); }
}
/* ═══════════════════════════════════════════════════════════════
4. UNIFIED INPUT FOCUS RING

View File

@@ -713,18 +713,23 @@
}
.pago-tabs {
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: var(--space-2);
padding: var(--space-4) var(--space-6);
border-bottom: none;
}
.pago-tab {
padding: var(--space-3) var(--space-5); font-family: var(--font-body);
font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
background: transparent; border: none; color: var(--color-text-muted);
cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: var(--transition-fast);
display: flex; align-items: center; gap: var(--space-2);
padding: var(--space-3) var(--space-2); font-family: var(--font-body);
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius-md); color: var(--color-text-muted);
cursor: pointer; transition: var(--transition-fast);
display: flex; align-items: center; justify-content: center;
text-align: center; min-height: 44px;
}
.pago-tab:hover { color: var(--color-text-primary); }
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
.tab-content { padding: var(--space-6); display: none; }
.tab-content.active { display: block; }
@@ -901,8 +906,10 @@
font-family: 'Courier New', 'Consolas', monospace;
font-size: 11px; line-height: 1.4; padding: 12px; text-align: left;
border: 1px dashed #ccc;
box-sizing: border-box;
}
.ticket-80 { width: 302px; }
.ticket-80 { width: 80mm; max-width: 80mm; }
.ticket-58 { width: 48mm; max-width: 48mm; }
.ticket .store-name { font-size: 14px; font-weight: bold; text-align: center; margin-bottom: 2px; }
.ticket .store-tagline { font-size: 9px; text-align: center; color: #555; margin-bottom: 4px; }
.ticket .store-info { font-size: 9px; text-align: center; color: #333; margin-bottom: 6px; line-height: 1.3; }
@@ -921,17 +928,26 @@
gap: 8px; align-items: baseline; font-size: 10px; margin-bottom: 3px;
}
.ticket-80 .item-line-wide .qty { font-weight: bold; min-width: 24px; text-align: right; }
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: break-word; }
.ticket-80 .item-line-wide .price { text-align: right; min-width: 55px; }
.ticket-80 .item-line-wide .subtotal { text-align: right; font-weight: bold; min-width: 60px; }
.ticket-line { page-break-inside: avoid; }
@media print {
body * { display: none !important; }
.ticket-print-area, .ticket-print-area * { display: block !important; }
.ticket-print-area { position: fixed; top: 0; left: 0; }
.ticket { border: none; box-shadow: none; padding: 4px; }
.ticket .item-line-wide { display: grid !important; }
@page { margin: 0; size: 72mm auto; }
html, body { height: auto !important; overflow: visible !important; }
body > * { display: none !important; }
.ticket-print-area,
.ticket-print-area * { display: block !important; }
.ticket-print-area {
position: static !important; width: 72mm !important; margin: 0 !important; padding: 0 !important;
}
.ticket, .ticket-80 { width: 72mm !important; max-width: 72mm !important; border: none !important; box-shadow: none !important; padding: 3mm !important; font-size: 9pt !important; }
.ticket-58 { width: 48mm !important; max-width: 48mm !important; }
.ticket * { word-break: break-word !important; }
.ticket .item-line-wide { display: grid !important; grid-template-columns: auto 1fr auto auto !important; gap: 2mm !important; font-size: 8pt !important; }
.ticket .ticket-row, .ticket .folio-line, .ticket .total-line { display: flex !important; }
.ticket .name { white-space: normal !important; }
}
/* =====================================================================

View File

@@ -0,0 +1,195 @@
/* Remission notes page — matches the Nexus POS design system */
/* Scrollable page content area */
.page-content {
flex: 1;
overflow-y: auto;
padding: var(--space-5) var(--space-6);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.page-content::-webkit-scrollbar { width: 6px; }
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
/* Filters card */
.filters-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-5);
box-shadow: var(--shadow-sm);
}
[data-theme="modern"] .filters-card {
background: var(--color-bg-overlay);
}
/* Date input reuse select-filter styling */
.select-filter[type="date"] {
padding: 0 var(--space-3);
}
/* Status badges for remission notes */
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
/* Action buttons in table */
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-2);
height: 28px;
border-radius: var(--radius-md);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
cursor: pointer;
border: 1px solid transparent;
transition: var(--transition-fast);
white-space: nowrap;
}
.action-btn svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.action-btn--ghost {
background: var(--btn-ghost-bg);
color: var(--btn-ghost-text);
border-color: var(--btn-ghost-border);
}
.action-btn--ghost:hover {
background: var(--color-surface-2);
border-color: var(--color-border-strong);
color: var(--color-text-primary);
}
.action-btn--primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-border);
}
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
/* Empty state */
.empty-state {
padding: var(--space-10) var(--space-6);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: var(--z-modal, 1050);
padding: var(--space-4);
}
.modal-overlay.is-open { display: flex; }
.modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
}
[data-theme="modern"] .modal {
background: var(--color-bg-overlay);
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-family: var(--font-heading);
font-size: var(--text-h6);
font-weight: var(--heading-weight-primary);
color: var(--color-text-primary);
margin: 0;
}
.modal__close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.25rem;
cursor: pointer;
line-height: 1;
padding: var(--space-1);
}
.modal__close:hover { color: var(--color-text-primary); }
.modal__body {
padding: var(--space-5);
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
}
/* Ticket preview (monospace) */
.ticket-preview {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-primary);
}
.ticket-preview__center { text-align: center; }
.ticket-preview__bold { font-weight: 700; }
.ticket-preview__line {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__divider {
border-top: 1px dashed var(--color-border-strong);
margin: var(--space-3) 0;
}
.ticket-preview__items { margin: var(--space-3) 0; }
.ticket-preview__item {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__footer {
text-align: center;
margin-top: var(--space-3);
}
/* Responsive */
@media (max-width: 1024px) {
.page-content {
padding: var(--space-4);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box, .select-filter {
max-width: 100%;
width: 100%;
}
.toolbar__spacer { display: none; }
}

1023
pos/static/css/workshop.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,9 @@
// /home/Autopartes/pos/static/js/accounting.js
// Accounting module — wired to design-system HTML IDs
// Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre
const Accounting = (() => {
const API = '/pos/api/accounting';
let accountsList = [];
function token() {
return localStorage.getItem('pos_token') || '';
@@ -14,7 +14,8 @@ const Accounting = (() => {
}
async function api(path, opts = {}) {
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
const url = path.startsWith('/pos/api/') ? path : `${API}${path}`;
const res = await fetch(url, { headers: headers(), ...opts });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Request failed');
@@ -25,6 +26,12 @@ const Accounting = (() => {
function fmt(n) {
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ---- Auth check ----
function checkAuth() {
@@ -35,6 +42,30 @@ const Accounting = (() => {
return true;
}
async function loadChartOfAccounts() {
if (accountsList.length) return;
try {
const res = await api('/accounts');
accountsList = (res.data || []).filter(a => a.is_active);
populateEntryAccountSelects();
} catch (e) {
console.warn('No se pudieron cargar cuentas contables:', e);
}
}
function accountSelectHtml(selectedId) {
if (!accountsList.length) {
return '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select>';
}
let html = '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Selecciona cuenta</option>';
accountsList.forEach(a => {
const selected = selectedId && String(selectedId) === String(a.id) ? ' selected' : '';
html += `<option value="${a.id}"${selected}>${esc(a.code)} - ${esc(a.name)}</option>`;
});
html += '</select>';
return html;
}
// ---- Tab switching (matches design system onclick="switchTab('xxx')") ----
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -90,25 +121,36 @@ const Accounting = (() => {
try {
const res = await api('/aging');
const rows = res.data || [];
let rows = res.data || [];
const statusFilter = document.getElementById('cxc-status-filter');
const selected = statusFilter ? statusFilter.value : 'all';
if (selected !== 'all') {
rows = rows.filter(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
return status === selected;
});
}
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por cobrar.</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente');
const actionLabel = r.balance > 0 ? 'Cobrar' : 'Ver';
return `<tr>
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
<td class="td--primary">${r.name || r.customer_name || '-'}</td>
<td class="td--primary">${r.customer_name || r.name || '-'}</td>
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--amount">$${fmt(r.total)}</td>
<td class="td--amount">$${fmt(r.paid || 0)}</td>
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
<td>${statusBadge(status, label)}</td>
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.showReceivableDetail(${r.sale_id})">${esc(actionLabel)}</button></td>
</tr>`;
}).join('');
@@ -123,6 +165,90 @@ const Accounting = (() => {
}
}
// ---- Receivable detail / cancel ticket ----
async function showReceivableDetail(saleId) {
try {
const sale = await api('/pos/api/sales/' + saleId);
if (!sale || sale.error) {
alert('No se pudo cargar el detalle de la venta');
return;
}
const itemsHtml = (sale.items || []).map(function (item) {
return '<tr>' +
'<td>' + esc(item.part_number || '-') + '</td>' +
'<td>' + esc(item.name) + '</td>' +
'<td style="text-align:right">' + item.quantity + '</td>' +
'<td style="text-align:right">$' + fmt(item.unit_price) + '</td>' +
'<td style="text-align:right">$' + fmt(item.subtotal) + '</td>' +
'</tr>';
}).join('');
const paid = (sale.payments || []).reduce(function (sum, p) { return sum + (p.amount || 0); }, 0) + (sale.amount_paid || 0);
const balance = (sale.total || 0) - paid;
const canCancel = sale.status !== 'cancelled' && balance > 0;
const html = '<div class="modal-overlay open" id="receivableDetailOverlay" style="z-index:2000;">' +
'<div class="modal-pago" style="max-width:600px;width:90%;max-height:90vh;overflow:auto;">' +
'<div class="modal-header"><h3>Detalle de Venta a Crédito</h3>' +
'<button class="modal-close" onclick="Accounting.closeReceivableDetail()">&#x2715;</button></div>' +
'<div style="padding:var(--space-4);">' +
'<p><strong>Ticket:</strong> VTA-' + sale.id + '</p>' +
'<p><strong>Cliente:</strong> ' + esc(sale.customer_name || '-') + '</p>' +
'<p><strong>Fecha:</strong> ' + (sale.created_at ? new Date(sale.created_at).toLocaleString('es-MX') : '-') + '</p>' +
'<p><strong>Estado:</strong> ' + esc(sale.status) + '</p>' +
'<p><strong>Total:</strong> $' + fmt(sale.total) + '</p>' +
'<p><strong>Pagado:</strong> $' + fmt(paid) + '</p>' +
'<p><strong>Saldo:</strong> $' + fmt(balance) + '</p>' +
'<h4 style="margin-top:var(--space-4);margin-bottom:var(--space-2);">Artículos</h4>' +
'<table class="data-table"><thead><tr><th>Clave</th><th>Producto</th><th>Cant</th><th>P.Unit</th><th>Subtotal</th></tr></thead><tbody>' +
(itemsHtml || '<tr><td colspan="5" style="text-align:center;">Sin artículos</td></tr>') +
'</tbody></table>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-ghost" onclick="Accounting.closeReceivableDetail()">Cerrar</button>' +
(canCancel ? '<button class="btn btn-danger" onclick="Accounting.cancelReceivable(' + sale.id + ')">Cancelar Ticket</button>' : '') +
'</div>' +
'</div></div>';
const existing = document.getElementById('receivableDetailOverlay');
if (existing) existing.remove();
document.body.insertAdjacentHTML('beforeend', html);
} catch (e) {
alert('Error al cargar detalle: ' + e.message);
}
}
function closeReceivableDetail() {
const el = document.getElementById('receivableDetailOverlay');
if (el) el.remove();
}
async function cancelReceivable(saleId) {
const reason = prompt('Motivo de cancelación del ticket (mínimo 3 caracteres):');
if (!reason || reason.trim().length < 3) {
alert('Se requiere un motivo para cancelar.');
return;
}
if (!confirm('¿Estás seguro de cancelar el ticket VTA-' + saleId + '? Esta acción reversa el inventario y el crédito del cliente.')) {
return;
}
try {
const res = await api('/pos/api/sales/' + saleId + '/cancel', {
method: 'PUT',
body: JSON.stringify({ reason: reason.trim() })
});
if (res.error) {
alert('Error: ' + res.error);
return;
}
alert('Ticket cancelado correctamente.');
closeReceivableDetail();
loadAging();
} catch (e) {
alert('Error al cancelar: ' + e.message);
}
}
// ---- Tab 2: Cuentas por Pagar ----
async function loadAccountsPayable() {
const panel = document.getElementById('panel-cxp');
@@ -133,25 +259,35 @@ const Accounting = (() => {
try {
// Use accounts endpoint filtered for payables or a dedicated endpoint
const res = await api('/aging?type=payable');
const rows = res.data || [];
let rows = res.data || [];
const statusFilter = document.getElementById('cxp-status-filter');
const selected = statusFilter ? statusFilter.value : 'all';
if (selected !== 'all') {
rows = rows.filter(r => {
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
return status === selected;
});
}
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por pagar.</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Pendiente');
return `<tr>
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
<td class="td--primary">${r.name || r.vendor_name || '-'}</td>
<td>${r.receipt_date ? new Date(r.receipt_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--primary">${r.vendor_name || r.name || '-'}</td>
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--amount">$${fmt(r.total)}</td>
<td class="td--amount">$${fmt(r.paid || 0)}</td>
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
<td>${statusBadge(status, label)}</td>
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.registerPayablePayment(${r.id || 0})">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
</tr>`;
}).join('');
@@ -384,6 +520,8 @@ const Accounting = (() => {
loadSummaryCards();
// Load initial tab data (cxc is active by default)
loadAging();
// Preload chart of accounts for the manual entry modal
loadChartOfAccounts();
}
document.addEventListener('DOMContentLoaded', init);
@@ -423,6 +561,27 @@ const Accounting = (() => {
URL.revokeObjectURL(url);
}
async function exportarCuentasPorCobrar() {
try {
const res = await fetch(`${API}/aging/export?type=receivable&format=pdf`, { headers: headers() });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Error al exportar');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ventas_por_cobrar_' + new Date().toISOString().slice(0, 10) + '.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
alert('Error al exportar: ' + e.message);
}
}
// ---- Nueva Poliza modal ----
function showNewEntryModal() {
const overlay = document.getElementById('newEntryModalOverlay');
@@ -448,13 +607,20 @@ const Accounting = (() => {
line.className = 'entry-line';
line.style.cssText = 'display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;';
line.innerHTML =
'<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
accountSelectHtml() +
'<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
'<button class="btn btn--ghost btn--sm" onclick="this.closest(\'.entry-line\').remove()">&times;</button>';
container.appendChild(line);
}
function populateEntryAccountSelects() {
document.querySelectorAll('#entryLines .entry-account').forEach(sel => {
const selected = sel.value;
sel.outerHTML = accountSelectHtml(selected);
});
}
async function submitNewEntry() {
const date = document.getElementById('entryDate').value;
const type = document.getElementById('entryType').value;
@@ -468,16 +634,16 @@ const Accounting = (() => {
const lines = [];
document.querySelectorAll('#entryLines .entry-line').forEach(row => {
const account = row.querySelector('.entry-account').value.trim();
const accountId = row.querySelector('.entry-account').value;
const debit = parseFloat(row.querySelector('.entry-debit').value) || 0;
const credit = parseFloat(row.querySelector('.entry-credit').value) || 0;
if (account && (debit || credit)) {
lines.push({ account, debit, credit });
if (accountId && (debit || credit)) {
lines.push({ account_id: parseInt(accountId, 10), debit, credit });
}
});
if (!lines.length) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos una partida.</span>';
if (lines.length < 2) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos dos partidas.</span>';
return;
}
@@ -495,17 +661,23 @@ const Accounting = (() => {
// Expose switchTab globally for onclick handlers in HTML
window.switchTab = switchTab;
function registerPayablePayment(payableId) {
// Placeholder until supplier payments module is implemented
alert('Registrar pago a proveedor — próximamente' + (payableId ? ' (OC ' + payableId + ')' : ''));
}
function runPeriodClose() {
// Placeholder until fiscal period close module is implemented
alert('Ejecutar cierre de período — próximamente');
}
window.exportarContabilidad = exportarContabilidad;
window.exportarCuentasPorCobrar = exportarCuentasPorCobrar;
window.showNewEntryModal = showNewEntryModal;
window.closeNewEntryModal = closeNewEntryModal;
window.addEntryLine = addEntryLine;
window.submitNewEntry = submitNewEntry;
return {
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -514,4 +686,13 @@ const Accounting = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
window.Accounting = {
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
showReceivableDetail, closeReceivableDetail, cancelReceivable,
registerPayablePayment, runPeriodClose,
};
return window.Accounting;
})();

27
pos/static/js/api.js Normal file
View File

@@ -0,0 +1,27 @@
// /home/Autopartes/pos/static/js/api.js
// Minimal shared API helper used by standalone pages (e.g. historical_sales).
async function api(path, options = {}) {
const token = localStorage.getItem('pos_token') || '';
const url = path.startsWith('http') ? path : path;
const res = await fetch(url, {
...options,
headers: {
'Authorization': token ? 'Bearer ' + token : '',
'Content-Type': 'application/json',
...(options.headers || {})
}
});
if (res.status === 401) {
window.location.href = '/pos/login';
throw new Error('Sesión expirada');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.message || 'Error ' + res.status);
}
return data;
}

View File

@@ -43,7 +43,8 @@
var _t = typeof window.t === 'function' ? window.t : function(k) { return k; };
var roleLabels = {
'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'),
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant')
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant'),
'workshop': 'Taller', 'mechanic': 'Mecanico'
};
var roleLabel = roleLabels[role] || role;
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
@@ -117,6 +118,7 @@
localStorage.removeItem('pos_employee');
localStorage.removeItem('pos_tenant_id');
localStorage.removeItem('pos_cart');
document.cookie = 'pos_role=; path=/pos; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.href = '/pos/login';
};
@@ -180,6 +182,147 @@
permissions: payload.permissions || []
};
// ─── Page guard based on role + permissions ───
function moduleEnabled(key) {
try {
var modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
return modules[key] !== false;
} catch(e) { return true; }
}
function updateHomeLinks(userRole, userPerms) {
// Point the dashboard/home icon to the role-specific default page.
var homeHref = '/pos/dashboard';
if (userRole === 'counter' && userPerms.indexOf('inventory.view') !== -1) {
homeHref = '/pos/inventory';
} else if (userRole === 'cashier' && userPerms.indexOf('pos.sell') !== -1) {
homeHref = '/pos/sale';
} else if (userRole === 'workshop' && userPerms.indexOf('workshop.view') !== -1 && moduleEnabled('workshop')) {
homeHref = '/pos/workshop';
}
document.querySelectorAll('a[href="/pos/dashboard"]').forEach(function(a) {
a.setAttribute('href', homeHref);
});
}
function isPageAllowed(pagePath, userRole, userPerms) {
if (userRole === 'owner' || userRole === 'admin') return true;
// Restricted roles (workshop/mechanic/counter/cashier) see modules based on permissions.
if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
var allowed = [];
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
allowed = ['/pos/workshop'];
}
var permMap = {
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'catalog.view': '/pos/catalog',
'inventory.view': '/pos/inventory',
'customers.view': '/pos/customers',
'workshop.view': '/pos/workshop',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'dashboard.view': '/pos/dashboard'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
// Hide workshop if its module is disabled
if (permMap[p] === '/pos/workshop' && !moduleEnabled('workshop')) continue;
allowed.push(permMap[p]);
}
}
return allowed.indexOf(pagePath) !== -1;
}
// Always allow login/logout pages so users can sign out without hitting the guard.
if (pagePath === '/pos/login' || pagePath === '/pos/logout') return true;
// Any other role (accountant, warehouse, sales, etc.) keeps the previous permissive behavior.
return true;
}
function enforcePageGuard(userRole, userPerms) {
if (isPageAllowed(path, userRole, userPerms)) return true;
// Build the actual list of allowed pages so we can pick a safe fallback.
var fallback = null;
if (userRole === 'owner' || userRole === 'admin') {
fallback = '/pos/dashboard';
} else if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
var allowed = [];
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
allowed = ['/pos/workshop'];
}
var permMap = {
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'catalog.view': '/pos/catalog',
'inventory.view': '/pos/inventory',
'customers.view': '/pos/customers',
'workshop.view': '/pos/workshop',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'dashboard.view': '/pos/dashboard'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
if (permMap[p] === '/pos/workshop' && !moduleEnabled('workshop')) continue;
allowed.push(permMap[p]);
}
}
// Role-specific default landing pages.
if (userRole === 'counter' && allowed.indexOf('/pos/inventory') !== -1) {
fallback = '/pos/inventory';
} else if (userRole === 'cashier' && allowed.indexOf('/pos/sale') !== -1) {
fallback = '/pos/sale';
} else {
fallback = allowed.length ? allowed[0] : '/pos/login';
}
} else {
fallback = '/pos/dashboard';
}
window.location.replace(fallback);
return false;
}
// ─── Refresh permissions/token from server before enforcing the guard ───
// This makes permission changes effective without requiring a full re-login.
try {
fetch('/pos/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) {
if (r.ok) return r.json();
return null;
}).then(function(data) {
if (data && data.token) {
localStorage.setItem('pos_token', data.token);
localStorage.setItem('pos_employee', JSON.stringify(data.employee));
token = data.token;
window.POS_USER.token = data.token;
window.POS_USER.permissions = data.permissions || [];
window.POS_USER.branchId = data.employee.branch_id;
}
if (!enforcePageGuard(window.POS_USER.role, window.POS_USER.permissions)) return;
if (typeof window.renderSidebar === 'function') {
window.renderSidebar(window.POS_USER.modules || JSON.parse(localStorage.getItem('pos_modules') || '{}'));
}
updateHomeLinks(window.POS_USER.role, window.POS_USER.permissions);
}).catch(function() {
enforcePageGuard(role, payload.permissions || []);
updateHomeLinks(role, payload.permissions || []);
});
} catch(e) {
enforcePageGuard(role, payload.permissions || []);
updateHomeLinks(role, payload.permissions || []);
}
// ─── Preload enabled modules for sidebar filtering ───
try {
fetch('/pos/api/config/modules', {
@@ -197,6 +340,16 @@
}).catch(function() {});
} catch(e) {}
// ─── Hide POS "Sistema" button for roles that cannot access the dashboard ───
(function hideBackToSystemForRestrictedRoles() {
var backBtn = document.getElementById('backToSystemBtn');
if (!backBtn) return;
// owner/admin always see it; others only if they have dashboard.view.
if (role === 'owner' || role === 'admin') return;
if ((window.POS_USER.permissions || []).indexOf('dashboard.view') !== -1) return;
backBtn.style.display = 'none';
})();
// ─── Service Worker update handler ───
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function (event) {
@@ -207,4 +360,21 @@
});
}
// ─── Global toast utility ───
window.toast = function(msg, type) {
type = type || 'success';
var bg = type === 'error' ? '#d32f2f' : '#388e3c';
var el = document.createElement('div');
el.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:99999;padding:12px 20px;border-radius:8px;background:' + bg + ';color:#fff;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.2);opacity:0;transition:opacity .3s;';
el.textContent = msg;
document.body.appendChild(el);
// trigger reflow
el.offsetHeight;
el.style.opacity = '1';
setTimeout(function() {
el.style.opacity = '0';
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
}, 3000);
};
})();

View File

@@ -14,6 +14,7 @@
brandId: null,
model: null,
modelId: null,
modelVariantIds: null, // array of TecDoc model ids for grouped variants
year: null,
yearId: null,
engine: null,
@@ -51,7 +52,7 @@
return;
}
this.el('brandCatalogOverlay').style.display = 'block';
document.body.style.overflow = 'hidden';
//document.body.style.overflow = 'hidden';
this.loadBrands();
},
@@ -60,10 +61,10 @@
document.body.style.overflow = '';
this.reset();
},
reset: function() {
this.state = 'brands';
this.nav = { brand: null, brandId: null, model: null, modelId: null, year: null, yearId: null, engine: null, myeId: null, category: null, categoryId: null };
this.nav = { brand: null, brandId: null, model: null, modelId: null, modelVariantIds: null, year: null, yearId: null, engine: null, myeId: null, category: null, categoryId: null };
this._allBrands = [];
this._lastItems = [];
this._offset = 0;
@@ -96,7 +97,7 @@
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')\'>' + escapeHtml(this.nav.brand) + '</a>');
}
if (this.nav.model) {
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')\'>' + escapeHtml(this.nav.model) + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ',' + JSON.stringify(this.nav.modelVariantIds || [this.nav.modelId]) + ')\'>' + escapeHtml(this.nav.model) + '</a>');
}
if (this.nav.year) {
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')\'>' + this.nav.year + '</a>');
@@ -202,7 +203,8 @@
renderModelList: function(models) {
var html = '<div class="nav-grid">';
models.forEach(function(m) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')\'>' +
var variantIds = (m.variant_ids && m.variant_ids.length) ? m.variant_ids : [m.id_model];
html += '<div class="nav-card" onclick=\'BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ',' + JSON.stringify(variantIds) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
'</div>';
});
@@ -210,20 +212,22 @@
this.setContent(html);
},
selectModel: function(modelId, modelName) {
selectModel: function(modelId, modelName, variantIds) {
this.nav.model = modelName;
this.nav.modelId = modelId;
this.loadYears(modelId);
this.nav.modelVariantIds = variantIds && variantIds.length ? variantIds : [modelId];
this.loadYears(this.nav.modelVariantIds);
},
// ---------- YEARS ----------
loadYears: function(modelId) {
loadYears: function(modelIds) {
this.loading(true);
this.state = 'years';
this.setSearch('');
this.buildBreadcrumb();
var self = this;
fetch('/pos/api/catalog/years?model_id=' + encodeURIComponent(modelId), { headers: this._headers() })
var modelIdParam = Array.isArray(modelIds) ? modelIds.join(',') : String(modelIds);
fetch('/pos/api/catalog/years?model_id=' + encodeURIComponent(modelIdParam), { headers: this._headers() })
.then(function(r) {
if (!self._checkAuth(r)) return null;
return r.json();
@@ -258,17 +262,18 @@
selectYear: function(yearId, yearCar) {
this.nav.year = yearCar;
this.nav.yearId = yearId;
this.loadEngines(this.nav.modelId, yearId);
this.loadEngines(this.nav.modelVariantIds || [this.nav.modelId], yearId);
},
// ---------- ENGINES ----------
loadEngines: function(modelId, yearId) {
loadEngines: function(modelIds, yearId) {
this.loading(true);
this.state = 'engines';
this.setSearch('');
this.buildBreadcrumb();
var self = this;
fetch('/pos/api/catalog/engines?model_id=' + encodeURIComponent(modelId) + '&year_id=' + encodeURIComponent(yearId), { headers: this._headers() })
var modelIdParam = Array.isArray(modelIds) ? modelIds.join(',') : String(modelIds);
fetch('/pos/api/catalog/engines?model_id=' + encodeURIComponent(modelIdParam) + '&year_id=' + encodeURIComponent(yearId), { headers: this._headers() })
.then(function(r) {
if (!self._checkAuth(r)) return null;
return r.json();
@@ -292,8 +297,9 @@
renderEngineList: function(engines) {
var html = '<div class="nav-grid">';
engines.forEach(function(e) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(e.name_engine) + '</div>' +
var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
html += '<div class="nav-card" onclick=\'BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(name) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(name) + '</div>' +
'<div class="nav-card__sub">' + escapeHtml(e.trim_level || '') + '</div>' +
'</div>';
});

View File

@@ -276,7 +276,20 @@
if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null });
else if (nav.partType) parts.push({ label: nav.partType.name, action: null });
//Botón para retroceder
var backAction = null;
if (parts.length > 1) {
var prev = parts[parts.length - 2];
backAction = prev ? prev.action : 'loadBrands';
}
var html = '';
//Botón para retroceder
if (backAction) {
html += '<button class="breadcrumb__back" data-bc-action="' + backAction + '">&#8592; Atr&aacute;s</button>';
html += '<span class="breadcrumb__sep" aria-hidden="true">|</span>';
}
//--------
for (var i = 0; i < parts.length; i++) {
if (i > 0) html += '<span class="breadcrumb__sep" aria-hidden="true">/</span>';
if (i < parts.length - 1 && parts[i].action) {
@@ -303,6 +316,7 @@
else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); }
});
});
}
function resetNav() {
@@ -427,6 +441,12 @@
});
}
function modelIdsParam(model) {
if (!model) return '';
if (model.variant_ids && model.variant_ids.length) return model.variant_ids.join(',');
return String(model.id);
}
function loadModels() {
nav.level = 'models';
pushNavState();
@@ -440,14 +460,15 @@
if (!data || !data.data || !data.data.length) { showEmpty('Sin modelos', 'No hay modelos con partes para ' + nav.brand.name); return; }
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (m) {
return '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-name="' + esc(m.display_name || m.name_model) + '">' +
return '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-variant-ids="' + esc((m.variant_ids || [m.id_model]).join(',')) + '" data-name="' + esc(m.display_name || m.name_model) + '">' +
'<div class="nav-card__name">' + esc(m.display_name || m.name_model) + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name };
var variantIds = (this.dataset.variantIds || this.dataset.modelId).split(',').map(function(x){ return parseInt(x); });
nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name, variant_ids: variantIds };
loadYears();
});
});
@@ -462,7 +483,7 @@
setupLevelFilter(false);
showLoading();
apiFetch(API + '/years?model_id=' + nav.model.id).then(function (data) {
apiFetch(API + '/years?model_id=' + modelIdsParam(nav.model)).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin anios', 'No hay anios con partes para este modelo.'); return; }
navGrid.className = 'nav-grid nav-grid--years';
@@ -489,23 +510,30 @@
setupLevelFilter(false);
showLoading();
apiFetch(API + '/engines?model_id=' + nav.model.id + '&year_id=' + nav.year.id).then(function (data) {
apiFetch(API + '/engines?model_id=' + modelIdsParam(nav.model) + '&year_id=' + nav.year.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin motores', 'No hay configuraciones de motor para esta combinacion.'); return; }
// Helper: avoid showing raw "N/A" as engine name
function engineLabel(e) {
var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
return name + (e.trim_level ? ' — ' + e.trim_level : '');
}
// If only one engine, auto-select it
if (data.data.length === 1) {
var e = data.data[0];
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') };
nav.engine = { id_mye: e.id_mye, name: engineLabel(e) };
loadCategoriesForMode();
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (e) {
var label = e.name_engine + (e.trim_level ? ' — ' + e.trim_level : '');
var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
var label = name + (e.trim_level ? ' — ' + e.trim_level : '');
return '<div class="nav-card" role="listitem" data-mye-id="' + e.id_mye + '" data-name="' + esc(label) + '">' +
'<div class="nav-card__name">' + esc(e.name_engine) + '</div>' +
'<div class="nav-card__name">' + esc(name) + '</div>' +
(e.trim_level ? '<div class="nav-card__sub">' + esc(e.trim_level) + '</div>' : '') +
'</div>';
}).join('');
@@ -1258,10 +1286,8 @@
html += '</div>';
}
// Compatibilities — deduplicate by (make, model, year, engine)
// Compatibilities — deduplicate by (make, model, year, engine)
if (p.compatibilities && p.compatibilities.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Vehiculos compatibles</div>';
var seenCompat = {};
var uniqCompat = [];
p.compatibilities.forEach(function(c) {
@@ -1270,22 +1296,69 @@
seenCompat[key] = true;
uniqCompat.push(c);
});
var currentMake = '';
uniqCompat.forEach(function(c) {
if (c.make !== currentMake) {
currentMake = c.make;
html += '<div style="font-weight:600;margin-top:8px;">' + esc(c.make) + '</div>';
}
html += '<div style="padding-left:12px;color:var(--color-text-muted);font-size:var(--text-body-sm);">' +
esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '</div>';
});
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Vehiculos compatibles</div>';
html += '<div id="compat-list"></div>';
html += '<div id="compat-pager" class="compat-pager"></div>';
html += '</div>';
compatFullList = uniqCompat;
} else {
compatFullList = [];
}
detailBody.innerHTML = html;
if (compatFullList.length) {
renderCompatPage(1);
}
});
}
// --- Pagination for "Vehiculos compatibles" ---
var compatFullList = [];
var compatPageSize = 15;
var compatCurrentPage = 1;
function renderCompatPage(page) {
var listEl = document.getElementById('compat-list');
var pagerEl = document.getElementById('compat-pager');
if (!listEl || !pagerEl) return;
var totalItems = compatFullList.length;
var totalPages = Math.max(1, Math.ceil(totalItems / compatPageSize));
page = Math.min(Math.max(1, page), totalPages);
compatCurrentPage = page;
var start = (page - 1) * compatPageSize;
var pageItems = compatFullList.slice(start, start + compatPageSize);
var listHtml = '';
var currentMake = '';
pageItems.forEach(function(c) {
if (c.make !== currentMake) {
currentMake = c.make;
listHtml += '<div style="font-weight:600;margin-top:8px;">' + esc(c.make) + '</div>';
}
listHtml += '<div style="padding-left:12px;color:var(--color-text-muted);font-size:var(--text-body-sm);">' +
esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '</div>';
});
listEl.innerHTML = listHtml;
if (totalPages > 1) {
pagerEl.innerHTML =
'<button type="button" id="compat-prev" class="compat-pager__btn"' + (page <= 1 ? ' disabled' : '') + '>&lsaquo; Anterior</button>' +
'<span class="compat-pager__info">Pagina ' + page + ' de ' + totalPages + ' (' + totalItems + ' vehiculos)</span>' +
'<button type="button" id="compat-next" class="compat-pager__btn"' + (page >= totalPages ? ' disabled' : '') + '>Siguiente &rsaquo;</button>';
var prevBtn = document.getElementById('compat-prev');
var nextBtn = document.getElementById('compat-next');
if (prevBtn) prevBtn.addEventListener('click', function () { renderCompatPage(compatCurrentPage - 1); });
if (nextBtn) nextBtn.addEventListener('click', function () { renderCompatPage(compatCurrentPage + 1); });
} else {
pagerEl.innerHTML = '';
}
}
function closeDetail() {
detailPanel.classList.remove('is-open');
detailOverlay.classList.remove('is-visible');
@@ -1788,20 +1861,22 @@
});
}
function vsYearChanged() {
var yearId = vsYear.value;
vsBrand.innerHTML = '<option value="">Marca...</option>';
vsModel.innerHTML = '<option value="">Modelo...</option>';
vsEngine.innerHTML = '<option value="">Motor...</option>';
vsBrand.disabled = true;
vsModel.disabled = true;
vsEngine.disabled = true;
vsClear.style.display = yearId ? '' : 'none';
vsClear.style.display = (yearId || vsBrand.value || vsModel.value) ? '' : 'none';
if (!yearId) return;
// Load brands filtered by year
// Resetear marca solo si no hay nada seleccionado en ella
vsBrand.disabled = false;
vsEngine.innerHTML = '<option value="">Motor...</option>';
vsEngine.disabled = true;
if (!yearId) {
//carga todo
vsLoadAllBrands();
return;
}
// Filtrar marcas por año
apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) {
var brands = data.data || data;
if (!brands) return;
@@ -1809,54 +1884,99 @@
brands.map(function (b) {
return '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>';
}).join('');
// si hay marca seleccionada despliega modelos
if (vsBrand.value) vsBrandChanged();
});
}
function vsLoadAllBrands() {
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
var brands = data.data || data;
if (!brands) return;
var current = vsBrand.value;
vsBrand.innerHTML = '<option value="">Marca...</option>' +
brands.map(function (b) {
return '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>';
}).join('');
if (current) vsBrand.value = current;
});
}
function vsBrandChanged() {
var brandId = vsBrand.value;
var yearId = vsYear.value;
vsClear.style.display = (yearId || brandId) ? '' : 'none';
vsModel.innerHTML = '<option value="">Modelo...</option>';
vsEngine.innerHTML = '<option value="">Motor...</option>';
vsModel.disabled = true;
vsModel.disabled = false; // cambio
vsEngine.disabled = true;
if (!brandId) return;
if (!brandId) {
vsModel.disabled = true;
return;
}
// Load models filtered by brand AND year
vsModel.disabled = false;
// Cargar modelos con o sin año
apiFetch(API + '/models?brand_id=' + brandId + (yearId ? '&year_id=' + yearId : '')).then(function (data) {
var models = data.data || data;
if (!models) return;
vsModel.innerHTML = '<option value="">Modelo...</option>' +
models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>';
var variants = (m.variant_ids || [m.id_model]).join(',');
return '<option value="' + m.id_model + '" data-variant-ids="' + esc(variants) + '">' + esc(m.display_name || m.name_model) + '</option>';
}).join('');
// si hay modelo seleccionado despliega año
if (vsModel.value) vsModelChanged();
});
}
function vsModelChanged() {
var modelId = vsModel.value;
var yearVal = vsYear.value;
var selectedOption = vsModel.options[vsModel.selectedIndex];
var variantIds = selectedOption && selectedOption.dataset.variantIds
? selectedOption.dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: (modelId ? [parseInt(modelId)] : []);
vsEngine.innerHTML = '<option value="">Motor...</option>';
vsEngine.disabled = true;
if (!modelId || !yearVal) return;
if (!modelId || !variantIds.length) return;
vsEngine.disabled = false;
apiFetch(API + '/engines?model_id=' + modelId + '&year_id=' + yearVal).then(function (data) {
var engines = data.data || data;
if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' +
engines.map(function (e) {
var label = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : '');
return '<option value="' + e.id_mye + '">' + esc(label) + '</option>';
}).join('');
// If only 1 engine, auto-select
if (engines.length === 1) {
vsEngine.value = engines[0].id_mye;
vsEngineChanged();
}
});
// Si hay año carga motores usando todas las variantes del modelo
if (yearVal) {
vsEngine.disabled = false;
apiFetch(API + '/engines?model_id=' + variantIds.join(',') + '&year_id=' + yearVal).then(function (data) {
var engines = data.data || data;
if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' +
engines.map(function (e) {
var name = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
var label = name + (e.trim_level ? ' (' + e.trim_level + ')' : '');
return '<option value="' + e.id_mye + '">' + esc(label) + '</option>';
}).join('');
if (engines.length === 1) {
vsEngine.value = engines[0].id_mye;
vsEngineChanged();
}
});
} else {
// Sin año: cargar categorías a nivel modelo
var brandId = vsBrand.value;
if (!brandId) return;
nav.brand = { id: parseInt(brandId), name: vsBrand.options[vsBrand.selectedIndex].text };
nav.model = { id: parseInt(modelId), name: vsModel.options[vsModel.selectedIndex].text };
nav.year = null;
nav.engine = null;
nav.level = 'categories';
pushNavState();
loadCategoriesForMode();
setTimeout(function () {
var body = document.getElementById('pageBody');
if (body) body.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 300);
}
}
function vsEngineChanged() {
@@ -1869,8 +1989,12 @@
var modelText = vsModel.options[vsModel.selectedIndex].text;
var engineText = vsEngine.options[vsEngine.selectedIndex].text;
var selectedModelOption = vsModel.options[vsModel.selectedIndex];
var modelVariantIds = selectedModelOption && selectedModelOption.dataset.variantIds
? selectedModelOption.dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: [parseInt(vsModel.value)];
nav.brand = { id: parseInt(vsBrand.value), name: brandText };
nav.model = { id: parseInt(vsModel.value), name: modelText };
nav.model = { id: parseInt(vsModel.value), name: modelText, variant_ids: modelVariantIds };
nav.year = { id: parseInt(vsYear.value), year: yearText };
nav.engine = { id_mye: parseInt(myeId), name: engineText };
nav.level = 'categories';
@@ -2058,19 +2182,35 @@
if (!models) return;
vsModel.innerHTML = '<option value="">Modelo...</option>' +
models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>';
var variants = (m.variant_ids || [m.id_model]).join(',');
return '<option value="' + m.id_model + '" data-variant-ids="' + esc(variants) + '">' + esc(m.display_name || m.name_model) + '</option>';
}).join('');
vsModel.disabled = false;
if (match.model_id) {
vsModel.value = String(match.model_id);
// The VIN match may point to a variant that is now grouped under
// a canonical model; select the option whose variants include it.
var matchedOption = Array.from(vsModel.options).find(function (opt) {
if (!opt.value) return false;
var vids = (opt.dataset.variantIds || opt.value).split(',').map(function(x){ return parseInt(x); });
return vids.indexOf(match.model_id) !== -1;
});
if (matchedOption) {
vsModel.value = matchedOption.value;
} else {
vsModel.value = String(match.model_id);
}
var selectedVariantIds = vsModel.selectedIndex >= 0 && vsModel.options[vsModel.selectedIndex].dataset.variantIds
? vsModel.options[vsModel.selectedIndex].dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: [match.model_id];
// Load engines
apiFetch(API + '/engines?model_id=' + match.model_id + '&year_id=' + match.year_id).then(function (engData) {
apiFetch(API + '/engines?model_id=' + selectedVariantIds.join(',') + '&year_id=' + match.year_id).then(function (engData) {
var engines = engData && (engData.data || engData);
if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' +
engines.map(function (e) {
var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : '');
var ename = (e.name_engine && e.name_engine !== 'N/A') ? e.name_engine : 'Sin especificar';
var elabel = ename + (e.trim_level ? ' (' + e.trim_level + ')' : '');
return '<option value="' + e.id_mye + '">' + esc(elabel) + '</option>';
}).join('');
vsEngine.disabled = false;
@@ -2148,6 +2288,31 @@
}
}
async function downloadPriceTemplate() {
try {
var res = await fetch('/pos/api/supplier-catalog/prices/template', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) {
var data = await res.json().catch(function() { return {}; });
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">' + esc(data.error || 'Error al descargar plantilla') + '</span>';
return;
}
var blob = await res.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'supplier_prices_template.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-success);">✓ Plantilla descargada.</span>';
} catch (e) {
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(e.message) + '</span>';
}
}
function shouldShowUploadPricesButton() {
try {
var user = JSON.parse(localStorage.getItem('pos_employee') || '{}');
@@ -2180,6 +2345,7 @@
openUploadPricesModal: openUploadPricesModal,
closeUploadPricesModal: closeUploadPricesModal,
submitUploadPrices: submitUploadPrices,
downloadPriceTemplate: downloadPriceTemplate,
};
// ─── INIT ───

View File

@@ -4,6 +4,9 @@
const Config = (() => {
const API = '/pos/api/config';
const user = window.POS_USER || {};
const canDeleteBranch = (user.role === 'owner' || user.role === 'admin');
// Cache for branches (used by employee modal selector)
let _branches = [];
@@ -57,7 +60,7 @@ const Config = (() => {
// -------------------------------------------------------------------------
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {}
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
document.querySelectorAll('.theme-btn').forEach(function(btn) {
btn.classList.toggle('is-active', btn.dataset.themeTarget === theme);
@@ -105,16 +108,22 @@ const Config = (() => {
owner: 'Dueno',
admin: 'Admin',
cashier: 'Cajero',
counter: 'Mostrador',
warehouse: 'Almacenista',
accountant: 'Contador'
accountant: 'Contador',
workshop: 'Taller',
mechanic: 'Mecanico'
};
var ROLE_BADGE = {
owner: 'badge--owner',
admin: 'badge--blue',
cashier: 'badge--green',
counter: 'badge--gray',
warehouse: 'badge--yellow',
accountant: 'badge--purple'
accountant: 'badge--purple',
workshop: 'badge--orange',
mechanic: 'badge--teal'
};
function roleBadge(role) {
@@ -179,6 +188,7 @@ const Config = (() => {
+ '</div>'
+ '<div class="device-card__actions">'
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editBranch(' + b.id + ')">Editar</button>'
+ (canDeleteBranch && b.is_active && !b.is_main ? '<button class="btn btn--danger btn--sm" style="margin-left:4px;" onclick="Config.deleteBranch(' + b.id + ')">Eliminar</button>' : '')
+ '</div></div>';
});
@@ -234,6 +244,22 @@ const Config = (() => {
openBranchModal(b);
}
async function deleteBranch(branchId) {
var b = _branches.find(function(x) { return x.id === branchId; });
if (!b) { toast('Sucursal no encontrada', 'error'); return; }
if (b.is_main) { toast('No se puede eliminar la sucursal principal', 'error'); return; }
if (!confirm('¿Eliminar la sucursal "' + b.name + '"? Se marcará como inactiva.')) return;
try {
var res = await fetch(API + '/branches/' + branchId, { method: 'DELETE', headers: headers() });
var json = await res.json().catch(function() { return {}; });
if (!res.ok) throw new Error(json.error || res.statusText);
toast('Sucursal eliminada');
loadBranches();
} catch (e) {
toast(e.message || 'Error al eliminar sucursal', 'error');
}
}
async function saveBranch(data) {
var branchId = document.getElementById('branch-id').value;
var url = API + '/branches' + (branchId ? '/' + branchId : '');
@@ -294,7 +320,10 @@ const Config = (() => {
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
+ '<td>' + statusBadge + '</td>'
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
+ '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></td>'
+ '<td>'
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button>'
+ (emp.role !== 'owner' ? ' <button class="btn btn--danger btn--sm" onclick="Config.deleteEmployee(' + emp.id + ', \'' + escHtml(emp.name).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')">Eliminar</button>' : '')
+ '</td>'
+ '</tr>';
});
@@ -304,7 +333,7 @@ const Config = (() => {
async function saveEmployee(data) {
// Check if we're editing (modal has editId) or creating
var modal = document.getElementById('employee-modal');
var modal = document.getElementById('modal-employee');
var editId = modal ? modal.dataset.editId : null;
var url = API + '/employees';
var method = 'POST';
@@ -358,6 +387,21 @@ const Config = (() => {
return el ? el.value.trim() : '';
}
async function deleteEmployee(empId, name) {
if (!confirm('¿Eliminar al empleado "' + name + '"? Esta accion lo desactiva.')) return;
try {
var res = await fetch(API + '/employees/' + empId, { method: 'DELETE', headers: headers() });
if (!res.ok) {
var err = await res.json().catch(function() { return { error: 'Error ' + res.status }; });
throw new Error(err.error || 'Error al eliminar');
}
toast('Empleado eliminado', 'ok');
loadEmployees();
} catch (e) {
toast(e.message, 'error');
}
}
async function editEmployee(empId) {
if (!checkAuth()) return;
// Find the employee in the loaded data by re-fetching
@@ -368,24 +412,25 @@ const Config = (() => {
var emp = (json.data || []).find(function(e) { return e.id === empId; });
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
// Pre-fill the "new employee" modal with existing data for editing
setVal('new-emp-name', emp.name);
setVal('new-emp-email', emp.email || '');
var roleSelect = document.getElementById('new-emp-role');
// Pre-fill the employee modal with existing data for editing
setVal('emp-name', emp.name);
setVal('emp-email', emp.email || '');
setVal('emp-phone', emp.phone || '');
var roleSelect = document.getElementById('emp-role');
if (roleSelect) roleSelect.value = emp.role || 'cashier';
var branchSelect = document.getElementById('new-emp-branch');
var branchSelect = document.getElementById('emp-branch');
if (branchSelect) branchSelect.value = emp.branch_id || '';
setVal('new-emp-discount', emp.max_discount_pct || '');
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
setVal('emp-discount', emp.max_discount_pct || '');
setVal('emp-pin', ''); // Don't pre-fill PIN for security
// Store the ID so saveEmployee knows it's an update
var modal = document.getElementById('employee-modal');
var modal = document.getElementById('modal-employee');
if (modal) {
modal.dataset.editId = empId;
var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Editar Empleado';
}
openModal('employee-modal');
openModal('modal-employee');
} catch (e) {
toast('Error: ' + e.message, 'error');
}
@@ -442,6 +487,156 @@ const Config = (() => {
}
}
// -------------------------------------------------------------------------
// Receipt / ticket customization
// -------------------------------------------------------------------------
let _receiptLogo = '';
async function loadReceiptConfig() {
try {
var res = await fetch(API + '/receipt', { headers: headers() });
if (!res.ok) return;
var d = await res.json();
_receiptLogo = d.logo || '';
setVal('receipt-store-name', d.store_name);
setVal('receipt-tagline', d.tagline);
setVal('receipt-rfc', d.rfc);
setVal('receipt-address', d.address);
setVal('receipt-phone', d.phone);
setVal('receipt-paper-width', d.paper_width || '80');
setVal('receipt-thanks', d.thanks_message);
setVal('receipt-footer', d.footer);
setChecked('receipt-show-logo', d.show_logo);
setChecked('receipt-show-rfc', d.show_rfc);
setChecked('receipt-show-address', d.show_address);
setChecked('receipt-show-phone', d.show_phone);
setChecked('receipt-show-iva', d.show_iva_breakdown);
setChecked('receipt-show-payment', d.show_payment_details);
setChecked('receipt-show-employee', d.show_employee);
renderReceiptLogoThumb();
} catch (e) {
console.error('Config.loadReceiptConfig:', e);
}
}
function setChecked(id, v) {
var el = document.getElementById(id);
if (el) el.checked = !!v;
}
function getChecked(id) {
var el = document.getElementById(id);
return el ? el.checked : false;
}
function renderReceiptLogoThumb() {
var thumb = document.getElementById('receipt-logo-thumb');
var removeBtn = document.getElementById('receipt-logo-remove');
if (!thumb) return;
if (_receiptLogo) {
thumb.innerHTML = '<img src="' + escapeHtml(_receiptLogo) + '" style="max-width:100%;max-height:100%;object-fit:contain;" alt="Logo ticket">';
if (removeBtn) removeBtn.style.display = '';
} else {
thumb.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>';
if (removeBtn) removeBtn.style.display = 'none';
}
}
function handleReceiptLogo(input) {
var file = input && input.files ? input.files[0] : null;
if (!file) return;
if (!file.type.match(/^image\/(png|jpeg|jpg|webp)$/)) {
toast('Solo se permiten imágenes PNG, JPG o WebP', 'error');
input.value = '';
return;
}
var reader = new FileReader();
reader.onload = function(e) {
var img = new Image();
img.onload = function() {
var maxWidth = 300;
var scale = Math.min(1, maxWidth / img.width);
var w = Math.round(img.width * scale);
var h = Math.round(img.height * scale);
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
_receiptLogo = canvas.toDataURL('image/jpeg', 0.85);
renderReceiptLogoThumb();
toast('Logo cargado. Guarda los cambios para aplicarlo.', 'ok');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
input.value = '';
}
function removeReceiptLogo() {
_receiptLogo = '';
renderReceiptLogoThumb();
}
async function saveReceiptConfig() {
if (!checkAuth()) return;
var data = {
logo: _receiptLogo,
store_name: getVal('receipt-store-name'),
tagline: getVal('receipt-tagline'),
rfc: getVal('receipt-rfc'),
address: getVal('receipt-address'),
phone: getVal('receipt-phone'),
paper_width: getVal('receipt-paper-width') || '80',
thanks_message: getVal('receipt-thanks'),
footer: getVal('receipt-footer'),
show_logo: getChecked('receipt-show-logo'),
show_rfc: getChecked('receipt-show-rfc'),
show_address: getChecked('receipt-show-address'),
show_phone: getChecked('receipt-show-phone'),
show_iva_breakdown: getChecked('receipt-show-iva'),
show_payment_details: getChecked('receipt-show-payment'),
show_employee: getChecked('receipt-show-employee'),
};
try {
var res = await fetch(API + '/receipt', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Error al guardar');
}
toast('Configuración de ticket guardada', 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
async function saveAll() {
if (!checkAuth()) return;
var btn = document.getElementById('btn-save-all');
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
await saveBusiness();
await saveTaxParams();
await saveCurrency();
await saveVehicleCompatSource();
await saveAllowedBrands();
await saveModules();
await saveSalesSettings();
await saveReceiptConfig();
toast('Configuración guardada', 'ok');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Cambios'; }
}
}
// -------------------------------------------------------------------------
// Event bindings
// -------------------------------------------------------------------------
@@ -506,6 +701,12 @@ const Config = (() => {
var btnNewEmp = document.getElementById('btn-new-employee');
if (btnNewEmp) {
btnNewEmp.addEventListener('click', function() {
var modal = document.getElementById('modal-employee');
if (modal) {
delete modal.dataset.editId;
var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Nuevo Empleado';
}
openModal('modal-employee');
});
}
@@ -529,6 +730,7 @@ const Config = (() => {
btnSaveEmp.disabled = true;
btnSaveEmp.textContent = 'Guardando...';
var isEdit = !!document.getElementById('modal-employee').dataset.editId;
try {
await saveEmployee({
name: name,
@@ -539,7 +741,7 @@ const Config = (() => {
branch_id: branchId ? parseInt(branchId, 10) : null,
max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0
});
toast('Empleado creado');
toast(isEdit ? 'Empleado actualizado' : 'Empleado creado');
closeModal('modal-employee');
// Reset form
document.getElementById('emp-name').value = '';
@@ -756,25 +958,42 @@ const Config = (() => {
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
var cbWork = document.getElementById('cfg-module-workshop');
if (cbWa) cbWa.checked = data.whatsapp !== false;
if (cbMp) cbMp.checked = data.marketplace !== false;
if (cbMeli) cbMeli.checked = data.meli !== false;
if (cbCat) cbCat.checked = data.catalog !== false;
if (cbWork) cbWork.checked = data.workshop !== false;
localStorage.setItem('pos_modules', JSON.stringify(data));
} catch (e) {
console.error('Config.loadModules:', e);
}
try {
var res2 = await fetch(API + '/counter-remission', { headers: headers() });
if (!res2.ok) return;
var d2 = await res2.json();
var cbCr = document.getElementById('cfg-module-counter-remission');
if (cbCr) cbCr.checked = d2.enabled === true;
} catch (e) {
console.error('Config.loadCounterRemission:', e);
}
}
async function saveModules() {
var btn = event.target;
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
var cbWa = document.getElementById('cfg-module-whatsapp');
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
var cbWork = document.getElementById('cfg-module-workshop');
var cbCr = document.getElementById('cfg-module-counter-remission');
if (!cbWa && !cbMp && !cbMeli && !cbCat && !cbWork && !cbCr) return;
try {
var data = {
whatsapp: document.getElementById('cfg-module-whatsapp').checked,
marketplace: document.getElementById('cfg-module-marketplace').checked,
meli: document.getElementById('cfg-module-meli').checked,
catalog: document.getElementById('cfg-module-catalog').checked,
whatsapp: cbWa ? cbWa.checked : true,
marketplace: cbMp ? cbMp.checked : true,
meli: cbMeli ? cbMeli.checked : true,
catalog: cbCat ? cbCat.checked : true,
workshop: cbWork ? cbWork.checked : true,
};
var res = await fetch(API + '/modules', {
method: 'PUT',
@@ -786,14 +1005,257 @@ const Config = (() => {
throw new Error(err.error || 'Save failed');
}
localStorage.setItem('pos_modules', JSON.stringify(data));
if (cbCr) {
var res2 = await fetch(API + '/counter-remission', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ enabled: cbCr.checked })
});
if (!res2.ok) {
var err2 = await res2.json().catch(function() { return { error: res2.statusText }; });
throw new Error(err2.error || 'Save failed');
}
}
toast('Módulos actualizados');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar módulos'; }
}
}
async function loadSalesSettings() {
try {
var res = await fetch(API + '/sales-settings', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
var cbZero = document.getElementById('cfg-allow-zero-price');
if (cbZero) cbZero.checked = data.allow_zero_price_sales !== false;
var cbNeg = document.getElementById('cfg-allow-negative-stock');
if (cbNeg) cbNeg.checked = data.allow_negative_stock === true;
} catch (e) {
console.error('Config.loadSalesSettings:', e);
}
}
async function saveSalesSettings() {
var cbZero = document.getElementById('cfg-allow-zero-price');
var cbNeg = document.getElementById('cfg-allow-negative-stock');
if (!cbZero && !cbNeg) return;
try {
var body = {};
if (cbZero) body.allow_zero_price_sales = cbZero.checked;
if (cbNeg) body.allow_negative_stock = cbNeg.checked;
var res = await fetch(API + '/sales-settings', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(body)
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Save failed');
}
} catch (e) {
toast(e.message, 'error');
throw e;
}
}
// -------------------------------------------------------------------------
// Tab navigation
// -------------------------------------------------------------------------
function switchTab(tab) {
document.querySelectorAll('.cfg-tab-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.settings-section[data-tab]').forEach(function(sec) {
var isActive = sec.dataset.tab === tab;
sec.classList.toggle('active', isActive);
sec.style.display = isActive ? '' : 'none';
});
try { localStorage.setItem('pos_config_tab', tab); } catch(e) {}
}
// -------------------------------------------------------------------------
// Role permissions editor
// -------------------------------------------------------------------------
var _rolePermissions = {};
var _availablePermissions = [];
var _workshopPermissions = {};
var _workshopSchema = { statuses: [], actions: [] };
var _currentPermTab = 'modules';
async function loadRolePermissions() {
try {
var res = await fetch(API + '/role-permissions', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_rolePermissions = data.roles || {};
_availablePermissions = data.available || [];
renderRolePermissions();
} catch (e) {
console.error('Config.loadRolePermissions:', e);
}
}
function renderRolePermissions() {
var container = document.getElementById('role-permissions-container');
var roleSel = document.getElementById('cfg-perm-role');
if (!container || !roleSel) return;
var role = roleSel.value;
if (!role) {
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>';
return;
}
var current = _rolePermissions[role] || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-4);">';
_availablePermissions.forEach(function(group) {
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">' + escapeHtml(group.module) + '</h4>';
group.permissions.forEach(function(p) {
var checked = current.indexOf(p.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-perm-key="' + escapeHtml(p.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(p.label) + '</span>';
html += '</label>';
});
html += '</div>';
});
html += '</div>';
container.innerHTML = html;
}
async function saveRolePermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('role-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var selected = [];
document.querySelectorAll('#role-permissions-container input[data-perm-key]').forEach(function(cb) {
if (cb.checked) selected.push(cb.dataset.permKey);
});
var payload = { roles: {} };
payload.roles[role] = selected;
try {
var res = await fetch(API + '/role-permissions', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload)
});
var data = await res.json().catch(function() { return { error: res.statusText }; });
if (!res.ok) throw new Error(data.error || 'Error al guardar');
_rolePermissions[role] = selected;
if (status) status.textContent = 'Permisos guardados y aplicados a empleados existentes';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
// Workshop-specific permissions editor
// -------------------------------------------------------------------------
async function loadWorkshopPermissions() {
try {
var res = await fetch(API + '/role-permissions/workshop', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_workshopPermissions = data.roles || {};
_workshopSchema = { statuses: data.statuses || [], actions: data.actions || [] };
renderWorkshopPermissions();
} catch (e) {
console.error('Config.loadWorkshopPermissions:', e);
}
}
function renderWorkshopPermissions() {
var container = document.getElementById('workshop-permissions-container');
var roleSel = document.getElementById('cfg-perm-role');
if (!container || !roleSel) return;
var role = roleSel.value;
if (!role) {
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos de Taller.</p>';
return;
}
var cfg = _workshopPermissions[role] || { statuses: [], actions: [] };
var visibleStatuses = cfg.statuses || [];
var visibleActions = cfg.actions || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--space-4);">';
// Statuses
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">Estatus visibles en Taller</h4>';
(_workshopSchema.statuses || []).forEach(function(s) {
var checked = visibleStatuses.indexOf(s.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-ws-status="' + escapeHtml(s.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(s.label) + '</span>';
html += '</label>';
});
html += '</div>';
// Actions
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">Acciones permitidas en Taller</h4>';
(_workshopSchema.actions || []).forEach(function(a) {
var checked = visibleActions.indexOf(a.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-ws-action="' + escapeHtml(a.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(a.label) + '</span>';
html += '</label>';
});
html += '</div>';
html += '</div>';
container.innerHTML = html;
}
async function saveWorkshopPermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('workshop-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var statuses = [];
var actions = [];
document.querySelectorAll('#workshop-permissions-container input[data-ws-status]').forEach(function(cb) {
if (cb.checked) statuses.push(cb.dataset.wsStatus);
});
document.querySelectorAll('#workshop-permissions-container input[data-ws-action]').forEach(function(cb) {
if (cb.checked) actions.push(cb.dataset.wsAction);
});
var payload = { roles: {} };
payload.roles[role] = { statuses: statuses, actions: actions };
try {
var res = await fetch(API + '/role-permissions/workshop', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload)
});
var data = await res.json().catch(function() { return { error: res.statusText }; });
if (!res.ok) throw new Error(data.error || 'Error al guardar');
_workshopPermissions[role] = { statuses: statuses, actions: actions };
if (status) status.textContent = 'Permisos de Taller guardados';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
function switchPermTab(tab) {
_currentPermTab = tab;
document.querySelectorAll('#perm-panel-modules, #perm-panel-workshop').forEach(function(el) { el.style.display = 'none'; el.classList.remove('active'); });
document.querySelectorAll('#tab-perm-modules, #tab-perm-workshop').forEach(function(el) { el.classList.remove('active'); });
document.getElementById('perm-panel-' + tab).style.display = 'block';
document.getElementById('perm-panel-' + tab).classList.add('active');
document.getElementById('tab-perm-' + tab).classList.add('active');
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -802,7 +1264,7 @@ const Config = (() => {
// Restore theme
try {
var saved = localStorage.getItem('nexus-theme');
var saved = localStorage.getItem('pos_theme');
if (saved === 'industrial' || saved === 'modern') {
setTheme(saved);
}
@@ -815,6 +1277,10 @@ const Config = (() => {
// Bind UI events
bindEvents();
// Global save button
var btnSaveAll = document.getElementById('btn-save-all');
if (btnSaveAll) btnSaveAll.addEventListener('click', saveAll);
// Vehicle compat source save button
var btnCompat = document.getElementById('btn-save-compat-source');
if (btnCompat) {
@@ -842,6 +1308,11 @@ const Config = (() => {
});
}
// Show permissions tab only for owner/admin
var isAdmin = user.role === 'owner' || user.role === 'admin';
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
// Load real data in parallel
loadBranches();
loadEmployees();
@@ -850,18 +1321,24 @@ const Config = (() => {
loadVehicleCompatSource();
loadAllowedBrands();
loadModules();
loadSalesSettings();
loadReceiptConfig();
if (isAdmin) {
loadRolePermissions();
loadWorkshopPermissions();
}
// Activate default or stored tab
var defaultTab = 'general';
try {
var storedTab = localStorage.getItem('pos_config_tab');
if (storedTab) defaultTab = storedTab;
} catch(e) {}
switchTab(defaultTab);
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
loadModules, saveModules,
openModal, closeModal, openBranchModal, editBranch
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -870,4 +1347,18 @@ const Config = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
return {
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee, deleteEmployee,
deleteBranch,
loadBusiness, saveBusiness, saveTaxParams, saveAll,
loadCurrency, saveCurrency,
loadVehicleCompatSource, saveVehicleCompatSource,
loadModules, saveModules,
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
openModal, closeModal, openBranchModal, editBranch,
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions,
loadWorkshopPermissions, renderWorkshopPermissions, saveWorkshopPermissions, switchPermTab
};
})();

View File

@@ -5,12 +5,20 @@
* Wired to the design-system HTML (customers.html).
*/
const Customers = (() => {
let token = localStorage.getItem('pos_token') || '';
let currentPage = 1;
let totalPages = 1;
let currentCustomer = null;
let searchTimeout = null;
const user = window.POS_USER || {};
const userRole = (user.role || '').toLowerCase();
const userPerms = user.permissions || [];
const canDeleteCustomer = userRole === 'owner' || userRole === 'admin' || userPerms.includes('customers.delete');
function getToken() {
return localStorage.getItem('pos_token') || '';
}
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
@@ -23,7 +31,7 @@ const Customers = (() => {
};
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
}
async function api(url, options = {}) {
@@ -43,6 +51,9 @@ const Customers = (() => {
const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' };
function statusBadge(c) {
if (c.is_active === false) {
return '<span class="badge badge--inactive"><span class="badge-dot"></span>Inactivo</span>';
}
// Derive status: if credit_balance > credit_limit => Mora, else Activo
if (c.credit_balance > 0 && c.credit_limit > 0 && c.credit_balance > c.credit_limit) {
return '<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>';
@@ -82,14 +93,27 @@ const Customers = (() => {
const searchEl = document.getElementById('searchInput');
q = q !== undefined ? q : (searchEl ? searchEl.value || '' : '');
const tipoEl = document.getElementById('tipoFilter');
const estadoEl = document.getElementById('estadoFilter');
const tipo = tipoEl ? tipoEl.value : '';
// Map UI status labels to backend values
const estadoMap = { 'Activo': 'active', 'Inactivo': 'inactive', 'Mora': 'overdue' };
const estado = estadoEl ? (estadoMap[estadoEl.value] || 'all') : 'active';
const tbody = document.getElementById('customersBody');
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderLoadingState({ message: 'Cargando clientes...' }) + '</td></tr>';
try {
const params = new URLSearchParams({ page, per_page: 50 });
if (q) params.append('q', q);
if (tipo) params.append('price_tier', tipo);
if (estado) params.append('status', estado);
const data = await api(`/pos/api/customers?${params}`);
renderTable(data.data || []);
renderPagination(data.pagination || {});
} catch (e) {
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los clientes.' }) + '</td></tr>';
console.error('Load customers failed:', e);
}
}
@@ -107,7 +131,7 @@ const Customers = (() => {
const num = String(c.id).padStart(5, '0');
const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : '';
const isChecked = selectedCustomers.has(c.id) ? 'checked' : '';
return '<tr class="' + selClass + '">' +
return '<tr class="' + selClass + '" onclick="Customers.selectCustomer(' + c.id + ')">' +
'<td onclick="event.stopPropagation();"><input type="checkbox" ' + isChecked + ' onchange="Customers.toggleCustomerSelection(' + c.id + ')"></td>' +
'<td class="cell-num">' + num + '</td>' +
'<td>' +
@@ -121,6 +145,7 @@ const Customers = (() => {
'<td class="cell-credit ' + creditClass + '">' + fmt(available) + '</td>' +
'<td class="cell-date hide-mobile">' + formatDate(c.last_purchase || c.created_at) + '</td>' +
'<td>' + statusBadge(c) + '</td>' +
'<td onclick="event.stopPropagation();"><button class="btn btn-sm btn-ghost" title="Editar cliente" onclick="Customers.editCustomer(' + c.id + ')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button></td>' +
'</tr>';
}
@@ -131,11 +156,11 @@ const Customers = (() => {
if (!tbody) return;
if (!customers || customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="9">' + renderEmptyState({
tbody.innerHTML = '<tr><td colspan="11">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
title: 'Sin clientes',
subtitle: 'No se encontraron clientes registrados.',
action: '<button class="btn btn--primary btn--sm" onclick="Customers.openCreateModal()">Nuevo cliente</button>'
action: '<button class="btn btn--primary btn--sm" onclick="Customers.showCreateModal()">Nuevo cliente</button>'
}) + '</td></tr>';
return;
}
@@ -146,7 +171,7 @@ const Customers = (() => {
rowHeight: 52,
buffer: 3,
renderRow: renderCustomerRow,
emptyHtml: '<tr><td colspan="9">' + renderEmptyState({ title: 'Sin clientes', subtitle: 'No hay clientes registrados.' }) + '</td></tr>'
emptyHtml: '<tr><td colspan="11">' + renderEmptyState({ title: 'Sin clientes', subtitle: 'No hay clientes registrados.' }) + '</td></tr>'
});
}
customersVS.setData(customers);
@@ -278,6 +303,28 @@ const Customers = (() => {
const discountEl = document.getElementById('detailMaxDiscount');
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
// Vehicles
const vehiclesEl = document.getElementById('detailVehicles');
if (vehiclesEl) {
const vehicles = c.fleet_vehicles || [];
if (vehicles.length === 0) {
vehiclesEl.innerHTML = '<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>';
} else {
vehiclesEl.innerHTML = vehicles.map(v => {
const title = [v.year, v.make, v.model].filter(Boolean).join(' ');
const subtitle = [v.plate, v.vin, v.color].filter(Boolean).join(' · ');
return `<div class="vehicle-row" style="padding:var(--space-2);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-weight:600;">${title || 'Veh&iacute;culo'}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${subtitle}</div>
</div>`;
}).join('');
}
}
// Delete button visibility
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.style.display = canDeleteCustomer ? 'inline-flex' : 'none';
// Re-wire action buttons after detail panel is visible
wireActionButtons();
@@ -379,15 +426,106 @@ const Customers = (() => {
// Wire action buttons in detail panel
function wireActionButtons() {
const btns = document.querySelectorAll('.quick-actions .action-btn');
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial, Eliminar
if (btns.length >= 1) btns[0].onclick = () => {
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
};
if (btns.length >= 2) btns[1].onclick = () => editCurrent();
if (btns.length >= 3) btns[2].onclick = () => showStatement();
if (btns.length >= 4) btns[3].onclick = () => {
if (currentCustomer) selectCustomer(currentCustomer.id);
if (currentCustomer) showCustomerHistory(currentCustomer.id);
};
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.onclick = () => deleteCustomer();
}
async function deleteCustomer() {
if (!currentCustomer) return;
if (!canDeleteCustomer) {
alert('No tienes permiso para eliminar clientes');
return;
}
if (!confirm(`¿Eliminar al cliente "${currentCustomer.name}"? Se borrará completamente. Las ventas, órdenes y vehículos conservarán sus datos pero quedarán sin cliente asignado.`)) return;
try {
await api(`/pos/api/customers/${currentCustomer.id}`, { method: 'DELETE' });
alert('Cliente eliminado');
currentCustomer = null;
closeDetail();
loadCustomers(currentPage);
} catch (e) {
alert('Error: ' + e.message);
}
}
window.deleteCustomer = deleteCustomer;
async function showCustomerHistory(customerId) {
try {
const res = await api(`/pos/api/customers/${customerId}/purchases`);
const purchases = res.data || [];
let modal = document.getElementById('customerHistoryModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'customerHistoryModal';
modal.className = 'modal-overlay';
modal.style.display = 'none';
modal.innerHTML = `
<div class="modal-content" style="max-width:700px;">
<div class="modal-header">
<h3>Historial de Compras — <span id="customerHistoryName"></span></h3>
<button class="modal-close" onclick="Customers.closeCustomerHistoryModal()">&times;</button>
</div>
<div class="modal-body">
<table class="history-table" style="width:100%;">
<thead>
<tr>
<th>Fecha</th>
<th>Folio</th>
<th>Total</th>
<th>Pago</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="customerHistoryBody"></tbody>
</table>
</div>
</div>
`;
document.body.appendChild(modal);
}
const tbody = document.getElementById('customerHistoryBody');
const title = document.getElementById('customerHistoryName');
if (title && currentCustomer) title.textContent = currentCustomer.name;
if (purchases.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--color-text-muted);padding:var(--space-4);">Sin compras registradas</td></tr>';
} else {
tbody.innerHTML = purchases.map(p => {
const statusClass = p.status === 'paid' ? 'mbadge--paid' : p.status === 'cancelled' ? 'mbadge--error' : p.status === 'overdue' ? 'mbadge--overdue' : 'mbadge--pending';
const statusLabel = p.status === 'paid' ? 'Pagado' : p.status === 'cancelled' ? 'Cancelado' : p.status === 'overdue' ? 'Vencido' : 'Pendiente';
return `<tr>
<td class="date">${formatDate(p.created_at)}</td>
<td class="folio">NX-${String(p.id).padStart(5, '0')}</td>
<td class="total">${fmt(p.total)}</td>
<td>${p.payment_method || '-'}</td>
<td><span class="mbadge ${statusClass}">${statusLabel}</span></td>
</tr>`;
}).join('');
}
modal.style.display = 'flex';
modal.classList.add('active');
} catch (e) {
console.error('Error loading customer history:', e);
alert('Error al cargar historial: ' + e.message);
}
}
function closeCustomerHistoryModal() {
const modal = document.getElementById('customerHistoryModal');
if (modal) {
modal.style.display = 'none';
modal.classList.remove('active');
}
}
// ─── Create/Edit Modal ───────────────
@@ -415,9 +553,22 @@ const Customers = (() => {
function editCurrent() {
if (!currentCustomer) return;
const c = currentCustomer;
openEditModal(currentCustomer);
}
async function editCustomer(id) {
try {
const c = await api(`/pos/api/customers/${id}`);
currentCustomer = c;
openEditModal(c);
} catch (e) {
alert('Error: ' + e.message);
}
}
function openEditModal(c) {
const modal = document.getElementById('customerModal');
if (!modal) return;
if (!modal || !c) return;
document.getElementById('modalTitle').textContent = 'Editar Cliente';
document.getElementById('editId').value = c.id;
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
@@ -566,7 +717,7 @@ const Customers = (() => {
try {
await api(`/pos/api/customers/${currentCustomer.id}/payment`, {
method: 'POST',
body: JSON.stringify({ amount, method, reference }),
body: JSON.stringify({ amount, payment_method: method, reference }),
});
closePayment();
selectCustomer(currentCustomer.id);
@@ -585,7 +736,7 @@ const Customers = (() => {
// ─── Init ────────────────────────────
function init() {
// Auth check
if (!token) {
if (!getToken()) {
window.location.href = '/pos/login';
return;
}
@@ -802,11 +953,21 @@ const Customers = (() => {
const publicApi = {
search, goToPage, loadCustomers,
showDetail, selectCustomer, closeDetail,
showCreateModal, editCurrent, closeModal, save,
showCreateModal, editCurrent, editCustomer, closeModal, save,
showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment,
showCustomerHistory, closeCustomerHistoryModal,
deleteCustomer,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
// Bulk selection
publicApi.toggleCustomerSelection = function(id) {
if (selectedCustomers.has(id)) selectedCustomers.delete(id);
@@ -842,15 +1003,15 @@ const Customers = (() => {
updateBulkToolbar();
};
publicApi.featureProximamente = function(name) {
if (typeof window.featureProximamente === 'function') {
window.featureProximamente(name);
} else {
alert(name + ' — próximamente');
}
};
// Expose globally for inline HTML onclick handlers
window.Customers = publicApi;
return publicApi;
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

View File

@@ -48,7 +48,7 @@ const Dashboard = (() => {
// -------------------------------------------------------------------------
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {}
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
const btnInd = document.getElementById('btn-industrial');
const btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('active', theme === 'industrial');
@@ -367,16 +367,51 @@ const Dashboard = (() => {
}
// -------------------------------------------------------------------------
// 4. Top Products (from today's sales detail)
// 4. Credit alerts
// -------------------------------------------------------------------------
async function loadCreditAlerts() {
const data = await apiFetch('/pos/api/dashboard/credit-alerts');
const tbody = document.getElementById('credit-alerts-tbody');
const meta = document.getElementById('credit-alerts-meta');
if (!tbody) return;
if (!data || !data.data || data.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:var(--space-4);color:var(--color-text-muted);">No hay créditos por vencer.</td></tr>';
if (meta) meta.textContent = 'Vencidos: 0 / Por vencer: 0';
return;
}
if (meta) {
meta.innerHTML = `<span style="color:var(--color-error);font-weight:600;">Vencidos: ${data.overdue_count || 0}</span> &nbsp;|&nbsp; <span style="color:var(--color-warning);font-weight:600;">Por vencer: ${data.due_soon_count || 0}</span>`;
}
tbody.innerHTML = data.data.map(function(r) {
const dueDate = r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-';
const daysText = r.days_until_due < 0 ? `${Math.abs(r.days_until_due)} días vencido` : `${r.days_until_due} días restantes`;
const statusClass = r.status === 'overdue' ? 'error' : (r.status === 'due_soon' ? 'warning' : 'success');
return `<tr>
<td><span class="td-client">${escHtml(r.customer_name)}</span></td>
<td><span class="td-mono">${escHtml(r.folio)}</span></td>
<td>${dueDate}</td>
<td>${daysText}</td>
<td class="align-right"><span class="td-mono">${fmt(r.balance)}</span></td>
<td><span class="badge badge--${statusClass}">${r.status_label}</span></td>
</tr>`;
}).join('');
}
// -------------------------------------------------------------------------
// 5. Top Products (from today's sales detail)
// -------------------------------------------------------------------------
async function loadTopProducts() {
const today = todayStr();
// Fetch all today's sales with pagination
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&status=completed&per_page=200`);
// Single optimized endpoint: returns today's top products already aggregated
const data = await apiFetch('/pos/api/dashboard/stats');
const container = document.getElementById('top-products-list');
if (!container) return;
if (!data || !data.data || data.data.length === 0) {
const top = data && data.top_products ? data.top_products : [];
if (!top.length) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
@@ -386,37 +421,7 @@ const Dashboard = (() => {
return;
}
// Fetch detail for each sale to get items (up to 20 sales for performance)
const salesToFetch = data.data.slice(0, 20);
const details = await Promise.all(
salesToFetch.map(s => apiFetch(`/pos/api/sales/${s.id}`))
);
// Aggregate items
const productMap = {};
for (const sale of details) {
if (!sale || !sale.items) continue;
for (const item of sale.items) {
const key = item.part_number || item.name;
if (!productMap[key]) {
productMap[key] = { name: item.name, part_number: item.part_number || '', qty: 0, revenue: 0 };
}
productMap[key].qty += item.quantity || 0;
productMap[key].revenue += item.subtotal || 0;
}
}
const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
if (sorted.length === 0) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
title: 'Sin productos vendidos',
subtitle: 'No hay suficiente información para mostrar el ranking.'
});
return;
}
const sorted = top.slice(0, 5);
const maxRev = sorted[0].revenue || 1;
container.innerHTML = sorted.map((p, i) => {
const pct = Math.round((p.revenue / maxRev) * 100);
@@ -425,7 +430,7 @@ const Dashboard = (() => {
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
<div class="rank-item__info">
<div class="rank-item__name">${escHtml(p.name)}</div>
<div class="rank-item__sub">${escHtml(p.part_number)} &nbsp;&middot;&nbsp; ${p.qty} pzas vendidas</div>
<div class="rank-item__sub">${p.quantity} pzas vendidas</div>
<div class="rank-item__bar-bg">
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
</div>
@@ -619,11 +624,12 @@ const Dashboard = (() => {
// -------------------------------------------------------------------------
async function loadRecentSales() {
const today = todayStr();
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`);
const data = await apiFetch(`/pos/api/sales/recent?date_from=${today}&date_to=${today}&limit=10`);
const tbody = document.getElementById('recent-sales-tbody');
if (!tbody) return;
if (!data || !data.data || data.data.length === 0) {
const sales = data && data.data ? data.data : [];
if (!sales.length) {
tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
@@ -633,26 +639,22 @@ const Dashboard = (() => {
return;
}
// Fetch items for first 5 sales
const salesToShow = data.data.slice(0, 5);
const details = await Promise.all(
salesToShow.map(s => apiFetch(`/pos/api/sales/${s.id}`))
);
const salesToShow = sales.slice(0, 5);
tbody.innerHTML = salesToShow.map((sale, idx) => {
const detail = details[idx];
tbody.innerHTML = salesToShow.map((sale) => {
const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--';
const client = sale.customer_name || 'Publico General';
const total = sale.total || 0;
const method = sale.payment_method || 'efectivo';
// Build products summary from detail items
// Build products summary from items already included in the response
let productsSummary = '';
if (detail && detail.items && detail.items.length > 0) {
productsSummary = detail.items.slice(0, 3).map(it =>
const items = sale.items || [];
if (items.length > 0) {
productsSummary = items.slice(0, 3).map(it =>
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
).join(', ');
if (detail.items.length > 3) productsSummary += '...';
if (items.length > 3) productsSummary += '...';
}
const methodClass = getPaymentBadgeClass(method);
@@ -689,7 +691,7 @@ const Dashboard = (() => {
// Restore theme
try {
const saved = localStorage.getItem('nexus-theme');
const saved = localStorage.getItem('pos_theme');
if (saved === 'industrial' || saved === 'modern') {
setTheme(saved);
}
@@ -701,6 +703,7 @@ const Dashboard = (() => {
loadDailySummary();
loadHistoricalSummary();
loadAlerts();
loadCreditAlerts();
loadTopProducts();
loadChart('semana');
loadRecentSales();
@@ -709,6 +712,7 @@ const Dashboard = (() => {
setInterval(() => {
loadDailySummary();
loadRecentSales();
loadCreditAlerts();
}, 120000);
}

View File

@@ -12,6 +12,16 @@ var Fleet = (function() {
var currentPage = 1;
var searchTimeout = null;
var user = window.POS_USER || {};
var role = (user.role || '').toLowerCase();
var perms = user.permissions || [];
function hasPerm(p) {
return role === 'owner' || perms.indexOf(p) !== -1;
}
var canCreate = hasPerm('fleet.create');
var canEdit = hasPerm('fleet.edit');
var canDelete = hasPerm('fleet.delete');
// ─── Helpers ───
function headers() {
@@ -75,9 +85,16 @@ var Fleet = (function() {
}
// New vehicle button
document.getElementById('btnNewVehicle').addEventListener('click', function() {
openVehicleModal();
});
var btnNewVehicle = document.getElementById('btnNewVehicle');
if (btnNewVehicle) {
if (!canCreate) {
btnNewVehicle.style.display = 'none';
} else {
btnNewVehicle.addEventListener('click', function() {
openVehicleModal();
});
}
}
// Load initial data
loadStats();
@@ -105,6 +122,8 @@ var Fleet = (function() {
var url = API + '/vehicles?page=' + currentPage + '&per_page=50';
if (q) url += '&q=' + encodeURIComponent(q);
document.getElementById('vehicleGrid').innerHTML = renderLoadingState({ message: 'Cargando vehiculos...' });
fetch(url, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
@@ -115,7 +134,7 @@ var Fleet = (function() {
})
.catch(function() {
document.getElementById('vehicleGrid').innerHTML =
'<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los vehiculos.' });
});
}
@@ -125,7 +144,7 @@ var Fleet = (function() {
grid.innerHTML = '<div class="empty-state">' +
'<div class="empty-state__icon">&#x1F69A;</div>' +
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
'<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
(canCreate ? '<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' : '') +
'</div>';
return;
}
@@ -135,7 +154,7 @@ var Fleet = (function() {
var label = (v.make || '') + ' ' + (v.model || '');
if (v.year) label += ' ' + v.year;
html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
html += '<div class="vehicle-card" ' + (canEdit ? 'onclick="Fleet.viewVehicle(' + v.id + ')" style="cursor:pointer;"' : '') + '>' +
'<div class="vehicle-card__header">' +
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
@@ -200,9 +219,11 @@ var Fleet = (function() {
function openVehicleModal(data) {
var modal = document.getElementById('vehicleModal');
var title = document.getElementById('vehicleModalTitle');
var saveBtn = document.getElementById('btnSaveVehicle');
if (data && data.id) {
title.textContent = 'Editar Vehiculo';
if (saveBtn) saveBtn.style.display = canEdit ? '' : 'none';
document.getElementById('vehEditId').value = data.id;
document.getElementById('vehPlate').value = data.plate || '';
document.getElementById('vehVin').value = data.vin || '';
@@ -216,6 +237,7 @@ var Fleet = (function() {
document.getElementById('vehNotes').value = data.notes || '';
} else {
title.textContent = 'Nuevo Vehiculo';
if (saveBtn) saveBtn.style.display = canCreate ? '' : 'none';
document.getElementById('vehEditId').value = '';
['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes']
.forEach(function(id) { document.getElementById(id).value = ''; });
@@ -232,6 +254,8 @@ var Fleet = (function() {
function saveVehicle() {
var editId = document.getElementById('vehEditId').value;
if (editId && !canEdit) { alert('No tienes permiso para editar vehiculos'); return; }
if (!editId && !canCreate) { alert('No tienes permiso para crear vehiculos'); return; }
var payload = {
plate: document.getElementById('vehPlate').value.trim(),
vin: document.getElementById('vehVin').value.trim(),
@@ -275,26 +299,36 @@ var Fleet = (function() {
// ─── Maintenance Tab ───
function loadMaintenance() {
// Load all vehicles with their schedules
fetch(API + '/vehicles?per_page=200', {headers: headers()})
// Single bulk endpoint replaces N+1 per-vehicle schedule requests
document.getElementById('maintBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando programas...' }) + '</td></tr>';
fetch(API + '/vehicles/schedules', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var allVehicles = d.data || [];
var promises = allVehicles.map(function(v) {
return fetch(API + '/vehicles/' + v.id + '/schedules', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(s) {
return {vehicle: v, schedules: s.data || []};
});
var schedules = d.data || [];
var results = schedules.map(function(s) {
return {
vehicle: s.vehicle || {},
schedules: [{
id: s.id,
maintenance_type: s.maintenance_type,
interval_km: s.interval_km,
interval_months: s.interval_months,
last_done_at: s.last_done_at,
last_done_km: s.last_done_km,
next_due_at: s.next_due_at,
next_due_km: s.next_due_km,
notes: s.notes
}]
};
});
return Promise.all(promises);
})
.then(function(results) {
renderMaintenance(results);
})
.catch(function() {
.catch(function(e) {
console.error('loadMaintenance error:', e);
document.getElementById('maintBody').innerHTML =
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los programas.' }) + '</td></tr>';
});
}
@@ -326,7 +360,7 @@ var Fleet = (function() {
'<td>' + (next || '—') + '</td>' +
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
'<td>' + (canCreate ? '<button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button>' : '') + '</td>' +
'</tr>';
}
@@ -344,7 +378,9 @@ var Fleet = (function() {
if (!items.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
'No hay programas de mantenimiento.<br><button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button></td></tr>';
'No hay programas de mantenimiento.<br>' +
(canCreate ? '<button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button>' : '') +
'</td></tr>';
return;
}
@@ -363,25 +399,25 @@ var Fleet = (function() {
// ─── History Tab ───
function loadHistory() {
fetch(API + '/vehicles?per_page=200', {headers: headers()})
// Single bulk endpoint replaces N+1 per-vehicle detail requests
document.getElementById('historyBody').innerHTML =
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando historial...' }) + '</td></tr>';
fetch(API + '/vehicles/history', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var allVehicles = d.data || [];
var promises = allVehicles.map(function(v) {
return fetch(API + '/vehicles/' + v.id, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(detail) {
return {vehicle: v, logs: detail.recent_logs || []};
});
var logs = d.data || [];
var results = logs.map(function(l) {
l._plate = (l.vehicle && l.vehicle.plate) || 'S/P';
l._make = ((l.vehicle && l.vehicle.make) || '') + ' ' + ((l.vehicle && l.vehicle.model) || '');
return {vehicle: l.vehicle || {}, logs: [l]};
});
return Promise.all(promises);
})
.then(function(results) {
renderHistory(results);
})
.catch(function() {
.catch(function(e) {
console.error('loadHistory error:', e);
document.getElementById('historyBody').innerHTML =
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudo cargar el historial.' }) + '</td></tr>';
});
}
@@ -437,6 +473,8 @@ var Fleet = (function() {
// ─── Alerts Tab ───
function loadAlerts() {
document.getElementById('alertsList').innerHTML = renderLoadingState({ message: 'Cargando alertas...' });
fetch(API + '/alerts', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
@@ -444,7 +482,7 @@ var Fleet = (function() {
})
.catch(function() {
document.getElementById('alertsList').innerHTML =
'<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar las alertas.' });
});
}
@@ -475,7 +513,7 @@ var Fleet = (function() {
' &nbsp; ' + detail +
'</div>' +
'</div>' +
'<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
(canCreate ? '<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' : '') +
'</div>';
});
el.innerHTML = html;
@@ -504,6 +542,7 @@ var Fleet = (function() {
}
function saveSchedule() {
if (!canCreate) { alert('No tienes permiso para crear programas'); return; }
var vehicleId = document.getElementById('schedVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
@@ -574,6 +613,7 @@ var Fleet = (function() {
}
function saveLog() {
if (!canCreate) { alert('No tienes permiso para registrar mantenimiento'); return; }
var vehicleId = document.getElementById('logVehicleSelect').value;
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }

View File

@@ -10,6 +10,7 @@ var I18N = {
'catalog': 'Catalogo',
'inventory': 'Inventario',
'diagrams': 'Diagramas',
'remission_notes': 'Notas de Remisión',
'customers': 'Clientes',
'invoicing': 'Facturacion',
'accounting': 'Contabilidad',
@@ -166,6 +167,7 @@ var I18N = {
'catalog': 'Catalog',
'inventory': 'Inventory',
'diagrams': 'Diagrams',
'remission_notes': 'Remission Notes',
'customers': 'Customers',
'invoicing': 'Invoicing',
'accounting': 'Accounting',
@@ -317,7 +319,9 @@ var I18N = {
}
};
var currentLang = localStorage.getItem('pos_lang') || 'es';
// Spanish is mandatory for all tenants; ignore any previously stored language.
var currentLang = 'es';
localStorage.setItem('pos_lang', 'es');
/**
* Translate a key to the current language.
@@ -328,12 +332,11 @@ window.t = function(key) {
};
/**
* Switch the UI language and reload.
* Language switcher is disabled; the system stays in Spanish.
*/
window.setLang = function(lang) {
currentLang = lang;
localStorage.setItem('pos_lang', lang);
location.reload();
currentLang = 'es';
localStorage.setItem('pos_lang', 'es');
};
/**

View File

@@ -6,19 +6,38 @@
'use strict';
var API = '/pos/api/inventory';
var token = localStorage.getItem('pos_token');
if (!token) { window.location.href = '/pos/login'; return; }
function getToken() { return localStorage.getItem('pos_token') || ''; }
if (!getToken()) { window.location.href = '/pos/login'; return; }
var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
function authHeaders() { return { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' }; }
var currentPage = 1;
var currentSearch = '';
var draftCountId = null;
var inventoryVS = null;
var compatSource = 'both'; // default, loaded from config
var inventorySearchController = null;
var user = window.POS_USER || {};
var userRole = (user.role || '').toLowerCase();
var userPerms = user.permissions || [];
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
var canCreateItem = userRole === 'owner' || userRole === 'admin' || userRole === 'counter' || userRole === 'cashier' || userRole === 'warehouse' || userPerms.indexOf('inventory.create') !== -1;
var canEditItem = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
var canImportItems = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
// Hide toolbar actions the user is not allowed to use
(function applyInventoryPermissions() {
var headerNew = document.getElementById('btnHeaderNewProduct');
var stockNew = document.getElementById('btnStockNewProduct');
var headerImport = document.getElementById('btnHeaderImport');
if (headerNew) headerNew.style.display = canCreateItem ? '' : 'none';
if (stockNew) stockNew.style.display = canCreateItem ? '' : 'none';
if (headerImport) headerImport.style.display = canImportItems ? '' : 'none';
})();
// Load compatibility source setting
(function loadCompatSource() {
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.source) compatSource = d.source;
@@ -27,7 +46,7 @@
// --- API helper ---
function apiFetch(url, opts) {
return fetch(url, Object.assign({ headers: headers }, opts || {}))
return fetch(url, Object.assign({ headers: authHeaders() }, opts || {}))
.then(function (resp) {
if (resp.status === 401) {
localStorage.removeItem('pos_token');
@@ -46,6 +65,15 @@
d.textContent = s;
return d.innerHTML;
}
function formatDateTime(isoStr) {
if (!isoStr) return '-';
var d = new Date(isoStr);
if (isNaN(d.getTime())) return esc(isoStr);
return d.toLocaleString('es-MX', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
// --- Dashboard summary badges ---
function loadSummary() {
@@ -100,7 +128,7 @@
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
fetch(API + '/tier-discounts', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
}).then(function(r) { return r.json(); })
.then(function(res) {
@@ -171,10 +199,11 @@
'<td>' + esc(it.location) + '</td>' +
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') +
(canCreateItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' : '') +
(userPerms.indexOf('marketplace.manage') !== -1 || userRole === 'owner' || userRole === 'admin' ? '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' : '') +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
'</td></tr>';
}
@@ -225,20 +254,27 @@
var tbody = document.getElementById('productTableBody');
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
apiFetch(API + '/items?' + params.toString()).then(function (data) {
if (!data) return;
if (inventorySearchController) {
inventorySearchController.abort();
}
inventorySearchController = new AbortController();
var items = data.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="12">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
title: 'Sin productos',
subtitle: currentSearch ? 'No se encontraron resultados para "' + esc(currentSearch) + '". Intenta con otro término.' : 'El inventario está vacío. Crea tu primer producto para empezar.',
action: currentSearch ? '<button class="btn btn--ghost btn--sm" onclick="document.getElementById(\'productSearch\').value=\'\';loadItems(1,\'\')">Limpiar búsqueda</button>' : '<button class="btn btn--primary btn--sm" onclick="openCreateModal()">Crear producto</button>'
}) + '</td></tr>';
document.getElementById('productPagination').innerHTML = '';
return;
}
apiFetch(API + '/items?' + params.toString(), { signal: inventorySearchController.signal })
.then(function (data) {
inventorySearchController = null;
if (!data) return;
var items = data.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="12">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
title: 'Sin productos',
subtitle: currentSearch ? 'No se encontraron resultados para "' + esc(currentSearch) + '". Intenta con otro término.' : 'El inventario está vacío. Crea tu primer producto para empezar.',
action: currentSearch ? '<button class="btn btn--ghost btn--sm" onclick="document.getElementById(\'productSearch\').value=\'\';loadItems(1,\'\')">Limpiar búsqueda</button>' : '<button class="btn btn--primary btn--sm" onclick="openCreateModal()">Crear producto</button>'
}) + '</td></tr>';
document.getElementById('productPagination').innerHTML = '';
return;
}
if (!inventoryVS) {
inventoryVS = new VirtualScroll({
@@ -268,6 +304,9 @@
} else {
pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>';
}
}).catch(function (err) {
if (err.name === 'AbortError') return;
console.error('Inventory load error:', err);
});
}
@@ -287,39 +326,49 @@
// CREATE ITEM (createModal)
// =====================================================================
function loadCategories() {
function loadCategories(selectedId) {
var sel = document.getElementById('newCategory');
if (!sel) return;
apiFetch(API + '/categories').then(function(data) {
apiFetch(API + '/categories/all').then(function(data) {
if (!data || !data.categories) return;
sel.innerHTML = '<option value="">Selecciona categoría</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
var cats = data.categories;
var top = cats.filter(function(c) { return !c.parent_id; });
var subs = cats.filter(function(c) { return c.parent_id; });
sel.innerHTML = '<option value="">Sin categoría</option>';
top.forEach(function(c) {
sel.innerHTML += '<optgroup label="' + esc(c.name) + '">' +
'<option value="' + c.id + '"' + (c.id === selectedId ? ' selected' : '') + '>' + esc(c.name) + '</option>';
subs.filter(function(s) { return s.parent_id === c.id; }).forEach(function(s) {
sel.innerHTML += '<option value="' + s.id + '"' + (s.id === selectedId ? ' selected' : '') + '>&nbsp;&nbsp;' + esc(s.name) + '</option>';
});
sel.innerHTML += '</optgroup>';
});
});
}
window.loadCategories = loadCategories;
function onCategoryChange(categoryId) {
var subSel = document.getElementById('newSubcategory');
if (!subSel) return;
if (!categoryId) {
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
subSel.disabled = true;
return;
}
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
if (!data || !data.subcategories) return;
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
data.subcategories.forEach(function(s) {
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
});
subSel.disabled = false;
});
// Kept for backwards compatibility; selector is now flat.
}
window.onCategoryChange = onCategoryChange;
function resetCreateModal() {
document.getElementById('editItemId').value = '';
document.getElementById('createModalTitle').textContent = 'Nuevo Producto';
document.getElementById('createModalBtn').textContent = 'Crear Producto';
var ids = ['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newUnit','newCost','newPrice1','newMinStock','newInitialStock','newMaxStock','newTaxRate','newLocation','newDescription'];
ids.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('newCategory').innerHTML = '<option value="">Sin categoría</option>';
document.getElementById('newIsActive').value = 'true';
document.getElementById('initialStockField').style.display = '';
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = false; });
}
function showCreateModal() {
resetCreateModal();
document.getElementById('createModal').classList.add('is-open');
loadCategories();
// Attach AI classification on part number blur
@@ -337,6 +386,41 @@
}
}
function showEditItemModal(itemId) {
resetCreateModal();
document.getElementById('editItemId').value = itemId;
document.getElementById('createModalTitle').textContent = 'Editar Producto';
document.getElementById('createModalBtn').textContent = 'Guardar Cambios';
document.getElementById('initialStockField').style.display = 'none';
document.getElementById('createModal').classList.add('is-open');
apiFetch(API + '/items/' + itemId).then(function(it) {
if (!it) return;
document.getElementById('newPartNumber').value = it.part_number || '';
document.getElementById('newName').value = it.name || '';
document.getElementById('newBrand').value = it.brand || '';
document.getElementById('newBarcode').value = it.barcode || '';
document.getElementById('newUnit').value = it.unit || '';
document.getElementById('newCost').value = it.cost != null ? it.cost : '';
document.getElementById('newPrice1').value = it.price_1 != null ? it.price_1 : '';
document.getElementById('newMinStock').value = it.min_stock != null ? it.min_stock : '';
document.getElementById('newMaxStock').value = it.max_stock != null ? it.max_stock : '';
document.getElementById('newTaxRate').value = it.tax_rate != null ? it.tax_rate : '';
document.getElementById('newLocation').value = it.location || '';
document.getElementById('newDescription').value = it.description || '';
document.getElementById('newIsActive').value = (it.is_active === false ? 'false' : 'true');
document.getElementById('newSku2').value = (it.sku_aliases && it.sku_aliases[0]) ? it.sku_aliases[0].sku : '';
document.getElementById('newSku3').value = (it.sku_aliases && it.sku_aliases[1]) ? it.sku_aliases[1].sku : '';
if (!canEditPrices) {
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = true; });
}
loadCategories(it.category_id);
}).catch(function(e) {
alert('Error al cargar producto: ' + e.message);
closeCreateModal();
});
}
window.showEditItemModal = showEditItemModal;
function classifyPartNumber(partNumber) {
var resultEl = document.getElementById('createResult');
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
@@ -367,58 +451,57 @@
function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = '';
var catSel = document.getElementById('newCategory');
var subSel = document.getElementById('newSubcategory');
if (catSel) catSel.innerHTML = '<option value="">Selecciona categoría</option>';
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; subSel.disabled = true; }
resetCreateModal();
}
function createItem() {
var elPrice2 = document.getElementById('newPrice2');
var elPrice3 = document.getElementById('newPrice3');
var editId = document.getElementById('editItemId').value;
var data = {
part_number: document.getElementById('newPartNumber').value.trim(),
name: document.getElementById('newName').value.trim(),
brand: document.getElementById('newBrand').value.trim(),
barcode: document.getElementById('newBarcode').value.trim() || undefined,
unit: document.getElementById('newUnit').value.trim() || undefined,
cost: parseFloat(document.getElementById('newCost').value) || 0,
price_1: parseFloat(document.getElementById('newPrice1').value) || 0,
price_2: elPrice2 ? (parseFloat(elPrice2.value) || 0) : 0,
price_3: elPrice3 ? (parseFloat(elPrice3.value) || 0) : 0,
min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
max_stock: parseInt(document.getElementById('newMaxStock').value) || 0,
tax_rate: parseFloat(document.getElementById('newTaxRate').value) || 0,
location: document.getElementById('newLocation').value.trim(),
description: document.getElementById('newDescription').value.trim(),
is_active: document.getElementById('newIsActive').value === 'true',
sku_aliases: []
};
var sku2 = document.getElementById('newSku2').value.trim();
var sku3 = document.getElementById('newSku3').value.trim();
var categoryId = document.getElementById('newCategory').value;
var subcategoryId = document.getElementById('newSubcategory').value;
if (categoryId) data.category_id = parseInt(categoryId);
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'});
if (subcategoryId) {
data.category_id = parseInt(subcategoryId);
} else if (categoryId) {
data.category_id = parseInt(categoryId);
}
if (!data.part_number || !data.name) {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
return;
}
apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
if (result && result.id) {
var msg = 'Creado ID ' + result.id + ' | Barcode: ' + result.barcode;
if (result.vehicle_compatibilities_added > 0) {
var url = API + '/items';
var method = 'POST';
if (editId) {
url = API + '/items/' + editId;
method = 'PUT';
// Stock inicial solo en creación
} else {
data.initial_stock = parseInt(document.getElementById('newInitialStock').value) || 0;
}
apiFetch(url, { method: method, body: JSON.stringify(data) }).then(function (result) {
if (result && (result.id || result.message)) {
var msg = editId ? 'Producto actualizado' : ('Creado ID ' + result.id + ' | Barcode: ' + result.barcode);
if (!editId && result.vehicle_compatibilities_added > 0) {
msg += ' | ' + result.vehicle_compatibilities_added + ' vehiculo(s) asignado(s) por IA';
}
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">' + msg + '</span>';
loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
if (window.loadInventoryStats) window.loadInventoryStats();
} else {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
@@ -444,7 +527,7 @@
fetch(API + '/items/bulk-import', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Authorization': 'Bearer ' + getToken(),
'X-Import-Mode': mode,
'X-Import-Strategy': strategy
},
@@ -474,6 +557,28 @@
}
window.submitBulkImport = submitBulkImport;
function downloadBulkImportTemplate() {
var headers = [
'numero_de_parte','nombre','marca','precio','cantidad','costo',
'sku_secundario','descripcion','categoria','fabricante','modelo','anio','motor','codigo_motor'
];
var example = [
'EJ-001','Filtro de aceite','ACDelco','150.00','10','90.00',
'EJ001-ALT','Filtro para sedan','Filtros','Nissan','Sentra','2020','1.8','MR18DE'
];
var csv = [headers.join(','), example.join(',')].join('\n');
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'plantilla_inventario.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
window.downloadBulkImportTemplate = downloadBulkImportTemplate;
// =====================================================================
// PURCHASE / ENTRADA (purchaseModal)
// =====================================================================
@@ -1004,7 +1109,7 @@
history.forEach(function (h) {
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
html += '<tr>' +
'<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' +
'<td>' + esc(h.type) + '</td>' +
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '—') + '</td>' +
@@ -1029,10 +1134,9 @@
function deleteItem(itemId) {
if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return;
var token = localStorage.getItem('pos_token') || '';
fetch(API + '/items/' + itemId, {
method: 'DELETE',
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
headers: getToken() ? { 'Authorization': 'Bearer ' + getToken() } : {}
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { showToast(data.error, 'error', { title: 'Error al eliminar' }); return; }
@@ -1085,7 +1189,7 @@
var ids = Array.from(selectedItems);
fetch('/pos/api/marketplace-ext/inventory-check', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventory_ids: ids })
}).then(function(r){ return r.json(); })
.then(function(data) {
@@ -1140,7 +1244,7 @@
formData.append('file', file);
fetch('/pos/api/inventory/items/' + itemId + '/image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
headers: { 'Authorization': 'Bearer ' + getToken() },
body: formData
}).then(function(r){ return r.json(); })
.then(function(data) {
@@ -1165,7 +1269,7 @@
clearTimeout(meliCategorySearchTimeout);
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
meliCategorySearchTimeout = setTimeout(function() {
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } })
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(data) {
var cats = data.categories || [];
@@ -1212,7 +1316,7 @@
var section = document.getElementById('meliAttrsSection');
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
section.style.display = 'block';
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r){ return r.json(); })
.then(function(data) {
meliCategoryAttrs = data.attributes || [];
@@ -1341,7 +1445,7 @@
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
fetch('/pos/api/marketplace-ext/listings/validate', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({
inventory_ids: ids,
category_id: categoryId,
@@ -1431,7 +1535,7 @@
var maxAttempts = 60; // 2 min
var interval = setInterval(function() {
attempts++;
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.status === 'done') {
@@ -1468,7 +1572,7 @@
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({
inventory_ids: ids,
category_id: categoryId,
@@ -1527,7 +1631,7 @@
if (statusEl) statusEl.textContent = 'Subiendo...';
fetch(API + '/items/' + itemId + '/image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
headers: { 'Authorization': 'Bearer ' + getToken() },
body: fd
})
.then(function (r) { return r.json(); })
@@ -1550,7 +1654,7 @@
if (!confirm('Eliminar imagen de este producto?')) return;
fetch(API + '/items/' + itemId + '/image', {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
headers: { 'Authorization': 'Bearer ' + getToken() }
})
.then(function (r) { return r.json(); })
.then(function (result) {
@@ -1660,7 +1764,7 @@
? '/pos/api/catalog/part/' + catalogPartId
: '/pos/api/catalog/search?q=' + encodeURIComponent(partNumber);
fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
fetch(url, { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('crossRefContent');
@@ -1672,7 +1776,7 @@
// If it was a search, get alternatives from first result
if (!catalogPartId && d.data && d.data.length > 0) {
// Fetch detail for first match
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/catalog/part/' + d.data[0].id_part, { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r2) { return r2.json(); })
.then(function(d2) {
renderCrossRefs(el, d2.alternatives || [], d2.bodegas || []);
@@ -1745,7 +1849,7 @@
// Load SKU aliases
(function loadSkuAliases() {
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('skuAliasContent');
@@ -1777,7 +1881,7 @@
// Load vehicle compatibilities and makes
(function loadCompatPanel() {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('compatContent');
@@ -1803,7 +1907,7 @@
});
// Load makes
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
var sel = document.getElementById('manualMake');
@@ -1828,7 +1932,7 @@
history.forEach(function (h) {
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
html += '<tr>' +
'<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
'<td style="font-size:var(--text-caption);">' + formatDateTime(h.date) + '</td>' +
'<td>' + esc(h.type) + '</td>' +
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '\u2014') + '</td>' +
@@ -1847,7 +1951,7 @@
function autoMatchCompat(itemId) {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/auto-match', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
headers: { 'Authorization': 'Bearer ' + getToken() }
}).then(function(r) { return r.json(); })
.then(function(d) {
var msg = '';
@@ -1870,7 +1974,7 @@
if (!confirm('Quitar compatibilidad con este vehiculo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/' + compatId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
headers: { 'Authorization': 'Bearer ' + getToken() }
}).then(function(r) { return r.json(); })
.then(function() {
viewProductDetail(itemId);
@@ -1903,7 +2007,7 @@
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
return;
}
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona modelo</option>';
@@ -1929,7 +2033,7 @@
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
return;
}
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona ano</option>';
@@ -1955,7 +2059,7 @@
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
return;
}
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } })
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + getToken() } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona motor</option>';
@@ -1983,7 +2087,7 @@
}
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode })
}).then(function(r) { return r.json(); })
.then(function(d) {
@@ -2001,7 +2105,7 @@
if (!sku) { alert('Ingresa un SKU'); return; }
fetch('/pos/api/inventory/items/' + itemId + '/skus', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: sku, label: label })
}).then(function(r) { return r.json(); })
.then(function(d) {
@@ -2014,7 +2118,7 @@
if (!confirm('Eliminar este SKU alternativo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
headers: { 'Authorization': 'Bearer ' + getToken() }
}).then(function(r) { return r.json(); })
.then(function() {
viewProductDetail(itemId);

View File

@@ -62,7 +62,10 @@ const Invoicing = (() => {
if (name === 'notas') loadNotas();
if (name === 'complementos') loadComplementos();
if (name === 'cancelaciones') loadCancelaciones();
if (name === 'config') loadFacturapiStatus();
if (name === 'config') {
loadFacturapiStatus();
loadEmisorData();
}
}
// ---- Badge helpers ----
@@ -86,46 +89,93 @@ const Invoicing = (() => {
}
// ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ----
let facturasCache = [];
async function loadFacturas() {
const panel = document.getElementById('panel-facturas');
if (!panel) return;
const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return;
try {
const res = await api('/queue?per_page=50&type=Ingreso');
const items = res.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay facturas en este periodo.</td></tr>';
return;
}
tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || item.id || '-'}</td>
<td class="td--primary">${item.serie || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td>
<td class="td--mono">${item.rfc || '-'}</td>
<td class="td--amount">$${fmt(item.subtotal)}</td>
<td class="td--amount">$${fmt(item.tax)}</td>
<td class="td--amount">$${fmt(item.total)}</td>
<td style="font-size:var(--text-caption);">${item.uso_cfdi || '-'}</td>
<td>${statusBadge(item.status)}</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
</div>
</td>
</tr>`).join('');
const statusFilter = document.getElementById('facturas-status-filter');
const status = statusFilter ? statusFilter.value : '';
const url = status ? `/queue?per_page=50&type=ingreso&status=${status}` : '/queue?per_page=50&type=ingreso';
// Update footer count
const footer = panel.querySelector('.table-footer span');
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${res.pagination?.total || items.length} facturas`;
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando facturas...' }) + '</td></tr>';
try {
const res = await api(url);
facturasCache = res.data || [];
renderFacturas(facturasCache, res.pagination?.total || facturasCache.length);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
}
}
function renderFacturas(items, total) {
const panel = document.getElementById('panel-facturas');
const tbody = panel.querySelector('.data-table tbody');
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin facturas', subtitle: 'No hay facturas en este periodo.' }) + '</td></tr>';
return;
}
tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || item.id || '-'}</td>
<td class="td--primary">${item.serie || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td>
<td class="td--mono">${item.rfc || '-'}</td>
<td class="td--amount">$${fmt(item.subtotal)}</td>
<td class="td--amount">$${fmt(item.tax_total)}</td>
<td class="td--amount">$${fmt(item.total)}</td>
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
<td>${statusBadge(item.status)}</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
</div>
</td>
</tr>`).join('');
const footer = panel.querySelector('.table-footer span');
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${total} facturas`;
}
function filterFacturas() {
const q = (document.getElementById('facturas-search')?.value || '').toLowerCase();
const filtered = facturasCache.filter(item =>
(item.provisional_folio || '').toLowerCase().includes(q) ||
(item.customer_name || '').toLowerCase().includes(q) ||
(item.rfc || '').toLowerCase().includes(q)
);
renderFacturas(filtered, facturasCache.length);
}
function exportFacturasCSV() {
if (typeof window.exportVisibleTableCSV === 'function') {
window.exportVisibleTableCSV('facturas');
} else {
alert('Exportador no disponible');
}
}
function exportNotasCSV() {
if (typeof window.exportVisibleTableCSV === 'function') {
window.exportVisibleTableCSV('notas_de_credito');
} else {
alert('Exportador no disponible');
}
}
function newCreditNote() {
alert('Nueva nota de crédito — próximamente');
}
function newPaymentComplement() {
alert('Nuevo complemento de pago — próximamente');
}
// ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ----
async function loadNotas() {
const panel = document.getElementById('panel-notas');
@@ -133,18 +183,20 @@ const Invoicing = (() => {
const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando notas de credito...' }) + '</td></tr>';
try {
const res = await api('/queue?per_page=50&type=Egreso');
const res = await api('/queue?per_page=50&type=egreso');
const items = res.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay notas de credito.</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin notas de credito', subtitle: 'No hay notas de credito registradas.' }) + '</td></tr>';
return;
}
tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || '-'}</td>
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td>
<td>${item.description || '-'}</td>
<td>${item.cancel_motive || '-'}</td>
<td class="td--amount">$${fmt(item.total)}</td>
<td>${statusBadge(item.status)}</td>
<td>
@@ -155,7 +207,7 @@ const Invoicing = (() => {
</td>
</tr>`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
}
}
@@ -166,33 +218,47 @@ const Invoicing = (() => {
const tbody = panel.querySelector('.data-table tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando complementos...' }) + '</td></tr>';
try {
const res = await api('/queue?per_page=50&type=Pago');
const items = res.data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay complementos de pago.</td></tr>';
return;
}
tbody.innerHTML = items.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || '-'}</td>
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td>
<td class="td--amount">$${fmt(item.total)}</td>
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : '-'}</td>
<td>${statusBadge(item.status)}</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
</div>
</td>
</tr>`).join('');
const res = await api('/queue?per_page=50&type=pago');
complementosCache = res.data || [];
renderComplementos(complementosCache);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
}
}
let complementosCache = [];
function renderComplementos(items) {
const panel = document.getElementById('panel-complementos');
const tbody = panel.querySelector('.data-table tbody');
const methodFilter = document.getElementById('complementos-method-filter');
const method = methodFilter ? methodFilter.value : '';
const filtered = method ? items.filter(item => (item.payment_method || '').startsWith(method)) : items;
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin complementos', subtitle: 'No hay complementos de pago registrados.' }) + '</td></tr>';
return;
}
tbody.innerHTML = filtered.map(item => `<tr>
<td class="td--mono">${item.provisional_folio || '-'}</td>
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
<td class="td--primary">${item.customer_name || '-'}</td>
<td class="td--amount">$${fmt(item.total)}</td>
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : '-'}</td>
<td>${statusBadge(item.status)}</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
</div>
</td>
</tr>`).join('');
}
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
async function loadCancelaciones() {
const panel = document.getElementById('panel-cancelaciones');
@@ -288,10 +354,18 @@ const Invoicing = (() => {
let pendingHtml = '';
if (status.pending_steps && status.pending_steps.length) {
pendingHtml = '<ul style="margin:var(--space-2) 0 0 0;padding-left:var(--space-5);color:var(--color-warning);">' +
status.pending_steps.map(s => `<li>${s.description || s.type}</li>`).join('') +
status.pending_steps.map(s => `<li>${s.description || s.type || s}</li>`).join('') +
'</ul>';
}
const configuredHtml = status.configured
? '<span style="color:var(--color-success);">Sí</span>'
: '<span style="color:var(--color-error);">No</span>';
const retryButton = !status.configured || status.error
? '<button class="btn btn--secondary" style="margin-top:var(--space-3);" onclick="Invoicing.setupFacturapi(this)">Reintentar configuración</button>'
: '';
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
<div>
@@ -301,34 +375,207 @@ const Invoicing = (() => {
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${status.org_id || ''}</div>
</div>
<div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">CSD</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Configurada</div>
<div style="font-weight:var(--font-weight-semibold);">${configuredHtml}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">CSD</div>
<div style="font-weight:var(--font-weight-semibold);">${csdHtml}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">Pasos pendientes</div>
${pendingHtml || '<span style="color:var(--color-success);">Ninguno</span>'}
</div>
</div>
${status.error ? `<p style="color:var(--color-error);margin-top:var(--space-3);">Error: ${escapeHtml(status.error)}</p>` : ''}
${retryButton}
`;
} catch (e) {
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
}
}
// ---- Emisor data (config tab) ----
async function loadEmisorData() {
const rfcEl = document.getElementById('rfc-emisor');
const cpEl = document.getElementById('cp-fiscal');
const razonEl = document.getElementById('razon-social');
const regimenEl = document.getElementById('regimen-fiscal');
const direccionEl = document.getElementById('direccion-fiscal');
const exteriorEl = document.getElementById('numero-exterior');
const interiorEl = document.getElementById('numero-interior');
const coloniaEl = document.getElementById('colonia-fiscal');
const ciudadEl = document.getElementById('ciudad-fiscal');
const municipioEl = document.getElementById('municipio-fiscal');
const estadoEl = document.getElementById('estado-fiscal');
if (!rfcEl || !cpEl || !razonEl || !regimenEl) return;
try {
const res = await fetch('/pos/api/config/business', { headers: headers() });
if (!res.ok) throw new Error('Error al cargar datos fiscales');
const data = await res.json();
rfcEl.value = data.rfc || '';
cpEl.value = data.cp || '';
razonEl.value = data.razon_social || '';
if (direccionEl) direccionEl.value = data.direccion || '';
if (exteriorEl) exteriorEl.value = data.numero_exterior || '';
if (interiorEl) interiorEl.value = data.numero_interior || '';
if (coloniaEl) coloniaEl.value = data.colonia || '';
if (ciudadEl) ciudadEl.value = data.ciudad || '';
if (municipioEl) municipioEl.value = data.municipio || '';
if (estadoEl) estadoEl.value = data.estado || '';
const regimen = data.regimen_fiscal || '601';
regimenEl.value = regimen + ' — ' + (regimenEl.querySelector('option[value^="' + regimen + '"')?.textContent.split('—')[1]?.trim() || '');
// If exact value not matched, leave first option selected by SAT code prefix
if (!regimenEl.value.startsWith(regimen)) {
Array.from(regimenEl.options).forEach(function(opt) {
if (opt.value.startsWith(regimen)) opt.selected = true;
});
}
} catch (e) {
console.error('Invoicing.loadEmisorData:', e);
}
}
async function saveEmisorData() {
const rfc = (document.getElementById('rfc-emisor')?.value || '').trim();
const cp = (document.getElementById('cp-fiscal')?.value || '').trim();
const razon_social = (document.getElementById('razon-social')?.value || '').trim();
const regimenValue = document.getElementById('regimen-fiscal')?.value || '601';
const regimen_fiscal = regimenValue.split('—')[0].trim();
const direccion = (document.getElementById('direccion-fiscal')?.value || '').trim();
const numero_exterior = (document.getElementById('numero-exterior')?.value || '').trim();
const numero_interior = (document.getElementById('numero-interior')?.value || '').trim();
const colonia = (document.getElementById('colonia-fiscal')?.value || '').trim();
const ciudad = (document.getElementById('ciudad-fiscal')?.value || '').trim();
const municipio = (document.getElementById('municipio-fiscal')?.value || '').trim();
const estado = (document.getElementById('estado-fiscal')?.value || '').trim();
const statusEl = document.getElementById('emisor-save-status');
if (!rfc || !razon_social || !cp) {
if (statusEl) statusEl.textContent = 'RFC, Razón Social y C.P. son obligatorios';
return;
}
try {
const res = await fetch('/pos/api/config/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({
rfc, razon_social, regimen_fiscal, cp,
direccion, numero_exterior, numero_interior, colonia, ciudad, municipio, estado,
cfdi_regimen_fiscal: regimen_fiscal, cfdi_serie: 'A'
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Error al guardar');
}
if (statusEl) {
statusEl.textContent = 'Datos fiscales guardados';
statusEl.style.color = 'var(--color-success)';
}
setTimeout(() => { if (statusEl) { statusEl.textContent = ''; statusEl.style.color = ''; } }, 4000);
} catch (e) {
if (statusEl) {
statusEl.textContent = e.message;
statusEl.style.color = 'var(--color-error)';
}
}
}
async function setupFacturapi(btn) {
if (!btn) return;
btn.disabled = true;
btn.textContent = 'Configurando...';
try {
const res = await api('/facturapi/setup', { method: 'POST' });
alert('Organización vinculada: ' + res.org_id);
if (res.error) {
alert('Aviso: ' + res.error);
} else if (res.configured) {
alert('Organización vinculada y configurada: ' + res.org_id);
} else {
alert('Organización vinculada: ' + res.org_id + '. Revisa pasos pendientes.');
}
loadFacturapiStatus();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Crear / Vincular Organización';
}
}
function resetCsdForm() {
document.getElementById('csd-form').reset();
document.getElementById('csd-cer-label').textContent = 'Subir certificado .cer';
document.getElementById('csd-key-label').textContent = 'Subir llave privada .key';
}
function reloadManifiesto() {
const iframe = document.getElementById('manifiesto-iframe');
if (iframe) {
iframe.src = iframe.src;
}
}
function updateFileLabels() {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
if (cer && cer.files.length) {
document.getElementById('csd-cer-label').textContent = cer.files[0].name;
}
if (key && key.files.length) {
document.getElementById('csd-key-label').textContent = key.files[0].name;
}
}
async function uploadCsd(btn) {
if (!btn) return;
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
const password = document.getElementById('contrasena-csd').value.trim();
if (!cer || !cer.files.length || !key || !key.files.length) {
alert('Selecciona el archivo .cer y .key');
return;
}
if (!password) {
alert('Escribe la contraseña del CSD');
return;
}
const formData = new FormData();
formData.append('certificate', cer.files[0]);
formData.append('private_key', key.files[0]);
formData.append('password', password);
btn.disabled = true;
const originalText = btn.innerHTML;
btn.textContent = 'Subiendo...';
try {
const res = await fetch(`${API}/facturapi/csd`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token()}` },
body: formData,
});
const data = await res.json().catch(() => ({ error: res.statusText }));
if (!res.ok) throw new Error(data.error || 'Upload failed');
alert('CSD actualizado correctamente');
resetCsdForm();
loadFacturapiStatus();
} catch (e) {
alert('Error al subir CSD: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// Wire file input change listeners
setTimeout(() => {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
if (cer) cer.addEventListener('change', updateFileLabels);
if (key) key.addEventListener('change', updateFileLabels);
}, 0);
// ---- Detail modal (uses modalDetalleOverlay) ----
async function showDetail(cfdiId) {
const overlay = document.getElementById('modalDetalleOverlay');
@@ -607,12 +854,6 @@ const Invoicing = (() => {
window.submitNewInvoice = submitNewInvoice;
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
return {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
@@ -621,4 +862,15 @@ const Invoicing = (() => {
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
return {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
loadEmisorData, saveEmisorData,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm, reloadManifiesto,
filterFacturas, exportFacturasCSV, exportNotasCSV,
newCreditNote, newPaymentComplement,
};
})();

View File

@@ -82,7 +82,13 @@
localStorage.setItem('pos_token', result.data.token);
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
localStorage.setItem('pos_tenant_id', tenantId);
window.location.href = '/pos/catalog';
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
var role = (result.data.employee.role || '').toLowerCase();
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
window.location.href = '/pos/workshop';
} else {
window.location.href = '/pos/catalog';
}
})
.catch(function() {
errorEl.textContent = 'Error de conexion';

View File

@@ -1 +1 @@
!function(){"use strict";var t="",e=document.querySelectorAll("#pinDots .pin-dot"),n=document.getElementById("loginError"),o=new URLSearchParams(window.location.search).get("tenant")||localStorage.getItem("pos_tenant_id"),a=localStorage.getItem("pos_device_id");function i(){e.forEach((function(e,n){e.classList.toggle("filled",n<t.length)}))}a||(a="dev-"+Date.now()+"-"+Math.random().toString(36).substr(2,9),localStorage.setItem("pos_device_id",a)),window.addDigit=function(e){t.length>=4||(t+=e,i(),n.textContent="",4===t.length&&submitPin())},window.clearPin=function(){t="",i(),n.textContent=""},window.submitPin=function(){4===t.length&&(n.textContent="",fetch("/pos/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tenant_id:parseInt(o),pin:t,device_id:a})}).then((function(t){return t.json().then((function(e){return{ok:t.ok,data:e}}))})).then((function(t){if(!t.ok)return n.textContent=t.data.error||"Error de autenticacion",void clearPin();localStorage.setItem("pos_token",t.data.token),localStorage.setItem("pos_employee",JSON.stringify(t.data.employee)),localStorage.setItem("pos_tenant_id",o),window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}();
!function(){"use strict";var t="",e=document.querySelectorAll("#pinDots .pin-dot"),n=document.getElementById("loginError"),o=new URLSearchParams(window.location.search).get("tenant")||localStorage.getItem("pos_tenant_id"),a=localStorage.getItem("pos_device_id");function i(){e.forEach((function(e,n){e.classList.toggle("filled",n<t.length)}))}a||(a="dev-"+Date.now()+"-"+Math.random().toString(36).substr(2,9),localStorage.setItem("pos_device_id",a)),window.addDigit=function(e){t.length>=4||(t+=e,i(),n.textContent="",4===t.length&&submitPin())},window.clearPin=function(){t="",i(),n.textContent=""},window.submitPin=function(){4===t.length&&(n.textContent="",fetch("/pos/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tenant_id:parseInt(o),pin:t,device_id:a})}).then((function(t){return t.json().then((function(e){return{ok:t.ok,data:e}}))})).then((function(t){if(!t.ok)return n.textContent=t.data.error||"Error de autenticacion",void clearPin();localStorage.setItem("pos_token",t.data.token),localStorage.setItem("pos_employee",JSON.stringify(t.data.employee)),localStorage.setItem("pos_tenant_id",o),document.cookie="pos_role="+(t.data.employee.role||"")+"; path=/pos; SameSite=Lax",window.location.href="/pos/catalog"})).catch((function(){n.textContent="Error de conexion",clearPin()})))},document.addEventListener("keydown",(function(t){t.key>="0"&&t.key<="9"?addDigit(t.key):"Backspace"===t.key?clearPin():"Enter"===t.key&&submitPin()}));var r=localStorage.getItem("pos_token");r&&o&&(!function(t){try{var e=t.split(".");if(3!==e.length)return!1;var n=e[1].replace(/-/g,"+").replace(/_/g,"/"),o=JSON.parse(atob(n));return!!o.exp&&1e3*o.exp>Date.now()+3e4}catch(t){return!1}}(r)?(localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee")):window.location.href="/pos/catalog")}();

View File

@@ -64,7 +64,7 @@
}
}
window.startOAuth = function() {
window.startOAuth = async function() {
var clientId = document.getElementById('cfgClientId').value.trim();
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
var category = document.getElementById('cfgCategory').value.trim();
@@ -75,15 +75,34 @@
return;
}
// Save config locally for the callback
localStorage.setItem('meli_client_id', clientId);
localStorage.setItem('meli_client_secret', clientSecret);
// Purge any previously stored secret from older versions
localStorage.removeItem('meli_client_secret');
localStorage.removeItem('meli_client_id');
localStorage.setItem('meli_category', category);
localStorage.setItem('meli_shipping', shipping);
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access';
window.location.href = authUrl;
try {
var res = await fetch(API + '/connect/init', {
method: 'POST',
headers: headers(),
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
category: category,
shipping: shipping
})
});
var data = await res.json();
if (!res.ok) {
alert('Error iniciando conexión: ' + (data.error || 'Unknown'));
return;
}
if (data.auth_url) {
window.location.href = data.auth_url;
}
} catch (e) {
alert('Error: ' + e.message);
}
};
window.disconnectMeli = async function() {
@@ -363,8 +382,6 @@
var authCode = urlParams.get('code');
if (authCode && window.location.pathname.includes('marketplace-external')) {
(async function() {
var clientId = localStorage.getItem('meli_client_id');
var clientSecret = localStorage.getItem('meli_client_secret');
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
try {
var res = await fetch(API + '/connect', {
@@ -372,8 +389,6 @@
headers: headers(),
body: JSON.stringify({
code: authCode,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
})
});

View File

@@ -160,7 +160,15 @@
delete el.dataset.originalContent;
};
// ── Empty state helper ────────────────────────────────────────
// ── Loading / Empty state helpers ────────────────────────────────────────
window.renderLoadingState = function(opts) {
opts = opts || {};
var message = opts.message || 'Cargando...';
return '<div class="empty-state empty-state--loading" role="status" aria-live="polite">' +
'<div class="empty-state__icon"><svg class="empty-state__spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg></div>' +
'<div class="empty-state__title">' + message + '</div>' +
'</div>';
};
window.renderEmptyState = function(opts) {
opts = opts || {};
var icon = opts.icon || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';

View File

@@ -21,10 +21,24 @@ const POS = (() => {
let paymentMethod = 'efectivo';
let canViewCost = false;
let employeeMaxDiscount = 100;
let lastSaleId = null;
let lastSaleId = sessionStorage.getItem('pos_last_sale_id') || null;
let lastSaleData = null;
let searchTimeout = null;
let customerSearchTimeout = null;
let canCancel = false;
let canDiscount = false;
let canEditPrice = false;
let canCreateWorkshopOrder = false;
let canCreateLayaway = false;
let canCreateRemission = false;
let counterRemissionEnabled = false;
let allowZeroPriceSales = true;
let allowNegativeStock = false;
let workshopModuleEnabled = true;
let currentPerms = [];
let receiptConfig = {};
let couriers = [];
let selectedCourierId = null;
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -32,6 +46,16 @@ const POS = (() => {
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Price based on customer tier: base price (price_1) with tier discount.
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
function priceForTier(basePrice, tier) {
const p = parseFloat(basePrice) || 0;
const t = parseInt(tier, 10) || 1;
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
return p;
}
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
@@ -54,6 +78,48 @@ const POS = (() => {
setTimeout(() => el.remove(), 2100);
}
// ─── Permission-based UI toggles ─────
function applyPermissionsUI() {
function hide(selector) {
const el = document.querySelector(selector);
if (el) el.style.display = 'none';
}
function show(selector) {
const el = document.querySelector(selector);
if (el) el.style.display = '';
}
if (!canCancel) {
hide('#btnCancelSale');
hide('#fkeyEsc');
}
if (!canDiscount) hide('[onclick="POS.applyDiscount()"]');
if (!canEditPrice) hide('[onclick="POS.modifyPrice()"]');
if (!canCreateWorkshopOrder || !workshopModuleEnabled) {
hide('[onclick="POS.createServiceOrder()"]');
hide('[title="Orden de servicio"]');
}
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
// Counter remission workflow
if (canCreateRemission) {
hide('#btnCobrar');
hide('.fkey[onclick="POS.checkout()"]');
hide('[onclick="POS.createLayaway()"]');
hide('[onclick="POS.saveQuotation()"]');
show('#btnRemission');
show('#courierSelectField');
} else {
hide('#btnRemission');
hide('#courierSelectField');
}
if (!currentPerms.includes('pos.sell')) {
hide('#btnPayRemission');
} else {
show('#btnPayRemission');
}
}
// ─── Init ────────────────────────────
async function init() {
// Parse JWT to get employee info
@@ -61,9 +127,44 @@ const POS = (() => {
const payload = JSON.parse(atob(token.split('.')[1]));
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
document.getElementById('branchName').textContent = payload.branch_name || '';
canViewCost = (payload.permissions || []).includes('pos.view_cost');
const perms = payload.permissions || [];
currentPerms = perms;
const employeeRole = payload.role || '';
canViewCost = perms.includes('pos.view_cost');
canCancel = perms.includes('pos.cancel');
canDiscount = perms.includes('pos.discount');
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
canCreateWorkshopOrder = perms.includes('workshop.edit');
canCreateLayaway = perms.includes('pos.sell');
employeeMaxDiscount = payload.max_discount_pct || 100;
// Counter remission feature
try {
const crCfg = await api('/pos/api/config/counter-remission');
counterRemissionEnabled = crCfg.enabled === true;
} catch (e) {
counterRemissionEnabled = false;
}
// Sales settings (zero-price and negative-stock toggles)
try {
const ssCfg = await api('/pos/api/config/sales-settings');
allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false;
allowNegativeStock = ssCfg.allow_negative_stock === true;
} catch (e) {
allowZeroPriceSales = true;
allowNegativeStock = false;
}
// Module toggles (from app-init preloaded modules)
try {
var modules = window.POS_USER && window.POS_USER.modules;
if (!modules) modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
workshopModuleEnabled = modules.workshop !== false;
} catch (e) {
workshopModuleEnabled = true;
}
// Counter remission workflow applies only to the counter role.
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
// Show cost/margin columns and toggle button if permission
if (canViewCost) {
document.getElementById('thCost').style.display = '';
@@ -78,6 +179,8 @@ const POS = (() => {
const parts = payload.name.split(' ');
avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase();
}
applyPermissionsUI();
} catch (e) {
console.warn('Could not parse token:', e);
}
@@ -102,8 +205,10 @@ const POS = (() => {
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
}
// Load current register
// Load current register and receipt config
await loadRegister();
await loadReceiptConfig();
await loadCouriers();
// Setup event listeners
setupKeyboard();
@@ -123,14 +228,48 @@ const POS = (() => {
currentRegister = null;
document.getElementById('registerInfo').innerHTML =
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>';
// Force open register modal on first load
showOpenRegisterModal();
// Force open register modal on first load only for users who can sell
if (currentPerms.includes('pos.sell')) {
showOpenRegisterModal();
}
}
} catch (e) {
console.warn('Register check failed:', e);
}
}
async function loadReceiptConfig() {
try {
receiptConfig = await api('/pos/api/config/receipt');
} catch (e) {
console.warn('Could not load receipt config:', e);
receiptConfig = {};
}
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('remissionCourier');
if (sel) {
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.addEventListener('change', () => {
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
});
}
} catch (e) {
console.warn('Could not load couriers:', e);
couriers = [];
}
}
function rc(key, fallback) {
const v = receiptConfig[key];
return v !== undefined && v !== null && v !== '' ? v : fallback;
}
function showOpenRegisterModal() {
document.getElementById('openRegisterModal').classList.add('open');
document.getElementById('registerOpenResult').innerHTML = '';
@@ -278,6 +417,7 @@ const POS = (() => {
}
function openCancelModal() {
if (!canCancel) { showToast('No tienes permiso para cancelar'); return; }
const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta');
if (overlay) overlay.classList.add('active');
@@ -307,6 +447,7 @@ const POS = (() => {
}
function applyDiscount() {
if (!canDiscount) { showToast('No tienes permiso para aplicar descuentos'); return; }
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
@@ -321,7 +462,20 @@ const POS = (() => {
}
}
function isZeroPriceItem(item) {
const unitPrice = parseFloat(item.unit_price || 0);
const qty = parseFloat(item.quantity || 1);
const discount = parseFloat(item.discount_pct || 0);
return (unitPrice * qty * (1 - discount / 100)) <= 0;
}
function findZeroPriceItem() {
if (allowZeroPriceSales) return null;
return cart.find(isZeroPriceItem) || null;
}
function modifyPrice() {
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
@@ -329,10 +483,13 @@ const POS = (() => {
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
if (p !== null) {
const n = parseFloat(p);
if (n >= 0) {
cart[selectedRow].unit_price = n;
renderCart();
if (n < 0) { showToast('Precio no válido'); return; }
if (!allowZeroPriceSales && n === 0) {
showToast('No está permitido dejar el precio en $0');
return;
}
cart[selectedRow].unit_price = n;
renderCart();
}
}
@@ -519,26 +676,11 @@ const POS = (() => {
if (data.data.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
if (window.BarcodeFeedback) BarcodeFeedback.error();
} else if (data.data.length === 1 && q.length >= 8) {
// Auto-select single result on barcode scan (long codes)
const item = data.data[0];
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
addFromSearch(item, price);
input.value = '';
hideSearchResults();
return;
} else {
let html = '';
data.data.forEach(item => {
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
const price = priceForTier(item.price_1, tier);
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div>
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
@@ -633,7 +775,7 @@ const POS = (() => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => {
if (item.price_1 > 0) {
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
item.unit_price = priceForTier(item.price_1, tier);
}
});
}
@@ -829,9 +971,12 @@ const POS = (() => {
transferencia: 'refPayment',
tarjeta: 'refPayment',
mixto: 'mixedPayment',
credito: 'creditPayment',
cheque: 'chequePayment',
pendiente: 'pendingPayment',
};
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
const el = document.getElementById(id);
if (el) {
const isActive = el.id === tabs[method];
@@ -845,6 +990,12 @@ const POS = (() => {
const ref = document.getElementById('paymentRef');
if (ref) ref.focus();
}
if (method === 'cheque') {
const chequeAmount = document.getElementById('chequeAmount');
if (chequeAmount) chequeAmount.value = fmt(getTotal());
const chequeRef = document.getElementById('chequeRef');
if (chequeRef) chequeRef.focus();
}
}
function updateChange() {
@@ -882,6 +1033,7 @@ const POS = (() => {
let amountPaid = 0;
let paymentDetails = [];
let reference = '';
let saleType = 'cash';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
@@ -901,6 +1053,20 @@ const POS = (() => {
}
});
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
} else if (paymentMethod === 'credito') {
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (total > available) {
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
return;
}
saleType = 'credit';
amountPaid = 0;
} else if (paymentMethod === 'cheque') {
amountPaid = total;
reference = document.getElementById('chequeRef').value.trim();
} else if (paymentMethod === 'pendiente') {
amountPaid = 0;
}
const saleData = {
@@ -913,7 +1079,7 @@ const POS = (() => {
})),
customer_id: currentCustomer ? currentCustomer.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
@@ -921,6 +1087,12 @@ const POS = (() => {
generate_cfdi: document.getElementById('cfdiCheck').checked,
};
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const confirmBtn = document.getElementById('btnConfirmPayment');
confirmBtn.disabled = true;
confirmBtn.textContent = 'Procesando...';
@@ -932,9 +1104,10 @@ const POS = (() => {
const convertData = {
register_id: currentRegister ? currentRegister.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
amount_paid: amountPaid,
payment_details: paymentDetails,
reference: reference,
};
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
method: 'POST',
@@ -952,6 +1125,7 @@ const POS = (() => {
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
closePaymentModal();
showTicket(sale);
@@ -968,22 +1142,18 @@ const POS = (() => {
}
}
// ─── Credit Sale ─────────────────────
async function creditSale() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
if (!currentRegister) { alert('No hay caja abierta.'); return; }
const total = getTotal();
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (currentCustomer.credit_limit > 0 && total > available) {
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
return;
}
// ─── Counter Remission Note ────────────
async function createRemissionNote() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const saleData = {
const total = getTotal();
const noteData = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
@@ -991,34 +1161,158 @@ const POS = (() => {
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer.id,
payment_method: 'credito',
sale_type: 'credit',
customer_id: currentCustomer ? currentCustomer.id : null,
notes: 'Nota de remision generada desde mostrador',
register_id: currentRegister ? currentRegister.id : null,
amount_paid: 0,
courier_id: selectedCourierId,
};
try {
const sale = await api('/pos/api/sales', {
const sale = await api('/pos/api/sales/remission', {
method: 'POST',
body: JSON.stringify(saleData),
body: JSON.stringify(noteData),
});
if (selectedCourierId) {
const courier = couriers.find(c => c.id === selectedCourierId);
sale.courier_name = courier ? courier.name : '';
}
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
showTicket(sale);
cart = [];
selectedRow = -1;
clearCustomer();
selectedCourierId = null;
const sel = document.getElementById('remissionCourier');
if (sel) sel.value = '';
renderCart();
showToast(`Nota de remision NR-${sale.id} generada`);
} catch (e) {
alert('Error: ' + e.message);
alert('Error al generar nota de remision: ' + e.message);
}
}
async function openPayRemissionModal() {
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
document.getElementById('payRemissionFolio').value = '';
document.getElementById('payRemissionDetail').innerHTML = '';
document.getElementById('payRemissionResult').innerHTML = '';
document.getElementById('payRemissionActions').style.display = 'none';
document.getElementById('payRemissionModal').classList.add('open');
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
}
function closePayRemissionModal() {
document.getElementById('payRemissionModal').classList.remove('open');
}
let pendingRemissionToPay = null;
async function searchRemissionToPay() {
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
if (!folio) { showToast('Ingresa un folio valido'); return; }
pendingRemissionToPay = null;
try {
const sale = await api('/pos/api/sales/' + folio);
if (sale.status !== 'pending_payment') {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
return;
}
pendingRemissionToPay = sale;
let itemsHtml = (sale.items || []).map(it => `
<div class="ticket-line">
<span class="qty">${it.quantity}</span>
<span class="name">${it.name || ''}</span>
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
</div>
`).join('');
document.getElementById('payRemissionDetail').innerHTML = `
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
<hr class="divider">
${itemsHtml}
`;
document.getElementById('payRemissionActions').style.display = '';
} catch (e) {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
}
}
async function confirmPayRemission() {
if (!pendingRemissionToPay) return;
const sale = pendingRemissionToPay;
const paymentMethod = document.getElementById('payRemissionMethod').value;
let amountPaid = parseFloat(sale.total);
let paymentDetails = [];
let reference = '';
if (paymentMethod === 'efectivo') {
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
if (received < sale.total) { alert('Monto insuficiente'); return; }
amountPaid = received;
} else if (paymentMethod === 'mixto') {
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
let sum = 0;
rows.forEach(row => {
const method = row.querySelector('select').value;
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
const ref = row.querySelectorAll('input')[1]?.value || '';
if (amount > 0) {
paymentDetails.push({ method, amount, reference: ref });
sum += amount;
}
});
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
amountPaid = sum;
} else {
reference = document.getElementById('payRemissionReference').value.trim();
}
try {
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: paymentMethod,
amount_paid: amountPaid,
payment_details: paymentDetails,
register_id: currentRegister ? currentRegister.id : null,
reference: reference,
}),
});
closePayRemissionModal();
showToast(`Nota NR-${sale.id} pagada`);
// Refresh sale object to print paid ticket
const updated = await api('/pos/api/sales/' + sale.id);
lastSaleId = updated.id;
lastSaleData = updated;
showTicket(updated);
} catch (e) {
alert('Error al cobrar nota: ' + e.message);
}
}
function updatePayRemissionMethod() {
const method = document.getElementById('payRemissionMethod').value;
const cashEl = document.getElementById('payRemissionCash');
const refEl = document.getElementById('payRemissionRef');
const mixedEl = document.getElementById('payRemissionMixed');
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
}
// ─── Quotation ───────────────────────
async function saveQuotation() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido cotizar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const body = {
items: cart.map(item => ({
@@ -1057,7 +1351,13 @@ const POS = (() => {
// ─── Layaway ─────────────────────────
async function createLayaway() {
if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
if (cart.length === 0) { alert('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
alert('No está permitido apartar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
const total = getTotal();
@@ -1097,6 +1397,138 @@ const POS = (() => {
}
}
// ─── Service Order from POS ──────────
function createServiceOrder() {
if (!workshopModuleEnabled) { showToast('El módulo de taller no está habilitado'); return; }
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
if (cart.length === 0) { showToast('Carrito vacio'); return; }
const zeroItem = findZeroPriceItem();
if (zeroItem) {
showToast('No está permitido generar orden con artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
return;
}
const custInput = document.getElementById('soCustomer');
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
document.getElementById('serviceOrderModal').classList.add('open');
}
function closeServiceOrderModal() {
document.getElementById('serviceOrderModal').classList.remove('open');
}
async function confirmServiceOrder() {
const customerId = currentCustomer ? currentCustomer.id : null;
if (!customerId) { alert('Seleccione un cliente para la orden de servicio'); return; }
const vehicleText = document.getElementById('soVehicle').value.trim();
const delivery = document.getElementById('soDelivery').value;
const notes = document.getElementById('soNotes').value.trim();
const isDirect = document.getElementById('soDirect').checked;
const items = cart.map(item => ({
inventory_id: item.inventory_id,
part_number: item.part_number,
name: item.name,
quantity: item.quantity,
unit_price: item.unit_price,
status: 'pending'
}));
try {
const order = await api('/pos/api/service-orders/from-pos', {
method: 'POST',
body: JSON.stringify({
customer_id: customerId,
priority: 'normal',
reception_notes: (vehicleText ? 'Vehiculo: ' + vehicleText + '. ' : '') + (notes || ''),
delivery_method: delivery || null,
is_direct: isDirect,
items: items
})
});
showToast(`Orden de servicio ${order.order_number} creada`);
closeServiceOrderModal();
cart = [];
selectedRow = -1;
renderCart();
showServiceOrderTicket(order);
} catch (e) {
alert('Error: ' + e.message);
}
}
function showServiceOrderTicket(order) {
const dateStr = new Date(order.created_at).toLocaleString('es-MX', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const customerName = order.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
let itemsHtml = '';
(order.items || []).forEach(item => {
const itemTotal = item.unit_price * item.quantity;
itemsHtml += `
<div class="item-line-wide ticket-line">
<span class="qty">${item.quantity}</span>
<span class="name">${item.name || ''}</span>
<span class="price">${fmt(item.unit_price)}</span>
<span class="subtotal">${fmt(itemTotal)}</span>
</div>`;
});
const ticketHtml = `
<div class="store-name">NEXUS AUTOPARTS</div>
<div class="store-tagline">Orden de servicio</div>
<div class="store-info">
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
</div>
<hr class="divider-double">
<div class="folio-line">
<span>ORDEN: ${order.order_number}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</span>
</div>
<hr class="divider">
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
<span class="qty">Cant</span>
<span class="name">Descripcion</span>
<span class="price">P. Unit</span>
<span class="subtotal">Importe</span>
</div>
<hr class="divider" style="margin: 2px 0;">
${itemsHtml}
<hr class="divider-double">
<div class="total-section">
<div class="total-line">
<span>Refacciones:</span><span>${fmt(order.total_parts || 0)}</span>
</div>
<div class="total-line">
<span>Mano de obra:</span><span>${fmt(order.total_labor || 0)}</span>
</div>
<div class="total-line grand">
<span>TOTAL:</span><span>${fmt(order.total || 0)}</span>
</div>
</div>
<hr class="divider">
<div class="footer-section">
<div class="thanks">Gracias por su preferencia!</div>
<div>Conserve su ticket como comprobante.</div>
</div>
`;
const printArea = document.getElementById('ticketContent');
if (printArea) printArea.innerHTML = ticketHtml;
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = 'Ticket de Orden de Servicio';
document.getElementById('ticketModal').classList.add('open');
}
// ─── Ticket ──────────────────────────
function showTicket(sale) {
const dateStr = new Date(sale.created_at).toLocaleString('es-MX', {
@@ -1104,14 +1536,15 @@ const POS = (() => {
hour: '2-digit', minute: '2-digit'
});
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
const isRemission = sale.status === 'pending_payment';
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
let itemsHtml = '';
(sale.items || []).forEach(item => {
const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
itemsHtml += `
<div class="item-line-wide">
<div class="item-line-wide ticket-line">
<span class="qty">${item.quantity}</span>
<span class="name">${item.name || ''}</span>
<span class="price">${fmt(item.unit_price)}</span>
@@ -1119,22 +1552,48 @@ const POS = (() => {
</div>`;
});
const cfg = receiptConfig || {};
const showLogo = cfg.show_logo && cfg.logo;
const showRfc = cfg.show_rfc !== false;
const showAddress = cfg.show_address;
const showPhone = cfg.show_phone;
const showIva = cfg.show_iva_breakdown !== false;
const showPayment = cfg.show_payment_details !== false;
const showEmployee = cfg.show_employee;
const storeName = rc('store_name', 'NEXUS AUTOPARTS');
const storeTagline = rc('tagline', 'Tu conexion con las refacciones');
const storeRfc = rc('rfc', 'NAU210315XX1');
const storeAddress = rc('address', '');
const storePhone = rc('phone', '');
const thanksMsg = rc('thanks_message', 'Gracias por su compra!');
const footerMsg = rc('footer', 'Conserve su ticket como comprobante.');
let storeInfoLines = [];
if (currentRegister && currentRegister.branch_name) storeInfoLines.push(`Sucursal: ${currentRegister.branch_name}`);
if (showEmployee && sale.employee_name) storeInfoLines.push(`Atendió: ${sale.employee_name}`);
if (showRfc && storeRfc) storeInfoLines.push(`RFC: ${storeRfc}`);
if (showAddress && storeAddress) storeInfoLines.push(storeAddress);
if (showPhone && storePhone) storeInfoLines.push(`Tel: ${storePhone}`);
const logoHtml = showLogo ? `<div class="ticket-logo-wrap"><img src="${cfg.logo}" alt="Logo" class="ticket-logo" style="max-width:140px;max-height:70px;object-fit:contain;"></div>` : '';
const ticketHtml = `
<div class="store-name">NEXUS AUTOPARTS</div>
<div class="store-tagline">Tu conexion con las refacciones</div>
${logoHtml}
<div class="store-name">${storeName}</div>
${storeTagline ? `<div class="store-tagline">${storeTagline}</div>` : ''}
<div class="store-info">
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
${storeInfoLines.join('<br>')}
</div>
<hr class="divider-double">
<div class="folio-line">
<span>VENTA: V-${sale.id}</span>
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</span>
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
</div>
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
<hr class="divider">
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
<span class="qty">Cant</span>
@@ -1150,15 +1609,20 @@ const POS = (() => {
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
</div>
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
<div class="total-line">
<span>IVA 16%:</span><span>${fmt(sale.tax_total)}</span>
</div>
${showIva ? `<div class="total-line"><span>IVA:</span><span>${fmt(sale.tax_total)}</span></div>` : ''}
<div class="total-line grand">
<span>TOTAL:</span><span>${fmt(sale.total)}</span>
</div>
</div>
${showPayment ? `
<hr class="divider">
<div class="payment-section">
${isRemission ? `
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
</div>
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
` : `
<div class="ticket-row">
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
</div>
@@ -1169,11 +1633,13 @@ const POS = (() => {
<div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''}
</div>
`}
</div>` : ''}
<hr class="divider">
<div class="footer-section">
<div class="thanks">Gracias por su compra!</div>
<div>Conserve su ticket como comprobante.</div>
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
</div>
`;
@@ -1182,6 +1648,8 @@ const POS = (() => {
if (printArea) printArea.innerHTML = ticketHtml;
const preview = document.getElementById('ticketPreviewContent');
if (preview) preview.innerHTML = ticketHtml;
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
document.getElementById('ticketModal').classList.add('open');
}
@@ -1191,11 +1659,84 @@ const POS = (() => {
}
function printTicket() {
// Make print area visible for @media print
const area = document.getElementById('ticketPrintArea');
if (area) area.style.display = 'block';
window.print();
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
const src = document.getElementById('ticketContent');
if (!src) { console.warn('printTicket: ticketContent not found'); return; }
try {
// Paper width: 58 mm -> 48 mm printable; 80 mm -> 72 mm printable.
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
const is58 = paperWidth === 58;
const printWidth = is58 ? 48 : 72;
const fontSize = is58 ? '12pt' : '14pt';
const smallFont = is58 ? '10pt' : '12pt';
const nameMax = is58 ? '20mm' : '38mm';
let iframe = document.getElementById('ticketPrintFrame');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'ticketPrintFrame';
iframe.style.position = 'fixed';
iframe.style.left = '-9999px';
iframe.style.top = '0';
iframe.style.width = printWidth + 'mm';
iframe.style.border = '0';
document.body.appendChild(iframe);
}
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@page { margin: 0; size: ${printWidth}mm auto; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: auto; overflow: visible; background: #fff; color: #000; font-family: 'Courier New', Courier, 'Lucida Console', monospace; font-size: ${fontSize}; line-height: 1.55; -webkit-font-smoothing: none; -moz-osx-font-smoothing: unset; text-rendering: geometricPrecision; }
body { width: ${printWidth}mm; }
.ticket { width: ${printWidth}mm; padding: 3mm 2.5mm 4mm; border: none !important; box-shadow: none !important; }
.item-line-wide { display: grid !important; grid-template-columns: ${is58 ? '9mm 1fr auto' : '10mm 1fr auto auto'} !important; gap: 2mm; font-size: ${fontSize}; margin-bottom: 2.5mm; padding-left: 1mm; align-items: start; }
.item-line-wide .qty { text-align: center; }
.item-line-wide .name { white-space: normal !important; word-break: break-word; overflow-wrap: anywhere; max-width: ${nameMax}; padding-left: 1mm; }
.item-line-wide .price { text-align: right; white-space: nowrap; ${is58 ? 'display: none !important;' : ''} }
.item-line-wide .subtotal { text-align: right; white-space: nowrap; }
.ticket-row, .folio-line, .total-line { display: flex !important; justify-content: space-between; gap: 2mm; }
.total-line.grand { font-size: ${is58 ? '13pt' : '15pt'}; font-weight: bold; border-top: 0.6mm solid #000; padding-top: 1.5mm; margin-top: 1.5mm; }
.store-name { font-size: ${is58 ? '14pt' : '16pt'}; font-weight: bold; text-align: center; margin-bottom: 3px; }
.store-tagline, .store-info { font-size: ${smallFont}; text-align: center; color: #000; margin-bottom: 5px; line-height: 1.45; }
.divider { border: none; border-top: 1px dashed #999; margin: 5px 0; }
.divider-double { border: none; border-top: 2px solid #333; margin: 5px 0; }
.footer-section { text-align: center; margin-top: 8px; font-size: ${smallFont}; color: #000; line-height: 1.45; }
.payment-section, .total-section { width: 100%; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${src.innerHTML}</body>
</html>`);
doc.close();
// Measure real content height and set exact page size (avoids phantom 3276 mm).
const bodyEl = doc.body;
const heightPx = Math.max(bodyEl.scrollHeight, bodyEl.offsetHeight);
const heightMm = Math.ceil((heightPx * 25.4 / 96) + 5);
const exactPage = doc.createElement('style');
exactPage.textContent = `@page { margin: 0; size: ${printWidth}mm ${heightMm}mm; }`;
doc.head.appendChild(exactPage);
iframe.contentWindow.focus();
setTimeout(() => {
try {
iframe.contentWindow.print();
} catch (e) {
console.error('printTicket: iframe.print failed', e);
window.print();
}
}, 200);
} catch (e) {
console.error('printTicket error:', e);
alert('No se pudo preparar la impresion del ticket. Revise la consola.');
}
}
// ─── Thermal Printing ─────────────────
@@ -1217,7 +1758,8 @@ const POS = (() => {
return;
}
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
const ok = await NexusPrinter.printSale(lastSaleId);
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
const ok = await NexusPrinter.printSale(lastSaleId, paperWidth);
if (ok) {
showToast('Ticket enviado a impresora termica');
} else {
@@ -1286,6 +1828,10 @@ const POS = (() => {
e.preventDefault();
openDrawer();
break;
case 'F7':
e.preventDefault();
createServiceOrder();
break;
case 'Escape':
e.preventDefault();
if (document.getElementById('paymentModal').classList.contains('open')) {
@@ -1294,6 +1840,8 @@ const POS = (() => {
closeNewCustomerModal();
} else if (document.getElementById('ticketModal').classList.contains('open')) {
closeTicketModal();
} else if (document.getElementById('serviceOrderModal').classList.contains('open')) {
closeServiceOrderModal();
} else if (document.querySelector('.confirm-overlay.active')) {
// Close cancel modal
const overlay = document.getElementById('overlay-cancelar-venta');
@@ -1361,6 +1909,14 @@ const POS = (() => {
});
}
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
// ─── Public API ──────────────────────
init();
@@ -1372,7 +1928,10 @@ const POS = (() => {
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
selectPaymentMethod, updateChange, updateMixedTotal,
creditSale, saveQuotation, createLayaway,
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
saveQuotation, createLayaway,
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,
connectThermal, thermalPrint,
@@ -1380,12 +1939,5 @@ const POS = (() => {
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

View File

@@ -134,6 +134,31 @@ window.NexusPrinter = (function () {
return sendRaw(new Uint8Array(buf));
}
/**
* Print a workshop service order ticket.
* @param {number} soId
* @param {number} [width=80] — 58 or 80 mm
* @returns {Promise<boolean>}
*/
async function printServiceOrder(soId, width) {
width = width || 80;
const token = localStorage.getItem('pos_token');
const resp = await fetch('/pos/api/service-orders/' + soId + '/print', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ printer_type: 'escpos_raw', width: width })
});
if (!resp.ok) {
console.error('[NexusPrinter] backend error', resp.status);
return false;
}
const buf = await resp.arrayBuffer();
return sendRaw(new Uint8Array(buf));
}
// ── Persistence helpers ────────────────────────
function _save() {
@@ -147,6 +172,7 @@ window.NexusPrinter = (function () {
disconnect: disconnect,
isConnected: isConnected,
sendRaw: sendRaw,
printSale: printSale
printSale: printSale,
printServiceOrder: printServiceOrder
};
})();

View File

@@ -54,14 +54,27 @@ const Reports = (() => {
}
// Track which tabs have been loaded
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false };
function currentUser() {
return (typeof window.POS_USER !== 'undefined') ? window.POS_USER : {};
}
function hasPerm(p) {
var u = currentUser();
return (u.role === 'owner' || u.role === 'admin') || (u.permissions || []).indexOf(p) !== -1;
}
function isLimitedUser() {
var u = currentUser();
var r = (u.role || '').toLowerCase();
return (r === 'cashier' || r === 'counter') && !hasPerm('pos.view');
}
// -------------------------------------------------------------------------
// Theme switcher
// -------------------------------------------------------------------------
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('nexus-theme', theme); } catch(e) {}
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
var btnInd = document.getElementById('btn-industrial');
var btnMod = document.getElementById('btn-modern');
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
@@ -86,6 +99,7 @@ const Reports = (() => {
else if (id === 'clientes') loadClientes();
else if (id === 'financieros') loadFinancieros();
else if (id === 'historico') loadHistorico();
else if (id === 'cortes') loadCortes();
}
}
window.switchTab = switchTab;
@@ -504,7 +518,7 @@ const Reports = (() => {
agingEl.innerHTML = spinner();
try {
var data = await apiFetch('/pos/api/accounting/aging');
var data = await apiFetch('/pos/api/accounting/aging-summary');
var clients = data.data || [];
var totals = data.totals || {};
@@ -737,6 +751,153 @@ const Reports = (() => {
}
}
// =========================================================================
// TAB 6: CORTES DE CAJA
// =========================================================================
async function loadCortes() {
loaded.cortes = true;
var dateFrom = document.getElementById('cortes-date-from').value;
var dateTo = document.getElementById('cortes-date-to').value;
var employeeId = document.getElementById('cortes-employee').value;
// Cashiers/counters without pos.view can only see their own cuts
var u = currentUser();
if (isLimitedUser()) {
employeeId = u.employeeId || '';
var empSel = document.getElementById('cortes-employee');
if (empSel) empSel.value = employeeId;
}
var kpiEl = document.getElementById('cortes-kpis');
var detalleEl = document.getElementById('cortes-detalle');
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
kpiEl.innerHTML = spinner();
detalleEl.innerHTML = spinner();
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
var params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (employeeId) params.set('employee_id', employeeId);
params.set('per_page', '200');
try {
var data = await apiFetch('/pos/api/register/history?' + params.toString());
var regs = data.data || [];
var totalEsperado = 0;
var totalCierre = 0;
var totalDiferencia = 0;
regs.forEach(function(r) {
totalEsperado += r.expected_amount || 0;
totalCierre += r.closing_amount || 0;
totalDiferencia += r.difference || 0;
});
kpiEl.innerHTML =
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
'<th>Acciones</th>' +
'</tr></thead><tbody>';
regs.forEach(function(r) {
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
'<td class="td-mono">#' + r.register_number + '</td>' +
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
});
cHtml += '</tbody></table></div>';
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
} catch (err) {
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
detalleEl.innerHTML = '';
}
}
// -------------------------------------------------------------------------
// Detail: sales for a selected cash cut
// -------------------------------------------------------------------------
async function showCorteVentas(registerId) {
var panel = document.getElementById('corte-ventas-detalle');
if (!panel) return;
panel.style.display = 'block';
panel.innerHTML = spinner();
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
var methodLabels = {
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
};
try {
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
var sales = data.sales || [];
var summary = data.summary || {};
var total = summary.total || 0;
var count = summary.count || 0;
var byMethod = summary.by_method || {};
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
// Summary by payment method
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
if (methods.length) {
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
methods.forEach(function(m) {
var label = methodLabels[m[0]] || m[0];
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
});
hHtml += '</div>';
}
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
'<th class="align-right">Total</th><th>Estado</th>' +
'</tr></thead><tbody>';
sales.forEach(function(s) {
var statusPill = s.status === 'completed' ? 'pill--success' :
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
var statusLabel = s.status === 'completed' ? 'Completada' :
s.status === 'cancelled' ? 'Cancelada' : s.status;
var method = methodLabels[s.payment_method] || s.payment_method || '--';
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
'<td>' + fmtDateTime(s.created_at) + '</td>' +
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
'<td>' + method + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
});
hHtml += '</tbody></table></div>';
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
} catch (err) {
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
}
}
window.showCorteVentas = showCorteVentas;
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -745,7 +906,7 @@ const Reports = (() => {
// Restore theme
try {
var saved = localStorage.getItem('nexus-theme') || 'industrial';
var saved = localStorage.getItem('pos_theme') || 'industrial';
setTheme(saved);
} catch(e) {}
@@ -753,6 +914,9 @@ const Reports = (() => {
updateClock();
setInterval(updateClock, 1000);
var u = currentUser();
var limited = isLimitedUser();
// Set default date range: first day of current month to today
var now = new Date();
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -761,6 +925,49 @@ const Reports = (() => {
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
if (toEl) toEl.value = now.toISOString().substring(0, 10);
// Set default date range for cortes de caja
var cortesFrom = document.getElementById('cortes-date-from');
var cortesTo = document.getElementById('cortes-date-to');
if (cortesFrom) cortesFrom.value = firstDay.toISOString().substring(0, 10);
if (cortesTo) cortesTo.value = now.toISOString().substring(0, 10);
// Populate cajero filter for cortes de caja (admins/owners only see it)
var empSel = document.getElementById('cortes-employee');
if (empSel && !limited) {
apiFetch('/pos/api/config/employees?per_page=200').then(function(data) {
(data.data || []).forEach(function(e) {
var opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name;
empSel.appendChild(opt);
});
}).catch(function() {});
} else if (empSel && limited) {
empSel.value = u.employeeId || '';
}
// For cashiers/counters without pos.view, limit the page to "Mis cortes"
if (limited) {
['ventas', 'inventario', 'clientes', 'financieros', 'historico'].forEach(function(id) {
var btn = document.querySelector('.tab-btn[onclick="switchTab(\'' + id + '\', this)"]');
if (btn) btn.style.display = 'none';
});
var empFilter = document.getElementById('cortes-employee-filter');
if (empFilter) empFilter.style.display = 'none';
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
var cortesPanel = document.getElementById('panel-cortes');
var cortesBtn = document.querySelector('.tab-btn[onclick="switchTab(\'cortes\', this)"]');
if (cortesPanel) cortesPanel.classList.add('is-active');
if (cortesBtn) {
cortesBtn.classList.add('is-active');
var svg = '<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1" y="3" width="13" height="10" rx="1"/><path d="M4 7h7M4 10h5"/><circle cx="11" cy="10" r="1.5" fill="currentColor"/></svg>';
cortesBtn.innerHTML = svg + '<span>Mis cortes de caja</span>';
}
loadCortes();
}
// Populate financial period selectors
var monthSel = document.getElementById('fin-month');
var yearSel = document.getElementById('fin-year');
@@ -784,15 +991,18 @@ const Reports = (() => {
}
}
// Load the default active tab (ventas)
loadVentas();
// Load the default active tab (ventas) only for privileged users
if (!limited) {
loadVentas();
}
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, switchTab,
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
showCorteVentas, fmt
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -15,7 +15,6 @@ window.renderSidebar = function(modulesOverride) {
var initials = u.initials || '?';
var currentPath = window.location.pathname;
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
var currentLang = localStorage.getItem('pos_lang') || 'es';
var modules = {};
if (modulesOverride && typeof modulesOverride === 'object') {
@@ -31,29 +30,73 @@ window.renderSidebar = function(modulesOverride) {
return modules[key] !== false;
}
var role = (u.role || '').toLowerCase();
var perms = u.permissions || [];
function hasPerm(p) {
return role === 'owner' || perms.indexOf(p) !== -1;
}
// Role-based section filtering. Owner/admin bypasses; other roles get
// only the sections allowed by their permissions.
function itemAllowed(id) {
if (role === 'owner' || role === 'admin') return true;
var base = [];
if (role === 'workshop' || role === 'mechanic') {
base = ['workshop'];
}
var permMap = {
'pos.sell': 'pos',
'pos.view': 'pos',
'catalog.view': 'catalog',
'inventory.view': 'inventory',
'customers.view': 'customers',
'workshop.view': 'workshop',
'pos.remission': 'remission_notes',
'invoicing.view': 'invoicing',
'quotations.view': 'quotations',
'accounting.view': 'accounting',
'reports.view': 'reports',
'dashboard.view': 'dashboard'
};
var allowed = base.slice();
for (var p in permMap) {
if (perms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
allowed.push(permMap[p]);
}
}
return allowed.indexOf(id) !== -1;
}
var navSections = [
{ label: _t('nav_main'), items: [
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
{ name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
moduleEnabled('catalog') ? { name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
{ name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
].filter(Boolean)},
{ id: 'dashboard', name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
{ id: 'pos', name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
moduleEnabled('catalog') ? { id: 'catalog', name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
{ id: 'inventory', name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
{ label: _t('nav_management'), items: [
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
{ name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
].filter(Boolean)},
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ id: 'remission_notes', name: _t('remission_notes'), href: '/pos/remission-notes', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
moduleEnabled('workshop') ? { id: 'workshop', name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' } : null,
{ id: 'quotations', name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
moduleEnabled('marketplace') ? { id: 'marketplace', name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
moduleEnabled('meli') ? { id: 'meli', name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
{ id: 'invoicing', name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ id: 'accounting', name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ id: 'reports', name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
moduleEnabled('whatsapp') ? { id: 'whatsapp', name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
{ label: _t('nav_system'), items: [
{ name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
]},
{ id: 'config', name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
].filter(function(i){ return itemAllowed(i.id); })},
];
// Remove sections whose items were all filtered out
navSections = navSections.filter(function(sec) { return sec.items.length > 0; });
function svgIcon(paths) {
return '<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>';
}
@@ -81,16 +124,6 @@ window.renderSidebar = function(modulesOverride) {
+ '</button>'
+ '</div>';
// Language toggle buttons
var langHtml = '<div class="sidebar__lang-toggle">'
+ '<button class="lang-toggle-btn' + (currentLang === 'es' ? ' is-active' : '') + '" onclick="setLang(\'es\')" title="Espanol">'
+ '<span class="lang-flag">MX</span> ES'
+ '</button>'
+ '<button class="lang-toggle-btn' + (currentLang === 'en' ? ' is-active' : '') + '" onclick="setLang(\'en\')" title="English">'
+ '<span class="lang-flag">US</span> EN'
+ '</button>'
+ '</div>';
window.updateThemeButtons = function() {
var t = localStorage.getItem('pos_theme') || 'industrial';
document.querySelectorAll('.theme-toggle-btn').forEach(function(b, i) {
@@ -108,7 +141,6 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>'
+ '<nav class="sidebar__nav">' + navHtml + '</nav>'
+ themeHtml
+ langHtml
+ '<div class="sidebar__footer">'
+ ' <div class="sidebar__user-avatar">' + initials + '</div>'
+ ' <div class="sidebar__user-info">'
@@ -121,14 +153,14 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>';
// Replace existing sidebar
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
if (existing) {
existing.className = 'pos-sidebar';
existing.className = 'pos-sidebar sidebar';
existing.innerHTML = sidebarHtml;
existing.removeAttribute('style');
} else {
var el = document.createElement('aside');
el.className = 'pos-sidebar';
el.className = 'pos-sidebar sidebar';
el.innerHTML = sidebarHtml;
document.body.insertBefore(el, document.body.firstChild);
}

1517
pos/static/js/workshop.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
// /home/Autopartes/pos/static/pwa/sw.js
// Nexus POS — Service Worker v17
// Nexus POS — Service Worker
// Self-contained vanilla JS. No external imports.
//
// Bump CACHE_NAME whenever static assets change significantly.
// Bump VERSION whenever static assets change significantly.
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
const CACHE_NAME = 'nexus-pos-v18';
const VERSION = 54;
const CACHE_NAME = 'nexus-pos-v' + VERSION;
const APP_SHELL = [
'/pos/static/css/tokens.css',

View File

@@ -8,15 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/accounting.css">
</head>
<link rel="stylesheet" href="/pos/static/css/accounting.css?v=33"></head>
<body>
@@ -85,6 +84,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Clientes
</a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Taller
</a>
<div class="nav-section-label">Finanzas</div>
<a class="nav-item" href="/pos/invoicing">
@@ -224,19 +227,18 @@
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="Buscar cliente o factura..." />
</div>
<select class="select-filter">
<option>Todos los estados</option>
<option>Vigente</option>
<option>Vencida</option>
<option>Parcial</option>
<select id="cxc-status-filter" class="select-filter" onchange="Accounting.loadAging()">
<option value="all">Todos los estados</option>
<option value="pending">Vigente</option>
<option value="overdue">Vencida</option>
<option value="partial">Parcial</option>
<option value="ok">Pagada</option>
</select>
<select class="select-filter">
<select class="select-filter" title="Filtro de sucursal (próximamente)">
<option>Todas las sucursales</option>
<option>Matriz</option>
<option>Sucursal Norte</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--ghost btn--sm">
<button class="btn btn--ghost btn--sm" onclick="window.exportarCuentasPorCobrar()">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar
</button>
@@ -277,13 +279,15 @@
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="Buscar proveedor o factura..." />
</div>
<select class="select-filter">
<option>Todos los estados</option>
<option>Vigente</option>
<option>Vencida</option>
<select id="cxp-status-filter" class="select-filter" onchange="Accounting.loadAccountsPayable()">
<option value="all">Todos los estados</option>
<option value="pending">Vigente</option>
<option value="overdue">Vencida</option>
<option value="partial">Parcial</option>
<option value="ok">Pagada</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--primary btn--sm">
<button id="cxp-register-payment" class="btn btn--primary btn--sm" onclick="Accounting.registerPayablePayment()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Registrar Pago
</button>
@@ -420,13 +424,11 @@
=============================================================== -->
<div class="tab-panel" id="panel-cierre">
<div class="toolbar">
<select class="select-filter">
<option>Marzo 2026</option>
<option>Febrero 2026 (cerrado)</option>
<option>Enero 2026 (cerrado)</option>
<select id="cierre-period-filter" class="select-filter" title="Período a cerrar (próximamente)">
<option>Seleccionar período</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--primary">
<button id="cierre-run-btn" class="btn btn--primary" onclick="Accounting.runPeriodClose()">
<svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Ejecutar Cierre
</button>
@@ -474,7 +476,7 @@
</div>
<div id="entryLines">
<div class="entry-line" style="display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;">
<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select>
<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />
<button class="btn btn--ghost btn--sm" onclick="this.closest('.entry-line').remove()">&times;</button>
@@ -490,12 +492,12 @@
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/accounting.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/accounting.v9.js?v=33" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -7,7 +7,7 @@
<title>Catalogo — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/chat.css" />
@@ -17,8 +17,7 @@
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<script src="/pos/static/js/native-bridge.js" defer></script>
<link rel="stylesheet" href="/pos/static/css/catalog.css">
</head>
<link rel="stylesheet" href="/pos/static/css/catalog.css"></head>
<body>
@@ -124,7 +123,7 @@
</div>
<div class="search-bar" id="searchBar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" aria-label="Buscar productos" />
<button type="button" id="btnScanBarcode" title="Escanear codigo de barras" style="background:none;border:none;cursor:pointer;padding:4px 8px;color:var(--color-text-muted);display:flex;align-items:center;" onclick="CatalogApp.startBarcodeScan()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7V5a2 2 0 012-2h2"/><path d="M17 3h2a2 2 0 012 2v2"/><path d="M21 17v2a2 2 0 01-2 2h-2"/><path d="M7 21H5a2 2 0 01-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="7" y1="8" x2="17" y2="8"/><line x1="7" y1="16" x2="17" y2="16"/></svg>
</button>
@@ -145,14 +144,14 @@
<div class="vs-arrow"></div>
<div class="vs-group">
<label class="vs-label">Marca</label>
<select class="vs-select" id="vsBrand" disabled onchange="CatalogApp.vsBrandChanged()">
<select class="vs-select" id="vsBrand" onchange="CatalogApp.vsBrandChanged()">
<option value="">Seleccionar...</option>
</select>
</div>
<div class="vs-arrow"></div>
<div class="vs-group">
<label class="vs-label">Modelo</label>
<select class="vs-select" id="vsModel" disabled onchange="CatalogApp.vsModelChanged()">
<select class="vs-select" id="vsModel" onchange="CatalogApp.vsModelChanged()">
<option value="">Seleccionar...</option>
</select>
</div>
@@ -195,7 +194,7 @@
<!-- Level title + optional filter -->
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); flex-wrap:wrap;">
<h2 class="level-title" id="levelTitle">Selecciona una marca</h2>
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" />
<input type="text" class="level-filter" id="levelFilter" placeholder="Filtrar..." style="display:none;" aria-label="Filtrar niveles de catalogo" />
</div>
<!-- Loading spinner -->
@@ -294,15 +293,15 @@
<input type="file" id="uploadPricesFile" accept=".csv,.xlsx,.xls" style="width:100%;" />
</div>
<div style="display:flex;gap:var(--space-2);justify-content:flex-end;">
<a href="/pos/api/supplier-catalog/prices/template" class="btn btn--ghost" style="text-decoration:none;">Descargar plantilla</a>
<button class="btn btn-primary" onclick="CatalogApp.submitUploadPrices()">Subir precios</button>
<button class="btn btn--ghost" onclick="CatalogApp.downloadPriceTemplate()">Descargar plantilla</button>
<button class="btn btn--primary" onclick="CatalogApp.submitUploadPrices()">Subir precios</button>
</div>
<div id="uploadPricesStatus" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div>
</div>
</div>
<!-- Brand Catalog Overlay (full-screen overlay for brand-first browsing) -->
<div id="brandCatalogOverlay" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9000;background:var(--color-bg-base);overflow:auto;padding:var(--space-4);">
<div id="brandCatalogOverlay" style="display:none;position:relative;top:0;left:0;right:0;bottom:0;background:var(--color-bg-base);overflow:auto;padding:var(--space-4);">
<div style="max-width:1200px;margin:0 auto;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
<h2 style="margin:0;font-family:var(--font-heading);font-size:var(--text-h3);">Catalogo por Marca</h2>
@@ -315,17 +314,17 @@
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/catalog.js?v=6" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/catalog.js?v=33" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script src="/pos/static/js/onboarding.js?v=2" defer></script>
<script src="/pos/static/js/onboarding.js?v=33" defer></script>
<script>
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'}).then(function(reg){
@@ -341,6 +340,6 @@
}
</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/brand-catalog.js?v=9" defer></script>
<script src="/pos/static/js/brand-catalog.js?v=33" defer></script>
</body>
</html>

View File

@@ -8,14 +8,45 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/config.css?v=2">
<link rel="stylesheet" href="/pos/static/css/config.css?v=34">
<style>
.cfg-tabs {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-1);
position: sticky;
top: 0;
z-index: 10;
flex-wrap: wrap;
}
.cfg-tab-btn {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
}
.cfg-tab-btn:hover { background: var(--color-surface-3); }
.cfg-tab-btn.active {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.settings-section[data-tab] { display: none !important; }
.settings-section[data-tab].active { display: block !important; }
</style>
</head>
<body>
@@ -85,6 +116,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Clientes
</a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Taller
</a>
<div class="nav-section-label">Finanzas</div>
<a class="nav-item" href="/pos/invoicing">
@@ -126,7 +161,7 @@
<h1 class="page-header__title">Configuración</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--primary">
<button id="btn-save-all" class="btn btn--primary" type="button">
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Guardar Cambios
</button>
@@ -135,11 +170,20 @@
<!-- Scrollable Content -->
<div class="content-scroll">
<!-- Config tabs -->
<div class="cfg-tabs" id="configTabs">
<button class="cfg-tab-btn active" data-tab="general" onclick="Config.switchTab('general')">General</button>
<button class="cfg-tab-btn" data-tab="fiscal" onclick="Config.switchTab('fiscal')">Fiscal</button>
<button class="cfg-tab-btn" data-tab="catalog" onclick="Config.switchTab('catalog')">Catálogo</button>
<button class="cfg-tab-btn" data-tab="employees" onclick="Config.switchTab('employees')">Empleados</button>
<button class="cfg-tab-btn cfg-tab-btn--permissions" data-tab="permissions" onclick="Config.switchTab('permissions')" style="display:none;">Permisos</button>
</div>
<!-- ===============================================================
SECTION 1: APARIENCIA / TEMA
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
@@ -202,7 +246,7 @@
<!-- ===============================================================
SECTION 2: DATOS DE LA EMPRESA
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
@@ -251,9 +295,88 @@
</div>
<!-- ===============================================================
SECTION 3: MÓDULOS E INTEGRACIONES
SECTION 3: PERSONALIZACIÓN DE TICKET
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</div>
<div>
<div class="settings-section__title">Personalización de Ticket</div>
<div class="settings-section__desc">Logo, datos y opciones que aparecen en el ticket de venta</div>
</div>
</div>
<div class="settings-card">
<div class="form-grid">
<div class="form-group form-group--full">
<label class="form-label">Logo del negocio</label>
<input type="file" id="receipt-logo" accept="image/png,image/jpeg,image/jpg,image/webp" onchange="Config.handleReceiptLogo(this)" style="display:none;" />
<div id="receipt-logo-preview" style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;">
<div id="receipt-logo-thumb" style="width:80px;height:80px;border:1px dashed var(--color-border);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--color-surface-2);">
<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>
</div>
<button class="btn btn--secondary btn--sm" onclick="document.getElementById('receipt-logo').click()">Subir imagen</button>
<button class="btn btn--ghost btn--sm" id="receipt-logo-remove" onclick="Config.removeReceiptLogo()" style="display:none;">Quitar</button>
</div>
<div class="form-hint">Recomendado: PNG/JPG con fondo blanco o transparente, máx. 300x150 px.</div>
</div>
<div class="form-group">
<label class="form-label">Nombre en ticket</label>
<input class="form-input" id="receipt-store-name" type="text" placeholder="Ej: Refacciones El Toro" />
</div>
<div class="form-group">
<label class="form-label">Slogan / Línea debajo del nombre</label>
<input class="form-input" id="receipt-tagline" type="text" placeholder="Ej: Tu conexion con las refacciones" />
</div>
<div class="form-group">
<label class="form-label">RFC en ticket</label>
<input class="form-input" id="receipt-rfc" type="text" placeholder="Ej: RET260101ABC" maxlength="13" style="text-transform:uppercase;" />
</div>
<div class="form-group form-group--full">
<label class="form-label">Dirección en ticket</label>
<input class="form-input" id="receipt-address" type="text" placeholder="Calle, Número, Colonia, CP, Ciudad" />
</div>
<div class="form-group">
<label class="form-label">Teléfono en ticket</label>
<input class="form-input" id="receipt-phone" type="tel" placeholder="Ej: 664-123-4567" />
</div>
<div class="form-group">
<label class="form-label">Ancho del papel</label>
<select class="form-select" id="receipt-paper-width">
<option value="58">58 mm (térmica pequeña)</option>
<option value="80" selected>80 mm (térmica estándar)</option>
</select>
</div>
<div class="form-group form-group--full">
<label class="form-label">Mensaje de agradecimiento</label>
<input class="form-input" id="receipt-thanks" type="text" placeholder="Gracias por su compra!" />
</div>
<div class="form-group form-group--full">
<label class="form-label">Pie de página adicional</label>
<input class="form-input" id="receipt-footer" type="text" placeholder="Ej: Conserve su ticket como comprobante." />
</div>
</div>
<div class="form-grid" style="margin-top:var(--space-4);">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-logo" style="width:auto;" checked /> Mostrar logo</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-rfc" style="width:auto;" checked /> Mostrar RFC</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-address" style="width:auto;" /> Mostrar dirección</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-phone" style="width:auto;" /> Mostrar teléfono</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-iva" style="width:auto;" checked /> Mostrar desglose de IVA</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-payment" style="width:auto;" checked /> Mostrar detalle de pago</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;"><input type="checkbox" id="receipt-show-employee" style="width:auto;" /> Mostrar nombre del empleado</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveReceiptConfig()">Guardar ticket</button>
</div>
</div>
</div>
<!-- ===============================================================
SECTION 4: MÓDULOS E INTEGRACIONES
=============================================================== -->
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
@@ -305,6 +428,26 @@
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Taller / Servicio</span>
<span class="toggle-row__desc">Mostrar el módulo de órdenes de servicio y taller</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-workshop" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Notas de remisión en mostrador</span>
<span class="toggle-row__desc">Permite generar notas de remisión desde el POS para cobrar posteriormente en caja</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-module-counter-remission" />
<span class="toggle__slider"></span>
</label>
</div>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
</div>
@@ -314,13 +457,13 @@
<!-- ===============================================================
SECTION 4: USUARIOS Y PERMISOS
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="employees">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div>
<div class="settings-section__title">Usuarios y Permisos</div>
<div class="settings-section__title">Empleados</div>
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
</div>
</div>
@@ -357,7 +500,7 @@
<!-- ===============================================================
SECTION 4: IMPRESORAS
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
@@ -383,7 +526,7 @@
<!-- ===============================================================
SECTION 5: SUCURSALES
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="fiscal">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
@@ -402,7 +545,7 @@
<!-- ===============================================================
SECTION 6: PARÁMETROS FISCALES
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="fiscal">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@@ -538,7 +681,7 @@
<!-- ===============================================================
SECTION 7: PREFERENCIAS DEL SISTEMA
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="general">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -610,13 +753,33 @@
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Permitir ventas en $0</span>
<span class="toggle-row__desc">Permite vender artículos con precio unitario o total de línea igual a $0</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-allow-zero-price" checked />
<span class="toggle__slider"></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Permitir venta sin stock</span>
<span class="toggle-row__desc">Permite vender aunque no haya existencias suficientes en la sucursal (stock negativo)</span>
</div>
<label class="toggle">
<input type="checkbox" id="cfg-allow-negative-stock" />
<span class="toggle__slider"></span>
</label>
</div>
</div>
</div>
<!-- ===============================================================
SECTION 8: VEHICLE COMPATIBILITY SOURCE
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="catalog">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@@ -649,7 +812,7 @@
<!-- ===============================================================
SECTION 9: MARCAS DE PARTES PERMITIDAS
=============================================================== -->
<div class="settings-section">
<div class="settings-section" data-tab="catalog">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
@@ -710,6 +873,64 @@
</div>
</div>
<!-- ===============================================================
SECTION: PERMISOS POR ROL
=============================================================== -->
<div class="settings-section" data-tab="permissions">
<div class="settings-section__header">
<div class="settings-section__icon">
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div>
<div class="settings-section__title">Permisos por Rol</div>
<div class="settings-section__desc">Define los permisos predeterminados para cada rol del sistema</div>
</div>
</div>
<div class="settings-card">
<div class="form-group" style="max-width:320px;">
<label class="form-label">Rol</label>
<select class="form-input" id="cfg-perm-role" onchange="Config.renderRolePermissions(); Config.renderWorkshopPermissions();">
<option value="">Selecciona un rol</option>
<option value="admin">Administrador</option>
<option value="cashier">Cajero</option>
<option value="counter">Mostrador</option>
<option value="warehouse">Almacén</option>
<option value="accountant">Contador</option>
<option value="workshop">Taller</option>
<option value="mechanic">Mecánico</option>
</select>
</div>
<!-- Permission tabs -->
<div class="cfg-tabs" style="margin-top:var(--space-4);position:static;">
<button type="button" class="cfg-tab-btn active" id="tab-perm-modules" onclick="Config.switchPermTab('modules')">Módulos</button>
<button type="button" class="cfg-tab-btn" id="tab-perm-workshop" onclick="Config.switchPermTab('workshop')">Taller</button>
</div>
<div id="perm-panel-modules" class="perm-panel active" style="margin-top:var(--space-4);">
<div id="role-permissions-container">
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>
</div>
<div style="margin-top:var(--space-4);">
<button class="btn btn--primary" id="btn-save-role-permissions" onclick="Config.saveRolePermissions()">Guardar permisos del rol</button>
<span id="role-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
</div>
</div>
<div id="perm-panel-workshop" class="perm-panel" style="margin-top:var(--space-4);display:none;">
<div id="workshop-permissions-container">
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos de Taller.</p>
</div>
<div style="margin-top:var(--space-4);">
<button class="btn btn--primary" id="btn-save-workshop-permissions" onclick="Config.saveWorkshopPermissions()">Guardar permisos de Taller</button>
<span id="workshop-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
</div>
</div>
</div>
</div>
</div><!-- /content-scroll -->
</main>
</div><!-- /app-shell -->
@@ -818,8 +1039,11 @@
<option value="">-- Seleccionar --</option>
<option value="admin">Administrador</option>
<option value="cashier">Cajero</option>
<option value="counter">Mostrador</option>
<option value="warehouse">Almacenista</option>
<option value="accountant">Contador</option>
<option value="workshop">Taller</option>
<option value="mechanic">Mecanico</option>
</select>
</div>
<div class="form-group">
@@ -845,13 +1069,13 @@
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/config.js?v=3" defer></script>
<script src="/pos/static/js/config.js?v=41" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -8,15 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/customers.css">
</head>
<link rel="stylesheet" href="/pos/static/css/customers.css?v=34"></head>
<body>
@@ -298,15 +297,15 @@
<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
</svg>
</span>
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" />
<input type="text" class="search-input" placeholder="Buscar por nombre, RFC, teléfono…" id="searchInput" oninput="filterCustomers()" aria-label="Buscar clientes" />
</div>
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter">
<select class="filter-select" onchange="filterCustomers()" id="tipoFilter" aria-label="Filtrar por tipo de cliente">
<option value="">Todos los tipos</option>
<option value="Taller">Taller</option>
<option value="Mostrador">Mostrador</option>
<option value="Mayoreo">Mayoreo</option>
</select>
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter">
<select class="filter-select" onchange="filterCustomers()" id="estadoFilter" aria-label="Filtrar por estado de cliente">
<option value="">Todos los estados</option>
<option value="Activo">Activo</option>
<option value="Inactivo">Inactivo</option>
@@ -330,6 +329,7 @@
<th>Crédito Disp.</th>
<th class="hide-mobile">Última Compra</th>
<th>Estado</th>
<th></th>
</tr>
</thead>
<tbody id="customersBody">
@@ -341,15 +341,7 @@
<!-- Pagination -->
<div class="table-footer">
<div class="table-footer__info" id="tableInfo"></div>
<div class="pagination">
<button class="page-btn">&#8249;</button>
<button class="page-btn active">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<span style="color:var(--color-text-muted);font-size:var(--text-caption);padding:0 4px;"></span>
<button class="page-btn">86</button>
<button class="page-btn">&#8250;</button>
</div>
<div class="pagination" id="customersPagination"></div>
</div>
</div>
@@ -374,12 +366,12 @@
<!-- Header -->
<div class="detail-header">
<div class="customer-avatar" id="detailAvatar">MA</div>
<div class="customer-avatar" id="detailAvatar"></div>
<div class="detail-header__info">
<div class="detail-header__name" id="detailName">MIGUEL ÁNGEL TORRES</div>
<div class="detail-header__rfc" id="detailRFC">TOAM820115HDF</div>
<div class="detail-header__name" id="detailName"></div>
<div class="detail-header__rfc" id="detailRFC"></div>
<div class="detail-header__meta">
<span class="tipo-chip tipo-chip--taller" id="detailTipo">Taller</span>
<span class="tipo-chip tipo-chip--taller" id="detailTipo"></span>
<span class="badge badge--active" id="detailStatus"><span class="badge-dot"></span>Activo</span>
</div>
</div>
@@ -400,7 +392,7 @@
</div>
<div class="info-row info-row--full">
<span class="info-label">Dirección</span>
<span class="info-value" id="detailAddress">Av. Insurgentes Sur 1602, Col. Crédito Constructor, CDMX</span>
<span class="info-value" id="detailAddress"></span>
</div>
<div class="info-row">
<span class="info-label">CP</span>
@@ -459,6 +451,14 @@
</div>
</div>
<!-- Vehicles -->
<div class="detail-section">
<div class="detail-section__title">Veh&iacute;culos</div>
<div id="detailVehicles" style="display:flex;flex-direction:column;gap:var(--space-2);">
<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>
</div>
</div>
<!-- Quick Actions -->
<div class="detail-section">
<div class="detail-section__title">Acciones Rápidas</div>
@@ -497,6 +497,15 @@
</span>
Historial
</button>
<button class="action-btn action-btn--danger" id="btnDeleteCustomer" onclick="deleteCustomer()">
<span class="action-btn__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<polyline points="3 6 5 6 13 6"/><path d="M5 6v7a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V6"/>
<line x1="7" y1="3" x2="9" y2="3"/><line x1="4" y1="3" x2="14" y2="3"/>
</svg>
</span>
Eliminar
</button>
</div>
</div>
@@ -641,13 +650,13 @@
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/customers.js?v=2" defer></script>
<script src="/pos/static/js/customers.js?v=35" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -8,15 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=3">
</head>
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=33"></head>
<body>
@@ -159,6 +158,16 @@
Ventas Históricas
</a>
<a href="/pos/remission-notes" class="nav-link">
<span class="nav-link__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 3h12v10H2z"/>
<path d="M5 7h6M5 10h4" stroke-width="1.2"/>
</svg>
</span>
Notas de Remisión
</a>
<div class="sidebar__section-label">Gestión</div>
<a href="/pos/marketplace" class="nav-link">
@@ -511,6 +520,40 @@
</div><!-- end alerts-grid -->
</section>
<!-- =================================================================
CRÉDITOS POR VENCER
================================================================= -->
<section>
<div class="table-card">
<div class="table-header">
<div>
<div class="section-title">Créditos por Cobrar</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:2px;" id="credit-alerts-meta">Vencidos: -- / Por vencer: --</div>
</div>
<a href="/pos/accounting" class="section-action" style="text-decoration:none;color:inherit;">Ir a contabilidad &rarr;</a>
</div>
<div class="table-wrap themed-scrollbar">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Folio</th>
<th>Vencimiento</th>
<th>Días</th>
<th class="align-right">Saldo</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="credit-alerts-tbody">
<tr><td colspan="6"><div class="skeleton skeleton--text" style="width:100%;"></div></td></tr>
<tr><td colspan="6"><div class="skeleton skeleton--text" style="width:80%;"></div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- =================================================================
RECENT SALES TABLE
================================================================= -->
@@ -562,15 +605,15 @@
<script src="/pos/static/js/chart.umd.min.js" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=3" defer></script>
<script src="/pos/static/js/dashboard.js?v=7" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=33" defer></script>
<script src="/pos/static/js/dashboard.js?v=33" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js?v=34',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
@@ -17,8 +17,7 @@
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<script src="/pos/static/js/native-bridge.js" defer></script>
<link rel="stylesheet" href="/pos/static/css/diagrams.css">
</head>
<link rel="stylesheet" href="/pos/static/css/diagrams.css"></head>
<body>
<div class="app-shell">
@@ -149,12 +148,12 @@
</main>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/diagrams.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -8,15 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/fleet.css">
</head>
<link rel="stylesheet" href="/pos/static/css/fleet.css"></head>
<body>
<div class="page-shell">
@@ -303,11 +302,11 @@
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/fleet.js" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -34,8 +34,7 @@
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination span { font-size: 13px; color: #4b5563; }
.loading { text-align: center; padding: 40px; color: #6b7280; }
</style>
</head>
</style></head>
<body>
<div class="header">
<h1>📊 Ventas Históricas - Atlas</h1>

View File

@@ -8,15 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=8">
</head>
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34"></head>
<body>
@@ -193,7 +192,7 @@
<h1 class="page-header__title">Inventario</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Importar CSV
</button>
@@ -205,7 +204,7 @@
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
Sincronizar
</button>
<button class="btn btn--primary" onclick="showCreateModal()">
<button class="btn btn--primary" id="btnHeaderNewProduct" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
</button>
@@ -337,7 +336,7 @@
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
</button>
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
<button class="btn btn--primary btn--sm" id="btnStockNewProduct" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
</button>
@@ -644,13 +643,13 @@
});
// Persist preference
try { localStorage.setItem('nexus-theme', theme); } catch(e) {}
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
}
// Restore on load
(function() {
var saved;
try { saved = localStorage.getItem('nexus-theme'); } catch(e) {}
try { saved = localStorage.getItem('pos_theme'); } catch(e) {}
if (saved === 'industrial' || saved === 'modern') {
setTheme(saved);
}
@@ -711,42 +710,48 @@
<!-- ===== MODALS ===== -->
<!-- Create Item Modal -->
<!-- Create/Edit Item Modal -->
<div class="inv-modal-overlay" id="createModal">
<div class="inv-modal">
<div class="inv-modal__header">
<h3>Nuevo Producto</h3>
<h3 id="createModalTitle">Nuevo Producto</h3>
<button class="inv-modal__close" onclick="closeCreateModal()">&times;</button>
</div>
<div class="inv-modal__body">
<input type="hidden" id="editItemId" />
<div class="inv-form-grid">
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
<div class="inv-field"><label>Categoría</label>
<select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
<option value="">Selecciona categoría</option>
</select>
</div>
<div class="inv-field"><label>Subcategoría</label>
<select class="select-filter" id="newSubcategory" style="width:100%;" disabled>
<option value="">Selecciona categoría primero</option>
<select class="select-filter" id="newCategory" style="width:100%;">
<option value="">Sin categoría</option>
</select>
</div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div>
<div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div>
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Unidad</label><input type="text" id="newUnit" placeholder="pza, kit, lt..." /></div>
<div class="inv-field price-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field price-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field" id="initialStockField"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Máximo</label><input type="number" id="newMaxStock" placeholder="0" /></div>
<div class="inv-field"><label>Impuesto (%)</label><input type="number" id="newTaxRate" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
<div class="inv-field"><label>Estado</label>
<select class="select-filter" id="newIsActive" style="width:100%;">
<option value="true">Activo</option>
<option value="false">Inactivo</option>
</select>
</div>
<div class="inv-field inv-field--full"><label>Descripción</label><textarea id="newDescription" rows="2" placeholder="Descripción del producto"></textarea></div>
</div>
<div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button>
<button class="btn btn--primary" onclick="createItem()">Crear Producto</button>
<button class="btn btn--primary" id="createModalBtn" onclick="createItem()">Crear Producto</button>
</div>
</div>
</div>
@@ -1013,6 +1018,9 @@
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body">
<div style="margin-bottom:12px;">
<button class="btn btn--ghost btn--sm" onclick="downloadBulkImportTemplate()" type="button">📥 Descargar plantilla CSV</button>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
@@ -1034,8 +1042,8 @@
</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
<strong>Columnas esperadas:</strong>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, sku_secondary, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, sku_secundario, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
</div>
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
</div>
@@ -1053,13 +1061,13 @@
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">&times;</button>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js?v=2" defer></script>
<script src="/pos/static/js/inventory.js?v=18" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
<script src="/pos/static/js/inventory.js?v=38" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -8,15 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/invoicing.css">
</head>
<link rel="stylesheet" href="/pos/static/css/invoicing.css"></head>
<body>
@@ -118,6 +117,12 @@
</svg>
<span>Clientes</span>
</a>
<a class="nav-item" href="/pos/workshop">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<span>Taller</span>
</a>
<a class="nav-item is-active" href="/pos/invoicing" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
@@ -333,25 +338,14 @@
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" placeholder="Buscar folio, RFC, cliente…" />
<input id="facturas-search" type="text" placeholder="Buscar folio, RFC, cliente…" oninput="Invoicing.filterFacturas()" aria-label="Buscar facturas" />
</div>
<div class="date-range">
<svg viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
01 Mar 2026 — 31 Mar 2026
</div>
<select class="select-filter" aria-label="Filtrar por estatus">
<select id="facturas-status-filter" class="select-filter" aria-label="Filtrar por estatus" onchange="Invoicing.loadFacturas()">
<option value="">Todas</option>
<option value="timbrada">Timbradas</option>
<option value="pendiente">Pendientes</option>
<option value="cancelada">Canceladas</option>
<option value="ppd">PPD</option>
<option value="stamped">Timbradas</option>
<option value="pending">Pendientes</option>
<option value="cancelled">Canceladas</option>
</select>
<div class="toolbar__spacer"></div>
@@ -366,7 +360,7 @@
Factura Global
</button>
<button class="btn btn--ghost">
<button id="facturas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportFacturasCSV()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
@@ -416,10 +410,10 @@
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" placeholder="Buscar nota de crédito…" />
<input type="text" placeholder="Buscar nota de crédito…" aria-label="Buscar notas de credito" />
</div>
<div class="toolbar__spacer"></div>
<button class="btn btn--ghost">
<button id="notas-export-csv" class="btn btn--ghost" onclick="Invoicing.exportNotasCSV()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
@@ -427,7 +421,7 @@
</svg>
Exportar
</button>
<button class="btn btn--primary">
<button id="notas-new" class="btn btn--primary" onclick="Invoicing.newCreditNote()">
<svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
@@ -472,9 +466,9 @@
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" placeholder="Buscar complemento de pago…" />
<input type="text" placeholder="Buscar complemento de pago…" aria-label="Buscar complementos de pago" />
</div>
<select class="select-filter" aria-label="Método de pago">
<select id="complementos-method-filter" class="select-filter" aria-label="Método de pago" onchange="Invoicing.loadComplementos()">
<option value="">Todos los métodos</option>
<option value="03">03 Transferencia electrónica</option>
<option value="04">04 Tarjeta de crédito</option>
@@ -483,7 +477,7 @@
<option value="99">99 Por definir</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--primary">
<button id="complementos-new" class="btn btn--primary" onclick="Invoicing.newPaymentComplement()">
<svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
@@ -513,17 +507,7 @@
<div class="table-footer">
<span></span>
<div class="pagination">
<button class="page-btn" aria-label="Anterior">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="page-btn is-active">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<button class="page-btn" aria-label="Siguiente">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="pagination" id="complementos-pagination"></div>
</div>
</div>
</div><!-- /panel-complementos -->
@@ -773,11 +757,47 @@
<span class="form-hint">Régimen del emisor según el SAT</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="direccion-fiscal">Calle</label>
<input class="form-input" id="direccion-fiscal" type="text" value="" placeholder="Ej: Av. Insurgentes Sur" />
</div>
<div class="form-field">
<label class="form-label" for="numero-exterior">Número Exterior</label>
<input class="form-input" id="numero-exterior" type="text" value="" placeholder="Ej: 123" />
</div>
<div class="form-field">
<label class="form-label" for="numero-interior">Número Interior</label>
<input class="form-input" id="numero-interior" type="text" value="" placeholder="Ej: 4B" />
</div>
<div class="form-field">
<label class="form-label" for="colonia-fiscal">Colonia</label>
<input class="form-input" id="colonia-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="ciudad-fiscal">Ciudad</label>
<input class="form-input" id="ciudad-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="municipio-fiscal">Municipio / Alcaldía</label>
<input class="form-input" id="municipio-fiscal" type="text" value="" />
</div>
<div class="form-field">
<label class="form-label" for="estado-fiscal">Estado</label>
<input class="form-input" id="estado-fiscal" type="text" value="" />
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button class="btn btn--ghost">Cancelar</button>
<button class="btn btn--primary">
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);align-items:center;">
<span id="emisor-save-status" style="font-size:var(--text-caption);color:var(--color-text-muted);"></span>
<button class="btn btn--ghost" type="button" onclick="Invoicing.loadEmisorData()">Cancelar</button>
<button class="btn btn--primary" type="button" onclick="Invoicing.saveEmisorData()">
<svg viewBox="0 0 24 24">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
@@ -800,7 +820,7 @@
</div>
<div class="config-section__body">
<div class="cert-status">
<div class="cert-status" id="csd-status">
<div class="cert-status__icon">
<svg viewBox="0 0 24 24">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
@@ -808,60 +828,63 @@
</svg>
</div>
<div class="cert-status__info">
<div class="cert-status__name">
CSD Activo &nbsp;<span class="badge badge--vigente">Vigente</span>
<div class="cert-status__name" id="csd-status-name">
CSD — Consultar estado en Facturapi
</div>
<div class="cert-status__detail">
No. Certificado: 20001000000300022779 &nbsp;·&nbsp; Vence: 14/07/2026
<div class="cert-status__detail" id="csd-status-detail">
El estado del certificado se muestra en la sección Facturapi (PAC).
</div>
</div>
<button class="btn btn--ghost btn--sm">Ver</button>
</div>
<div class="form-grid">
<div class="form-field">
<label class="form-label">Archivo .cer</label>
<button class="btn btn--secondary" style="width:100%;justify-content:center;">
<form id="csd-form" enctype="multipart/form-data">
<div class="form-grid">
<div class="form-field">
<label class="form-label">Archivo .cer</label>
<input type="file" id="csd-cer" name="certificate" accept=".cer" style="display:none;" />
<button type="button" class="btn btn--secondary" id="csd-cer-btn" style="width:100%;justify-content:center;" onclick="document.getElementById('csd-cer').click()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span id="csd-cer-label">Subir certificado .cer</span>
</button>
<span class="form-hint">Certificado público del SAT</span>
</div>
<div class="form-field">
<label class="form-label">Archivo .key</label>
<input type="file" id="csd-key" name="private_key" accept=".key" style="display:none;" />
<button type="button" class="btn btn--secondary" id="csd-key-btn" style="width:100%;justify-content:center;" onclick="document.getElementById('csd-key').click()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span id="csd-key-label">Subir llave privada .key</span>
</button>
<span class="form-hint">Llave privada del CSD</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="contrasena-csd">Contraseña del CSD</label>
<input class="form-input" id="contrasena-csd" name="password" type="password" placeholder="Contraseña de la llave privada" />
<span class="form-hint">Contraseña asignada al generar el CSD en el SAT</span>
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button type="button" class="btn btn--ghost" onclick="Invoicing.resetCsdForm()">Cancelar</button>
<button type="button" class="btn btn--primary" id="csd-submit-btn" onclick="Invoicing.uploadCsd(this)">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Subir certificado .cer
Actualizar CSD
</button>
<span class="form-hint">Certificado público del SAT</span>
</div>
<div class="form-field">
<label class="form-label">Archivo .key</label>
<button class="btn btn--secondary" style="width:100%;justify-content:center;">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Subir llave privada .key
</button>
<span class="form-hint">Llave privada del CSD</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="contrasena-csd">Contraseña del CSD</label>
<input class="form-input" id="contrasena-csd" type="password" placeholder="Contraseña de la llave privada" />
<span class="form-hint">Contraseña asignada al generar el CSD en el SAT</span>
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button class="btn btn--ghost">Cancelar</button>
<button class="btn btn--primary">
<svg viewBox="0 0 24 24">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Actualizar CSD
</button>
</div>
</form>
</div>
</div>
@@ -879,6 +902,44 @@
</div>
</div>
<!-- CARTA MANIFIESTO -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
<svg viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span class="config-section__title">Carta Manifiesto (SAT)</span>
</div>
<div class="config-section__body" id="manifiesto-panel">
<p style="color:var(--color-text-muted); margin-bottom:var(--space-3);">
Firma la carta manifiesto con tu <strong>FIEL</strong> (e.firma) para autorizar a Facturapi a timbrar CFDI ante el SAT.
Si no la ves, usa el botón para abrirla en una pestaña nueva.
</p>
<div style="border:1px solid var(--color-border); border-radius:var(--radius-md); overflow:hidden; background:var(--color-bg-base);">
<iframe
id="manifiesto-iframe"
src="https://www.facturapi.io/embedded/manifiesto"
title="Firma de Carta Manifiesto"
style="width:100%; height:720px; border:0; display:block;"
loading="lazy"
allow="fullscreen"
></iframe>
</div>
<div style="margin-top:var(--space-3); display:flex; gap:var(--space-3); justify-content:flex-end;">
<a class="btn btn--ghost btn--sm" href="https://www.facturapi.io/manifiesto" target="_blank" rel="noopener">
Abrir portal de firma
</a>
<button type="button" class="btn btn--secondary btn--sm" onclick="Invoicing.reloadManifiesto()">
Recargar firma
</button>
</div>
</div>
</div>
<!-- CONFIGURACIÓN DE SERIES — full width -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
@@ -1076,12 +1137,12 @@
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/invoicing.js?v=3" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/invoicing.js?v=35" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -155,6 +155,7 @@
------------------------------------------------------------------ */
const state = {
selectedUser: null,
selectedUserId: null,
pin: [],
maxPinLength: 6,
};
@@ -325,18 +326,21 @@
localStorage.setItem('pos_device_id', deviceId);
}
// Auto-redirect if already logged in with valid token
// Optional auto-redirect disabled to allow switching users on shared devices.
// Users with a valid session can navigate directly to /pos/catalog or /pos/workshop.
(function checkExistingSession() {
var token = localStorage.getItem('pos_token');
if (token && tenantId) {
try {
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 > Date.now() + 30000) {
window.location.href = '/pos/catalog';
return;
if (payload.exp * 1000 <= Date.now() + 30000) {
localStorage.removeItem('pos_token');
localStorage.removeItem('pos_employee');
}
} catch(e) {}
localStorage.removeItem('pos_token');
} catch(e) {
localStorage.removeItem('pos_token');
localStorage.removeItem('pos_employee');
}
}
})();
@@ -351,6 +355,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenant_id: parseInt(tenantId),
employee_id: state.selectedUserId,
pin: enteredPin,
device_id: deviceId
})
@@ -374,13 +379,19 @@
localStorage.setItem('pos_token', result.data.token);
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
localStorage.setItem('pos_tenant_id', tenantId);
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
btnLogin.innerHTML = '<span class="btn-login__icon" aria-hidden="true">✓</span> Bienvenido, ' + result.data.employee.name;
btnLogin.style.background = 'var(--color-success)';
showToast('¡Acceso concedido! Redirigiendo…');
setTimeout(function() {
window.location.href = '/pos/catalog';
var role = (result.data.employee.role || '').toLowerCase();
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
window.location.href = '/pos/workshop';
} else {
window.location.href = '/pos/catalog';
}
}, 1000);
})
.catch(function() {
@@ -398,6 +409,7 @@
function resetLoginState() {
state.selectedUser = null;
state.selectedUserId = null;
state.pin = [];
userBtns.forEach(b => {
@@ -487,6 +499,7 @@
employees.forEach(function(emp) {
var btn = document.createElement('button');
btn.className = 'user-avatar-btn';
btn.setAttribute('data-id', emp.id);
btn.setAttribute('data-user', emp.initials);
btn.setAttribute('data-name', emp.name);
btn.setAttribute('data-role', emp.role_label);
@@ -506,6 +519,7 @@
btn.classList.add('selected');
btn.setAttribute('aria-checked', 'true');
state.selectedUser = emp.initials;
state.selectedUserId = emp.id;
state.pin = [];
enablePinPad();
updatePinDisplay();

View File

@@ -8,15 +8,14 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/marketplace.css">
</head>
<link rel="stylesheet" href="/pos/static/css/marketplace.css"></head>
<body>
<header class="page-header">

View File

@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/inventory.css" />
@@ -28,8 +28,7 @@
.meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); }
.meli-config-row label { display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px; }
.meli-config-row input, .meli-config-row select { padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface-0);color:var(--color-text-primary); }
</style>
</head>
</style></head>
<body>
@@ -343,12 +342,12 @@
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=4" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=33" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>

View File

@@ -7,7 +7,7 @@
<title>Nexus Autoparts — Punto de Venta</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/chat.css" />
@@ -16,8 +16,7 @@
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<script src="/pos/static/js/native-bridge.js" defer></script>
<link rel="stylesheet" href="/pos/static/css/pos.css?v=4">
</head>
<link rel="stylesheet" href="/pos/static/css/pos.css?v=35"></head>
<body class="pos-shell" id="appBody">
@@ -86,7 +85,7 @@
<div class="cart-header">
<div class="cart-header__top">
<div class="cart-header__sale-id">Venta Activa</div>
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" style="display:none;">C/M</button>
<button class="cost-toggle" id="costToggle" title="Mostrar costo/margen (Admin)" aria-label="Mostrar costo y margen" style="display:none;">C/M</button>
<span class="cart-header__status">Activa</span>
</div>
@@ -199,15 +198,31 @@
</div>
</div>
<!-- Courier selector for counter remission notes -->
<div class="form-field" id="courierSelectField" style="display:none; margin-bottom: 12px;">
<label class="form-label" for="remissionCourier">Repartidor</label>
<select class="form-input" id="remissionCourier">
<option value="">-- Sin repartidor --</option>
</select>
</div>
<!-- COBRAR Button -->
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
<span>COBRAR</span>
</button>
<!-- Counter remission button (shown for counter role when feature enabled) -->
<button class="btn-cobrar" id="btnRemission" onclick="POS.createRemissionNote()" aria-label="Generar nota de remision" style="display:none;background:var(--color-secondary);">
<span>NOTA DE REMISIÓN</span>
</button>
<!-- Secondary Actions -->
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
<button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button>
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
<button class="btn-secondary-action" id="btnPayRemission" onclick="POS.openPayRemissionModal()" title="Cobrar nota de remision" style="display:none;">Cobrar Nota</button>
<button class="btn-secondary-action" onclick="POS.createLayaway()" title="Apartado (requiere cliente)">Apartado</button>
<button class="btn-secondary-action" onclick="POS.createServiceOrder()" title="Orden de servicio (F7)">Orden Taller</button>
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
<button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button>
@@ -239,6 +254,9 @@
<div class="fkey" onclick="POS.openDrawer()" title="Abrir cajon">
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
</div>
<div class="fkey" onclick="POS.createServiceOrder()" title="Orden de servicio">
<span class="fkey-key">F7</span><span class="fkey-label">Orden Taller</span>
</div>
<div class="fkey-sep"></div>
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
@@ -290,6 +308,15 @@
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
Mixto
</button>
<button class="pago-tab" data-method="credito" onclick="POS.selectPaymentMethod('credito', this)">
Crédito
</button>
<button class="pago-tab" data-method="cheque" onclick="POS.selectPaymentMethod('cheque', this)">
Cheque
</button>
<button class="pago-tab" data-method="pendiente" onclick="POS.selectPaymentMethod('pendiente', this)">
Pendiente
</button>
</div>
<!-- TAB: Efectivo -->
@@ -325,26 +352,26 @@
<div class="tab-content" id="mixedPayment">
<div class="mixed-row" style="margin-bottom:var(--space-3);">
<div class="form-group">
<label class="form-label">Metodo 1</label>
<select class="form-input" style="margin-bottom:var(--space-2);">
<label class="form-label" id="label-method-1">Método 1</label>
<select id="mixed-method-1" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 1">
<option value="efectivo">Efectivo</option>
<option value="tarjeta">Tarjeta</option>
<option value="transferencia">Transferencia</option>
</select>
<input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
<input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
<input id="mixed-amount-1" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 1" />
<input id="mixed-ref-1" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 1" />
</div>
</div>
<div class="mixed-row" style="margin-bottom:var(--space-3);">
<div class="form-group">
<label class="form-label">Metodo 2</label>
<select class="form-input" style="margin-bottom:var(--space-2);">
<label class="form-label" id="label-method-2">Método 2</label>
<select id="mixed-method-2" class="form-input" style="margin-bottom:var(--space-2);" aria-label="Método de pago 2">
<option value="tarjeta">Tarjeta</option>
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
</select>
<input type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" />
<input type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" />
<input id="mixed-amount-2" type="number" class="form-input mixed-amount" placeholder="0.00" step="0.01" oninput="POS.updateMixedTotal()" aria-label="Monto método de pago 2" />
<input id="mixed-ref-2" type="text" class="form-input" placeholder="Referencia (si aplica)" style="margin-top:var(--space-2);" aria-label="Referencia método de pago 2" />
</div>
</div>
<div class="split-remaining">
@@ -353,6 +380,35 @@
</div>
</div>
<!-- TAB: Crédito -->
<div class="tab-content" id="creditPayment">
<div class="form-group">
<label class="form-label">Venta a crédito</label>
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Se registrará como venta a crédito para el cliente seleccionado y se agregará a su cuenta.</p>
</div>
</div>
<!-- TAB: Cheque -->
<div class="tab-content" id="chequePayment">
<div class="form-group">
<label class="form-label">Monto</label>
<input type="text" class="form-input form-input-lg" id="chequeAmount" readonly />
</div>
<div class="form-group">
<label class="form-label">No. de cheque / referencia</label>
<input type="text" class="form-input" id="chequeRef" placeholder="No. de cheque" />
</div>
<div class="form-hint">Verificar que el cheque sea válido antes de confirmar</div>
</div>
<!-- TAB: Pendiente -->
<div class="tab-content" id="pendingPayment">
<div class="form-group">
<label class="form-label">Pago pendiente</label>
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">La venta quedará pendiente de pago. Podrá cobrarse más tarde desde el listado de ventas o notas de remisión.</p>
</div>
</div>
<!-- CFDI Checkbox -->
<div class="cfdi-check">
<input type="checkbox" id="cfdiCheck" />
@@ -368,6 +424,68 @@
</div>
</div>
<!-- ================================================================
PAY REMISSION NOTE MODAL
================================================================ -->
<div class="modal-overlay" id="payRemissionModal">
<div class="modal-pago">
<div class="modal-header">
<h3>Cobrar Nota de Remisión</h3>
<button class="modal-close" onclick="POS.closePayRemissionModal()">&#x2715;</button>
</div>
<div class="form-row" style="padding: 16px; gap: 8px;">
<input class="form-input" type="number" id="payRemissionFolio" placeholder="Folio de la nota (NR-XXXX)" style="flex:1;" />
<button class="btn btn-primary" onclick="POS.searchRemissionToPay()">Buscar</button>
</div>
<div id="payRemissionDetail" style="padding: 0 16px 16px; max-height: 220px; overflow-y: auto;"></div>
<div id="payRemissionActions" style="display:none; padding: 0 16px 16px;">
<div class="form-row" style="gap: 8px; margin-bottom: 8px;">
<select class="form-input" id="payRemissionMethod" onchange="POS.updatePayRemissionMethod()">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
<option value="mixto">Mixto</option>
</select>
</div>
<div id="payRemissionCash" class="form-row" style="gap: 8px; margin-bottom: 8px;">
<input class="form-input" type="number" id="payRemissionReceived" placeholder="Recibido" step="0.01" />
</div>
<div id="payRemissionRef" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none;">
<input class="form-input" type="text" id="payRemissionReference" placeholder="Referencia" />
</div>
<div id="payRemissionMixed" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none; flex-direction: column;">
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
<select class="form-input" style="flex:1;">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
</select>
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
</div>
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
<select class="form-input" style="flex:1;">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
</select>
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
</div>
</div>
<div class="modal-footer" style="padding:0;">
<button class="btn btn-ghost" onclick="POS.closePayRemissionModal()">Cancelar</button>
<button class="btn btn-primary" id="btnConfirmPayRemission" onclick="POS.confirmPayRemission()">Confirmar Pago</button>
</div>
</div>
<div id="payRemissionResult" style="padding: 0 16px 16px;"></div>
</div>
</div>
<!-- ================================================================
CANCEL SALE CONFIRMATION MODAL
================================================================ -->
@@ -484,10 +602,10 @@
<div style="margin-top:var(--space-4);border-top:1px solid var(--color-border);padding-top:var(--space-4);">
<div class="form-label" style="margin-bottom:var(--space-3);">Vehiculo (opcional)</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);">
<div class="form-group"><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" /></div>
<div class="form-group"><input type="text" class="form-input" id="ncVehModel" placeholder="Modelo" /></div>
<div class="form-group"><input type="text" class="form-input" id="ncVehYear" placeholder="Ano" /></div>
<div class="form-group"><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" /></div>
<div class="form-group"><label class="form-label">Marca</label><input type="text" class="form-input" id="ncVehMake" placeholder="Marca" aria-label="Marca del vehiculo" /></div>
<div class="form-group"><label class="form-label">Modelo</label><input type="text" class="form-input" id="ncVehModel" placeholder="Modelo" aria-label="Modelo del vehiculo" /></div>
<div class="form-group"><label class="form-label">Año</label><input type="text" class="form-input" id="ncVehYear" placeholder="Ano" aria-label="Ano del vehiculo" /></div>
<div class="form-group"><label class="form-label">Placas</label><input type="text" class="form-input" id="ncVehPlates" placeholder="Placas" aria-label="Placas del vehiculo" /></div>
</div>
</div>
</div>
@@ -561,17 +679,61 @@
================================================================ -->
<div class="toast-container" id="toastContainer" aria-live="assertive" aria-atomic="true"></div>
<!-- ================================================================
SERVICE ORDER MODAL
================================================================ -->
<div class="modal-overlay" id="serviceOrderModal">
<div class="modal-pago" style="width:480px;">
<div class="modal-header">
<h3>Orden de servicio desde POS</h3>
<button class="modal-close" onclick="POS.closeServiceOrderModal()">&#x2715;</button>
</div>
<div style="padding:var(--space-6);">
<div class="form-group">
<label class="form-label">Cliente</label>
<input type="text" class="form-input" id="soCustomer" readonly />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);">
<div class="form-group">
<label class="form-label">Veh&iacute;culo</label>
<input type="text" class="form-input" id="soVehicle" placeholder="Marca / Modelo / Placas" />
</div>
<div class="form-group">
<label class="form-label">V&iacute;a de entrega</label>
<select class="form-input" id="soDelivery">
<option value=""></option>
<option value="pickup">Pasa cliente</option>
<option value="delivery">Env&iacute;o a domicilio</option>
<option value="courier">Motociclista</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Notas de recepci&oacute;n</label>
<textarea class="form-input" id="soNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
</div>
<label style="display:flex;align-items:center;gap:var(--space-2);color:var(--color-text-primary);font-size:var(--text-body-sm);">
<input type="checkbox" id="soDirect" /> Orden directa
</label>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="POS.closeServiceOrderModal()">Cancelar</button>
<button class="btn btn-primary" onclick="POS.confirmServiceOrder()">Crear orden e imprimir</button>
</div>
</div>
</div>
<!-- ================================================================
JAVASCRIPT
================================================================ -->
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/kiosk.js" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/push.js" defer></script>
<script src="/pos/static/js/printer.js" defer></script>
<script src="/pos/static/js/pos.js?v=7" defer></script>
<script src="/pos/static/js/pos.js?v=42" defer></script>
<script>
// Cancel sale button wiring

View File

@@ -8,17 +8,16 @@
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/quotations.css">
</head>
<link rel="stylesheet" href="/pos/static/css/quotations.css"></head>
<body>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<div class="page">
<h1 class="page-title">Cotizaciones</h1>

View File

@@ -0,0 +1,430 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notas de Remisión — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34" />
<link rel="stylesheet" href="/pos/static/css/remission_notes.css?v=1" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
</head>
<body>
<!-- THEME BAR -->
<header class="theme-bar" role="banner">
<div class="theme-bar__left">
<div class="theme-bar__store">
<span class="theme-bar__dot"></span>
Nexus Autoparts
</div>
<div class="theme-bar__sep"></div>
<span class="theme-bar__label">Sucursal Centro &mdash; Usuario: H. García</span>
</div>
<div class="theme-bar__right">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
<span class="theme-btn__swatch"></span>
Industrial
</button>
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
<span class="theme-btn__swatch"></span>
Moderno
</button>
</div>
</header>
<!-- APP SHELL -->
<div class="app-shell">
<!-- SIDEBAR -->
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
<div class="sidebar__brand">
<div class="brand-logo">NA</div>
<div class="brand-name">
<span class="brand-name__primary">Nexus</span>
<span class="brand-name__sub">Autoparts POS</span>
</div>
</div>
<nav class="sidebar__nav">
<div class="nav-section-label">Principal</div>
<a class="nav-item" href="/pos/dashboard">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<a class="nav-item" href="/pos/sale">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span>POS</span>
</a>
<a class="nav-item" href="/pos/catalog">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span>Catálogo</span>
</a>
<a class="nav-item" href="/pos/inventory">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Inventario</span>
</a>
<div class="nav-section-label">Gestión</div>
<a class="nav-item" href="/pos/customers">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>Clientes</span>
</a>
<a class="nav-item is-active" href="/pos/remission-notes" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span>Notas de Remisión</span>
</a>
<a class="nav-item" href="/pos/invoicing">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span>Facturación</span>
</a>
<a class="nav-item" href="/pos/reports">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>Reportes</span>
</a>
<div class="nav-section-label">Sistema</div>
<a class="nav-item" href="/pos/config">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
<span>Configuración</span>
</a>
</nav>
<div class="sidebar__footer">
<div class="sidebar__user-avatar">HG</div>
<div class="sidebar__user-info">
<div class="sidebar__user-name">Hugo García</div>
<div class="sidebar__user-role">Administrador</div>
</div>
</div>
</aside>
<!-- MAIN CONTENT -->
<main class="main" role="main">
<div class="page-header">
<div class="page-header__title-group">
<span class="page-header__eyebrow">Ventas</span>
<h1 class="page-header__title">Notas de Remisión</h1>
</div>
</div>
<div class="page-content">
<!-- Filters -->
<div class="filters-card">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="filterFolio" placeholder="Buscar folio NR-XXXX..." />
</div>
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="filterCustomer" placeholder="Cliente..." />
</div>
<select class="select-filter" id="filterStatus">
<option value="">Todos los estados</option>
<option value="pending_payment" selected>Pendientes</option>
<option value="completed">Pagadas</option>
<option value="cancelled">Canceladas</option>
</select>
<select class="select-filter" id="filterCourier">
<option value="">Todos los repartidores</option>
</select>
<input type="date" class="select-filter" id="dateFrom" />
<input type="date" class="select-filter" id="dateTo" />
<div class="toolbar__spacer"></div>
<button class="btn btn--primary" onclick="loadData(1)">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Buscar
</button>
</div>
</div>
<!-- Table -->
<div class="table-wrapper">
<table class="data-table" id="remissionTable">
<thead>
<tr>
<th>Folio</th>
<th>Fecha</th>
<th>Cliente</th>
<th>Vendedor</th>
<th>Repartidor</th>
<th style="text-align:right">Total</th>
<th>Estado</th>
<th style="text-align:right">Acciones</th>
</tr>
</thead>
<tbody id="remissionTableBody">
<tr><td colspan="8" style="text-align:center;padding:var(--space-8);">Cargando...</td></tr>
</tbody>
</table>
<div class="table-footer">
<div class="pagination" id="pagination"></div>
</div>
</div>
</div>
</main>
</div>
<!-- Ticket Modal -->
<div class="modal-overlay" id="ticketModal" onclick="if(event.target===this) closeTicketModal()">
<div class="modal">
<div class="modal__header">
<h3 class="modal__title">Vista previa</h3>
<button class="modal__close" onclick="closeTicketModal()">&times;</button>
</div>
<div class="modal__body">
<div id="ticketContent" class="ticket-preview"></div>
</div>
<div class="modal__footer">
<button class="btn btn--ghost" onclick="closeTicketModal()">Cerrar</button>
<button class="btn btn--primary" onclick="printTicket()">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Imprimir
</button>
</div>
</div>
</div>
<script src="/pos/static/js/i18n.js?v=39" defer></script>
<script src="/pos/static/js/app-init.js?v=8" defer></script>
<script src="/pos/static/js/sidebar.js?v=46" defer></script>
<script>
const token = localStorage.getItem('pos_token') || '';
let currentPage = 1;
let couriers = [];
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
async function api(url, options = {}) {
options.headers = headers();
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function statusLabel(status) {
return { pending_payment: 'Pendiente', completed: 'Pagada', cancelled: 'Cancelada' }[status] || status;
}
function statusClass(status) {
return { pending_payment: 'badge--pending_payment', completed: 'badge--completed', cancelled: 'badge--cancelled' }[status] || '';
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('filterCourier');
const current = sel.value;
sel.innerHTML = '<option value="">Todos los repartidores</option>' + couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.value = current;
} catch (e) {
console.warn('No se pudieron cargar repartidores', e);
}
}
async function loadData(page) {
currentPage = page;
const tbody = document.getElementById('remissionTableBody');
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Cargando...</td></tr>';
const params = new URLSearchParams();
params.set('sale_type', 'counter_remission');
params.set('per_page', '50');
params.set('page', String(page));
const status = document.getElementById('filterStatus').value;
if (status) params.set('status', status);
const folio = document.getElementById('filterFolio').value.trim();
if (folio) params.set('q', folio.replace(/^NR-/i, ''));
const customer = document.getElementById('filterCustomer').value.trim();
if (customer) params.set('customer', customer);
const courier = document.getElementById('filterCourier').value;
if (courier) params.set('courier_id', courier);
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
if (from) params.set('date_from', from);
if (to) params.set('date_to', to);
try {
const res = await api('/pos/api/sales?' + params.toString());
render(res.data || [], res.pagination || {});
} catch (e) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-error);">Error: ${e.message}</td></tr>`;
}
}
function render(rows, pagination) {
const tbody = document.getElementById('remissionTableBody');
const pag = document.getElementById('pagination');
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="8">
<div class="empty-state">
<div class="empty-state__title">No hay notas de remisión</div>
<div class="empty-state__subtitle">Ajusta los filtros o genera una nueva nota desde el POS.</div>
</div>
</td></tr>`;
pag.innerHTML = '';
return;
}
tbody.innerHTML = rows.map(r => `
<tr>
<td class="td--mono">NR-${r.id}</td>
<td>${new Date(r.created_at).toLocaleString('es-MX')}</td>
<td class="td--primary">${r.customer_name || 'Público General'}</td>
<td>${r.employee_name || '-'}</td>
<td>${r.courier_name || '-'}</td>
<td class="td--amount" style="text-align:right">${fmt(r.total)}</td>
<td><span class="badge ${statusClass(r.status)}">${statusLabel(r.status)}</span></td>
<td style="text-align:right">
<div style="display:flex;justify-content:flex-end;gap:var(--space-2);">
<button class="action-btn action-btn--ghost" onclick="viewTicket(${r.id})" title="Imprimir">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
if (pagination.total_pages > 1) {
pag.innerHTML = `
<button class="page-btn" onclick="loadData(${pagination.page - 1})" ${pagination.page <= 1 ? 'disabled' : ''}>&larr;</button>
<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Página ${pagination.page} de ${pagination.total_pages}</span>
<button class="page-btn" onclick="loadData(${pagination.page + 1})" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>&rarr;</button>
`;
} else {
pag.innerHTML = '';
}
}
async function viewTicket(saleId) {
try {
const data = await api('/pos/api/sales/' + saleId + '/print-remission', { method: 'POST', body: '{}' });
const dateStr = new Date(data.date).toLocaleString('es-MX');
const itemsHtml = (data.items || []).map(it => `
<div class="ticket-preview__item"><span>${it.quantity} x ${it.name}</span><span>${fmt(it.subtotal)}</span></div>
`).join('');
document.getElementById('ticketContent').innerHTML = `
<div class="ticket-preview__center ticket-preview__bold">${data.business_name || 'NEXUS AUTOPARTS'}</div>
<div class="ticket-preview__center">${data.business_rfc || ''}</div>
<div class="ticket-preview__center">${data.business_address || ''}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__center ticket-preview__bold">NOTA DE REMISIÓN</div>
<div class="ticket-preview__center">${data.folio}</div>
<div class="ticket-preview__center">${dateStr}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__line"><span>Cliente:</span><span>${data.customer || 'Público General'}</span></div>
<div class="ticket-preview__line"><span>Vendedor:</span><span>${data.employee || '-'}</span></div>
${data.courier ? `<div class="ticket-preview__line"><span>Repartidor:</span><span>${data.courier}</span></div>` : ''}
<div class="ticket-preview__items">${itemsHtml}</div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__line"><span>Subtotal:</span><span>${fmt(data.subtotal)}</span></div>
${data.discount_total ? `<div class="ticket-preview__line"><span>Descuento:</span><span>-${fmt(data.discount_total)}</span></div>` : ''}
<div class="ticket-preview__line"><span>IVA:</span><span>${fmt(data.tax_total)}</span></div>
<div class="ticket-preview__line ticket-preview__bold"><span>TOTAL:</span><span>${fmt(data.total)}</span></div>
<div class="ticket-preview__divider"></div>
<div class="ticket-preview__footer ticket-preview__bold">PENDIENTE DE PAGO</div>
<div class="ticket-preview__footer" style="font-size:0.75rem;opacity:0.8;">Presente esta nota en caja para pagar</div>
`;
document.getElementById('ticketModal').classList.add('is-open');
} catch (e) {
alert('Error: ' + e.message);
}
}
function closeTicketModal() {
document.getElementById('ticketModal').classList.remove('is-open');
}
function printTicket() {
const w = window.open('', '_blank');
w.document.write('<html><head><title>Nota de Remisión</title></head><body>' + document.getElementById('ticketContent').innerHTML + '</body></html>');
w.document.close();
w.print();
}
async function payNote(saleId, total) {
const method = prompt(`Cobrar nota NR-${saleId}\nTotal: ${fmt(total)}\n\nForma de pago: efectivo / transferencia / tarjeta`, 'efectivo');
if (!method) return;
const reference = ['transferencia', 'tarjeta'].includes(method) ? prompt('Referencia:') : '';
try {
await api('/pos/api/sales/' + saleId + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: method,
amount_paid: total,
reference: reference || ''
})
});
alert('Nota cobrada correctamente');
loadData(currentPage);
} catch (e) {
alert('Error al cobrar: ' + e.message);
}
}
(async function init() {
if (!token) {
document.getElementById('remissionTableBody').innerHTML = `<tr><td colspan="8">
<div class="empty-state">
<div class="empty-state__title">Inicia sesión</div>
<div class="empty-state__subtitle">Se requiere autenticación para ver las notas de remisión.</div>
</div>
</td></tr>`;
return;
}
await loadCouriers();
loadData(1);
})();
</script>
</body>
</html>

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