Merge branch 'main' into desarrollo_hector
This commit is contained in:
@@ -57,6 +57,13 @@ METABASE_ADMIN_EMAIL=admin@nexus.local
|
||||
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
||||
METABASE_DB_PASS=metabase_secret
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# FACTURAPI (OPTIONAL — auto-organization mode for new tenants)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# If set, new tenants can create Facturapi organizations automatically.
|
||||
# Otherwise each tenant must store its secret key in tenant_config.cfdi_facturapi_key.
|
||||
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CURRENCY
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"'},
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
pos/migrations/v4.3_facturapi.sql
Normal file
42
pos/migrations/v4.3_facturapi.sql
Normal 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';
|
||||
@@ -7,3 +7,4 @@ gunicorn>=22.0
|
||||
redis>=5.0
|
||||
meilisearch>=0.40
|
||||
orjson
|
||||
facturapi>=1.0
|
||||
|
||||
243
pos/services/cfdi_facturapi_builder.py
Normal file
243
pos/services/cfdi_facturapi_builder.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
426
pos/services/facturapi_service.py
Normal file
426
pos/services/facturapi_service.py
Normal 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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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") {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 →</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>
|
||||
|
||||
160
pos/templates/historical_sales.html
Normal file
160
pos/templates/historical_sales.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
loadData(1);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
scripts/apply_facturapi_to_all_tenants.py
Executable file
96
scripts/apply_facturapi_to_all_tenants.py
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply Facturapi configuration to all active tenant databases.
|
||||
|
||||
Reads FACTURAPI_SECRET_KEY from the environment and stores it in each
|
||||
tenant's tenant_config table as cfdi_facturapi_key. Also runs the
|
||||
v4.3_facturapi.sql migration against every tenant DB.
|
||||
|
||||
Usage:
|
||||
export MASTER_DB_URL=postgresql://...
|
||||
export TENANT_DB_URL_TEMPLATE="postgresql://.../{db_name}"
|
||||
export FACTURAPI_SECRET_KEY=sk_user_xxxxxxxxxxxxxxxx
|
||||
python3 scripts/apply_facturapi_to_all_tenants.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
|
||||
MIGRATION_SQL = """
|
||||
ALTER TABLE cfdi_queue RENAME COLUMN xml_unsigned TO payload_unsigned;
|
||||
|
||||
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);
|
||||
|
||||
INSERT INTO tenant_config (key, value)
|
||||
VALUES
|
||||
('cfdi_facturapi_key', ''),
|
||||
('cfdi_facturapi_org_id', ''),
|
||||
('cfdi_facturapi_customer_sync', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
UPDATE tenant_config SET value = %s WHERE key = 'cfdi_facturapi_key';
|
||||
"""
|
||||
|
||||
|
||||
def get_tenant_db_names(master_dsn):
|
||||
conn = psycopg2.connect(master_dsn)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return rows
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def apply_to_tenant(tenant_id, db_name, template_dsn, api_key):
|
||||
dsn = template_dsn.format(db_name=db_name)
|
||||
conn = psycopg2.connect(dsn)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(MIGRATION_SQL, (api_key,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
print(f"[OK] tenant {tenant_id} ({db_name})")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] tenant {tenant_id} ({db_name}): {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
master_dsn = os.environ.get("MASTER_DB_URL")
|
||||
template_dsn = os.environ.get("TENANT_DB_URL_TEMPLATE")
|
||||
api_key = os.environ.get("FACTURAPI_SECRET_KEY")
|
||||
|
||||
if not master_dsn or not template_dsn:
|
||||
print("Set MASTER_DB_URL and TENANT_DB_URL_TEMPLATE", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not api_key:
|
||||
print("Set FACTURAPI_SECRET_KEY", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tenants = get_tenant_db_names(master_dsn)
|
||||
if not tenants:
|
||||
print("No active tenants found.")
|
||||
return
|
||||
|
||||
print(f"Applying Facturapi config to {len(tenants)} tenant(s)...")
|
||||
for tenant_id, db_name in tenants:
|
||||
apply_to_tenant(tenant_id, db_name, template_dsn, api_key)
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
299
scripts/import_atlas_data.py
Normal file
299
scripts/import_atlas_data.py
Normal file
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Import Atlas data into Nexus POS tenant_refaccionaria_atlas.
|
||||
|
||||
Sources (expected in /home/):
|
||||
- Articulos Atlas.xlsx -> inventory catalog (stock = 0)
|
||||
- Clientes.xlsx -> customers
|
||||
- Historico V.xlsx -> historical_sales (read-only reference)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import psycopg2
|
||||
import pandas as pd
|
||||
|
||||
BASE_DIR = "/home"
|
||||
TENANT_DB = "tenant_refaccionaria_atlas"
|
||||
|
||||
|
||||
def get_tenant_conn():
|
||||
dsn = os.environ.get("TENANT_DB_URL", f"postgresql://postgres@localhost/{TENANT_DB}")
|
||||
return psycopg2.connect(dsn)
|
||||
|
||||
|
||||
def normalize_text(val, max_len=None):
|
||||
if pd.isna(val):
|
||||
return None
|
||||
s = str(val).strip()
|
||||
if s in ("", "nan", "None"):
|
||||
return None
|
||||
if max_len:
|
||||
s = s[:max_len]
|
||||
return s
|
||||
|
||||
|
||||
def normalize_price(val):
|
||||
if pd.isna(val):
|
||||
return Decimal("0")
|
||||
try:
|
||||
return Decimal(str(float(val))).quantize(Decimal("0.01"))
|
||||
except (ValueError, TypeError):
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def clean_part_number(val):
|
||||
s = normalize_text(val, max_len=100)
|
||||
if not s:
|
||||
return None
|
||||
# Remove problematic chars but keep basic alphanumeric + dash/underscore
|
||||
s = re.sub(r"[^\w\-./]", "", s)
|
||||
return s[:100] or None
|
||||
|
||||
|
||||
def import_inventory(conn):
|
||||
path = os.path.join(BASE_DIR, "Articulos Atlas.xlsx")
|
||||
df = pd.read_excel(path)
|
||||
print(f"[inventory] Read {len(df)} rows from {path}")
|
||||
|
||||
cur = conn.cursor()
|
||||
inserted = 0
|
||||
skipped_dup = 0
|
||||
seen = set()
|
||||
|
||||
for _, row in df.iterrows():
|
||||
part_number = clean_part_number(row.get("Clave"))
|
||||
if not part_number:
|
||||
continue
|
||||
if part_number in seen:
|
||||
skipped_dup += 1
|
||||
continue
|
||||
seen.add(part_number)
|
||||
|
||||
name = normalize_text(row.get("Producto"), max_len=300) or part_number
|
||||
description = normalize_text(row.get("Nombre Alterno"), max_len=500)
|
||||
category = normalize_text(row.get("Categoría 1"), max_len=100)
|
||||
subcategory = normalize_text(row.get("Categoría 2"), max_len=100)
|
||||
unit = normalize_text(row.get("Unidad"), max_len=20)
|
||||
barcode = clean_part_number(row.get("Código de barras"))
|
||||
price = normalize_price(row.get("Precio lista"))
|
||||
|
||||
# Concatenate category info into description if present
|
||||
extra_info = " | ".join(filter(None, [category, subcategory, unit]))
|
||||
if extra_info:
|
||||
description = f"{description or ''} [{extra_info}]".strip() if description else f"[{extra_info}]"
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory
|
||||
(part_number, name, description, cost, price_1, tax_rate,
|
||||
unit, barcode, brand, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (part_number) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
part_number,
|
||||
name,
|
||||
description,
|
||||
Decimal("0"),
|
||||
price,
|
||||
Decimal("0.16"),
|
||||
unit,
|
||||
barcode,
|
||||
category,
|
||||
True,
|
||||
),
|
||||
)
|
||||
if cur.fetchone():
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
print(f"[inventory] Inserted: {inserted}, Duplicates skipped: {skipped_dup}")
|
||||
|
||||
|
||||
def import_customers(conn):
|
||||
path = os.path.join(BASE_DIR, "Clientes.xlsx")
|
||||
df = pd.read_excel(path)
|
||||
print(f"[customers] Read {len(df)} rows from {path}")
|
||||
|
||||
cur = conn.cursor()
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
||||
for _, row in df.iterrows():
|
||||
name = normalize_text(row.get("Empresa"), max_len=200)
|
||||
if not name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Skip generic "Publico" row if it has no real name
|
||||
if name.lower() in ("publico", "publico en general", "público", "público en general"):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
credit_limit = normalize_price(row.get("Límite de crédito"))
|
||||
customer_type = normalize_text(row.get("Tipo de cliente"), max_len=50)
|
||||
segment = normalize_text(row.get("Segmento"), max_len=50)
|
||||
payment_terms = normalize_text(row.get("Condiciones de Pago"), max_len=100)
|
||||
notes = " | ".join(filter(None, [customer_type, segment, payment_terms]))
|
||||
|
||||
# Avoid duplicates by name
|
||||
cur.execute("SELECT 1 FROM customers WHERE name = %s LIMIT 1", (name,))
|
||||
if cur.fetchone():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO customers
|
||||
(name, razon_social, credit_limit, price_tier, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
name,
|
||||
name,
|
||||
credit_limit,
|
||||
1,
|
||||
True,
|
||||
),
|
||||
)
|
||||
if cur.fetchone():
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
print(f"[customers] Inserted: {inserted}, Skipped: {skipped}")
|
||||
|
||||
|
||||
def create_historical_sales_table(conn):
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS historical_sales (
|
||||
id SERIAL PRIMARY KEY,
|
||||
external_document_id VARCHAR(50),
|
||||
document_no VARCHAR(50),
|
||||
sale_date DATE,
|
||||
customer_name VARCHAR(200),
|
||||
total NUMERIC(12,2),
|
||||
subtotal NUMERIC(12,2),
|
||||
amount_paid NUMERIC(12,2),
|
||||
payment_method VARCHAR(50),
|
||||
discount NUMERIC(12,2) DEFAULT 0,
|
||||
balance NUMERIC(12,2) DEFAULT 0,
|
||||
raw_payment_code VARCHAR(20),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_historical_sales_date
|
||||
ON historical_sales(sale_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_historical_sales_customer
|
||||
ON historical_sales(customer_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_historical_sales_document
|
||||
ON historical_sales(document_no);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def payment_label(code):
|
||||
mapping = {
|
||||
"1": "Efectivo",
|
||||
"3": "Tarjeta",
|
||||
"4": "Transferencia",
|
||||
"6": "Cheque",
|
||||
"28": "Crédito",
|
||||
"99": "Por definir",
|
||||
}
|
||||
return mapping.get(str(code).strip(), f"Código {code}")
|
||||
|
||||
|
||||
def import_historical_sales(conn):
|
||||
path = os.path.join(BASE_DIR, "Historico V.xlsx")
|
||||
df = pd.read_excel(path)
|
||||
print(f"[historical_sales] Read {len(df)} rows from {path}")
|
||||
|
||||
create_historical_sales_table(conn)
|
||||
|
||||
cur = conn.cursor()
|
||||
inserted = 0
|
||||
|
||||
for _, row in df.iterrows():
|
||||
doc_id = normalize_text(row.get("ID Documento"), max_len=50)
|
||||
doc_no = normalize_text(row.get("Documento No."), max_len=50)
|
||||
|
||||
fecha = row.get("Fecha")
|
||||
if pd.isna(fecha):
|
||||
sale_date = None
|
||||
else:
|
||||
try:
|
||||
sale_date = pd.to_datetime(fecha).date()
|
||||
except Exception:
|
||||
sale_date = None
|
||||
|
||||
customer = normalize_text(row.get("Cliente"), max_len=200)
|
||||
total = normalize_price(row.get("Total"))
|
||||
subtotal = normalize_price(row.get("SubTotal"))
|
||||
paid = normalize_price(row.get("Total Pagado"))
|
||||
discount = normalize_price(row.get("Descuento"))
|
||||
balance = normalize_price(row.get("Saldo"))
|
||||
raw_payment = normalize_text(row.get("Forma de Pago"), max_len=20)
|
||||
payment_label_str = payment_label(raw_payment) if raw_payment else None
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO historical_sales
|
||||
(external_document_id, document_no, sale_date, customer_name,
|
||||
total, subtotal, amount_paid, payment_method, discount, balance,
|
||||
raw_payment_code)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(
|
||||
doc_id,
|
||||
doc_no,
|
||||
sale_date,
|
||||
customer,
|
||||
total,
|
||||
subtotal,
|
||||
paid,
|
||||
payment_label_str,
|
||||
discount,
|
||||
balance,
|
||||
raw_payment,
|
||||
),
|
||||
)
|
||||
inserted += cur.rowcount
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
print(f"[historical_sales] Inserted: {inserted}")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Connecting to tenant {TENANT_DB}...")
|
||||
conn = get_tenant_conn()
|
||||
|
||||
try:
|
||||
import_inventory(conn)
|
||||
import_customers(conn)
|
||||
import_historical_sales(conn)
|
||||
print("\nImport completed successfully.")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user