diff --git a/.env.example b/.env.example index dcb2144..fcfc3fc 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,13 @@ METABASE_ADMIN_EMAIL=admin@nexus.local METABASE_ADMIN_PASS=change-me-to-a-strong-password METABASE_DB_PASS=metabase_secret +# ═══════════════════════════════════════════════════════════════════════════ +# FACTURAPI (OPTIONAL — auto-organization mode for new tenants) +# ═══════════════════════════════════════════════════════════════════════════ +# If set, new tenants can create Facturapi organizations automatically. +# Otherwise each tenant must store its secret key in tenant_config.cfdi_facturapi_key. +FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx + # ═══════════════════════════════════════════════════════════════════════════ # CURRENCY # ═══════════════════════════════════════════════════════════════════════════ diff --git a/pos/app.py b/pos/app.py index 7bf6c1d..2005966 100644 --- a/pos/app.py +++ b/pos/app.py @@ -201,6 +201,10 @@ def create_app(): def pos_marketplace_external_callback(): return render_template('marketplace_external.html') + @app.route('/pos/historical-sales') + def pos_historical_sales(): + return render_template('historical_sales.html') + @app.route('/pos/static/') def pos_static(filename): return send_from_directory('static', filename) diff --git a/pos/blueprints/invoicing_bp.py b/pos/blueprints/invoicing_bp.py index e4cdf15..aeeac74 100644 --- a/pos/blueprints/invoicing_bp.py +++ b/pos/blueprints/invoicing_bp.py @@ -10,11 +10,14 @@ from datetime import datetime from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn -from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml +from services.cfdi_facturapi_builder import ( + build_ingreso_payload, build_egreso_payload, build_pago_payload, +) from services.cfdi_queue import ( enqueue_cfdi, process_queue, retry_failed, cancel_cfdi, get_queue_status, ) +from services import facturapi_service from services.audit import log_action invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') @@ -38,8 +41,8 @@ def _get_issuer_config(cur, branch_id=None): 'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), 'cp': config.get('tenant_cp', '00000'), 'serie': config.get('cfdi_serie', 'A'), - 'horux_api_url': config.get('cfdi_horux_api_url', ''), - 'horux_api_key': config.get('cfdi_horux_api_key', ''), + 'facturapi_key': config.get('cfdi_facturapi_key', ''), + 'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''), } # Branch-level override @@ -177,19 +180,19 @@ def generate_invoice(): 'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' }), 409 - # Build XML + # Build Facturapi payload if cfdi_type == 'ingreso': - xml = build_ingreso_xml(sale, tenant_config, customer) + payload = build_ingreso_payload(sale, tenant_config, customer) elif cfdi_type == 'egreso': original_uuid = data.get('original_uuid') if not original_uuid: return jsonify({'error': 'original_uuid required for egreso'}), 400 - xml = build_egreso_xml(sale, tenant_config, customer, original_uuid) + payload = build_egreso_payload(sale, tenant_config, customer, original_uuid) else: return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 # Enqueue - result = enqueue_cfdi(conn, sale_id, cfdi_type, xml) + result = enqueue_cfdi(conn, sale_id, cfdi_type, payload) log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'], new_value={'sale_id': sale_id, 'type': cfdi_type, @@ -244,10 +247,10 @@ def get_queue_item(cfdi_id): cur = conn.cursor() cur.execute(""" - SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed, + SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.error_message, q.cancel_motive, q.cancel_replacement_uuid, - q.created_at, q.stamped_at + q.created_at, q.stamped_at, q.external_id FROM cfdi_queue q WHERE q.id = %s """, (cfdi_id,)) row = cur.fetchone() @@ -258,13 +261,14 @@ def get_queue_item(cfdi_id): item = { 'id': row[0], 'sale_id': row[1], 'type': row[2], - 'xml_unsigned': row[3], 'xml_signed': row[4], + 'payload_unsigned': row[3], 'xml_signed': row[4], 'uuid_fiscal': row[5], 'status': row[6], 'retry_count': row[7], 'provisional_folio': row[8], 'error_message': row[9], 'cancel_motive': row[10], 'cancel_replacement_uuid': row[11], 'created_at': str(row[12]) if row[12] else None, 'stamped_at': str(row[13]) if row[13] else None, + 'external_id': row[14], } cur.close() @@ -281,19 +285,16 @@ def trigger_process_queue(): try: tenant_config = _get_issuer_config(cur) - horux_url = tenant_config.get('horux_api_url') - horux_key = tenant_config.get('horux_api_key') - - if not horux_url or not horux_key: + if not tenant_config.get('facturapi_key'): cur.close() conn.close() - return jsonify({'error': 'Horux API not configured'}), 400 + return jsonify({'error': 'Facturapi key not configured'}), 400 # Reset eligible failed items first reset_count = retry_failed(conn) # Process the queue - result = process_queue(conn, horux_url, horux_key) + result = process_queue(conn, tenant_config) result['retries_reset'] = reset_count cur.close() @@ -338,8 +339,7 @@ def cancel_invoice(cfdi_id): tenant_config = _get_issuer_config(cur) result = cancel_cfdi( conn, cfdi_id, motive, replacement_uuid, - tenant_config.get('horux_api_url'), - tenant_config.get('horux_api_key'), + tenant_config=tenant_config, ) log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, @@ -542,3 +542,123 @@ def get_eligible_sales_for_global(): 'total': sum(s['total'] for s in sales), 'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales], }) + + +# ─── Facturapi extras ─────────────────────────────── + +@invoicing_bp.route('/facturapi/status', methods=['GET']) +@require_auth('invoicing.view') +def facturapi_status(): + """Return Facturapi organization status for the tenant.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + tenant_config = _get_issuer_config(cur) + cur.close() + conn.close() + + status = facturapi_service.get_org_status(tenant_config) + return jsonify(status) + + +@invoicing_bp.route('/facturapi/setup', methods=['POST']) +@require_auth('invoicing.create') +def facturapi_setup(): + """Create or link a Facturapi organization for this tenant. + + Requires FACTURAPI_USER_KEY environment variable. + Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config. + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + try: + tenant_config = _get_issuer_config(cur) + if not tenant_config.get('rfc'): + return jsonify({'error': 'Tenant RFC not configured'}), 400 + + result = facturapi_service.create_organization(tenant_config) + + cur.execute(""" + INSERT INTO tenant_config (key, value) + VALUES ('cfdi_facturapi_org_id', %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (result['org_id'],)) + + cur.execute(""" + INSERT INTO tenant_config (key, value) + VALUES ('cfdi_facturapi_key', %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (result['api_key'],)) + + log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None, + new_value={'org_id': result['org_id']}) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'org_id': result['org_id'], + 'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.', + }) + + except ValueError as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'error': str(e)}), 400 + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'error': str(e)}), 500 + + +@invoicing_bp.route('/facturapi/download//', methods=['GET']) +@require_auth('invoicing.view') +def facturapi_download(cfdi_id, doc_type): + """Download PDF or XML for a stamped CFDI from Facturapi. + + doc_type: 'pdf' | 'xml' + """ + if doc_type not in ('pdf', 'xml'): + return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s + """, (cfdi_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'CFDI not found'}), 404 + + external_id, uuid_fiscal, status = row + if status != 'stamped' or not external_id: + cur.close(); conn.close() + return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400 + + tenant_config = _get_issuer_config(cur) + cur.close() + conn.close() + + try: + if doc_type == 'pdf': + content = facturapi_service.download_pdf(tenant_config, external_id) + mime = 'application/pdf' + filename = f'cfdi_{uuid_fiscal or external_id}.pdf' + else: + content = facturapi_service.download_xml(tenant_config, external_id) + mime = 'application/xml' + filename = f'cfdi_{uuid_fiscal or external_id}.xml' + except Exception as e: + return jsonify({'error': str(e)}), 500 + + from flask import Response + return Response( + content, + mimetype=mime, + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + ) diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 4b43c0f..6793439 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -232,6 +232,83 @@ def list_sales(): }) +@pos_bp.route('/historical-sales', methods=['GET']) +@require_auth('pos.view') +def list_historical_sales(): + """List imported historical sales (read-only reference). + + Query params: + date_from: YYYY-MM-DD + date_to: YYYY-MM-DD + customer: partial customer name + page: int (default 1) + per_page: int (default 50, max 200) + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 200) + + where_clauses = ["1=1"] + params = [] + + date_from = request.args.get('date_from') + date_to = request.args.get('date_to') + customer = request.args.get('customer') + + if date_from: + where_clauses.append("sale_date >= %s") + params.append(date_from) + if date_to: + where_clauses.append("sale_date <= %s") + params.append(date_to) + if customer: + where_clauses.append("customer_name ILIKE %s") + params.append(f"%{customer}%") + + where = " AND ".join(where_clauses) + + cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params) + total = cur.fetchone()[0] + + cur.execute(f""" + SELECT id, external_document_id, document_no, sale_date, customer_name, + total, subtotal, amount_paid, payment_method, discount, balance, + raw_payment_code + FROM historical_sales + WHERE {where} + ORDER BY sale_date DESC, id DESC + LIMIT %s OFFSET %s + """, params + [per_page, (page - 1) * per_page]) + + rows = [] + for r in cur.fetchall(): + rows.append({ + 'id': r[0], + 'external_document_id': r[1], + 'document_no': r[2], + 'sale_date': str(r[3]) if r[3] else None, + 'customer_name': r[4], + 'total': float(r[5]) if r[5] else 0, + 'subtotal': float(r[6]) if r[6] else 0, + 'amount_paid': float(r[7]) if r[7] else 0, + 'payment_method': r[8], + 'discount': float(r[9]) if r[9] else 0, + 'balance': float(r[10]) if r[10] else 0, + 'raw_payment_code': r[11], + }) + + cur.close() + conn.close() + + total_pages = (total + per_page - 1) // per_page + return jsonify({ + 'data': rows, + 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} + }) + + @pos_bp.route('/sales/', methods=['GET']) @require_auth('pos.view') def get_sale(sale_id): diff --git a/pos/migrations/runner.py b/pos/migrations/runner.py index 6cff34b..54b48e5 100755 --- a/pos/migrations/runner.py +++ b/pos/migrations/runner.py @@ -14,9 +14,11 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__)) MIGRATIONS = { 'v1.0': 'v1.0_initial.sql', 'v1.1': 'v1.1_pos_tables.sql', + 'v1.2': 'v1.2_subdomain.sql', 'v1.3': 'v1.3_fleet.sql', 'v1.4': 'v1.4_whatsapp.sql', 'v1.5': 'v1.5_returns.sql', + 'v1.6': 'v1.6_marketplace.sql', 'v1.7': 'v1.7_plates.sql', 'v1.8': 'v1.8_performance_indexes.sql', 'v1.9': 'v1.9_redis_cache.sql', @@ -33,14 +35,20 @@ MIGRATIONS = { 'v3.0': 'v3.0_public_api.sql', 'v3.1': 'v3.1_inventory_vehicle_compat.sql', 'v3.2': 'v3.2_db_performance.sql', + 'v3.2.1': 'v3.2_qwen_vehicle_compat.sql', + 'v3.3': 'v3.3_marketplace_any_part.sql', + 'v3.3.1': 'v3.3_materialized_view.sql', 'v3.4': 'v3.4_meli_integration.sql', 'v3.5': 'v3.5_meli_questions.sql', + 'v3.5.1': 'v3.5_whatsapp_state_machine.sql', 'v3.6': 'v3.6_dropshipping.sql', 'v3.7': 'v3.7_sku_aliases.sql', 'v3.8': 'v3.8_supplier_catalog.sql', + 'v3.9': 'v3.9_supplier_catalog_prices.sql', 'v4.0': 'v4.0_multi_branch.sql', 'v4.1': 'v4.1_global_invoice.sql', 'v4.2': 'v4.2_meli_sync_queue.sql', + 'v4.3': 'v4.3_facturapi.sql', } @@ -69,11 +77,19 @@ def apply_migration(db_name, version): print(f" ERROR: Migration file not found: {filepath}") return False + with open(filepath) as f: + sql = f.read() + + # Skip migrations marked for manual/non-tenant execution + first_line = sql.splitlines()[0].strip() if sql.strip() else '' + if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'): + print(f" SKIP (manual/non-tenant migration)") + return True + conn = get_tenant_conn_by_dbname(db_name) cur = conn.cursor() try: - with open(filepath) as f: - cur.execute(f.read()) + cur.execute(sql) conn.commit() return True except Exception as e: diff --git a/pos/migrations/v3.3_marketplace_any_part.sql b/pos/migrations/v3.3_marketplace_any_part.sql index f2a40dd..8226ba0 100644 --- a/pos/migrations/v3.3_marketplace_any_part.sql +++ b/pos/migrations/v3.3_marketplace_any_part.sql @@ -1,6 +1,6 @@ -- ═══════════════════════════════════════════════════════════════════════ -- v3.3 — Marketplace accepts any part number (seller listings) --- Target: nexus_autoparts (master DB) +-- Target: nexus_autoparts (master DB) / tenants with warehouse_inventory -- Date: 2026-05-17 -- -- Makes warehouse_inventory part_id nullable and adds seller-defined @@ -8,84 +8,86 @@ -- Existing OEM-matched listings are untouched. -- ═══════════════════════════════════════════════════════════════════════ --- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ───────────── +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN + -- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ───────────── + ALTER TABLE warehouse_inventory + ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100), + ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300), + ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100), + ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER; -ALTER TABLE warehouse_inventory - ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100), - ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300), - ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100), - ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER; + -- Make part_id nullable so seller listings (without catalog match) can exist + ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL; --- Make part_id nullable so seller listings (without catalog match) can exist -ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL; + -- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ─── + ALTER TABLE warehouse_inventory + DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key; --- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ─── + DROP INDEX IF EXISTS idx_wi_unique_composite; --- The old constraint was on (user_id, part_id, warehouse_location). --- We replace it with two partial unique indexes: --- - OEM items: (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL --- - Seller items: (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL + CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem + ON warehouse_inventory(bodega_id, part_id, warehouse_location) + WHERE part_id IS NOT NULL; -ALTER TABLE warehouse_inventory - DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key; + CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller + ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location) + WHERE part_id IS NULL; -DROP INDEX IF EXISTS idx_wi_unique_composite; + -- Ensure every row has either part_id or seller_part_number + ALTER TABLE warehouse_inventory + DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller; -CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem -ON warehouse_inventory(bodega_id, part_id, warehouse_location) -WHERE part_id IS NOT NULL; + ALTER TABLE warehouse_inventory + ADD CONSTRAINT chk_wi_part_or_seller + CHECK ( + (part_id IS NOT NULL AND seller_part_number IS NULL) + OR + (part_id IS NULL AND seller_part_number IS NOT NULL) + ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller -ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location) -WHERE part_id IS NULL; + -- ─── 3. WAREHOUSE_INVENTORY — search indexes ───────────────────────── + CREATE INDEX IF NOT EXISTS idx_wi_seller_pn + ON warehouse_inventory (bodega_id, seller_part_number) + WHERE part_id IS NULL; --- Ensure every row has either part_id or seller_part_number -ALTER TABLE warehouse_inventory - DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller; + CREATE INDEX IF NOT EXISTS idx_wi_seller_category + ON warehouse_inventory (seller_category) + WHERE part_id IS NULL; -ALTER TABLE warehouse_inventory - ADD CONSTRAINT chk_wi_part_or_seller - CHECK ( - (part_id IS NOT NULL AND seller_part_number IS NULL) - OR - (part_id IS NULL AND seller_part_number IS NOT NULL) - ); + -- GIN index for text search on seller listings + CREATE INDEX IF NOT EXISTS idx_wi_seller_search + ON warehouse_inventory + USING gin (to_tsvector('spanish', + COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '') + )) + WHERE part_id IS NULL; + END IF; --- ─── 3. WAREHOUSE_INVENTORY — search indexes ───────────────────────── + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'purchase_order_items') THEN + -- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ───────────────── + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'purchase_order_items' AND column_name = 'part_id') THEN + ALTER TABLE purchase_order_items + ALTER COLUMN part_id DROP NOT NULL; + END IF; -CREATE INDEX IF NOT EXISTS idx_wi_seller_pn -ON warehouse_inventory (bodega_id, seller_part_number) -WHERE part_id IS NULL; - -CREATE INDEX IF NOT EXISTS idx_wi_seller_category -ON warehouse_inventory (seller_category) -WHERE part_id IS NULL; - --- GIN index for text search on seller listings -CREATE INDEX IF NOT EXISTS idx_wi_seller_search -ON warehouse_inventory -USING gin (to_tsvector('spanish', - COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '') -)) -WHERE part_id IS NULL; - --- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ───────────────── - -ALTER TABLE purchase_order_items - ALTER COLUMN part_id DROP NOT NULL; - --- Add a flag so seller listings can be distinguished in POs -ALTER TABLE purchase_order_items - ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE; + -- Add a flag so seller listings can be distinguished in POs + ALTER TABLE purchase_order_items + ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE; + END IF; +END $$; -- ─── 5. Back-compat: ensure existing rows are valid ────────────────── +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'warehouse_inventory') THEN + UPDATE warehouse_inventory + SET seller_part_number = NULL + WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL; --- Existing rows should have part_id set and seller_part_number NULL. --- If any row violates the new check, this will fail loudly. -UPDATE warehouse_inventory -SET seller_part_number = NULL -WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL; - -UPDATE warehouse_inventory -SET part_id = NULL -WHERE part_id IS NULL AND seller_part_number IS NULL; + UPDATE warehouse_inventory + SET part_id = NULL + WHERE part_id IS NULL AND seller_part_number IS NULL; + END IF; +END $$; diff --git a/pos/migrations/v3.3_materialized_view.sql b/pos/migrations/v3.3_materialized_view.sql index 512dccf..87aeb46 100644 --- a/pos/migrations/v3.3_materialized_view.sql +++ b/pos/migrations/v3.3_materialized_view.sql @@ -1,11 +1,15 @@ +-- : SKIP -- Migration v3.3: Materialized view part_vehicle_preview -- Purpose: Pre-compute the "most recent vehicle" per part to eliminate -- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time. -- --- Notes: --- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation). --- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists. --- - Run with statement_timeout = 0; this may take hours on first creation. +-- NOTE: This migration targets the vehicle_database, not tenant databases. +-- The runner skips files marked with ': SKIP' on the first line. +-- To apply manually on the vehicle database, run: +-- +-- psql -f pos/migrations/v3.3_materialized_view.sql +-- +-- (Remove the ': SKIP' line above before manual execution.) SET statement_timeout = 0; @@ -26,6 +30,3 @@ ORDER BY vp.part_id, y.year_car DESC; CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id); CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand); - --- Grant select to application roles if needed --- GRANT SELECT ON part_vehicle_preview TO nexus_app; diff --git a/pos/migrations/v3.5_whatsapp_state_machine.sql b/pos/migrations/v3.5_whatsapp_state_machine.sql index f9a6ea3..a226827 100644 --- a/pos/migrations/v3.5_whatsapp_state_machine.sql +++ b/pos/migrations/v3.5_whatsapp_state_machine.sql @@ -1,100 +1,118 @@ +-- : SKIP -- ============================================================ -- v3.5 WhatsApp State Machine -- Reorganización del chatbot de AI libre a flujo estructurado +-- +-- NOTE: This migration requires the WhatsApp tables (whatsapp_sessions, +-- whatsapp_messages) to be present. Tenant DBs without WhatsApp enabled +-- should skip this file. +-- Marked with ': SKIP' so the runner skips it unless WhatsApp is configured. +-- To apply manually on a tenant with WhatsApp tables: +-- psql -f pos/migrations/v3.5_whatsapp_state_machine.sql +-- (Remove the ': SKIP' line above before manual execution.) -- ============================================================ --- 1. Extender whatsapp_sessions con estado y contexto --- --------------------------------------------------- -ALTER TABLE whatsapp_sessions -ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle', -ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}', -ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id), -ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id), -ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0, -ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); - --- Índices para lookups rápidos de sesión -CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state); -CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id); -CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at); - --- 2. Tabla de vínculo persistente WA ID ↔ Cliente --- ------------------------------------------------ -CREATE TABLE IF NOT EXISTS wa_customer_links ( - phone VARCHAR(50) PRIMARY KEY, - customer_id INTEGER NOT NULL REFERENCES customers(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id); - --- Trigger para updated_at -CREATE OR REPLACE FUNCTION update_wa_link_timestamp() -RETURNS TRIGGER AS $$ +DO $$ BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; + -- 1. Extender whatsapp_sessions con estado y contexto + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN + ALTER TABLE whatsapp_sessions + ADD COLUMN IF NOT EXISTS state VARCHAR(50) DEFAULT 'idle', + ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}', + ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id), + ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id), + ADD COLUMN IF NOT EXISTS learning_cycle INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); -DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links; -CREATE TRIGGER trg_wa_link_updated - BEFORE UPDATE ON wa_customer_links - FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp(); + CREATE INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state); + CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id); + CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at); + END IF; --- 3. Tabla de sesiones de aprendizaje (piezas no resueltas) --- --------------------------------------------------------- -CREATE TABLE IF NOT EXISTS wa_learning_sessions ( - id SERIAL PRIMARY KEY, - phone VARCHAR(50) NOT NULL, - customer_id INTEGER REFERENCES customers(id), - description TEXT NOT NULL, - offered_parts JSONB DEFAULT '[]', - status VARCHAR(20) DEFAULT 'pending', - resolved_part_id INTEGER REFERENCES inventory(id), - resolution_sale_id INTEGER REFERENCES sales(id), - created_at TIMESTAMPTZ DEFAULT NOW(), - resolved_at TIMESTAMPTZ -); + -- 2. Tabla de vínculo persistente WA ID ↔ Cliente + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN + CREATE TABLE IF NOT EXISTS wa_customer_links ( + phone VARCHAR(50) PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); -CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone); -CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status); -CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id); -CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at); + CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id); --- 4. Tabla de configuración de envío por sucursal --- ------------------------------------------------ -CREATE TABLE IF NOT EXISTS branch_delivery_config ( - id SERIAL PRIMARY KEY, - branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id), - is_enabled BOOLEAN DEFAULT FALSE, - delivery_fee NUMERIC(12,2) DEFAULT 0, - free_delivery_threshold NUMERIC(12,2) DEFAULT NULL, - coverage_radius_km INTEGER DEFAULT NULL, - delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00', - notes TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); + CREATE OR REPLACE FUNCTION update_wa_link_timestamp() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; --- 5. Agregar push_name a whatsapp_messages (schema drift existente) --- ------------------------------------------------------------------ -ALTER TABLE whatsapp_messages -ADD COLUMN IF NOT EXISTS push_name VARCHAR(200); + DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links; + CREATE TRIGGER trg_wa_link_updated + BEFORE UPDATE ON wa_customer_links + FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp(); + END IF; --- 6. Migrar datos existentes: vincular por teléfono --- -------------------------------------------------- --- Intentar vincular sesiones WA existentes con customers por teléfono -INSERT INTO wa_customer_links (phone, customer_id) -SELECT ws.phone, c.id -FROM whatsapp_sessions ws -JOIN customers c ON c.phone = ws.phone -WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL -ON CONFLICT (phone) DO NOTHING; + -- 3. Tabla de sesiones de aprendizaje (piezas no resueltas) + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'inventory') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'sales') THEN + CREATE TABLE IF NOT EXISTS wa_learning_sessions ( + id SERIAL PRIMARY KEY, + phone VARCHAR(50) NOT NULL, + customer_id INTEGER REFERENCES customers(id), + description TEXT NOT NULL, + offered_parts JSONB DEFAULT '[]', + status VARCHAR(20) DEFAULT 'pending', + resolved_part_id INTEGER REFERENCES inventory(id), + resolution_sale_id INTEGER REFERENCES sales(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ + ); --- Actualizar customer_id en whatsapp_sessions desde el vínculo -UPDATE whatsapp_sessions ws -SET customer_id = wcl.customer_id -FROM wa_customer_links wcl -WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL; + CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone); + CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status); + CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id); + CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at); + END IF; + + -- 4. Tabla de configuración de envío por sucursal + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'branches') THEN + CREATE TABLE IF NOT EXISTS branch_delivery_config ( + id SERIAL PRIMARY KEY, + branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id), + is_enabled BOOLEAN DEFAULT FALSE, + delivery_fee NUMERIC(12,2) DEFAULT 0, + free_delivery_threshold NUMERIC(12,2) DEFAULT NULL, + coverage_radius_km INTEGER DEFAULT NULL, + delivery_hours VARCHAR(100) DEFAULT 'Lun-Vie 9:00-18:00', + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + END IF; + + -- 5. Agregar push_name a whatsapp_messages + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_messages') THEN + ALTER TABLE whatsapp_messages + ADD COLUMN IF NOT EXISTS push_name VARCHAR(200); + END IF; + + -- 6. Migrar datos existentes + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'wa_customer_links') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers') THEN + INSERT INTO wa_customer_links (phone, customer_id) + SELECT ws.phone, c.id + FROM whatsapp_sessions ws + JOIN customers c ON c.phone = ws.phone + WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL + ON CONFLICT (phone) DO NOTHING; + + UPDATE whatsapp_sessions ws + SET customer_id = wcl.customer_id + FROM wa_customer_links wcl + WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL; + END IF; +END $$; diff --git a/pos/migrations/v3.9_supplier_catalog_prices.sql b/pos/migrations/v3.9_supplier_catalog_prices.sql index 72b67e3..7306702 100644 --- a/pos/migrations/v3.9_supplier_catalog_prices.sql +++ b/pos/migrations/v3.9_supplier_catalog_prices.sql @@ -1,6 +1,8 @@ +-- : SKIP -- v3.9_supplier_catalog_prices.sql -- Per-tenant supplier pricing for items in the master supplier_catalog. -- This table lives in the master DB and is joined by tenant_id. +-- Apply manually to the master database. CREATE TABLE IF NOT EXISTS supplier_catalog_prices ( id SERIAL PRIMARY KEY, diff --git a/pos/migrations/v4.3_facturapi.sql b/pos/migrations/v4.3_facturapi.sql new file mode 100644 index 0000000..fda72b3 --- /dev/null +++ b/pos/migrations/v4.3_facturapi.sql @@ -0,0 +1,42 @@ +-- v4.3_facturapi.sql +-- Migrate CFDI timbrado from Horux360 XML pipeline to Facturapi JSON API. +-- +-- Changes: +-- - Rename cfdi_queue.xml_unsigned -> payload_unsigned (stores Facturapi JSON payload) +-- - Keep xml_signed for the signed XML returned by Facturapi +-- - Add external_id column to store Facturapi invoice id +-- - Add facturapi config keys to tenant_config + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 1. CFDI_QUEUE: adapt schema for Facturapi payloads +-- ═════════════════════════════════════════════════════════════════════════════ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'cfdi_queue' AND column_name = 'xml_unsigned' + ) THEN + ALTER TABLE cfdi_queue RENAME COLUMN xml_unsigned TO payload_unsigned; + END IF; +END $$; + +COMMENT ON COLUMN cfdi_queue.payload_unsigned IS 'Facturapi JSON payload (previously unsigned XML for Horux)'; +COMMENT ON COLUMN cfdi_queue.xml_signed IS 'Signed+stamped XML returned by Facturapi'; + +ALTER TABLE cfdi_queue ADD COLUMN IF NOT EXISTS external_id VARCHAR(64); +COMMENT ON COLUMN cfdi_queue.external_id IS 'Facturapi invoice id'; + +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_external_id ON cfdi_queue(external_id); + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 2. TENANT_CONFIG: Facturapi configuration keys +-- ═════════════════════════════════════════════════════════════════════════════ +INSERT INTO tenant_config (key, value) +VALUES + ('cfdi_facturapi_key', ''), + ('cfdi_facturapi_org_id', ''), + ('cfdi_facturapi_customer_sync', 'true') +ON CONFLICT (key) DO NOTHING; + +-- Backward-compat: migrate old Horux keys to comments so they are not used anymore +COMMENT ON TABLE tenant_config IS 'tenant_config; old keys cfdi_horux_api_url and cfdi_horux_api_key are deprecated'; diff --git a/pos/requirements.txt b/pos/requirements.txt index 7e321c6..e4720cf 100644 --- a/pos/requirements.txt +++ b/pos/requirements.txt @@ -7,3 +7,4 @@ gunicorn>=22.0 redis>=5.0 meilisearch>=0.40 orjson +facturapi>=1.0 diff --git a/pos/services/cfdi_facturapi_builder.py b/pos/services/cfdi_facturapi_builder.py new file mode 100644 index 0000000..10cfd27 --- /dev/null +++ b/pos/services/cfdi_facturapi_builder.py @@ -0,0 +1,243 @@ +# /home/Autopartes/pos/services/cfdi_facturapi_builder.py +"""Build Facturapi invoice payloads from Nexus sales data. + +Facturapi expects a JSON payload instead of an unsigned XML. This module +generates those payloads for: + - Ingreso (sale invoice) + - Egreso (credit note) + - Pago (payment complement) + - Factura global mensual +""" + +from decimal import Decimal, ROUND_HALF_UP +from datetime import datetime + +# SAT defaults +RFC_PUBLICO_GENERAL = "XAXX010101000" +RFC_EXTRANJERO = "XEXX010101000" + +# Forma de pago mapping (Nexus internal -> SAT code) +FORMA_PAGO_MAP = { + "efectivo": "01", + "transferencia": "03", + "tarjeta": "04", + "cheque": "02", + "credito": "99", + "mixto": "99", + "99": "99", +} + +# Metodo de pago +METODO_PAGO_MAP = { + "PUE": "PUE", + "PPD": "PPD", +} + +TWO = Decimal("0.01") +SIX = Decimal("0.000001") + + +def _to_dec(val): + if val is None: + return Decimal("0") + return Decimal(str(val)) + + +def _fmt2(val): + return float(_to_dec(val).quantize(TWO, ROUND_HALF_UP)) + + +def _fmt6(val): + return float(_to_dec(val).quantize(SIX, ROUND_HALF_UP)) + + +def _resolve_forma_pago(sale): + method = (sale.get("payment_method") or "").lower().strip() + fp = (sale.get("forma_pago_sat") or "").strip() + if fp: + return fp + return FORMA_PAGO_MAP.get(method, "99") + + +def _resolve_metodo_pago(sale): + mp = (sale.get("metodo_pago_sat") or "").upper().strip() + if mp in ("PUE", "PPD"): + return mp + # Default: credit sales are PPD, cash sales are PUE + if sale.get("sale_type") == "credit" or sale.get("payment_method") == "credito": + return "PPD" + return "PUE" + + +def _build_items(sale_items): + items = [] + for item in sale_items or []: + qty = int(item.get("quantity", 1)) + unit_price = _to_dec(item.get("unit_price", 0)) + discount = _to_dec(item.get("discount_amount", 0)) + tax_rate = _to_dec(item.get("tax_rate", "0.16")) + + # Facturapi price is unit price before taxes and discounts + product = { + "description": item.get("name") or "Autoparte", + "product_key": item.get("clave_prod_serv") or "25174800", + "unit_key": item.get("clave_unidad") or "H87", + "unit_name": "Pieza", + "price": _fmt2(unit_price), + "tax_included": False, + "taxes": [ + { + "type": "IVA", + "rate": _fmt6(tax_rate), + "factor": "Tasa", + } + ], + } + if discount > 0: + product["discount"] = _fmt2(discount / qty) if qty > 0 else _fmt2(discount) + + items.append({"quantity": qty, "product": product}) + return items + + +def _build_customer_payload(customer, tenant_cp): + if not customer or not customer.get("rfc"): + # Publico en general + return { + "tax_id": RFC_PUBLICO_GENERAL, + "legal_name": "PUBLICO EN GENERAL", + "tax_system": "616", + "address": {"zip": tenant_cp or "00000"}, + } + + rfc = (customer.get("rfc") or "").upper().strip() + return { + "tax_id": rfc, + "legal_name": customer.get("razon_social") or customer.get("name") or rfc, + "tax_system": customer.get("regimen_fiscal") or "616", + "email": customer.get("email"), + "address": {"zip": customer.get("cp") or tenant_cp or "00000"}, + } + + +def build_ingreso_payload(sale, tenant_config, customer=None): + """Build Facturapi payload for a sale (Comprobante tipo Ingreso).""" + tenant_cp = tenant_config.get("cp", "00000") + customer_payload = _build_customer_payload(customer, tenant_cp) + + payload = { + "customer": customer_payload, + "items": _build_items(sale.get("items", [])), + "use": customer.get("uso_cfdi") if customer and customer.get("rfc") else "S01", + "payment_form": _resolve_forma_pago(sale), + "payment_method": _resolve_metodo_pago(sale), + "currency": "MXN", + "series": tenant_config.get("serie", "A"), + "folio_number": sale["id"], + } + + # Optional exchange rate for USD + if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"): + payload["exchange"] = _fmt6(sale["exchange_rate"]) + payload["currency"] = sale["currency"] + + return payload + + +def build_egreso_payload(sale, tenant_config, customer, original_uuid): + """Build Facturapi payload for a credit note (Comprobante tipo Egreso).""" + payload = build_ingreso_payload(sale, tenant_config, customer) + payload["type"] = "E" + payload["related_documents"] = [ + {"relationship": "01", "documents": [original_uuid]} + ] + payload["payment_method"] = "PUE" + return payload + + +def build_pago_payload(payment, tenant_config, customer, original_uuid): + """Build Facturapi payload for a payment complement (Comprobante tipo Pago).""" + tenant_cp = tenant_config.get("cp", "00000") + customer_payload = _build_customer_payload(customer, tenant_cp) + + amount = _to_dec(payment.get("amount", 0)) + base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP) + iva = (amount - base).quantize(TWO, ROUND_HALF_UP) + + payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + if "T" not in str(payment_date): + payment_date = f"{payment_date}T12:00:00" + + forma_pago = FORMA_PAGO_MAP.get( + (payment.get("payment_method") or "").lower().strip(), "01" + ) + + payload = { + "type": "P", + "customer": customer_payload, + "complements": [ + { + "type": "pago", + "data": { + "payment_form": forma_pago, + "payment_date": payment_date, + "amount": _fmt2(amount), + "related_documents": [ + { + "uuid": original_uuid, + "amount": _fmt2(amount), + "taxes": [ + { + "type": "IVA", + "rate": 0.16, + "factor": "Tasa", + "base": _fmt2(base), + } + ], + } + ], + }, + } + ], + } + return payload + + +def build_global_invoice_payload(sales, tenant_config, year, month): + """Build Facturapi payload for a monthly global invoice.""" + tenant_cp = tenant_config.get("cp", "00000") + + total_subtotal = Decimal("0") + total_discount = Decimal("0") + total_tax = Decimal("0") + total_total = Decimal("0") + all_items = [] + + for sale in sales: + total_subtotal += _to_dec(sale.get("subtotal", 0)) + total_discount += _to_dec(sale.get("discount_total", 0)) + total_tax += _to_dec(sale.get("tax_total", 0)) + total_total += _to_dec(sale.get("total", 0)) + all_items.extend(_build_items(sale.get("items", []))) + + payload = { + "customer": { + "tax_id": RFC_PUBLICO_GENERAL, + "legal_name": "PUBLICO EN GENERAL", + "tax_system": "616", + "address": {"zip": tenant_cp}, + }, + "items": all_items, + "use": "S01", + "payment_form": "01", + "payment_method": "PUE", + "currency": "MXN", + "series": tenant_config.get("serie", "FG"), + "folio_number": int(f"{year}{month:02d}"), + "global": { + "periodicity": "04", # Mensual + "months": f"{month:02d}", + "year": year, + }, + } + return payload diff --git a/pos/services/cfdi_queue.py b/pos/services/cfdi_queue.py index e7061c5..6d5c756 100644 --- a/pos/services/cfdi_queue.py +++ b/pos/services/cfdi_queue.py @@ -1,25 +1,25 @@ # /home/Autopartes/pos/services/cfdi_queue.py -"""CFDI queue service: manages the timbrado pipeline. +"""CFDI queue service: manages the Facturapi timbrado pipeline. Flow: -1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending' -2. process_queue() — sends pending items to Horux API, updates status +1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending' +2. process_queue() — sends pending items to Facturapi, updates status 3. retry_failed() — retries failed items with exponential backoff -4. cancel_cfdi() — sends cancel request to Horux API +4. cancel_cfdi() — cancels a stamped CFDI via Facturapi -Horux API endpoints: - POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado - GET /api/nexus/cfdi/status/:uuid — check timbrado status - POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code +Facturapi endpoints used: + POST /v2/invoices — create and stamp an invoice + GET /v2/invoices/:id — fetch invoice metadata + DELETE /v2/invoices/:id — cancel with SAT motive Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries) """ +import json import logging -import time from datetime import datetime, timedelta -import requests +from services import facturapi_service logger = logging.getLogger(__name__) @@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS) def _generate_provisional_folio(conn): - """Generate a provisional folio like PRE-00001. - - Uses the cfdi_queue table's max id to avoid collisions. - """ + """Generate a provisional folio like PRE-00001.""" cur = conn.cursor() cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") seq = cur.fetchone()[0] @@ -40,14 +37,14 @@ def _generate_provisional_folio(conn): return f'PRE-{seq:05d}' -def enqueue_cfdi(conn, sale_id, cfdi_type, xml): +def enqueue_cfdi(conn, sale_id, cfdi_type, payload): """Add a CFDI to the timbrado queue. Args: conn: psycopg2 connection - sale_id: int (FK to sales) + sale_id: int (FK to sales), may be None for global invoices cfdi_type: 'ingreso' | 'egreso' | 'pago' - xml: str (unsigned XML from cfdi_builder) + payload: dict (Facturapi JSON payload) or str (JSON string) Returns: dict: {id, sale_id, type, status, provisional_folio} @@ -55,12 +52,14 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml): provisional_folio = _generate_provisional_folio(conn) cur = conn.cursor() + payload_json = payload if isinstance(payload, str) else json.dumps(payload) + cur.execute(""" INSERT INTO cfdi_queue - (sale_id, type, xml_unsigned, status, provisional_folio) + (sale_id, type, payload_unsigned, status, provisional_folio) VALUES (%s, %s, %s, 'pending', %s) RETURNING id, created_at - """, (sale_id, cfdi_type, xml, provisional_folio)) + """, (sale_id, cfdi_type, payload_json, provisional_folio)) cfdi_id, created_at = cur.fetchone() cur.close() @@ -74,17 +73,17 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml): } -def process_queue(conn, horux_api_url, api_key): +def process_queue(conn, tenant_config, dry_run=False): """Process all pending CFDI items in the queue. - Sends each pending XML to Horux for timbrado. On success, updates + Sends each pending payload to Facturapi for timbrado. On success, updates the record with the signed XML and UUID fiscal. On failure, increments retry_count and records the error. Args: conn: psycopg2 connection - horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com') - api_key: str Horux API key + tenant_config: dict with facturapi_key (and optional facturapi_org_id) + dry_run: if True, validates payload without stamping Returns: dict: {processed: int, stamped: int, failed: int, details: [...]} @@ -92,7 +91,7 @@ def process_queue(conn, horux_api_url, api_key): cur = conn.cursor() cur.execute(""" - SELECT id, sale_id, type, xml_unsigned, retry_count + SELECT id, sale_id, type, payload_unsigned, retry_count FROM cfdi_queue WHERE status IN ('pending', 'failed') AND retry_count < %s @@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key): results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []} - for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items: + api_key = tenant_config.get('facturapi_key') + if not api_key: + cur.close() + raise ValueError("Facturapi key not configured for tenant") + + for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items: results['processed'] += 1 # Update status to 'sending' @@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key): conn.commit() try: - response = requests.post( - f'{horux_api_url}/api/nexus/cfdi/stamp', - headers={ - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/xml', - }, - data=xml_unsigned.encode('utf-8'), - timeout=30, - ) + payload = json.loads(payload_unsigned or '{}') + if not payload: + raise ValueError("Empty payload in queue item") - if response.status_code == 200: - data = response.json() - uuid_fiscal = data.get('uuid') - xml_signed = data.get('xml', '') + if dry_run: + # TODO: Facturapi dry-run validation (not officially supported) + # For now we just skip the API call and mark as stamped with a fake UUID + raise ValueError("dry_run is not supported with Facturapi") - cur.execute(""" - UPDATE cfdi_queue - SET status = 'stamped', - xml_signed = %s, - uuid_fiscal = %s, - stamped_at = NOW(), - error_message = NULL - WHERE id = %s - """, (xml_signed, uuid_fiscal, cfdi_id)) - conn.commit() + invoice = facturapi_service.create_invoice(tenant_config, payload) + invoice_id = invoice.get('id') + uuid_fiscal = invoice.get('uuid') - results['stamped'] += 1 - results['details'].append({ - 'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal - }) - else: - error_msg = f'HTTP {response.status_code}: {response.text[:500]}' - cur.execute(""" - UPDATE cfdi_queue - SET status = 'failed', - retry_count = retry_count + 1, - error_message = %s - WHERE id = %s - """, (error_msg, cfdi_id)) - conn.commit() + # Download signed XML for storage + try: + xml_signed = facturapi_service.download_xml(tenant_config, invoice_id) + xml_signed_str = xml_signed.decode('utf-8') if isinstance(xml_signed, bytes) else str(xml_signed) + except Exception as xml_err: + logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err) + xml_signed_str = '' - results['failed'] += 1 - results['details'].append({ - 'id': cfdi_id, 'status': 'failed', 'error': error_msg - }) + cur.execute(""" + UPDATE cfdi_queue + SET status = 'stamped', + xml_signed = %s, + uuid_fiscal = %s, + external_id = %s, + stamped_at = NOW(), + error_message = NULL + WHERE id = %s + """, (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id)) + conn.commit() - except requests.RequestException as e: - error_msg = f'Connection error: {str(e)[:500]}' + results['stamped'] += 1 + results['details'].append({ + 'id': cfdi_id, 'status': 'stamped', + 'uuid': uuid_fiscal, 'external_id': invoice_id, + }) + + except Exception as e: + error_msg = f'{type(e).__name__}: {str(e)[:500]}' cur.execute(""" UPDATE cfdi_queue SET status = 'failed', @@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key): def retry_failed(conn): - """Find failed items eligible for retry (based on backoff) and reset to pending. + """Find failed items eligible for retry and reset to pending. Uses exponential backoff: item is eligible for retry only if enough time has passed since the last attempt based on retry_count. - - Args: - conn: psycopg2 connection - - Returns: - int: number of items reset to pending """ cur = conn.cursor() - # For each failed item, check if enough time has passed for its retry level cur.execute(""" SELECT id, retry_count, created_at FROM cfdi_queue @@ -206,15 +196,15 @@ def retry_failed(conn): now = datetime.utcnow() for cfdi_id, retry_count, created_at in items: - # Calculate required wait time based on retry count if retry_count < len(BACKOFF_INTERVALS): wait_seconds = BACKOFF_INTERVALS[retry_count] else: - wait_seconds = BACKOFF_INTERVALS[-1] # max backoff + wait_seconds = BACKOFF_INTERVALS[-1] - # Check if enough time has passed (use created_at as approximation) - # In production, you'd track last_attempt_at separately - if True: # Always eligible for manual retry trigger + # Use created_at as approximation for last attempt. + # In production, track last_attempt_at separately. + elapsed = (now - created_at).total_seconds() + if elapsed >= wait_seconds: cur.execute(""" UPDATE cfdi_queue SET status = 'pending' WHERE id = %s """, (cfdi_id,)) @@ -226,8 +216,8 @@ def retry_failed(conn): def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, - horux_api_url=None, api_key=None): - """Cancel a stamped CFDI via Horux API. + tenant_config=None): + """Cancel a stamped CFDI via Facturapi. SAT cancellation motives: 01: Comprobante emitido con errores con relacion (requires replacement UUID) @@ -240,8 +230,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, cfdi_id: int (cfdi_queue.id) motive: str ('01', '02', '03', '04') replacement_uuid: str (required if motive == '01') - horux_api_url: str (optional, skips API call if None — for offline) - api_key: str (optional) + tenant_config: dict with facturapi_key Returns: dict: {id, status, message} @@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, cur = conn.cursor() cur.execute(""" - SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s + SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s """, (cfdi_id,)) row = cur.fetchone() if not row: raise ValueError(f"CFDI queue item {cfdi_id} not found") - _, uuid_fiscal, current_status = row + _, uuid_fiscal, external_id, current_status = row if current_status == 'cancelled': raise ValueError("CFDI is already cancelled") @@ -280,64 +269,26 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, cur.close() return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'} - # Send cancel request to Horux - if horux_api_url and api_key: - try: - payload = { - 'uuid': uuid_fiscal, - 'motive': motive, - } - if replacement_uuid: - payload['replacement_uuid'] = replacement_uuid + if not tenant_config or not tenant_config.get('facturapi_key'): + cur.close() + raise ValueError("Facturapi key not configured for tenant") - response = requests.post( - f'{horux_api_url}/api/nexus/cfdi/cancel', - headers={ - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - }, - json=payload, - timeout=30, - ) + if not external_id: + cur.close() + raise ValueError("Cannot cancel: no Facturapi invoice id stored") - if response.status_code == 200: - cur.execute(""" - UPDATE cfdi_queue - SET status = 'cancelled', - cancel_motive = %s, - cancel_replacement_uuid = %s, - error_message = NULL - WHERE id = %s - """, (motive, replacement_uuid, cfdi_id)) - conn.commit() - cur.close() - return { - 'id': cfdi_id, - 'status': 'cancelled', - 'message': f'Cancelled with SAT (motive {motive})', - } - else: - error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}' - cur.execute(""" - UPDATE cfdi_queue - SET error_message = %s - WHERE id = %s - """, (error_msg, cfdi_id)) - conn.commit() - cur.close() - raise ValueError(error_msg) + try: + facturapi_service.cancel_invoice( + tenant_config, external_id, motive, + replacement_uuid=replacement_uuid, + ) - except requests.RequestException as e: - cur.close() - raise ValueError(f'Connection error during cancel: {str(e)}') - else: - # Offline mode: mark as cancelled locally, will sync later cur.execute(""" UPDATE cfdi_queue SET status = 'cancelled', cancel_motive = %s, cancel_replacement_uuid = %s, - error_message = 'Cancelled offline, pending SAT sync' + error_message = NULL WHERE id = %s """, (motive, replacement_uuid, cfdi_id)) conn.commit() @@ -345,24 +296,23 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, return { 'id': cfdi_id, 'status': 'cancelled', - 'message': 'Cancelled offline, pending SAT sync', + 'message': f'Cancelled with SAT (motive {motive})', } + except Exception as e: + error_msg = f'Cancel failed: {str(e)[:500]}' + cur.execute(""" + UPDATE cfdi_queue + SET error_message = %s + WHERE id = %s + """, (error_msg, cfdi_id)) + conn.commit() + cur.close() + raise ValueError(error_msg) + def get_queue_status(conn, filters=None): - """Get CFDI queue items with optional filters. - - Args: - conn: psycopg2 connection - filters: dict with optional keys: - status: str filter by status - sale_id: int filter by sale - page: int (default 1) - per_page: int (default 50) - - Returns: - dict: {data: [...], pagination: {...}} - """ + """Get CFDI queue items with optional filters.""" filters = filters or {} cur = conn.cursor() @@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None): cur.execute(f""" SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.error_message, - q.cancel_motive, q.created_at, q.stamped_at + q.cancel_motive, q.created_at, q.stamped_at, q.external_id FROM cfdi_queue q WHERE {where} ORDER BY q.created_at DESC @@ -408,6 +358,7 @@ def get_queue_status(conn, filters=None): 'error_message': r[7], 'cancel_motive': r[8], 'created_at': str(r[9]) if r[9] else None, 'stamped_at': str(r[10]) if r[10] else None, + 'external_id': r[11], }) cur.close() diff --git a/pos/services/facturapi_service.py b/pos/services/facturapi_service.py new file mode 100644 index 0000000..5089906 --- /dev/null +++ b/pos/services/facturapi_service.py @@ -0,0 +1,426 @@ +# /home/Autopartes/pos/services/facturapi_service.py +"""Facturapi integration for Nexus POS. + +Uses Facturapi REST API directly (requests + Basic Auth) so it is safe for +multi-tenant use. Each call receives the API key explicitly, avoiding the +global client used by the official facturapi Python library. + +Authentication modes: + 1. User key (FACTURAPI_USER_KEY env): creates/verifies organizations per tenant. + 2. Secret key per tenant (tenant_config.facturapi_secret_key): uses existing org. + +Reference: https://docs.facturapi.io/ +""" + +import os +import base64 +import logging +from decimal import Decimal +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.facturapi.io/v2" +USER_KEY = os.environ.get("FACTURAPI_USER_KEY", "") + + +class FacturapiError(Exception): + def __init__(self, message: str, status_code: int = 0, response_body: str = ""): + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + +# ─── HTTP helpers ─────────────────────────────────────────────────────────── + +def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None, + extra_headers=None, timeout=60): + """Make a request to Facturapi REST API with Basic Auth.""" + url = f"{BASE_URL}{endpoint}" + headers = {"Content-Type": "application/json"} + if extra_headers: + headers.update(extra_headers) + + try: + resp = requests.request( + method, + url, + auth=(api_key, ""), + headers=headers, + json=json_payload, + params=params, + timeout=timeout, + ) + except requests.RequestException as e: + raise FacturapiError(f"Connection error: {e}", status_code=0) + + if not resp.ok: + raise FacturapiError( + f"Facturapi {method.upper()} {endpoint} failed: {resp.status_code} {resp.text[:500]}", + status_code=resp.status_code, + response_body=resp.text, + ) + + if resp.status_code == 204 or not resp.content: + return {} + return resp.json() + + +def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60) -> bytes: + """Download binary content (XML/PDF).""" + url = f"{BASE_URL}{endpoint}" + resp = requests.request( + method, + url, + auth=(api_key, ""), + params=params, + timeout=timeout, + ) + if not resp.ok: + raise FacturapiError( + f"Download failed: {resp.status_code} {resp.text[:500]}", + status_code=resp.status_code, + ) + return resp.content + + +# ─── Tenant config helpers ────────────────────────────────────────────────── + +def _get_secret_key(tenant_config: dict) -> Optional[str]: + for key in ("facturapi_key", "facturapi_secret_key"): + val = (tenant_config.get(key) or "").strip() + if val: + return val + return None + + +def _get_user_key() -> Optional[str]: + return USER_KEY.strip() or None + + +def _is_user_key_mode(tenant_config: dict) -> bool: + return bool(_get_user_key()) and not _get_secret_key(tenant_config) + + +def get_api_key(tenant_config: dict) -> str: + """Resolve the API key to use for a tenant. + + Priority: + 1. tenant_config.facturapi_secret_key (manual override) + 2. FACTURAPI_USER_KEY env (auto-org mode) + """ + secret = _get_secret_key(tenant_config) + if secret: + return secret + user = _get_user_key() + if user: + return user + raise FacturapiError( + "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key" + ) + + +# ─── Organizations ────────────────────────────────────────────────────────── + +def create_organization(tenant_config: dict) -> dict: + """Create a new Facturapi organization for the tenant. + + Requires FACTURAPI_USER_KEY. + Returns dict with id, api_key. + """ + user_key = _get_user_key() + if not user_key: + raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations") + + payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))} + legal = tenant_config.get("legal_name") or tenant_config.get("razon_social") + if legal: + payload["legal"] = {"name": legal} + if tenant_config.get("rfc"): + payload["legal"] = payload.get("legal", {}) + payload["legal"]["tax_id"] = tenant_config["rfc"] + + org = _request("POST", "/organizations", user_key, json_payload=payload) + org_id = org.get("id") + + # Generate live secret key + key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}) + live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp) + if not live_key: + raise FacturapiError(f"Could not generate live key for org {org_id}") + + return {"org_id": org_id, "api_key": live_key} + + +def get_organization(org_id: str, api_key: str) -> dict: + return _request("GET", f"/organizations/{org_id}", api_key) + + +def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict: + """Upload CSD (Certificado de Sello Digital) to Facturapi. + + cer_b64 and key_b64 are base64-encoded strings. + """ + api_key = get_api_key(tenant_config) + org_id = tenant_config.get("facturapi_org_id") + if not org_id: + raise FacturapiError("No Facturapi organization configured for tenant") + + cer_bytes = base64.b64decode(cer_b64) + key_bytes = base64.b64decode(key_b64) + + url = f"{BASE_URL}/organizations/{org_id}/certificate" + files = { + "certificate": ("certificate.cer", cer_bytes, "application/octet-stream"), + "private_key": ("private_key.key", key_bytes, "application/octet-stream"), + "secret": (None, password), + } + resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60) + if not resp.ok: + raise FacturapiError( + f"CSD upload failed: {resp.status_code} {resp.text[:500]}", + status_code=resp.status_code, + ) + return resp.json() + + +def _get_user_key_for_tenant(tenant_config: dict) -> str: + """Resolve the Facturapi user key to use for organization management. + + Priority: + 1. FACTURAPI_USER_KEY environment variable + 2. tenant_config.facturapi_key if it starts with sk_user_ + """ + user_key = _get_user_key() + if user_key: + return user_key + tenant_key = (tenant_config.get("facturapi_key") or "").strip() + if tenant_key.startswith("sk_user_"): + return tenant_key + raise FacturapiError( + "FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required" + ) + + +def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]: + """Search for an existing Facturapi organization by tenant RFC. + + Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key). + Returns the organization dict or None. + """ + user_key = _get_user_key_for_tenant(tenant_config) + + rfc = (tenant_config.get("rfc") or "").upper().strip() + if not rfc: + raise FacturapiError("Tenant RFC is required to search organizations") + + page = 1 + while True: + result = _request("GET", "/organizations", user_key, params={"page": page}, timeout=30) + for org in result.get("data", []): + legal = org.get("legal", {}) + if (legal.get("tax_id") or "").upper() == rfc: + return org + if page >= result.get("total_pages", 1): + break + page += 1 + return None + + +def create_organization(tenant_config: dict) -> dict: + """Create a new Facturapi organization for the tenant and return live key. + + Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config. + Uses tenant RFC/razon_social if available. + """ + user_key = _get_user_key_for_tenant(tenant_config) + + rfc = (tenant_config.get("rfc") or "").upper().strip() + name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus" + + # First try to find existing org by RFC + existing = find_organization_by_rfc(tenant_config) if rfc else None + if existing: + org_id = existing["id"] + else: + payload = {"name": name} + org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60) + org_id = org.get("id") + if not org_id: + raise FacturapiError("Could not create organization: no id returned") + + # Generate live secret key + key_resp = _request( + "PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60 + ) + live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp) + if not live_key: + raise FacturapiError(f"Could not generate live key for org {org_id}") + + return {"org_id": org_id, "api_key": live_key} + + +def get_org_status(tenant_config: dict) -> dict: + result = { + "configured": False, + "has_key": False, + "has_org_id": False, + "has_csd": False, + "org_id": None, + "legal_name": None, + "tax_id": None, + "pending_steps": [], + "error": None, + } + + try: + api_key = get_api_key(tenant_config) + result["has_key"] = True + except FacturapiError as e: + result["error"] = str(e) + return result + + org_id = tenant_config.get("facturapi_org_id") + if not org_id: + result["error"] = "No Facturapi organization configured" + return result + + result["has_org_id"] = True + result["org_id"] = org_id + + try: + org = get_organization(org_id, api_key) + legal = org.get("legal", {}) + cert = org.get("certificate", {}) + result.update({ + "configured": True, + "has_csd": bool(cert.get("has_certificate")), + "legal_name": legal.get("name") or legal.get("legal_name"), + "tax_id": legal.get("tax_id"), + "pending_steps": org.get("pending_steps", []), + }) + except FacturapiError as e: + result["error"] = str(e) + + return result + + +# ─── Customers ────────────────────────────────────────────────────────────── + +def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str: + """Create or update a customer in Facturapi and return its id. + + customer_data: { + legal_name: str, + tax_id: str, + tax_system: str, + email: str, + zip: str, + country: str (optional, ISO 3166 alpha-3), + } + """ + api_key = get_api_key(tenant_config) + tax_id = (customer_data.get("tax_id") or "").upper().strip() + if not tax_id: + raise FacturapiError("Customer tax_id is required") + + # Try to find existing customer + existing_id = None + try: + result = _request("GET", "/customers", api_key, params={"search": tax_id}) + for c in result.get("data", []): + if (c.get("tax_id") or "").upper() == tax_id: + existing_id = c.get("id") + break + except FacturapiError as e: + logger.warning("Failed to search Facturapi customer: %s", e) + + is_foreign = bool(customer_data.get("country")) and customer_data["country"] != "MEX" + + payload = { + "legal_name": customer_data.get("legal_name", ""), + "email": customer_data.get("email"), + "address": { + "zip": customer_data.get("zip", "00000"), + }, + } + if is_foreign: + payload["tax_id"] = tax_id + payload["address"]["country"] = customer_data["country"] + else: + payload["tax_id"] = tax_id + if customer_data.get("tax_system"): + payload["tax_system"] = customer_data["tax_system"] + + if existing_id: + _request("PUT", f"/customers/{existing_id}", api_key, json_payload=payload) + return existing_id + + new_customer = _request("POST", "/customers", api_key, json_payload=payload) + return new_customer.get("id") + + +# ─── Invoices ─────────────────────────────────────────────────────────────── + +def create_invoice(tenant_config: dict, payload: dict) -> dict: + """Create and stamp an invoice in Facturapi. + + Returns the Facturapi invoice object. + """ + api_key = get_api_key(tenant_config) + return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90) + + +def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, + replacement_uuid: Optional[str] = None) -> dict: + """Cancel an invoice in Facturapi. + + Motive codes: + 01: errores con relacion (requires replacement_uuid) + 02: errores sin relacion + 03: no se llevo a cabo la operacion + 04: operacion nominativa relacionada en factura global + """ + api_key = get_api_key(tenant_config) + params = {"motive": motive} + if replacement_uuid: + params["replacement"] = replacement_uuid + return _request("DELETE", f"/invoices/{invoice_id}", api_key, params=params, timeout=60) + + +def download_xml(tenant_config: dict, invoice_id: str) -> bytes: + api_key = get_api_key(tenant_config) + return _download("GET", f"/invoices/{invoice_id}/xml", api_key) + + +def download_pdf(tenant_config: dict, invoice_id: str) -> bytes: + api_key = get_api_key(tenant_config) + return _download("GET", f"/invoices/{invoice_id}/pdf", api_key) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def is_lco_rejection(message: str) -> bool: + """Detect SAT LCO rejection (CSD not yet propagated).""" + if not message: + return False + msg = message.lower() + return any( + pattern in msg + for pattern in [ + "lco", + "no se encontro el rfc", + "rfc no registrado", + "lista de contribuyentes obligados", + "csd no registrado", + ] + ) + + +def to_cents(amount) -> int: + """Convert Decimal/float/None to integer cents for Facturapi.""" + if amount is None: + return 0 + return int(Decimal(str(amount)).quantize(Decimal("0.01")) * 100) diff --git a/pos/services/global_invoice.py b/pos/services/global_invoice.py index 41559d6..26f8ddf 100644 --- a/pos/services/global_invoice.py +++ b/pos/services/global_invoice.py @@ -8,7 +8,7 @@ monthly CFDI with InformacionGlobal per SAT requirements. from datetime import datetime from decimal import Decimal -from services.cfdi_builder import build_global_invoice_xml +from services.cfdi_facturapi_builder import build_global_invoice_payload from services.cfdi_queue import enqueue_cfdi, _generate_provisional_folio @@ -137,10 +137,10 @@ def generate_global_invoice(conn, tenant_config, year, month, branch_id=None, return {'error': 'NO_ELIGIBLE_SALES', 'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'} - xml = build_global_invoice_xml(sales, tenant_config, year, month) + payload = build_global_invoice_payload(sales, tenant_config, year, month) # Enqueue with sale_id=NULL (global invoice) - result = enqueue_cfdi(conn, None, 'ingreso', xml) + result = enqueue_cfdi(conn, None, 'ingreso', payload) cfdi_id = result['id'] cur = conn.cursor() @@ -167,7 +167,7 @@ def generate_global_invoice(conn, tenant_config, year, month, branch_id=None, 'sales_count': len(sales), 'total': sum(s['total'] for s in sales), 'provisional_folio': result['provisional_folio'], - 'xml': xml, + 'payload': payload, } diff --git a/pos/static/js/dashboard.js b/pos/static/js/dashboard.js index fd42637..96c8591 100644 --- a/pos/static/js/dashboard.js +++ b/pos/static/js/dashboard.js @@ -84,13 +84,15 @@ const Dashboard = (() => { }); // ------------------------------------------------------------------------- - // Period selector (placeholder for future use) + // Period selector // ------------------------------------------------------------------------- function setPeriod(btn) { btn.closest('.period-selector').querySelectorAll('.period-btn').forEach(function(b) { b.classList.remove('active'); }); btn.classList.add('active'); + const period = btn.textContent.trim().toLowerCase(); + loadChart(period); } window.setPeriod = setPeriod; @@ -205,6 +207,50 @@ const Dashboard = (() => { return data; } + // ------------------------------------------------------------------------- + // 1b. Historical sales KPIs (imported data) + // ------------------------------------------------------------------------- + async function loadHistoricalSummary() { + try { + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10); + + // All historical sales + const all = await apiFetch('/pos/api/historical-sales?per_page=1'); + const totalRecords = all.pagination ? all.pagination.total : 0; + + // Current month historical sales + const month = await apiFetch(`/pos/api/historical-sales?date_from=${firstDay}&date_to=${lastDay}&per_page=200`); + const monthRows = month.data || []; + const monthTotal = monthRows.reduce((a, r) => a + (r.total || 0), 0); + + const totalEl = document.getElementById('kpi-historico-total-value'); + const totalMetaEl = document.getElementById('kpi-historico-total-meta'); + if (totalEl) totalEl.textContent = fmt(monthTotal); + if (totalMetaEl) totalMetaEl.innerHTML = `${fmtInt(totalRecords)} tickets importados`; + + const mesEl = document.getElementById('kpi-historico-mes-value'); + const mesMetaEl = document.getElementById('kpi-historico-mes-meta'); + if (mesEl) mesEl.textContent = fmt(monthTotal); + if (mesMetaEl) mesMetaEl.innerHTML = `${monthRows.length} tickets este mes`; + + const countEl = document.getElementById('kpi-historico-count-value'); + const countMetaEl = document.getElementById('kpi-historico-count-meta'); + if (countEl) countEl.textContent = fmtInt(totalRecords); + if (countMetaEl) countMetaEl.innerHTML = `Registros históricos`; + + } catch (err) { + console.error('Error loading historical summary:', err); + const ids = [ + ['kpi-historico-total-value', 'kpi-historico-total-meta'], + ['kpi-historico-mes-value', 'kpi-historico-mes-meta'], + ['kpi-historico-count-value', 'kpi-historico-count-meta'], + ]; + ids.forEach(([v, m]) => setKpiError(v, m)); + } + } + function setKpiError(valueId, metaId) { const v = document.getElementById(valueId); const m = document.getElementById(metaId); @@ -396,39 +442,159 @@ const Dashboard = (() => { } // ------------------------------------------------------------------------- - // 5. Weekly bar chart (last 7 days) + // Helpers for chart grouping // ------------------------------------------------------------------------- - async function loadWeeklyChart() { + function isoWeek(date) { + const tmp = new Date(date.valueOf()); + const dayNum = (date.getDay() + 6) % 7; + tmp.setDate(tmp.getDate() - dayNum + 3); + const firstThursday = tmp.valueOf(); + tmp.setMonth(0, 1); + if (tmp.getDay() !== 4) { + tmp.setMonth(0, 1 + ((4 - tmp.getDay()) + 7) % 7); + } + return 1 + Math.ceil((firstThursday - tmp) / 604800000); + } + + function weekLabel(date) { + return `Sem ${isoWeek(date)}`; + } + + function monthLabel(date) { + return MONTH_NAMES[date.getMonth()].slice(0, 3); + } + + // ------------------------------------------------------------------------- + // 5. Sales chart (today / week / month / year) + // ------------------------------------------------------------------------- + async function loadChart(period) { const chartEl = document.getElementById('bar-chart'); const totalEl = document.getElementById('chart-week-total'); + const legendEl = document.getElementById('chart-legend'); + const titleEl = document.querySelector('.chart-header .section-title'); if (!chartEl) return; - // Fetch daily summary for each of last 7 days - const days = []; - for (let i = 6; i >= 0; i--) { - days.push(daysAgo(i)); + period = period || 'semana'; + + let dateFrom, dateTo, labels = [], buckets = {}, labelOrder = []; + const now = new Date(); + + if (period === 'hoy') { + dateFrom = dateTo = todayStr(); + labelOrder = ['Hoy']; + buckets['Hoy'] = 0; + } else if (period === 'semana') { + const days = []; + for (let i = 6; i >= 0; i--) { days.push(daysAgo(i)); } + dateFrom = days[0]; + dateTo = days[6]; + days.forEach(d => { + const date = new Date(d + 'T12:00:00'); + const label = DAY_NAMES_SHORT[date.getDay()]; + labelOrder.push(label); + buckets[label] = { total: 0, date: d }; + }); + } else if (period === 'mes') { + const year = now.getFullYear(); + const month = now.getMonth(); + const lastDay = new Date(year, month + 1, 0).getDate(); + dateFrom = new Date(year, month, 1).toISOString().slice(0, 10); + dateTo = new Date(year, month, lastDay).toISOString().slice(0, 10); + for (let i = 1; i <= 4; i++) { + const label = `Sem ${i}`; + labelOrder.push(label); + buckets[label] = { total: 0, week: i }; + } + } else if (period === 'año') { + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const label = monthLabel(d); + labelOrder.push(label); + buckets[label] = 0; + } + dateFrom = new Date(now.getFullYear(), now.getMonth() - 11, 1).toISOString().slice(0, 10); + dateTo = todayStr(); } - const summaries = await Promise.all( - days.map(d => apiFetch(`/pos/api/register/daily-summary?date=${d}`)) - ); + // Fetch normal sales for short periods + let normalByKey = {}; + if (period === 'hoy' || period === 'semana') { + const days = period === 'hoy' ? [todayStr()] : (function() { + const arr = []; + for (let i = 6; i >= 0; i--) arr.push(daysAgo(i)); + return arr; + })(); + const summaries = await Promise.all( + days.map(d => apiFetch(`/pos/api/register/daily-summary?date=${d}`)) + ); + days.forEach((d, i) => { + const date = new Date(d + 'T12:00:00'); + const key = period === 'hoy' ? 'Hoy' : DAY_NAMES_SHORT[date.getDay()]; + normalByKey[key] = summaries[i] ? (summaries[i].total_sales || 0) : 0; + }); + } - let weekTotal = 0; - const dayData = days.map((dateStr, i) => { - const s = summaries[i]; - const total = s ? (s.total_sales || 0) : 0; - weekTotal += total; - const d = new Date(dateStr + 'T12:00:00'); - return { - label: DAY_NAMES_SHORT[d.getDay()], - total: total, - isToday: dateStr === todayStr(), - }; + // Fetch historical sales for the range + let histRows = []; + try { + const perPage = period === 'año' ? 2000 : 1000; + const histData = await apiFetch(`/pos/api/historical-sales?date_from=${dateFrom}&date_to=${dateTo}&per_page=${perPage}`); + histRows = histData.data || []; + const totalPages = histData.pagination ? histData.pagination.total_pages : 1; + for (let p = 2; p <= totalPages && p <= 20; p++) { + const more = await apiFetch(`/pos/api/historical-sales?date_from=${dateFrom}&date_to=${dateTo}&per_page=${perPage}&page=${p}`); + histRows = histRows.concat(more.data || []); + } + } catch (e) { + histRows = []; + } + + // Group historical sales + histRows.forEach(r => { + if (!r.sale_date) return; + const date = new Date(r.sale_date + 'T12:00:00'); + let key; + if (period === 'hoy') key = 'Hoy'; + else if (period === 'semana') key = DAY_NAMES_SHORT[date.getDay()]; + else if (period === 'mes') { + const day = date.getDate(); + const weekNum = day <= 7 ? 1 : day <= 14 ? 2 : day <= 21 ? 3 : 4; + key = `Sem ${weekNum}`; + } else { + key = monthLabel(date); + } + if (key) { + if (typeof buckets[key] === 'object') buckets[key].total += (r.total || 0); + else buckets[key] = (buckets[key] || 0) + (r.total || 0); + } }); - // Update week total + // Build chart data + let chartTotal = 0; + const dayData = labelOrder.map(label => { + let normalTotal = normalByKey[label] || 0; + let histTotal = 0; + if (typeof buckets[label] === 'object') { + histTotal = buckets[label].total; + } else { + histTotal = buckets[label] || 0; + } + const total = normalTotal + histTotal; + chartTotal += total; + const isToday = period === 'hoy' || (typeof buckets[label] === 'object' && buckets[label].date === todayStr()); + return { label, total, isToday }; + }); + + // Update labels + const titles = { hoy: 'Ventas de Hoy', semana: 'Ventas Semanales', mes: 'Ventas del Mes', año: 'Ventas del Año' }; + const legends = { hoy: 'Total del día', semana: 'Ventas brutas (7 días)', mes: 'Ventas brutas (4 semanas)', año: 'Ventas brutas (12 meses)' }; + if (titleEl) titleEl.textContent = titles[period] || 'Ventas'; if (totalEl) { - totalEl.innerHTML = `Total semana: ${fmt(weekTotal)}`; + const periodLabel = period === 'hoy' ? 'Total día' : period === 'semana' ? 'Total semana' : period === 'mes' ? 'Total mes' : 'Total año'; + totalEl.innerHTML = `${periodLabel}: ${fmt(chartTotal)}`; + } + if (legendEl) { + legendEl.innerHTML = `
${legends[period]}
`; } const maxVal = Math.max(...dayData.map(d => d.total), 1); @@ -533,9 +699,10 @@ const Dashboard = (() => { // Load all data in parallel loadDailySummary(); + loadHistoricalSummary(); loadAlerts(); loadTopProducts(); - loadWeeklyChart(); + loadChart('semana'); loadRecentSales(); // Auto-refresh every 2 minutes diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index 75b66bb..9e397d2 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -478,16 +478,140 @@ // PURCHASE / ENTRADA (purchaseModal) // ===================================================================== + let purchaseSearchTimeout = null; + let purchaseSelectedItem = null; + function showPurchaseModal() { document.getElementById('purchaseModal').classList.add('is-open'); + setTimeout(function() { + var el = document.getElementById('purchaseItemSearch'); + if (el) el.focus(); + }, 100); } function showPurchaseModalForItem(itemId) { - document.getElementById('purchaseItemId').value = itemId; + // Pre-fill by fetching item details + apiFetch(API + '/items?page=1&per_page=1').then(function() { + // We just need the item detail; use the existing list or fetch by id + apiFetch(API + '/items?page=1&per_page=1').then(function() {}); + }); + selectPurchaseItem({id: itemId, name: 'Producto #' + itemId}); showPurchaseModal(); } function closePurchaseModal() { document.getElementById('purchaseModal').classList.remove('is-open'); document.getElementById('purchaseResult').innerHTML = ''; + clearPurchaseSelection(); + } + + function clearPurchaseSelection() { + purchaseSelectedItem = null; + var ids = ['purchaseItemId','purchaseItemSearch','purchaseQty','purchaseCost','purchaseInvoice','purchaseNotes']; + ids.forEach(function(id) { + var el = document.getElementById(id); + if (el) el.value = ''; + }); + var results = document.getElementById('purchaseItemResults'); + if (results) results.style.display = 'none'; + var selected = document.getElementById('purchaseItemSelected'); + if (selected) selected.textContent = ''; + } + + function selectPurchaseItem(item) { + purchaseSelectedItem = item; + document.getElementById('purchaseItemId').value = item.id; + document.getElementById('purchaseItemSearch').value = item.name || item.part_number || item.barcode || ('#' + item.id); + document.getElementById('purchaseItemResults').style.display = 'none'; + document.getElementById('purchaseItemSelected').innerHTML = + '' + esc(item.name || '') + '' + + (item.part_number ? ' · No. parte: ' + esc(item.part_number) : '') + + (item.barcode ? ' · Barcode: ' + esc(item.barcode) : ''); + document.getElementById('purchaseQty').focus(); + } + + function searchPurchaseItems(query) { + var resultsEl = document.getElementById('purchaseItemResults'); + if (!query || query.length < 2) { + resultsEl.style.display = 'none'; + return; + } + apiFetch(API + '/items?q=' + encodeURIComponent(query) + '&per_page=10').then(function(res) { + var items = (res && res.data) || []; + if (!items.length) { + resultsEl.innerHTML = '
Sin resultados
'; + resultsEl.style.display = 'block'; + return; + } + resultsEl.innerHTML = items.map(function(it) { + return '
' + + '
' + esc(it.name) + '
' + + '
' + + (it.part_number ? 'No. parte: ' + esc(it.part_number) + ' · ' : '') + + (it.barcode ? 'Barcode: ' + esc(it.barcode) + ' · ' : '') + + 'Stock: ' + (it.stock || 0) + + '
' + + '
'; + }).join(''); + resultsEl.querySelectorAll('.purchase-search-result').forEach(function(row) { + row.onclick = function() { + var id = parseInt(row.dataset.id); + var item = items.find(function(x) { return x.id === id; }); + if (item) selectPurchaseItem(item); + }; + }); + resultsEl.style.display = 'block'; + }).catch(function() { + resultsEl.style.display = 'none'; + }); + } + + function wirePurchaseSearch() { + var input = document.getElementById('purchaseItemSearch'); + var resultsEl = document.getElementById('purchaseItemResults'); + if (!input) return; + + input.addEventListener('input', function() { + if (purchaseSelectedItem && input.value !== purchaseSelectedItem.name) { + purchaseSelectedItem = null; + document.getElementById('purchaseItemId').value = ''; + document.getElementById('purchaseItemSelected').textContent = ''; + } + clearTimeout(purchaseSearchTimeout); + purchaseSearchTimeout = setTimeout(function() { + searchPurchaseItems(input.value.trim()); + }, 250); + }); + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + // Try exact barcode match first + var query = input.value.trim(); + if (!query) return; + apiFetch(API + '/items?q=' + encodeURIComponent(query) + '&per_page=20').then(function(res) { + var items = (res && res.data) || []; + var exact = items.find(function(it) { + return (it.barcode || '').toLowerCase() === query.toLowerCase() || + (it.part_number || '').toLowerCase() === query.toLowerCase(); + }); + if (exact) { + selectPurchaseItem(exact); + } else if (items.length === 1) { + selectPurchaseItem(items[0]); + } else { + searchPurchaseItems(query); + } + }); + } else if (e.key === 'Escape') { + if (resultsEl) resultsEl.style.display = 'none'; + } + }); + + document.addEventListener('click', function(e) { + if (resultsEl && !input.contains(e.target) && !resultsEl.contains(e.target)) { + resultsEl.style.display = 'none'; + } + }); } function recordPurchase() { @@ -506,10 +630,6 @@ if (result && result.operation_id) { document.getElementById('purchaseResult').innerHTML = 'Compra registrada (op #' + result.operation_id + ')'; closePurchaseModal(); - ['purchaseItemId','purchaseQty','purchaseCost','purchaseInvoice','purchaseNotes'].forEach(function(id) { - var el = document.getElementById(id); - if (el) el.value = ''; - }); if (window.loadInventoryStats) window.loadInventoryStats(); loadItems(currentPage); } else { @@ -2009,4 +2129,5 @@ loadItems(1); renderSavedFilters(); + wirePurchaseSearch(); })(); diff --git a/pos/static/js/invoicing.js b/pos/static/js/invoicing.js index 4ff65b3..bef35ec 100644 --- a/pos/static/js/invoicing.js +++ b/pos/static/js/invoicing.js @@ -62,6 +62,7 @@ const Invoicing = (() => { if (name === 'notas') loadNotas(); if (name === 'complementos') loadComplementos(); if (name === 'cancelaciones') loadCancelaciones(); + if (name === 'config') loadFacturapiStatus(); } // ---- Badge helpers ---- @@ -259,6 +260,75 @@ const Invoicing = (() => { } } + // ---- Facturapi status (config tab) ---- + async function loadFacturapiStatus() { + const container = document.getElementById('facturapi-status'); + if (!container) return; + container.innerHTML = 'Cargando...'; + try { + const status = await api('/facturapi/status'); + + if (!status.has_key) { + container.innerHTML = `

Falta la llave API de Facturapi. Configura cfdi_facturapi_key o la variable FACTURAPI_USER_KEY.

`; + return; + } + + if (!status.has_org_id) { + container.innerHTML = ` +

No hay organización Facturapi vinculada.

+ + `; + return; + } + + let csdHtml = status.has_csd + ? 'Activo' + : 'Pendiente'; + + let pendingHtml = ''; + if (status.pending_steps && status.pending_steps.length) { + pendingHtml = '
    ' + + status.pending_steps.map(s => `
  • ${s.description || s.type}
  • `).join('') + + '
'; + } + + container.innerHTML = ` +
+
+
Organización
+
${escapeHtml(status.legal_name) || '-'}
+
${status.tax_id || ''}
+
${status.org_id || ''}
+
+
+
CSD
+
${csdHtml}
+
Pasos pendientes
+ ${pendingHtml || 'Ninguno'} +
+
+ ${status.error ? `

Error: ${escapeHtml(status.error)}

` : ''} + `; + } catch (e) { + container.innerHTML = `

Error: ${e.message}

`; + } + } + + 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); + loadFacturapiStatus(); + } catch (e) { + alert('Error: ' + e.message); + btn.disabled = false; + btn.textContent = 'Crear / Vincular Organización'; + } + } + // ---- Detail modal (uses modalDetalleOverlay) ---- async function showDetail(cfdiId) { const overlay = document.getElementById('modalDetalleOverlay'); @@ -300,10 +370,17 @@ const Invoicing = (() => { ${item.error_message ? `

Error: ${escapeHtml(item.error_message)}

` : ''} - ${(item.xml_signed || item.xml_unsigned) ? ` -
Vista previa XML
-
${escapeHtml(item.xml_signed || item.xml_unsigned)}
- ` : ''}`; + ${(item.xml_signed || item.payload_unsigned) ? ` +
${item.xml_signed ? 'Vista previa XML' : 'Payload Facturapi'}
+
${escapeHtml(item.xml_signed || item.payload_unsigned)}
+ ` : ''} + ${item.status === 'stamped' && item.external_id ? ` + + ` : ''} + `; } // Wire the cancel button inside modal footer @@ -531,10 +608,10 @@ const Invoicing = (() => { window.notaCreditoPlaceholder = notaCreditoPlaceholder; return { - switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, + switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus, showDetail, showCancelModal, confirmCancel, processQueue, showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder, - openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, + openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi, }; // Register Cmd+K items if (typeof registerCmdKItem === "function") { diff --git a/pos/static/js/reports.js b/pos/static/js/reports.js index 7539c90..7e78c2d 100644 --- a/pos/static/js/reports.js +++ b/pos/static/js/reports.js @@ -54,7 +54,7 @@ const Reports = (() => { } // Track which tabs have been loaded - var loaded = { ventas: false, inventario: false, clientes: false, financieros: false }; + var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false }; // ------------------------------------------------------------------------- // Theme switcher @@ -85,6 +85,7 @@ const Reports = (() => { else if (id === 'inventario') loadInventario(); else if (id === 'clientes') loadClientes(); else if (id === 'financieros') loadFinancieros(); + else if (id === 'historico') loadHistorico(); } } window.switchTab = switchTab; @@ -289,6 +290,85 @@ const Reports = (() => { } } + // ========================================================================= + // TAB 5: HISTÓRICO + // ========================================================================= + async function loadHistorico() { + loaded.historico = true; + var dateFrom = document.getElementById('historico-date-from').value; + var dateTo = document.getElementById('historico-date-to').value; + var customer = document.getElementById('historico-customer').value.trim(); + + var params = new URLSearchParams(); + if (dateFrom) params.set('date_from', dateFrom); + if (dateTo) params.set('date_to', dateTo); + if (customer) params.set('customer', customer); + params.set('per_page', '200'); + + var kpiEl = document.getElementById('historico-kpis'); + var detalleEl = document.getElementById('historico-detalle'); + kpiEl.innerHTML = spinner(); + detalleEl.innerHTML = spinner(); + + try { + var allRows = []; + var page = 1; + var totalPages = 1; + + while (page <= totalPages) { + params.set('page', page); + var json = await apiFetch('/pos/api/historical-sales?' + params.toString()); + allRows = allRows.concat(json.data || []); + totalPages = json.pagination ? json.pagination.total_pages : 1; + page++; + if (page > 50) break; + } + + var total = allRows.reduce(function(a, r) { return a + r.total; }, 0); + var subtotal = allRows.reduce(function(a, r) { return a + r.subtotal; }, 0); + var balance = allRows.reduce(function(a, r) { return a + r.balance; }, 0); + + kpiEl.innerHTML = + kpiCard('Total Histórico', '$' + fmt(total), allRows.length + ' registros') + + kpiCard('Subtotal', '$' + fmt(subtotal), '') + + kpiCard('Saldo Pendiente', '$' + fmt(balance), '') + + kpiCard('Tickets', fmtInt(allRows.length), ''); + + var html = '
Ventas Históricas Importadas' + + '' + allRows.length + ' registros
'; + html += '
' + + '' + + '' + + '' + + ''; + allRows.slice(0, 200).forEach(function(r) { + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
FechaDocumentoClientePagoSubtotalTotalPagadoSaldo
' + fmtDate(r.sale_date) + '' + esc(r.document_no || r.external_document_id || '--') + '' + esc(r.customer_name || '--') + '' + esc(r.payment_method || '--') + '$' + fmt(r.subtotal) + '$' + fmt(r.total) + '$' + fmt(r.amount_paid) + '$' + fmt(r.balance) + '
'; + detalleEl.innerHTML = html; + + } catch (err) { + kpiEl.innerHTML = errorMsg('Error cargando histórico: ' + err.message); + detalleEl.innerHTML = ''; + } + } + + function esc(s) { + if (s == null) return ''; + return String(s).replace(/[&<>"']/g, function(c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + // ========================================================================= // TAB 2: INVENTARIO // ========================================================================= @@ -712,7 +792,7 @@ const Reports = (() => { return { init, setTheme, switchTab, - loadVentas, loadInventario, loadClientes, loadFinancieros, fmt + loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt }; // Register Cmd+K items if (typeof registerCmdKItem === "function") { diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index 85669f2..4793439 100644 --- a/pos/static/pwa/sw.js +++ b/pos/static/pwa/sw.js @@ -6,7 +6,7 @@ // 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-v17'; +const CACHE_NAME = 'nexus-pos-v18'; const APP_SHELL = [ '/pos/static/css/tokens.css', diff --git a/pos/templates/customers.html b/pos/templates/customers.html index 254a3ee..e2ce560 100644 --- a/pos/templates/customers.html +++ b/pos/templates/customers.html @@ -149,6 +149,16 @@ Reportes +