Merge branch 'main' into desarrollo_hector

This commit is contained in:
2026-06-17 14:20:48 -06:00
28 changed files with 2445 additions and 380 deletions

View File

@@ -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/<path:filename>')
def pos_static(filename):
return send_from_directory('static', filename)

View File

@@ -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/<int:cfdi_id>/<doc_type>', methods=['GET'])
@require_auth('invoicing.view')
def facturapi_download(cfdi_id, doc_type):
"""Download PDF or XML for a stamped CFDI from Facturapi.
doc_type: 'pdf' | 'xml'
"""
if doc_type not in ('pdf', 'xml'):
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'CFDI not found'}), 404
external_id, uuid_fiscal, status = row
if status != 'stamped' or not external_id:
cur.close(); conn.close()
return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400
tenant_config = _get_issuer_config(cur)
cur.close()
conn.close()
try:
if doc_type == 'pdf':
content = facturapi_service.download_pdf(tenant_config, external_id)
mime = 'application/pdf'
filename = f'cfdi_{uuid_fiscal or external_id}.pdf'
else:
content = facturapi_service.download_xml(tenant_config, external_id)
mime = 'application/xml'
filename = f'cfdi_{uuid_fiscal or external_id}.xml'
except Exception as e:
return jsonify({'error': str(e)}), 500
from flask import Response
return Response(
content,
mimetype=mime,
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)

View File

@@ -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/<int:sale_id>', methods=['GET'])
@require_auth('pos.view')
def get_sale(sale_id):

View File

@@ -14,9 +14,11 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
MIGRATIONS = {
'v1.0': 'v1.0_initial.sql',
'v1.1': 'v1.1_pos_tables.sql',
'v1.2': 'v1.2_subdomain.sql',
'v1.3': 'v1.3_fleet.sql',
'v1.4': 'v1.4_whatsapp.sql',
'v1.5': 'v1.5_returns.sql',
'v1.6': 'v1.6_marketplace.sql',
'v1.7': 'v1.7_plates.sql',
'v1.8': 'v1.8_performance_indexes.sql',
'v1.9': 'v1.9_redis_cache.sql',
@@ -33,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:

View File

@@ -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 $$;

View File

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

View File

@@ -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 <tenant_db> -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 $$;

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}

View File

@@ -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 = `<span class="kpi-meta-text">${fmtInt(totalRecords)} tickets importados</span>`;
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 = `<span class="kpi-meta-text">${monthRows.length} tickets este mes</span>`;
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 = `<span class="kpi-meta-text">Registros históricos</span>`;
} 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: <strong style="color:var(--color-primary);font-family:var(--font-mono);">${fmt(weekTotal)}</strong>`;
const periodLabel = period === 'hoy' ? 'Total día' : period === 'semana' ? 'Total semana' : period === 'mes' ? 'Total mes' : 'Total año';
totalEl.innerHTML = `${periodLabel}: <strong style="color:var(--color-primary);font-family:var(--font-mono);">${fmt(chartTotal)}</strong>`;
}
if (legendEl) {
legendEl.innerHTML = `<div class="legend-item"><div class="legend-dot"></div>${legends[period]}</div>`;
}
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

View File

@@ -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 =
'<strong>' + esc(item.name || '') + '</strong>' +
(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 = '<div style="padding:var(--space-3);color:var(--color-text-muted);font-size:var(--text-caption);">Sin resultados</div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = items.map(function(it) {
return '<div class="purchase-search-result" style="padding:var(--space-3);cursor:pointer;border-bottom:1px solid var(--color-border);" ' +
'data-id="' + it.id + '">' +
'<div style="font-weight:var(--font-weight-semibold);">' + esc(it.name) + '</div>' +
'<div style="font-size:var(--text-caption);color:var(--color-text-muted);">' +
(it.part_number ? 'No. parte: ' + esc(it.part_number) + ' · ' : '') +
(it.barcode ? 'Barcode: ' + esc(it.barcode) + ' · ' : '') +
'Stock: ' + (it.stock || 0) +
'</div>' +
'</div>';
}).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 = '<span style="color:var(--color-success);">Compra registrada (op #' + result.operation_id + ')</span>';
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();
})();

View File

@@ -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 = `<p style="color:var(--color-error);">Falta la llave API de Facturapi. Configura <code>cfdi_facturapi_key</code> o la variable <code>FACTURAPI_USER_KEY</code>.</p>`;
return;
}
if (!status.has_org_id) {
container.innerHTML = `
<p style="color:var(--color-warning);margin-bottom:var(--space-3);">No hay organización Facturapi vinculada.</p>
<button class="btn btn--primary" onclick="Invoicing.setupFacturapi(this)">Crear / Vincular Organización</button>
`;
return;
}
let csdHtml = status.has_csd
? '<span style="color:var(--color-success);">Activo</span>'
: '<span style="color:var(--color-error);">Pendiente</span>';
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('') +
'</ul>';
}
container.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
<div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Organización</div>
<div style="font-weight:var(--font-weight-semibold);">${escapeHtml(status.legal_name) || '-'}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-body-sm);">${status.tax_id || ''}</div>
<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-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>` : ''}
`;
} catch (e) {
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
}
}
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 = (() => {
</div>
</div>
${item.error_message ? `<p style="color:var(--color-error);margin-bottom:var(--space-3);"><strong>Error:</strong> ${escapeHtml(item.error_message)}</p>` : ''}
${(item.xml_signed || item.xml_unsigned) ? `
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-2);">Vista previa XML</div>
<pre style="background:var(--color-surface-3); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-4); font-family:var(--font-mono); font-size:11px; color:var(--color-text-secondary); overflow-x:auto; max-height:200px; line-height:1.6;">${escapeHtml(item.xml_signed || item.xml_unsigned)}</pre>
` : ''}`;
${(item.xml_signed || item.payload_unsigned) ? `
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-2);">${item.xml_signed ? 'Vista previa XML' : 'Payload Facturapi'}</div>
<pre style="background:var(--color-surface-3); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-4); font-family:var(--font-mono); font-size:11px; color:var(--color-text-secondary); overflow-x:auto; max-height:200px; line-height:1.6;">${escapeHtml(item.xml_signed || item.payload_unsigned)}</pre>
` : ''}
${item.status === 'stamped' && item.external_id ? `
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-4);">
<a class="btn btn--ghost btn--sm" href="${API}/facturapi/download/${item.id}/xml" target="_blank">Descargar XML</a>
<a class="btn btn--ghost btn--sm" href="${API}/facturapi/download/${item.id}/pdf" target="_blank">Descargar PDF</a>
</div>
` : ''}
`;
}
// 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") {

View File

@@ -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 = '<div class="table-card__header"><span class="table-card__title">Ventas Históricas Importadas</span>' +
'<span class="pill pill--muted">' + allRows.length + ' registros</span></div>';
html += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th>Fecha</th><th>Documento</th><th>Cliente</th><th>Pago</th>' +
'<th class="align-right">Subtotal</th><th class="align-right">Total</th>' +
'<th class="align-right">Pagado</th><th class="align-right">Saldo</th>' +
'</tr></thead><tbody>';
allRows.slice(0, 200).forEach(function(r) {
html += '<tr>' +
'<td>' + fmtDate(r.sale_date) + '</td>' +
'<td class="td-mono">' + esc(r.document_no || r.external_document_id || '--') + '</td>' +
'<td>' + esc(r.customer_name || '--') + '</td>' +
'<td><span class="pill pill--muted">' + esc(r.payment_method || '--') + '</span></td>' +
'<td class="align-right td-mono">$' + fmt(r.subtotal) + '</td>' +
'<td class="align-right td-mono-accent">$' + fmt(r.total) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.amount_paid) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.balance) + '</td>' +
'</tr>';
});
html += '</tbody></table></div>';
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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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") {

View File

@@ -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',

View File

@@ -149,6 +149,16 @@
Reportes
</a>
</li>
<li class="nav-item">
<a href="/pos/historical-sales" class="nav-link">
<svg class="nav-link__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="12" height="11" rx="1"/>
<path d="M2 6h12"/>
<path d="M5 2v2M11 2v2"/>
</svg>
Ventas Históricas
</a>
</li>
<li class="nav-item">
<a href="/pos/config" class="nav-link">
<svg class="nav-link__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">

View File

@@ -148,6 +148,17 @@
Reportes
</a>
<a href="/pos/historical-sales" 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">
<rect x="2" y="3" width="12" height="11" rx="1"/>
<path d="M2 6h12" stroke-width="1.2"/>
<path d="M5 2v2M11 2v2"/>
</svg>
</span>
Ventas Históricas
</a>
<div class="sidebar__section-label">Gestión</div>
<a href="/pos/marketplace" class="nav-link">
@@ -318,6 +329,68 @@
</div><!-- end kpi-grid -->
</section>
<!-- =================================================================
HISTÓRICO IMPORTADO
================================================================= -->
<section>
<div class="section-header">
<span class="section-title">Histórico importado</span>
<a href="/pos/reports" class="section-action" style="text-decoration:none;color:inherit;">Ver en reportes &rarr;</a>
</div>
<div class="kpi-grid">
<div class="kpi-card" id="kpi-historico-total">
<div class="kpi-card__accent-bar"></div>
<div class="kpi-card__label">
Total Histórico
<span class="kpi-card__icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="1" y="2" width="12" height="11" rx="1" stroke="currentColor" stroke-width="1.4"/>
<path d="M4 1v2M10 1v2M1 6h12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</span>
</div>
<div class="kpi-card__value" id="kpi-historico-total-value"><div class="skeleton skeleton--text" style="width:60%;"></div></div>
<div class="kpi-card__meta" id="kpi-historico-total-meta">
<div class="skeleton skeleton--text-sm" style="width:80%;"></div>
</div>
</div>
<div class="kpi-card" id="kpi-historico-mes">
<div class="kpi-card__accent-bar"></div>
<div class="kpi-card__label">
Este Mes (Histórico)
<span class="kpi-card__icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 3h10v9H2z" stroke="currentColor" stroke-width="1.4"/>
<path d="M2 6h10M5 1v2M9 1v2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</div>
<div class="kpi-card__value" id="kpi-historico-mes-value"><div class="skeleton skeleton--text" style="width:60%;"></div></div>
<div class="kpi-card__meta" id="kpi-historico-mes-meta">
<div class="skeleton skeleton--text-sm" style="width:80%;"></div>
</div>
</div>
<div class="kpi-card" id="kpi-historico-count">
<div class="kpi-card__accent-bar"></div>
<div class="kpi-card__label">
Tickets Históricos
<span class="kpi-card__icon">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 2h10a1 1 0 011 1v8a1 1 0 01-1 1H2a1 1 0 01-1-1V3a1 1 0 011-1z" stroke="currentColor" stroke-width="1.4"/>
<path d="M3.5 5.5h7M3.5 7.5h5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</div>
<div class="kpi-card__value" id="kpi-historico-count-value"><div class="skeleton skeleton--text" style="width:40%;"></div></div>
<div class="kpi-card__meta" id="kpi-historico-count-meta">
<div class="skeleton skeleton--text-sm" style="width:70%;"></div>
</div>
</div>
</div>
</section>
<!-- =================================================================
SALES CHART (CSS-only bar chart)
================================================================= -->
@@ -495,7 +568,7 @@
<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=3" defer></script>
<script src="/pos/static/js/dashboard.js?v=7" 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

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ventas Históricas - Atlas</title>
<link rel="stylesheet" href="/pos/static/css/pos.css">
<style>
:root { --header-h: 56px; }
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f6f8; color: #1f2937; }
.header { position: fixed; top: 0; left: 0; right: 0; height: var(--header-h); background: #111827; color: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; z-index: 100; }
.header h1 { margin: 0; font-size: 16px; font-weight: 600; }
.header a { color: #9ca3af; text-decoration: none; font-size: 13px; }
.header a:hover { color: #fff; }
.container { padding: calc(var(--header-h) + 16px) 16px 24px; max-width: 1200px; margin: 0 auto; }
.filters { background: #fff; padding: 12px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 16px; }
.filters label { font-size: 12px; color: #6b7280; display: block; margin-bottom: 4px; }
.filters input { padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 13px; }
.filters button { padding: 7px 14px; background: #2563eb; color: #fff; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; }
.filters button:hover { background: #1d4ed8; }
.summary { display: flex; gap: 12px; margin-bottom: 16px; }
.card { background: #fff; padding: 12px 16px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); flex: 1; }
.card .label { font-size: 12px; color: #6b7280; }
.card .value { font-size: 18px; font-weight: 700; color: #111827; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); font-size: 13px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
th { background: #f9fafb; font-weight: 600; color: #374151; }
tr:hover { background: #f9fafb; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e5e7eb; font-size: 11px; color: #374151; }
.empty { text-align: center; padding: 40px; color: #6b7280; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 16px; }
.pagination button { padding: 6px 12px; border: 1px solid #d1d5db; background: #fff; border-radius: 4px; cursor: pointer; }
.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>
<body>
<div class="header">
<h1>📊 Ventas Históricas - Atlas</h1>
<a href="/pos/sale">← Regresar al POS</a>
</div>
<div class="container">
<div class="filters">
<div>
<label>Desde</label>
<input type="date" id="dateFrom">
</div>
<div>
<label>Hasta</label>
<input type="date" id="dateTo">
</div>
<div>
<label>Cliente</label>
<input type="text" id="customerFilter" placeholder="Nombre del cliente..." style="width:220px;">
</div>
<button onclick="loadData(1)">Buscar</button>
</div>
<div class="summary">
<div class="card">
<div class="label">Total de ventas</div>
<div class="value" id="totalCount">-</div>
</div>
<div class="card">
<div class="label">Total vendido</div>
<div class="value" id="totalSold">-</div>
</div>
<div class="card">
<div class="label">Saldo pendiente</div>
<div class="value" id="totalBalance">-</div>
</div>
</div>
<div id="content">
<div class="loading">Cargando...</div>
</div>
</div>
<script src="/pos/static/js/api.js"></script>
<script>
const fmt = n => n == null ? '-' : '$' + Number(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('es-MX') : '-';
async function loadData(page = 1) {
const content = document.getElementById('content');
content.innerHTML = '<div class="loading">Cargando...</div>';
const params = new URLSearchParams();
params.set('page', page);
params.set('per_page', 50);
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
const customer = document.getElementById('customerFilter').value.trim();
if (from) params.set('date_from', from);
if (to) params.set('date_to', to);
if (customer) params.set('customer', customer);
try {
const res = await api('/pos/api/historical-sales?' + params.toString());
render(res.data || [], res.pagination || {});
} catch (e) {
content.innerHTML = '<div class="empty">Error: ' + esc(e.message) + '</div>';
}
}
function render(rows, pagination) {
const content = document.getElementById('content');
if (!rows.length) {
content.innerHTML = '<div class="empty">No se encontraron ventas históricas</div>';
return;
}
let totalSold = 0, totalBalance = 0;
rows.forEach(r => { totalSold += r.total || 0; totalBalance += r.balance || 0; });
document.getElementById('totalCount').textContent = pagination.total || rows.length;
document.getElementById('totalSold').textContent = fmt(totalSold);
document.getElementById('totalBalance').textContent = fmt(totalBalance);
let html = '<table><thead><tr>' +
'<th>Fecha</th><th>Documento</th><th>Cliente</th><th>Forma de pago</th>' +
'<th class="num">Subtotal</th><th class="num">Total</th><th class="num">Pagado</th><th class="num">Saldo</th>' +
'</tr></thead><tbody>';
rows.forEach(r => {
html += '<tr>' +
'<td>' + fmtDate(r.sale_date) + '</td>' +
'<td>' + esc(r.document_no || r.external_document_id || '-') + '</td>' +
'<td>' + esc(r.customer_name || '-') + '</td>' +
'<td><span class="badge">' + esc(r.payment_method || '-') + '</span></td>' +
'<td class="num">' + fmt(r.subtotal) + '</td>' +
'<td class="num">' + fmt(r.total) + '</td>' +
'<td class="num">' + fmt(r.amount_paid) + '</td>' +
'<td class="num">' + fmt(r.balance) + '</td>' +
'</tr>';
});
html += '</tbody></table>';
const totalPages = pagination.total_pages || 1;
const page = pagination.page || 1;
html += '<div class="pagination">' +
'<button onclick="loadData(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>Anterior</button>' +
'<span>Página ' + page + ' de ' + totalPages + '</span>' +
'<button onclick="loadData(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>Siguiente</button>' +
'</div>';
content.innerHTML = html;
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
loadData(1);
</script>
</body>
</html>

View File

@@ -149,6 +149,16 @@
<span>Reportes</span>
</a>
<a class="nav-item" href="/pos/historical-sales">
<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="4" y="5" width="16" height="16" rx="1"/>
<line x1="4" y1="10" x2="20" y2="10"/>
<line x1="8" y1="3" x2="8" y2="5"/>
<line x1="16" y1="3" x2="16" y2="5"/>
</svg>
<span>Ventas Históricas</span>
</a>
<div class="nav-section-label">Sistema</div>
<a class="nav-item" href="/pos/config">
@@ -750,7 +760,13 @@
</div>
<div class="inv-modal__body">
<div class="inv-form-grid">
<div class="inv-field"><label>ID Producto *</label><input type="number" id="purchaseItemId" placeholder="ID inventario" /></div>
<div class="inv-field inv-field--full" style="position:relative;">
<label>Producto *</label>
<input type="hidden" id="purchaseItemId" />
<input type="text" id="purchaseItemSearch" placeholder="Escribe nombre, No. de parte o escanea código de barras..." autocomplete="off" />
<div id="purchaseItemResults" style="position:absolute;left:0;right:0;top:100%;background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-md);max-height:200px;overflow-y:auto;z-index:100;display:none;"></div>
<div id="purchaseItemSelected" style="margin-top:var(--space-1);font-size:var(--text-caption);color:var(--color-text-secondary);"></div>
</div>
<div class="inv-field"><label>Cantidad *</label><input type="number" id="purchaseQty" placeholder="Cantidad" /></div>
<div class="inv-field"><label>Costo Unitario *</label><input type="number" id="purchaseCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Factura Proveedor</label><input type="text" id="purchaseInvoice" placeholder="No. factura" /></div>
@@ -1043,7 +1059,7 @@
<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=17" defer></script>
<script src="/pos/static/js/inventory.js?v=18" 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

@@ -865,6 +865,20 @@
</div>
</div>
<!-- FACTURAPI STATUS -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
<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>
<span class="config-section__title">Facturapi (PAC)</span>
</div>
<div class="config-section__body" id="facturapi-status">
<p style="color:var(--color-text-muted);">Cargando estado de Facturapi...</p>
</div>
</div>
<!-- CONFIGURACIÓN DE SERIES — full width -->
<div class="config-section" style="grid-column: span 2;">
<div class="config-section__header">
@@ -1067,7 +1081,7 @@
<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=2" defer></script>
<script src="/pos/static/js/invoicing.js?v=3" 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

@@ -130,6 +130,16 @@
<span>Reportes</span>
</a>
<a href="/pos/historical-sales" class="nav-item">
<svg class="nav-item__icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="4" width="16" height="12" rx="1"/>
<line x1="1" y1="8" x2="17" y2="8"/>
<line x1="5" y1="2" x2="5" y2="4"/>
<line x1="13" y1="2" x2="13" y2="4"/>
</svg>
<span>Ventas Históricas</span>
</a>
<div class="nav-section-label">Sistema</div>
<a href="/pos/config" class="nav-item">
@@ -206,6 +216,14 @@
</svg>
Financieros
</button>
<button class="tab-btn" onclick="switchTab('historico', this)">
<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="1" y="4" width="13" height="10" rx="1"/>
<path d="M1 7h13"/>
<path d="M4 2v2M10 2v2"/>
</svg>
Histórico
</button>
</div>
<!-- ==================================================================
@@ -308,6 +326,30 @@
<!-- Cortes de caja -->
<div class="table-card mb-5" id="financieros-cortes"></div>
</div>
<!-- ==================================================================
TAB 5: HISTÓRICO
================================================================== -->
<div class="tab-panel" id="panel-historico">
<!-- Filter Bar -->
<div class="filter-bar">
<span class="filter-bar__label">Desde</span>
<input type="date" class="filter-input" id="historico-date-from" />
<span class="filter-bar__label">Hasta</span>
<input type="date" class="filter-input" id="historico-date-to" />
<span class="filter-bar__label">Cliente</span>
<input type="text" class="filter-input" id="historico-customer" placeholder="Nombre..." />
<div class="filter-bar__spacer"></div>
<button class="btn btn-primary btn-sm" onclick="Reports.loadHistorico()">Generar</button>
</div>
<!-- KPI Cards (dynamic) -->
<div class="kpi-grid" id="historico-kpis"></div>
<!-- Sales detail table -->
<div class="table-card mb-5" id="historico-detalle"></div>
</div>
<!-- End panels -->
@@ -323,7 +365,7 @@
<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/reports.js" defer></script>
<script src="/pos/static/js/reports.js?v=3" 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>