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_ADMIN_PASS=change-me-to-a-strong-password
|
||||||
METABASE_DB_PASS=metabase_secret
|
METABASE_DB_PASS=metabase_secret
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# FACTURAPI (OPTIONAL — auto-organization mode for new tenants)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# If set, new tenants can create Facturapi organizations automatically.
|
||||||
|
# Otherwise each tenant must store its secret key in tenant_config.cfdi_facturapi_key.
|
||||||
|
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# CURRENCY
|
# CURRENCY
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -201,6 +201,10 @@ def create_app():
|
|||||||
def pos_marketplace_external_callback():
|
def pos_marketplace_external_callback():
|
||||||
return render_template('marketplace_external.html')
|
return render_template('marketplace_external.html')
|
||||||
|
|
||||||
|
@app.route('/pos/historical-sales')
|
||||||
|
def pos_historical_sales():
|
||||||
|
return render_template('historical_sales.html')
|
||||||
|
|
||||||
@app.route('/pos/static/<path:filename>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ from datetime import datetime
|
|||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
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 (
|
from services.cfdi_queue import (
|
||||||
enqueue_cfdi, process_queue, retry_failed,
|
enqueue_cfdi, process_queue, retry_failed,
|
||||||
cancel_cfdi, get_queue_status,
|
cancel_cfdi, get_queue_status,
|
||||||
)
|
)
|
||||||
|
from services import facturapi_service
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
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'),
|
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||||
'cp': config.get('tenant_cp', '00000'),
|
'cp': config.get('tenant_cp', '00000'),
|
||||||
'serie': config.get('cfdi_serie', 'A'),
|
'serie': config.get('cfdi_serie', 'A'),
|
||||||
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
'facturapi_key': config.get('cfdi_facturapi_key', ''),
|
||||||
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Branch-level override
|
# 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]})'
|
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
# Build XML
|
# Build Facturapi payload
|
||||||
if cfdi_type == 'ingreso':
|
if cfdi_type == 'ingreso':
|
||||||
xml = build_ingreso_xml(sale, tenant_config, customer)
|
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||||
elif cfdi_type == 'egreso':
|
elif cfdi_type == 'egreso':
|
||||||
original_uuid = data.get('original_uuid')
|
original_uuid = data.get('original_uuid')
|
||||||
if not original_uuid:
|
if not original_uuid:
|
||||||
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||||
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
||||||
|
|
||||||
# Enqueue
|
# Enqueue
|
||||||
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
|
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
|
||||||
|
|
||||||
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
||||||
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
||||||
@@ -244,10 +247,10 @@ def get_queue_item(cfdi_id):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed,
|
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
|
||||||
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
||||||
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
||||||
q.created_at, q.stamped_at
|
q.created_at, q.stamped_at, q.external_id
|
||||||
FROM cfdi_queue q WHERE q.id = %s
|
FROM cfdi_queue q WHERE q.id = %s
|
||||||
""", (cfdi_id,))
|
""", (cfdi_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -258,13 +261,14 @@ def get_queue_item(cfdi_id):
|
|||||||
|
|
||||||
item = {
|
item = {
|
||||||
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
'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],
|
'uuid_fiscal': row[5], 'status': row[6],
|
||||||
'retry_count': row[7], 'provisional_folio': row[8],
|
'retry_count': row[7], 'provisional_folio': row[8],
|
||||||
'error_message': row[9], 'cancel_motive': row[10],
|
'error_message': row[9], 'cancel_motive': row[10],
|
||||||
'cancel_replacement_uuid': row[11],
|
'cancel_replacement_uuid': row[11],
|
||||||
'created_at': str(row[12]) if row[12] else None,
|
'created_at': str(row[12]) if row[12] else None,
|
||||||
'stamped_at': str(row[13]) if row[13] else None,
|
'stamped_at': str(row[13]) if row[13] else None,
|
||||||
|
'external_id': row[14],
|
||||||
}
|
}
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -281,19 +285,16 @@ def trigger_process_queue():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_config = _get_issuer_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
horux_url = tenant_config.get('horux_api_url')
|
if not tenant_config.get('facturapi_key'):
|
||||||
horux_key = tenant_config.get('horux_api_key')
|
|
||||||
|
|
||||||
if not horux_url or not horux_key:
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'error': 'Horux API not configured'}), 400
|
return jsonify({'error': 'Facturapi key not configured'}), 400
|
||||||
|
|
||||||
# Reset eligible failed items first
|
# Reset eligible failed items first
|
||||||
reset_count = retry_failed(conn)
|
reset_count = retry_failed(conn)
|
||||||
|
|
||||||
# Process the queue
|
# Process the queue
|
||||||
result = process_queue(conn, horux_url, horux_key)
|
result = process_queue(conn, tenant_config)
|
||||||
result['retries_reset'] = reset_count
|
result['retries_reset'] = reset_count
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -338,8 +339,7 @@ def cancel_invoice(cfdi_id):
|
|||||||
tenant_config = _get_issuer_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
result = cancel_cfdi(
|
result = cancel_cfdi(
|
||||||
conn, cfdi_id, motive, replacement_uuid,
|
conn, cfdi_id, motive, replacement_uuid,
|
||||||
tenant_config.get('horux_api_url'),
|
tenant_config=tenant_config,
|
||||||
tenant_config.get('horux_api_key'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
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),
|
'total': sum(s['total'] for s in sales),
|
||||||
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} 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'])
|
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_sale(sale_id):
|
def get_sale(sale_id):
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||||||
MIGRATIONS = {
|
MIGRATIONS = {
|
||||||
'v1.0': 'v1.0_initial.sql',
|
'v1.0': 'v1.0_initial.sql',
|
||||||
'v1.1': 'v1.1_pos_tables.sql',
|
'v1.1': 'v1.1_pos_tables.sql',
|
||||||
|
'v1.2': 'v1.2_subdomain.sql',
|
||||||
'v1.3': 'v1.3_fleet.sql',
|
'v1.3': 'v1.3_fleet.sql',
|
||||||
'v1.4': 'v1.4_whatsapp.sql',
|
'v1.4': 'v1.4_whatsapp.sql',
|
||||||
'v1.5': 'v1.5_returns.sql',
|
'v1.5': 'v1.5_returns.sql',
|
||||||
|
'v1.6': 'v1.6_marketplace.sql',
|
||||||
'v1.7': 'v1.7_plates.sql',
|
'v1.7': 'v1.7_plates.sql',
|
||||||
'v1.8': 'v1.8_performance_indexes.sql',
|
'v1.8': 'v1.8_performance_indexes.sql',
|
||||||
'v1.9': 'v1.9_redis_cache.sql',
|
'v1.9': 'v1.9_redis_cache.sql',
|
||||||
@@ -33,14 +35,20 @@ MIGRATIONS = {
|
|||||||
'v3.0': 'v3.0_public_api.sql',
|
'v3.0': 'v3.0_public_api.sql',
|
||||||
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
|
||||||
'v3.2': 'v3.2_db_performance.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.4': 'v3.4_meli_integration.sql',
|
||||||
'v3.5': 'v3.5_meli_questions.sql',
|
'v3.5': 'v3.5_meli_questions.sql',
|
||||||
|
'v3.5.1': 'v3.5_whatsapp_state_machine.sql',
|
||||||
'v3.6': 'v3.6_dropshipping.sql',
|
'v3.6': 'v3.6_dropshipping.sql',
|
||||||
'v3.7': 'v3.7_sku_aliases.sql',
|
'v3.7': 'v3.7_sku_aliases.sql',
|
||||||
'v3.8': 'v3.8_supplier_catalog.sql',
|
'v3.8': 'v3.8_supplier_catalog.sql',
|
||||||
|
'v3.9': 'v3.9_supplier_catalog_prices.sql',
|
||||||
'v4.0': 'v4.0_multi_branch.sql',
|
'v4.0': 'v4.0_multi_branch.sql',
|
||||||
'v4.1': 'v4.1_global_invoice.sql',
|
'v4.1': 'v4.1_global_invoice.sql',
|
||||||
'v4.2': 'v4.2_meli_sync_queue.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}")
|
print(f" ERROR: Migration file not found: {filepath}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
with open(filepath) as f:
|
||||||
|
sql = f.read()
|
||||||
|
|
||||||
|
# Skip migrations marked for manual/non-tenant execution
|
||||||
|
first_line = sql.splitlines()[0].strip() if sql.strip() else ''
|
||||||
|
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'):
|
||||||
|
print(f" SKIP (manual/non-tenant migration)")
|
||||||
|
return True
|
||||||
|
|
||||||
conn = get_tenant_conn_by_dbname(db_name)
|
conn = get_tenant_conn_by_dbname(db_name)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
with open(filepath) as f:
|
cur.execute(sql)
|
||||||
cur.execute(f.read())
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- ═══════════════════════════════════════════════════════════════════════
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
-- v3.3 — Marketplace accepts any part number (seller listings)
|
-- 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
|
-- Date: 2026-05-17
|
||||||
--
|
--
|
||||||
-- Makes warehouse_inventory part_id nullable and adds seller-defined
|
-- Makes warehouse_inventory part_id nullable and adds seller-defined
|
||||||
@@ -8,84 +8,86 @@
|
|||||||
-- Existing OEM-matched listings are untouched.
|
-- 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
|
-- Make part_id nullable so seller listings (without catalog match) can exist
|
||||||
ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100),
|
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
|
||||||
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
|
-- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ───
|
||||||
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
|
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).
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem
|
||||||
-- We replace it with two partial unique indexes:
|
ON warehouse_inventory(bodega_id, part_id, warehouse_location)
|
||||||
-- - OEM items: (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
|
WHERE part_id IS NOT NULL;
|
||||||
-- - Seller items: (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
|
|
||||||
|
|
||||||
ALTER TABLE warehouse_inventory
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller
|
||||||
DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key;
|
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
|
ALTER TABLE warehouse_inventory
|
||||||
ON warehouse_inventory(bodega_id, part_id, warehouse_location)
|
ADD CONSTRAINT chk_wi_part_or_seller
|
||||||
WHERE part_id IS NOT NULL;
|
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
|
-- ─── 3. WAREHOUSE_INVENTORY — search indexes ─────────────────────────
|
||||||
ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location)
|
CREATE INDEX IF NOT EXISTS idx_wi_seller_pn
|
||||||
WHERE part_id IS NULL;
|
ON warehouse_inventory (bodega_id, seller_part_number)
|
||||||
|
WHERE part_id IS NULL;
|
||||||
|
|
||||||
-- Ensure every row has either part_id or seller_part_number
|
CREATE INDEX IF NOT EXISTS idx_wi_seller_category
|
||||||
ALTER TABLE warehouse_inventory
|
ON warehouse_inventory (seller_category)
|
||||||
DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller;
|
WHERE part_id IS NULL;
|
||||||
|
|
||||||
ALTER TABLE warehouse_inventory
|
-- GIN index for text search on seller listings
|
||||||
ADD CONSTRAINT chk_wi_part_or_seller
|
CREATE INDEX IF NOT EXISTS idx_wi_seller_search
|
||||||
CHECK (
|
ON warehouse_inventory
|
||||||
(part_id IS NOT NULL AND seller_part_number IS NULL)
|
USING gin (to_tsvector('spanish',
|
||||||
OR
|
COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '')
|
||||||
(part_id IS NULL AND seller_part_number IS NOT NULL)
|
))
|
||||||
);
|
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
|
-- Add a flag so seller listings can be distinguished in POs
|
||||||
ON warehouse_inventory (bodega_id, seller_part_number)
|
ALTER TABLE purchase_order_items
|
||||||
WHERE part_id IS NULL;
|
ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
CREATE INDEX IF NOT EXISTS idx_wi_seller_category
|
END $$;
|
||||||
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;
|
|
||||||
|
|
||||||
-- ─── 5. Back-compat: ensure existing rows are valid ──────────────────
|
-- ─── 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.
|
UPDATE warehouse_inventory
|
||||||
-- If any row violates the new check, this will fail loudly.
|
SET part_id = NULL
|
||||||
UPDATE warehouse_inventory
|
WHERE part_id IS NULL AND seller_part_number IS NULL;
|
||||||
SET seller_part_number = NULL
|
END IF;
|
||||||
WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL;
|
END $$;
|
||||||
|
|
||||||
UPDATE warehouse_inventory
|
|
||||||
SET part_id = NULL
|
|
||||||
WHERE part_id IS NULL AND seller_part_number IS NULL;
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
-- : SKIP
|
||||||
-- Migration v3.3: Materialized view part_vehicle_preview
|
-- Migration v3.3: Materialized view part_vehicle_preview
|
||||||
-- Purpose: Pre-compute the "most recent vehicle" per part to eliminate
|
-- Purpose: Pre-compute the "most recent vehicle" per part to eliminate
|
||||||
-- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time.
|
-- DISTINCT ON + 4 JOINs over vehicle_parts (254 GB, 2B+ rows) at query time.
|
||||||
--
|
--
|
||||||
-- Notes:
|
-- NOTE: This migration targets the vehicle_database, not tenant databases.
|
||||||
-- - CREATE MATERIALIZED VIEW without CONCURRENTLY (first creation).
|
-- The runner skips files marked with ': SKIP' on the first line.
|
||||||
-- - REFRESH MATERIALIZED VIEW CONCURRENTLY is possible after the unique index exists.
|
-- To apply manually on the vehicle database, run:
|
||||||
-- - Run with statement_timeout = 0; this may take hours on first creation.
|
--
|
||||||
|
-- psql <vehicle_db> -f pos/migrations/v3.3_materialized_view.sql
|
||||||
|
--
|
||||||
|
-- (Remove the ': SKIP' line above before manual execution.)
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
SET statement_timeout = 0;
|
||||||
|
|
||||||
@@ -26,6 +30,3 @@ ORDER BY vp.part_id, y.year_car DESC;
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
|
CREATE UNIQUE INDEX idx_pvp_part ON part_vehicle_preview(part_id);
|
||||||
CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand);
|
CREATE INDEX idx_pvp_brand ON part_vehicle_preview(name_brand);
|
||||||
|
|
||||||
-- Grant select to application roles if needed
|
|
||||||
-- GRANT SELECT ON part_vehicle_preview TO nexus_app;
|
|
||||||
|
|||||||
@@ -1,100 +1,118 @@
|
|||||||
|
-- : SKIP
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- v3.5 WhatsApp State Machine
|
-- v3.5 WhatsApp State Machine
|
||||||
-- Reorganización del chatbot de AI libre a flujo estructurado
|
-- 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
|
DO $$
|
||||||
-- ---------------------------------------------------
|
|
||||||
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 $$
|
|
||||||
BEGIN
|
BEGIN
|
||||||
NEW.updated_at = NOW();
|
-- 1. Extender whatsapp_sessions con estado y contexto
|
||||||
RETURN NEW;
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
|
||||||
END;
|
ALTER TABLE whatsapp_sessions
|
||||||
$$ LANGUAGE plpgsql;
|
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 INDEX IF NOT EXISTS idx_wa_sessions_state ON whatsapp_sessions(state);
|
||||||
CREATE TRIGGER trg_wa_link_updated
|
CREATE INDEX IF NOT EXISTS idx_wa_sessions_customer ON whatsapp_sessions(customer_id);
|
||||||
BEFORE UPDATE ON wa_customer_links
|
CREATE INDEX IF NOT EXISTS idx_wa_sessions_updated ON whatsapp_sessions(updated_at);
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
|
END IF;
|
||||||
|
|
||||||
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
|
-- 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_learning_sessions (
|
CREATE TABLE IF NOT EXISTS wa_customer_links (
|
||||||
id SERIAL PRIMARY KEY,
|
phone VARCHAR(50) PRIMARY KEY,
|
||||||
phone VARCHAR(50) NOT NULL,
|
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||||
customer_id INTEGER REFERENCES customers(id),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
description TEXT NOT NULL,
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
offered_parts JSONB DEFAULT '[]',
|
);
|
||||||
status VARCHAR(20) DEFAULT 'pending',
|
|
||||||
resolved_part_id INTEGER REFERENCES inventory(id),
|
|
||||||
resolution_sale_id INTEGER REFERENCES sales(id),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
resolved_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
|
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
|
||||||
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);
|
|
||||||
|
|
||||||
-- 4. Tabla de configuración de envío por sucursal
|
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
|
||||||
-- ------------------------------------------------
|
RETURNS TRIGGER AS $$
|
||||||
CREATE TABLE IF NOT EXISTS branch_delivery_config (
|
BEGIN
|
||||||
id SERIAL PRIMARY KEY,
|
NEW.updated_at = NOW();
|
||||||
branch_id INTEGER NOT NULL UNIQUE REFERENCES branches(id),
|
RETURN NEW;
|
||||||
is_enabled BOOLEAN DEFAULT FALSE,
|
END;
|
||||||
delivery_fee NUMERIC(12,2) DEFAULT 0,
|
$$ LANGUAGE plpgsql;
|
||||||
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()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 5. Agregar push_name a whatsapp_messages (schema drift existente)
|
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
|
||||||
-- ------------------------------------------------------------------
|
CREATE TRIGGER trg_wa_link_updated
|
||||||
ALTER TABLE whatsapp_messages
|
BEFORE UPDATE ON wa_customer_links
|
||||||
ADD COLUMN IF NOT EXISTS push_name VARCHAR(200);
|
FOR EACH ROW EXECUTE FUNCTION update_wa_link_timestamp();
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- 6. Migrar datos existentes: vincular por teléfono
|
-- 3. Tabla de sesiones de aprendizaje (piezas no resueltas)
|
||||||
-- --------------------------------------------------
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'customers')
|
||||||
-- Intentar vincular sesiones WA existentes con customers por teléfono
|
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'inventory')
|
||||||
INSERT INTO wa_customer_links (phone, customer_id)
|
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'sales') THEN
|
||||||
SELECT ws.phone, c.id
|
CREATE TABLE IF NOT EXISTS wa_learning_sessions (
|
||||||
FROM whatsapp_sessions ws
|
id SERIAL PRIMARY KEY,
|
||||||
JOIN customers c ON c.phone = ws.phone
|
phone VARCHAR(50) NOT NULL,
|
||||||
WHERE ws.phone IS NOT NULL AND c.phone IS NOT NULL
|
customer_id INTEGER REFERENCES customers(id),
|
||||||
ON CONFLICT (phone) DO NOTHING;
|
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
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_phone ON wa_learning_sessions(phone);
|
||||||
UPDATE whatsapp_sessions ws
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_status ON wa_learning_sessions(status);
|
||||||
SET customer_id = wcl.customer_id
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_customer ON wa_learning_sessions(customer_id);
|
||||||
FROM wa_customer_links wcl
|
CREATE INDEX IF NOT EXISTS idx_wa_learn_created ON wa_learning_sessions(created_at);
|
||||||
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
|
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
|
-- v3.9_supplier_catalog_prices.sql
|
||||||
-- Per-tenant supplier pricing for items in the master supplier_catalog.
|
-- Per-tenant supplier pricing for items in the master supplier_catalog.
|
||||||
-- This table lives in the master DB and is joined by tenant_id.
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS supplier_catalog_prices (
|
||||||
id SERIAL PRIMARY KEY,
|
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
|
redis>=5.0
|
||||||
meilisearch>=0.40
|
meilisearch>=0.40
|
||||||
orjson
|
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
|
# /home/Autopartes/pos/services/cfdi_queue.py
|
||||||
"""CFDI queue service: manages the timbrado pipeline.
|
"""CFDI queue service: manages the Facturapi timbrado pipeline.
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
|
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||||
2. process_queue() — sends pending items to Horux API, updates status
|
2. process_queue() — sends pending items to Facturapi, updates status
|
||||||
3. retry_failed() — retries failed items with exponential backoff
|
3. retry_failed() — retries failed items with exponential backoff
|
||||||
4. cancel_cfdi() — sends cancel request to Horux API
|
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
||||||
|
|
||||||
Horux API endpoints:
|
Facturapi endpoints used:
|
||||||
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
|
POST /v2/invoices — create and stamp an invoice
|
||||||
GET /api/nexus/cfdi/status/:uuid — check timbrado status
|
GET /v2/invoices/:id — fetch invoice metadata
|
||||||
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
|
DELETE /v2/invoices/:id — cancel with SAT motive
|
||||||
|
|
||||||
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import requests
|
from services import facturapi_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
|||||||
|
|
||||||
|
|
||||||
def _generate_provisional_folio(conn):
|
def _generate_provisional_folio(conn):
|
||||||
"""Generate a provisional folio like PRE-00001.
|
"""Generate a provisional folio like PRE-00001."""
|
||||||
|
|
||||||
Uses the cfdi_queue table's max id to avoid collisions.
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||||
seq = cur.fetchone()[0]
|
seq = cur.fetchone()[0]
|
||||||
@@ -40,14 +37,14 @@ def _generate_provisional_folio(conn):
|
|||||||
return f'PRE-{seq:05d}'
|
return f'PRE-{seq:05d}'
|
||||||
|
|
||||||
|
|
||||||
def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
|
||||||
"""Add a CFDI to the timbrado queue.
|
"""Add a CFDI to the timbrado queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection
|
conn: psycopg2 connection
|
||||||
sale_id: int (FK to sales)
|
sale_id: int (FK to sales), may be None for global invoices
|
||||||
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
||||||
xml: str (unsigned XML from cfdi_builder)
|
payload: dict (Facturapi JSON payload) or str (JSON string)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {id, sale_id, type, status, provisional_folio}
|
dict: {id, sale_id, type, status, provisional_folio}
|
||||||
@@ -55,12 +52,14 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
|||||||
provisional_folio = _generate_provisional_folio(conn)
|
provisional_folio = _generate_provisional_folio(conn)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO cfdi_queue
|
INSERT INTO cfdi_queue
|
||||||
(sale_id, type, xml_unsigned, status, provisional_folio)
|
(sale_id, type, payload_unsigned, status, provisional_folio)
|
||||||
VALUES (%s, %s, %s, 'pending', %s)
|
VALUES (%s, %s, %s, 'pending', %s)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
""", (sale_id, cfdi_type, xml, provisional_folio))
|
""", (sale_id, cfdi_type, payload_json, provisional_folio))
|
||||||
cfdi_id, created_at = cur.fetchone()
|
cfdi_id, created_at = cur.fetchone()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
@@ -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.
|
"""Process all pending CFDI items in the queue.
|
||||||
|
|
||||||
Sends each pending XML to Horux for timbrado. On success, updates
|
Sends each pending payload to Facturapi for timbrado. On success, updates
|
||||||
the record with the signed XML and UUID fiscal. On failure, increments
|
the record with the signed XML and UUID fiscal. On failure, increments
|
||||||
retry_count and records the error.
|
retry_count and records the error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection
|
conn: psycopg2 connection
|
||||||
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
|
tenant_config: dict with facturapi_key (and optional facturapi_org_id)
|
||||||
api_key: str Horux API key
|
dry_run: if True, validates payload without stamping
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
||||||
@@ -92,7 +91,7 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, sale_id, type, xml_unsigned, retry_count
|
SELECT id, sale_id, type, payload_unsigned, retry_count
|
||||||
FROM cfdi_queue
|
FROM cfdi_queue
|
||||||
WHERE status IN ('pending', 'failed')
|
WHERE status IN ('pending', 'failed')
|
||||||
AND retry_count < %s
|
AND retry_count < %s
|
||||||
@@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
|
|
||||||
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
||||||
|
|
||||||
for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items:
|
api_key = tenant_config.get('facturapi_key')
|
||||||
|
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
|
results['processed'] += 1
|
||||||
|
|
||||||
# Update status to 'sending'
|
# Update status to 'sending'
|
||||||
@@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
payload = json.loads(payload_unsigned or '{}')
|
||||||
f'{horux_api_url}/api/nexus/cfdi/stamp',
|
if not payload:
|
||||||
headers={
|
raise ValueError("Empty payload in queue item")
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/xml',
|
|
||||||
},
|
|
||||||
data=xml_unsigned.encode('utf-8'),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
if dry_run:
|
||||||
data = response.json()
|
# TODO: Facturapi dry-run validation (not officially supported)
|
||||||
uuid_fiscal = data.get('uuid')
|
# For now we just skip the API call and mark as stamped with a fake UUID
|
||||||
xml_signed = data.get('xml', '')
|
raise ValueError("dry_run is not supported with Facturapi")
|
||||||
|
|
||||||
cur.execute("""
|
invoice = facturapi_service.create_invoice(tenant_config, payload)
|
||||||
UPDATE cfdi_queue
|
invoice_id = invoice.get('id')
|
||||||
SET status = 'stamped',
|
uuid_fiscal = invoice.get('uuid')
|
||||||
xml_signed = %s,
|
|
||||||
uuid_fiscal = %s,
|
|
||||||
stamped_at = NOW(),
|
|
||||||
error_message = NULL
|
|
||||||
WHERE id = %s
|
|
||||||
""", (xml_signed, uuid_fiscal, cfdi_id))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
results['stamped'] += 1
|
# Download signed XML for storage
|
||||||
results['details'].append({
|
try:
|
||||||
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
|
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)
|
||||||
else:
|
except Exception as xml_err:
|
||||||
error_msg = f'HTTP {response.status_code}: {response.text[:500]}'
|
logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
|
||||||
cur.execute("""
|
xml_signed_str = ''
|
||||||
UPDATE cfdi_queue
|
|
||||||
SET status = 'failed',
|
|
||||||
retry_count = retry_count + 1,
|
|
||||||
error_message = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""", (error_msg, cfdi_id))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
results['failed'] += 1
|
cur.execute("""
|
||||||
results['details'].append({
|
UPDATE cfdi_queue
|
||||||
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
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:
|
results['stamped'] += 1
|
||||||
error_msg = f'Connection error: {str(e)[:500]}'
|
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("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
@@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
|
|
||||||
|
|
||||||
def retry_failed(conn):
|
def retry_failed(conn):
|
||||||
"""Find failed items eligible for retry (based on backoff) and reset to pending.
|
"""Find failed items eligible for retry and reset to pending.
|
||||||
|
|
||||||
Uses exponential backoff: item is eligible for retry only if enough
|
Uses exponential backoff: item is eligible for retry only if enough
|
||||||
time has passed since the last attempt based on retry_count.
|
time has passed since the last attempt based on retry_count.
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: number of items reset to pending
|
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# For each failed item, check if enough time has passed for its retry level
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, retry_count, created_at
|
SELECT id, retry_count, created_at
|
||||||
FROM cfdi_queue
|
FROM cfdi_queue
|
||||||
@@ -206,15 +196,15 @@ def retry_failed(conn):
|
|||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
for cfdi_id, retry_count, created_at in items:
|
for cfdi_id, retry_count, created_at in items:
|
||||||
# Calculate required wait time based on retry count
|
|
||||||
if retry_count < len(BACKOFF_INTERVALS):
|
if retry_count < len(BACKOFF_INTERVALS):
|
||||||
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
||||||
else:
|
else:
|
||||||
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
|
wait_seconds = BACKOFF_INTERVALS[-1]
|
||||||
|
|
||||||
# Check if enough time has passed (use created_at as approximation)
|
# Use created_at as approximation for last attempt.
|
||||||
# In production, you'd track last_attempt_at separately
|
# In production, track last_attempt_at separately.
|
||||||
if True: # Always eligible for manual retry trigger
|
elapsed = (now - created_at).total_seconds()
|
||||||
|
if elapsed >= wait_seconds:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
||||||
""", (cfdi_id,))
|
""", (cfdi_id,))
|
||||||
@@ -226,8 +216,8 @@ def retry_failed(conn):
|
|||||||
|
|
||||||
|
|
||||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||||
horux_api_url=None, api_key=None):
|
tenant_config=None):
|
||||||
"""Cancel a stamped CFDI via Horux API.
|
"""Cancel a stamped CFDI via Facturapi.
|
||||||
|
|
||||||
SAT cancellation motives:
|
SAT cancellation motives:
|
||||||
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
||||||
@@ -240,8 +230,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cfdi_id: int (cfdi_queue.id)
|
cfdi_id: int (cfdi_queue.id)
|
||||||
motive: str ('01', '02', '03', '04')
|
motive: str ('01', '02', '03', '04')
|
||||||
replacement_uuid: str (required if motive == '01')
|
replacement_uuid: str (required if motive == '01')
|
||||||
horux_api_url: str (optional, skips API call if None — for offline)
|
tenant_config: dict with facturapi_key
|
||||||
api_key: str (optional)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {id, status, message}
|
dict: {id, status, message}
|
||||||
@@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
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,))
|
""", (cfdi_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
||||||
|
|
||||||
_, uuid_fiscal, current_status = row
|
_, uuid_fiscal, external_id, current_status = row
|
||||||
|
|
||||||
if current_status == 'cancelled':
|
if current_status == 'cancelled':
|
||||||
raise ValueError("CFDI is already cancelled")
|
raise ValueError("CFDI is already cancelled")
|
||||||
@@ -280,64 +269,26 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur.close()
|
cur.close()
|
||||||
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
||||||
|
|
||||||
# Send cancel request to Horux
|
if not tenant_config or not tenant_config.get('facturapi_key'):
|
||||||
if horux_api_url and api_key:
|
cur.close()
|
||||||
try:
|
raise ValueError("Facturapi key not configured for tenant")
|
||||||
payload = {
|
|
||||||
'uuid': uuid_fiscal,
|
|
||||||
'motive': motive,
|
|
||||||
}
|
|
||||||
if replacement_uuid:
|
|
||||||
payload['replacement_uuid'] = replacement_uuid
|
|
||||||
|
|
||||||
response = requests.post(
|
if not external_id:
|
||||||
f'{horux_api_url}/api/nexus/cfdi/cancel',
|
cur.close()
|
||||||
headers={
|
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
json=payload,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
try:
|
||||||
cur.execute("""
|
facturapi_service.cancel_invoice(
|
||||||
UPDATE cfdi_queue
|
tenant_config, external_id, motive,
|
||||||
SET status = 'cancelled',
|
replacement_uuid=replacement_uuid,
|
||||||
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)
|
|
||||||
|
|
||||||
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("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'cancelled',
|
SET status = 'cancelled',
|
||||||
cancel_motive = %s,
|
cancel_motive = %s,
|
||||||
cancel_replacement_uuid = %s,
|
cancel_replacement_uuid = %s,
|
||||||
error_message = 'Cancelled offline, pending SAT sync'
|
error_message = NULL
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (motive, replacement_uuid, cfdi_id))
|
""", (motive, replacement_uuid, cfdi_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -345,24 +296,23 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
return {
|
return {
|
||||||
'id': cfdi_id,
|
'id': cfdi_id,
|
||||||
'status': 'cancelled',
|
'status': 'cancelled',
|
||||||
'message': 'Cancelled offline, pending SAT sync',
|
'message': f'Cancelled with SAT (motive {motive})',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f'Cancel failed: {str(e)[:500]}'
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET error_message = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (error_msg, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
|
||||||
def get_queue_status(conn, filters=None):
|
def get_queue_status(conn, filters=None):
|
||||||
"""Get CFDI queue items with optional filters.
|
"""Get CFDI queue items with optional filters."""
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
filters: dict with optional keys:
|
|
||||||
status: str filter by status
|
|
||||||
sale_id: int filter by sale
|
|
||||||
page: int (default 1)
|
|
||||||
per_page: int (default 50)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {data: [...], pagination: {...}}
|
|
||||||
"""
|
|
||||||
filters = filters or {}
|
filters = filters or {}
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None):
|
|||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||||
q.retry_count, q.provisional_folio, q.error_message,
|
q.retry_count, q.provisional_folio, q.error_message,
|
||||||
q.cancel_motive, q.created_at, q.stamped_at
|
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||||
FROM cfdi_queue q
|
FROM cfdi_queue q
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY q.created_at DESC
|
ORDER BY q.created_at DESC
|
||||||
@@ -408,6 +358,7 @@ def get_queue_status(conn, filters=None):
|
|||||||
'error_message': r[7], 'cancel_motive': r[8],
|
'error_message': r[7], 'cancel_motive': r[8],
|
||||||
'created_at': str(r[9]) if r[9] else None,
|
'created_at': str(r[9]) if r[9] else None,
|
||||||
'stamped_at': str(r[10]) if r[10] else None,
|
'stamped_at': str(r[10]) if r[10] else None,
|
||||||
|
'external_id': r[11],
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
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 datetime import datetime
|
||||||
from decimal import Decimal
|
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
|
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',
|
return {'error': 'NO_ELIGIBLE_SALES',
|
||||||
'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'}
|
'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)
|
# 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']
|
cfdi_id = result['id']
|
||||||
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -167,7 +167,7 @@ def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
|||||||
'sales_count': len(sales),
|
'sales_count': len(sales),
|
||||||
'total': sum(s['total'] for s in sales),
|
'total': sum(s['total'] for s in sales),
|
||||||
'provisional_folio': result['provisional_folio'],
|
'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) {
|
function setPeriod(btn) {
|
||||||
btn.closest('.period-selector').querySelectorAll('.period-btn').forEach(function(b) {
|
btn.closest('.period-selector').querySelectorAll('.period-btn').forEach(function(b) {
|
||||||
b.classList.remove('active');
|
b.classList.remove('active');
|
||||||
});
|
});
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
const period = btn.textContent.trim().toLowerCase();
|
||||||
|
loadChart(period);
|
||||||
}
|
}
|
||||||
window.setPeriod = setPeriod;
|
window.setPeriod = setPeriod;
|
||||||
|
|
||||||
@@ -205,6 +207,50 @@ const Dashboard = (() => {
|
|||||||
return data;
|
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) {
|
function setKpiError(valueId, metaId) {
|
||||||
const v = document.getElementById(valueId);
|
const v = document.getElementById(valueId);
|
||||||
const m = document.getElementById(metaId);
|
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 chartEl = document.getElementById('bar-chart');
|
||||||
const totalEl = document.getElementById('chart-week-total');
|
const totalEl = document.getElementById('chart-week-total');
|
||||||
|
const legendEl = document.getElementById('chart-legend');
|
||||||
|
const titleEl = document.querySelector('.chart-header .section-title');
|
||||||
if (!chartEl) return;
|
if (!chartEl) return;
|
||||||
|
|
||||||
// Fetch daily summary for each of last 7 days
|
period = period || 'semana';
|
||||||
const days = [];
|
|
||||||
for (let i = 6; i >= 0; i--) {
|
let dateFrom, dateTo, labels = [], buckets = {}, labelOrder = [];
|
||||||
days.push(daysAgo(i));
|
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(
|
// Fetch normal sales for short periods
|
||||||
days.map(d => apiFetch(`/pos/api/register/daily-summary?date=${d}`))
|
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;
|
// Fetch historical sales for the range
|
||||||
const dayData = days.map((dateStr, i) => {
|
let histRows = [];
|
||||||
const s = summaries[i];
|
try {
|
||||||
const total = s ? (s.total_sales || 0) : 0;
|
const perPage = period === 'año' ? 2000 : 1000;
|
||||||
weekTotal += total;
|
const histData = await apiFetch(`/pos/api/historical-sales?date_from=${dateFrom}&date_to=${dateTo}&per_page=${perPage}`);
|
||||||
const d = new Date(dateStr + 'T12:00:00');
|
histRows = histData.data || [];
|
||||||
return {
|
const totalPages = histData.pagination ? histData.pagination.total_pages : 1;
|
||||||
label: DAY_NAMES_SHORT[d.getDay()],
|
for (let p = 2; p <= totalPages && p <= 20; p++) {
|
||||||
total: total,
|
const more = await apiFetch(`/pos/api/historical-sales?date_from=${dateFrom}&date_to=${dateTo}&per_page=${perPage}&page=${p}`);
|
||||||
isToday: dateStr === todayStr(),
|
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) {
|
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);
|
const maxVal = Math.max(...dayData.map(d => d.total), 1);
|
||||||
@@ -533,9 +699,10 @@ const Dashboard = (() => {
|
|||||||
|
|
||||||
// Load all data in parallel
|
// Load all data in parallel
|
||||||
loadDailySummary();
|
loadDailySummary();
|
||||||
|
loadHistoricalSummary();
|
||||||
loadAlerts();
|
loadAlerts();
|
||||||
loadTopProducts();
|
loadTopProducts();
|
||||||
loadWeeklyChart();
|
loadChart('semana');
|
||||||
loadRecentSales();
|
loadRecentSales();
|
||||||
|
|
||||||
// Auto-refresh every 2 minutes
|
// Auto-refresh every 2 minutes
|
||||||
|
|||||||
@@ -478,16 +478,140 @@
|
|||||||
// PURCHASE / ENTRADA (purchaseModal)
|
// PURCHASE / ENTRADA (purchaseModal)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
let purchaseSearchTimeout = null;
|
||||||
|
let purchaseSelectedItem = null;
|
||||||
|
|
||||||
function showPurchaseModal() {
|
function showPurchaseModal() {
|
||||||
document.getElementById('purchaseModal').classList.add('is-open');
|
document.getElementById('purchaseModal').classList.add('is-open');
|
||||||
|
setTimeout(function() {
|
||||||
|
var el = document.getElementById('purchaseItemSearch');
|
||||||
|
if (el) el.focus();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
function showPurchaseModalForItem(itemId) {
|
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();
|
showPurchaseModal();
|
||||||
}
|
}
|
||||||
function closePurchaseModal() {
|
function closePurchaseModal() {
|
||||||
document.getElementById('purchaseModal').classList.remove('is-open');
|
document.getElementById('purchaseModal').classList.remove('is-open');
|
||||||
document.getElementById('purchaseResult').innerHTML = '';
|
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() {
|
function recordPurchase() {
|
||||||
@@ -506,10 +630,6 @@
|
|||||||
if (result && result.operation_id) {
|
if (result && result.operation_id) {
|
||||||
document.getElementById('purchaseResult').innerHTML = '<span style="color:var(--color-success);">Compra registrada (op #' + result.operation_id + ')</span>';
|
document.getElementById('purchaseResult').innerHTML = '<span style="color:var(--color-success);">Compra registrada (op #' + result.operation_id + ')</span>';
|
||||||
closePurchaseModal();
|
closePurchaseModal();
|
||||||
['purchaseItemId','purchaseQty','purchaseCost','purchaseInvoice','purchaseNotes'].forEach(function(id) {
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if (el) el.value = '';
|
|
||||||
});
|
|
||||||
if (window.loadInventoryStats) window.loadInventoryStats();
|
if (window.loadInventoryStats) window.loadInventoryStats();
|
||||||
loadItems(currentPage);
|
loadItems(currentPage);
|
||||||
} else {
|
} else {
|
||||||
@@ -2009,4 +2129,5 @@
|
|||||||
|
|
||||||
loadItems(1);
|
loadItems(1);
|
||||||
renderSavedFilters();
|
renderSavedFilters();
|
||||||
|
wirePurchaseSearch();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const Invoicing = (() => {
|
|||||||
if (name === 'notas') loadNotas();
|
if (name === 'notas') loadNotas();
|
||||||
if (name === 'complementos') loadComplementos();
|
if (name === 'complementos') loadComplementos();
|
||||||
if (name === 'cancelaciones') loadCancelaciones();
|
if (name === 'cancelaciones') loadCancelaciones();
|
||||||
|
if (name === 'config') loadFacturapiStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Badge helpers ----
|
// ---- 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) ----
|
// ---- Detail modal (uses modalDetalleOverlay) ----
|
||||||
async function showDetail(cfdiId) {
|
async function showDetail(cfdiId) {
|
||||||
const overlay = document.getElementById('modalDetalleOverlay');
|
const overlay = document.getElementById('modalDetalleOverlay');
|
||||||
@@ -300,10 +370,17 @@ const Invoicing = (() => {
|
|||||||
</div>
|
</div>
|
||||||
</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.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) ? `
|
${(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);">Vista previa XML</div>
|
<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.xml_unsigned)}</pre>
|
<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
|
// Wire the cancel button inside modal footer
|
||||||
@@ -531,10 +608,10 @@ const Invoicing = (() => {
|
|||||||
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
|
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones,
|
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
|
||||||
showDetail, showCancelModal, confirmCancel, processQueue,
|
showDetail, showCancelModal, confirmCancel, processQueue,
|
||||||
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
||||||
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice,
|
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const Reports = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track which tabs have been loaded
|
// 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
|
// Theme switcher
|
||||||
@@ -85,6 +85,7 @@ const Reports = (() => {
|
|||||||
else if (id === 'inventario') loadInventario();
|
else if (id === 'inventario') loadInventario();
|
||||||
else if (id === 'clientes') loadClientes();
|
else if (id === 'clientes') loadClientes();
|
||||||
else if (id === 'financieros') loadFinancieros();
|
else if (id === 'financieros') loadFinancieros();
|
||||||
|
else if (id === 'historico') loadHistorico();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.switchTab = switchTab;
|
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
|
// TAB 2: INVENTARIO
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -712,7 +792,7 @@ const Reports = (() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
init, setTheme, switchTab,
|
init, setTheme, switchTab,
|
||||||
loadVentas, loadInventario, loadClientes, loadFinancieros, fmt
|
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
|
||||||
};
|
};
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||||
// so templates can use cache-busting query params freely.
|
// so templates can use cache-busting query params freely.
|
||||||
|
|
||||||
const CACHE_NAME = 'nexus-pos-v17';
|
const CACHE_NAME = 'nexus-pos-v18';
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
'/pos/static/css/tokens.css',
|
'/pos/static/css/tokens.css',
|
||||||
|
|||||||
@@ -149,6 +149,16 @@
|
|||||||
Reportes
|
Reportes
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a href="/pos/config" class="nav-link">
|
<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">
|
<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
|
Reportes
|
||||||
</a>
|
</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>
|
<div class="sidebar__section-label">Gestión</div>
|
||||||
|
|
||||||
<a href="/pos/marketplace" class="nav-link">
|
<a href="/pos/marketplace" class="nav-link">
|
||||||
@@ -318,6 +329,68 @@
|
|||||||
</div><!-- end kpi-grid -->
|
</div><!-- end kpi-grid -->
|
||||||
</section>
|
</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)
|
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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" 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-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 src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></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>
|
<span>Reportes</span>
|
||||||
</a>
|
</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>
|
<div class="nav-section-label">Sistema</div>
|
||||||
|
|
||||||
<a class="nav-item" href="/pos/config">
|
<a class="nav-item" href="/pos/config">
|
||||||
@@ -750,7 +760,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="inv-modal__body">
|
<div class="inv-modal__body">
|
||||||
<div class="inv-form-grid">
|
<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>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>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>
|
<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/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" 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/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/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.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>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|||||||
@@ -865,6 +865,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- CONFIGURACIÓN DE SERIES — full width -->
|
||||||
<div class="config-section" style="grid-column: span 2;">
|
<div class="config-section" style="grid-column: span 2;">
|
||||||
<div class="config-section__header">
|
<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/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=2" 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/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 src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|||||||
@@ -130,6 +130,16 @@
|
|||||||
<span>Reportes</span>
|
<span>Reportes</span>
|
||||||
</a>
|
</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>
|
<div class="nav-section-label">Sistema</div>
|
||||||
|
|
||||||
<a href="/pos/config" class="nav-item">
|
<a href="/pos/config" class="nav-item">
|
||||||
@@ -206,6 +216,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Financieros
|
Financieros
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ==================================================================
|
<!-- ==================================================================
|
||||||
@@ -308,6 +326,30 @@
|
|||||||
<!-- Cortes de caja -->
|
<!-- Cortes de caja -->
|
||||||
<div class="table-card mb-5" id="financieros-cortes"></div>
|
<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>
|
</div>
|
||||||
<!-- End panels -->
|
<!-- End panels -->
|
||||||
|
|
||||||
@@ -323,7 +365,7 @@
|
|||||||
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" 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 src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></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