Compare commits
10 Commits
aeac4387df
...
bc950efc26
| Author | SHA1 | Date | |
|---|---|---|---|
| bc950efc26 | |||
| c56709d45e | |||
| 4e6bac8661 | |||
| 62ea08de9f | |||
| 9a9031fd27 | |||
| e0773cdc1e | |||
| fe8eff5ea0 | |||
| 40e7dd371a | |||
| a665cdb737 | |||
| 3b804f2c34 |
14
pos/app.py
14
pos/app.py
@@ -25,6 +25,12 @@ def create_app():
|
|||||||
from blueprints.cashregister_bp import cashregister_bp
|
from blueprints.cashregister_bp import cashregister_bp
|
||||||
app.register_blueprint(cashregister_bp)
|
app.register_blueprint(cashregister_bp)
|
||||||
|
|
||||||
|
from blueprints.invoicing_bp import invoicing_bp
|
||||||
|
app.register_blueprint(invoicing_bp)
|
||||||
|
|
||||||
|
from blueprints.accounting_bp import accounting_bp
|
||||||
|
app.register_blueprint(accounting_bp)
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
@@ -52,6 +58,14 @@ def create_app():
|
|||||||
def pos_customers():
|
def pos_customers():
|
||||||
return render_template('customers.html')
|
return render_template('customers.html')
|
||||||
|
|
||||||
|
@app.route('/pos/invoicing')
|
||||||
|
def pos_invoicing():
|
||||||
|
return render_template('invoicing.html')
|
||||||
|
|
||||||
|
@app.route('/pos/accounting')
|
||||||
|
def pos_accounting():
|
||||||
|
return render_template('accounting.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)
|
||||||
|
|||||||
743
pos/blueprints/accounting_bp.py
Normal file
743
pos/blueprints/accounting_bp.py
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
# /home/Autopartes/pos/blueprints/accounting_bp.py
|
||||||
|
"""Accounting blueprint: chart of accounts, journal entries, financial reports.
|
||||||
|
|
||||||
|
Financial reports (balance sheet, income statement, trial balance, aging)
|
||||||
|
are computed via SQL aggregation on journal_entry_lines. All amounts are
|
||||||
|
NUMERIC(14,2) in the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from middleware import require_auth
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
from services.accounting_engine import create_manual_entry
|
||||||
|
from services.audit import log_action
|
||||||
|
|
||||||
|
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Chart of Accounts ─────────────────────────────
|
||||||
|
|
||||||
|
@accounting_bp.route('/accounts', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def list_accounts():
|
||||||
|
"""Get chart of accounts as a flat list (frontend builds tree).
|
||||||
|
|
||||||
|
Returns all active accounts with parent_id for tree construction.
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT a.id, a.code, a.name, a.parent_id, a.type, a.sat_code,
|
||||||
|
a.is_system, a.is_active,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT SUM(l.debit) - SUM(l.credit)
|
||||||
|
FROM journal_entry_lines l
|
||||||
|
JOIN journal_entries e ON l.journal_entry_id = e.id
|
||||||
|
WHERE l.account_id = a.id AND e.status = 'posted'), 0
|
||||||
|
) as balance
|
||||||
|
FROM accounts a
|
||||||
|
WHERE a.is_active = true
|
||||||
|
ORDER BY a.code
|
||||||
|
""")
|
||||||
|
|
||||||
|
accounts = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
accounts.append({
|
||||||
|
'id': r[0], 'code': r[1], 'name': r[2],
|
||||||
|
'parent_id': r[3], 'type': r[4], 'sat_code': r[5],
|
||||||
|
'is_system': r[6], 'is_active': r[7],
|
||||||
|
'balance': float(r[8]) if r[8] else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'data': accounts})
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/accounts', methods=['POST'])
|
||||||
|
@require_auth('accounting.create')
|
||||||
|
def create_account():
|
||||||
|
"""Create a new sub-account.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
code: str (must be unique),
|
||||||
|
name: str,
|
||||||
|
parent_id: int (must exist),
|
||||||
|
type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto',
|
||||||
|
sat_code: str (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
System accounts (is_system=true) cannot be created via API.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
code = data.get('code', '').strip()
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
parent_id = data.get('parent_id')
|
||||||
|
acct_type = data.get('type', '')
|
||||||
|
sat_code = data.get('sat_code')
|
||||||
|
|
||||||
|
if not code or not name:
|
||||||
|
return jsonify({'error': 'code and name are required'}), 400
|
||||||
|
|
||||||
|
valid_types = ('activo', 'pasivo', 'capital', 'ingreso', 'costo', 'gasto')
|
||||||
|
if acct_type not in valid_types:
|
||||||
|
return jsonify({'error': f'type must be one of: {", ".join(valid_types)}'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check uniqueness
|
||||||
|
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
||||||
|
if cur.fetchone():
|
||||||
|
return jsonify({'error': f'Account code {code} already exists'}), 409
|
||||||
|
|
||||||
|
# Validate parent exists
|
||||||
|
if parent_id:
|
||||||
|
cur.execute("SELECT id FROM accounts WHERE id = %s AND is_active = true", (parent_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
return jsonify({'error': f'Parent account {parent_id} not found'}), 404
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, false, true)
|
||||||
|
RETURNING id
|
||||||
|
""", (code, name, parent_id, acct_type, sat_code))
|
||||||
|
acct_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
log_action(conn, 'ACCOUNT_CREATED', 'account', acct_id,
|
||||||
|
new_value={'code': code, 'name': name, 'type': acct_type})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({'id': acct_id, 'code': code, 'name': name, 'type': acct_type}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Journal Entries ────────────────────────────────
|
||||||
|
|
||||||
|
@accounting_bp.route('/entries', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def list_entries():
|
||||||
|
"""List journal entries with filters.
|
||||||
|
|
||||||
|
Query params: date_from, date_to, type, reference_type, is_auto, page, per_page
|
||||||
|
"""
|
||||||
|
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')
|
||||||
|
entry_type = request.args.get('type')
|
||||||
|
ref_type = request.args.get('reference_type')
|
||||||
|
is_auto = request.args.get('is_auto')
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
where_clauses.append("je.date >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
where_clauses.append("je.date <= %s")
|
||||||
|
params.append(date_to)
|
||||||
|
if entry_type:
|
||||||
|
where_clauses.append("je.type = %s")
|
||||||
|
params.append(entry_type)
|
||||||
|
if ref_type:
|
||||||
|
where_clauses.append("je.reference_type = %s")
|
||||||
|
params.append(ref_type)
|
||||||
|
if is_auto is not None:
|
||||||
|
where_clauses.append("je.is_auto = %s")
|
||||||
|
params.append(is_auto.lower() in ('true', '1'))
|
||||||
|
|
||||||
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT count(*) FROM journal_entries je WHERE {where}", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT je.id, je.entry_number, je.date, je.type, je.description,
|
||||||
|
je.reference_type, je.reference_id, je.status, je.is_auto,
|
||||||
|
je.created_at, e.name as created_by_name,
|
||||||
|
(SELECT SUM(debit) FROM journal_entry_lines WHERE journal_entry_id = je.id) as total_debit
|
||||||
|
FROM journal_entries je
|
||||||
|
LEFT JOIN employees e ON je.created_by = e.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY je.date DESC, je.entry_number DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [per_page, (page - 1) * per_page])
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
entries.append({
|
||||||
|
'id': r[0], 'entry_number': r[1],
|
||||||
|
'date': str(r[2]) if r[2] else None,
|
||||||
|
'type': r[3], 'description': r[4],
|
||||||
|
'reference_type': r[5], 'reference_id': r[6],
|
||||||
|
'status': r[7], 'is_auto': r[8],
|
||||||
|
'created_at': str(r[9]) if r[9] else None,
|
||||||
|
'created_by_name': r[10],
|
||||||
|
'total_amount': float(r[11]) if r[11] else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
return jsonify({
|
||||||
|
'data': entries,
|
||||||
|
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/entries/<int:entry_id>', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def get_entry(entry_id):
|
||||||
|
"""Get journal entry detail with all lines."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT je.id, je.entry_number, je.date, je.type, je.description,
|
||||||
|
je.reference_type, je.reference_id, je.status, je.is_auto,
|
||||||
|
je.created_at, je.created_by, e.name as created_by_name
|
||||||
|
FROM journal_entries je
|
||||||
|
LEFT JOIN employees e ON je.created_by = e.id
|
||||||
|
WHERE je.id = %s
|
||||||
|
""", (entry_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Journal entry not found'}), 404
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
'id': row[0], 'entry_number': row[1],
|
||||||
|
'date': str(row[2]) if row[2] else None,
|
||||||
|
'type': row[3], 'description': row[4],
|
||||||
|
'reference_type': row[5], 'reference_id': row[6],
|
||||||
|
'status': row[7], 'is_auto': row[8],
|
||||||
|
'created_at': str(row[9]) if row[9] else None,
|
||||||
|
'created_by': row[10], 'created_by_name': row[11],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get lines
|
||||||
|
cur.execute("""
|
||||||
|
SELECT l.id, l.account_id, a.code, a.name, l.debit, l.credit, l.description
|
||||||
|
FROM journal_entry_lines l
|
||||||
|
JOIN accounts a ON l.account_id = a.id
|
||||||
|
WHERE l.journal_entry_id = %s
|
||||||
|
ORDER BY l.id
|
||||||
|
""", (entry_id,))
|
||||||
|
|
||||||
|
entry['lines'] = []
|
||||||
|
total_debit = 0
|
||||||
|
total_credit = 0
|
||||||
|
for r in cur.fetchall():
|
||||||
|
debit = float(r[4]) if r[4] else 0
|
||||||
|
credit = float(r[5]) if r[5] else 0
|
||||||
|
total_debit += debit
|
||||||
|
total_credit += credit
|
||||||
|
entry['lines'].append({
|
||||||
|
'id': r[0], 'account_id': r[1], 'account_code': r[2],
|
||||||
|
'account_name': r[3], 'debit': debit, 'credit': credit,
|
||||||
|
'description': r[6],
|
||||||
|
})
|
||||||
|
|
||||||
|
entry['total_debit'] = round(total_debit, 2)
|
||||||
|
entry['total_credit'] = round(total_credit, 2)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/entries', methods=['POST'])
|
||||||
|
@require_auth('accounting.create')
|
||||||
|
def create_entry():
|
||||||
|
"""Create a manual journal entry.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
date: 'YYYY-MM-DD',
|
||||||
|
description: str,
|
||||||
|
lines: [{account_id: int, debit: float, credit: float, description: str}]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
if not data.get('date'):
|
||||||
|
return jsonify({'error': 'date is required'}), 400
|
||||||
|
if not data.get('lines') or len(data['lines']) < 2:
|
||||||
|
return jsonify({'error': 'At least 2 lines are required'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry_id = create_manual_entry(conn, data)
|
||||||
|
|
||||||
|
log_action(conn, 'MANUAL_ENTRY', 'journal_entry', entry_id,
|
||||||
|
new_value={'date': data['date'], 'description': data.get('description', '')})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'id': entry_id, 'status': 'posted'}), 201
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Financial Reports ──────────────────────────────
|
||||||
|
|
||||||
|
@accounting_bp.route('/trial-balance', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def trial_balance():
|
||||||
|
"""Balanza de comprobacion for a period.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
year: int (default current year)
|
||||||
|
month: int (default current month)
|
||||||
|
|
||||||
|
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
year = int(request.args.get('year', date.today().year))
|
||||||
|
month = int(request.args.get('month', date.today().month))
|
||||||
|
|
||||||
|
# Period start/end dates
|
||||||
|
period_start = f'{year}-{month:02d}-01'
|
||||||
|
if month == 12:
|
||||||
|
period_end = f'{year + 1}-01-01'
|
||||||
|
else:
|
||||||
|
period_end = f'{year}-{month + 1:02d}-01'
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
WITH initial_balances AS (
|
||||||
|
SELECT l.account_id,
|
||||||
|
SUM(l.debit) as prev_debits,
|
||||||
|
SUM(l.credit) as prev_credits
|
||||||
|
FROM journal_entry_lines l
|
||||||
|
JOIN journal_entries e ON l.journal_entry_id = e.id
|
||||||
|
WHERE e.status = 'posted' AND e.date < %s
|
||||||
|
GROUP BY l.account_id
|
||||||
|
),
|
||||||
|
period_movements AS (
|
||||||
|
SELECT l.account_id,
|
||||||
|
SUM(l.debit) as period_debits,
|
||||||
|
SUM(l.credit) as period_credits
|
||||||
|
FROM journal_entry_lines l
|
||||||
|
JOIN journal_entries e ON l.journal_entry_id = e.id
|
||||||
|
WHERE e.status = 'posted' AND e.date >= %s AND e.date < %s
|
||||||
|
GROUP BY l.account_id
|
||||||
|
)
|
||||||
|
SELECT a.id, a.code, a.name, a.type, a.parent_id,
|
||||||
|
COALESCE(ib.prev_debits, 0) as prev_debits,
|
||||||
|
COALESCE(ib.prev_credits, 0) as prev_credits,
|
||||||
|
COALESCE(pm.period_debits, 0) as period_debits,
|
||||||
|
COALESCE(pm.period_credits, 0) as period_credits
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN initial_balances ib ON a.id = ib.account_id
|
||||||
|
LEFT JOIN period_movements pm ON a.id = pm.account_id
|
||||||
|
WHERE a.is_active = true
|
||||||
|
AND (ib.account_id IS NOT NULL OR pm.account_id IS NOT NULL)
|
||||||
|
ORDER BY a.code
|
||||||
|
""", (period_start, period_start, period_end))
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
prev_debits = float(r[5])
|
||||||
|
prev_credits = float(r[6])
|
||||||
|
saldo_inicial = prev_debits - prev_credits
|
||||||
|
cargos = float(r[7])
|
||||||
|
abonos = float(r[8])
|
||||||
|
saldo_final = saldo_inicial + cargos - abonos
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
'account_id': r[0], 'code': r[1], 'name': r[2],
|
||||||
|
'type': r[3], 'parent_id': r[4],
|
||||||
|
'saldo_inicial': round(saldo_inicial, 2),
|
||||||
|
'cargos': round(cargos, 2),
|
||||||
|
'abonos': round(abonos, 2),
|
||||||
|
'saldo_final': round(saldo_final, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/income-statement', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def income_statement():
|
||||||
|
"""Estado de resultados for a period.
|
||||||
|
|
||||||
|
Query params: year, month
|
||||||
|
|
||||||
|
Groups: Ingresos (400), Costos (500), Gastos (600)
|
||||||
|
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
year = int(request.args.get('year', date.today().year))
|
||||||
|
month = int(request.args.get('month', date.today().month))
|
||||||
|
|
||||||
|
period_start = f'{year}-{month:02d}-01'
|
||||||
|
if month == 12:
|
||||||
|
period_end = f'{year + 1}-01-01'
|
||||||
|
else:
|
||||||
|
period_end = f'{year}-{month + 1:02d}-01'
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT a.id, a.code, a.name, a.type,
|
||||||
|
COALESCE(SUM(l.debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(l.credit), 0) as total_credit
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines l ON a.id = l.account_id
|
||||||
|
LEFT JOIN journal_entries e ON l.journal_entry_id = e.id
|
||||||
|
AND e.status = 'posted'
|
||||||
|
AND e.date >= %s AND e.date < %s
|
||||||
|
WHERE a.type IN ('ingreso', 'costo', 'gasto')
|
||||||
|
AND a.is_active = true
|
||||||
|
AND a.parent_id IS NOT NULL
|
||||||
|
GROUP BY a.id, a.code, a.name, a.type
|
||||||
|
HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0
|
||||||
|
ORDER BY a.code
|
||||||
|
""", (period_start, period_end))
|
||||||
|
|
||||||
|
ingresos = []
|
||||||
|
costos = []
|
||||||
|
gastos = []
|
||||||
|
total_ingresos = 0
|
||||||
|
total_costos = 0
|
||||||
|
total_gastos = 0
|
||||||
|
|
||||||
|
for r in cur.fetchall():
|
||||||
|
debit = float(r[4])
|
||||||
|
credit = float(r[5])
|
||||||
|
# For income accounts: credit - debit = revenue
|
||||||
|
# For cost/expense accounts: debit - credit = expense
|
||||||
|
if r[3] == 'ingreso':
|
||||||
|
amount = round(credit - debit, 2)
|
||||||
|
total_ingresos += amount
|
||||||
|
ingresos.append({'code': r[1], 'name': r[2], 'amount': amount})
|
||||||
|
elif r[3] == 'costo':
|
||||||
|
amount = round(debit - credit, 2)
|
||||||
|
total_costos += amount
|
||||||
|
costos.append({'code': r[1], 'name': r[2], 'amount': amount})
|
||||||
|
elif r[3] == 'gasto':
|
||||||
|
amount = round(debit - credit, 2)
|
||||||
|
total_gastos += amount
|
||||||
|
gastos.append({'code': r[1], 'name': r[2], 'amount': amount})
|
||||||
|
|
||||||
|
utilidad_bruta = round(total_ingresos - total_costos, 2)
|
||||||
|
utilidad_neta = round(utilidad_bruta - total_gastos, 2)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'period': {'year': year, 'month': month},
|
||||||
|
'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)},
|
||||||
|
'costos': {'items': costos, 'total': round(total_costos, 2)},
|
||||||
|
'utilidad_bruta': utilidad_bruta,
|
||||||
|
'gastos': {'items': gastos, 'total': round(total_gastos, 2)},
|
||||||
|
'utilidad_neta': utilidad_neta,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/balance-sheet', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def balance_sheet():
|
||||||
|
"""Balance general as of a given date.
|
||||||
|
|
||||||
|
Query params: date (YYYY-MM-DD, default today)
|
||||||
|
|
||||||
|
Groups: Activo (100), Pasivo (200), Capital (300)
|
||||||
|
Validates: Activo = Pasivo + Capital
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
as_of = request.args.get('date', str(date.today()))
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT a.id, a.code, a.name, a.type, a.parent_id,
|
||||||
|
COALESCE(SUM(l.debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(l.credit), 0) as total_credit
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines l ON a.id = l.account_id
|
||||||
|
LEFT JOIN journal_entries e ON l.journal_entry_id = e.id
|
||||||
|
AND e.status = 'posted'
|
||||||
|
AND e.date <= %s
|
||||||
|
WHERE a.type IN ('activo', 'pasivo', 'capital')
|
||||||
|
AND a.is_active = true
|
||||||
|
AND a.parent_id IS NOT NULL
|
||||||
|
GROUP BY a.id, a.code, a.name, a.type, a.parent_id
|
||||||
|
HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0
|
||||||
|
ORDER BY a.code
|
||||||
|
""", (as_of,))
|
||||||
|
|
||||||
|
activo = []
|
||||||
|
pasivo = []
|
||||||
|
capital_items = []
|
||||||
|
total_activo = 0
|
||||||
|
total_pasivo = 0
|
||||||
|
total_capital = 0
|
||||||
|
|
||||||
|
for r in cur.fetchall():
|
||||||
|
debit = float(r[5])
|
||||||
|
credit = float(r[6])
|
||||||
|
if r[3] == 'activo':
|
||||||
|
balance = round(debit - credit, 2)
|
||||||
|
total_activo += balance
|
||||||
|
activo.append({'code': r[1], 'name': r[2], 'balance': balance})
|
||||||
|
elif r[3] == 'pasivo':
|
||||||
|
balance = round(credit - debit, 2)
|
||||||
|
total_pasivo += balance
|
||||||
|
pasivo.append({'code': r[1], 'name': r[2], 'balance': balance})
|
||||||
|
elif r[3] == 'capital':
|
||||||
|
balance = round(credit - debit, 2)
|
||||||
|
total_capital += balance
|
||||||
|
capital_items.append({'code': r[1], 'name': r[2], 'balance': balance})
|
||||||
|
|
||||||
|
# Include current period net income in capital
|
||||||
|
# (Ingresos - Costos - Gastos for the current year)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT a.type,
|
||||||
|
COALESCE(SUM(l.debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(l.credit), 0) as total_credit
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines l ON a.id = l.account_id
|
||||||
|
JOIN journal_entries e ON l.journal_entry_id = e.id
|
||||||
|
WHERE e.status = 'posted' AND e.date <= %s
|
||||||
|
AND a.type IN ('ingreso', 'costo', 'gasto')
|
||||||
|
GROUP BY a.type
|
||||||
|
""", (as_of,))
|
||||||
|
|
||||||
|
net_income = 0
|
||||||
|
for r in cur.fetchall():
|
||||||
|
debit = float(r[1])
|
||||||
|
credit = float(r[2])
|
||||||
|
if r[0] == 'ingreso':
|
||||||
|
net_income += (credit - debit)
|
||||||
|
elif r[0] in ('costo', 'gasto'):
|
||||||
|
net_income -= (debit - credit)
|
||||||
|
|
||||||
|
net_income = round(net_income, 2)
|
||||||
|
total_capital += net_income
|
||||||
|
capital_items.append({
|
||||||
|
'code': '320', 'name': 'Resultado del ejercicio', 'balance': net_income
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'as_of': as_of,
|
||||||
|
'activo': {'items': activo, 'total': round(total_activo, 2)},
|
||||||
|
'pasivo': {'items': pasivo, 'total': round(total_pasivo, 2)},
|
||||||
|
'capital': {'items': capital_items, 'total': round(total_capital, 2)},
|
||||||
|
'balanced': round(total_activo, 2) == round(total_pasivo + total_capital, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/aging', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def aging_report():
|
||||||
|
"""Antiguedad de saldos (accounts receivable aging).
|
||||||
|
|
||||||
|
Groups outstanding credit sales by age:
|
||||||
|
- Corriente (not yet due)
|
||||||
|
- 1-30 dias
|
||||||
|
- 31-60 dias
|
||||||
|
- 61-90 dias
|
||||||
|
- 90+ dias
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
||||||
|
s.id as sale_id, s.total, s.created_at,
|
||||||
|
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
||||||
|
FROM customers c
|
||||||
|
JOIN sales s ON s.customer_id = c.id
|
||||||
|
WHERE s.sale_type = 'credit'
|
||||||
|
AND s.status = 'completed'
|
||||||
|
AND c.credit_balance > 0
|
||||||
|
ORDER BY c.name, s.created_at
|
||||||
|
""")
|
||||||
|
|
||||||
|
customers = {}
|
||||||
|
for r in cur.fetchall():
|
||||||
|
cust_id = r[0]
|
||||||
|
if cust_id not in customers:
|
||||||
|
customers[cust_id] = {
|
||||||
|
'id': r[0], 'name': r[1], 'rfc': r[2],
|
||||||
|
'credit_limit': float(r[3]) if r[3] else 0,
|
||||||
|
'credit_balance': float(r[4]) if r[4] else 0,
|
||||||
|
'corriente': 0, 'd1_30': 0, 'd31_60': 0, 'd61_90': 0, 'd90_plus': 0,
|
||||||
|
'total': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
amount = float(r[6]) if r[6] else 0
|
||||||
|
days = r[8] or 0
|
||||||
|
|
||||||
|
if days <= 0:
|
||||||
|
customers[cust_id]['corriente'] += amount
|
||||||
|
elif days <= 30:
|
||||||
|
customers[cust_id]['d1_30'] += amount
|
||||||
|
elif days <= 60:
|
||||||
|
customers[cust_id]['d31_60'] += amount
|
||||||
|
elif days <= 90:
|
||||||
|
customers[cust_id]['d61_90'] += amount
|
||||||
|
else:
|
||||||
|
customers[cust_id]['d90_plus'] += amount
|
||||||
|
|
||||||
|
customers[cust_id]['total'] += amount
|
||||||
|
|
||||||
|
result = list(customers.values())
|
||||||
|
# Round all amounts
|
||||||
|
for c in result:
|
||||||
|
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
||||||
|
c[key] = round(c[key], 2)
|
||||||
|
|
||||||
|
# Totals row
|
||||||
|
totals = {
|
||||||
|
'corriente': round(sum(c['corriente'] for c in result), 2),
|
||||||
|
'd1_30': round(sum(c['d1_30'] for c in result), 2),
|
||||||
|
'd31_60': round(sum(c['d31_60'] for c in result), 2),
|
||||||
|
'd61_90': round(sum(c['d61_90'] for c in result), 2),
|
||||||
|
'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
|
||||||
|
'total': round(sum(c['total'] for c in result), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'data': result, 'totals': totals})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fiscal Periods ────────────────────────────────
|
||||||
|
|
||||||
|
@accounting_bp.route('/periods', methods=['GET'])
|
||||||
|
@require_auth('accounting.view')
|
||||||
|
def list_periods():
|
||||||
|
"""List fiscal periods with status."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT fp.id, fp.year, fp.month, fp.status,
|
||||||
|
fp.closed_by, e.name as closed_by_name, fp.closed_at
|
||||||
|
FROM fiscal_periods fp
|
||||||
|
LEFT JOIN employees e ON fp.closed_by = e.id
|
||||||
|
ORDER BY fp.year DESC, fp.month DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
periods = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
periods.append({
|
||||||
|
'id': r[0], 'year': r[1], 'month': r[2],
|
||||||
|
'status': r[3], 'closed_by': r[4],
|
||||||
|
'closed_by_name': r[5],
|
||||||
|
'closed_at': str(r[6]) if r[6] else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'data': periods})
|
||||||
|
|
||||||
|
|
||||||
|
@accounting_bp.route('/periods/close', methods=['POST'])
|
||||||
|
@require_auth('accounting.create')
|
||||||
|
def close_period():
|
||||||
|
"""Close a fiscal period. Owner only.
|
||||||
|
|
||||||
|
Body: {year: int, month: int}
|
||||||
|
|
||||||
|
Closing a period:
|
||||||
|
1. Validates the period is currently open
|
||||||
|
2. Prevents future journal entries in that period
|
||||||
|
3. Only the owner can close periods
|
||||||
|
"""
|
||||||
|
if g.employee_role != 'owner':
|
||||||
|
return jsonify({'error': 'Only the owner can close fiscal periods'}), 403
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
year = data.get('year')
|
||||||
|
month = data.get('month')
|
||||||
|
|
||||||
|
if not year or not month:
|
||||||
|
return jsonify({'error': 'year and month are required'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if period already exists
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, status FROM fiscal_periods WHERE year = %s AND month = %s
|
||||||
|
""", (year, month))
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if row and row[1] == 'closed':
|
||||||
|
return jsonify({'error': f'Period {year}-{month:02d} is already closed'}), 409
|
||||||
|
|
||||||
|
if row:
|
||||||
|
# Update existing
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE fiscal_periods
|
||||||
|
SET status = 'closed', closed_by = %s, closed_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (g.employee_id, row[0]))
|
||||||
|
period_id = row[0]
|
||||||
|
else:
|
||||||
|
# Create and close
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO fiscal_periods (year, month, status, closed_by, closed_at)
|
||||||
|
VALUES (%s, %s, 'closed', %s, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""", (year, month, g.employee_id))
|
||||||
|
period_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
log_action(conn, 'PERIOD_CLOSED', 'fiscal_period', period_id,
|
||||||
|
new_value={'year': year, 'month': month})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'id': period_id,
|
||||||
|
'year': year,
|
||||||
|
'month': month,
|
||||||
|
'status': 'closed',
|
||||||
|
'message': f'Period {year}-{month:02d} closed successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
399
pos/blueprints/invoicing_bp.py
Normal file
399
pos/blueprints/invoicing_bp.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# /home/Autopartes/pos/blueprints/invoicing_bp.py
|
||||||
|
"""Invoicing blueprint: CFDI generation, queue management, cancellation.
|
||||||
|
|
||||||
|
All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
|
||||||
|
This blueprint is the HTTP layer that validates input and returns JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from middleware import require_auth
|
||||||
|
from tenant_db import get_tenant_conn
|
||||||
|
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
|
||||||
|
from services.cfdi_queue import (
|
||||||
|
enqueue_cfdi, process_queue, retry_failed,
|
||||||
|
cancel_cfdi, get_queue_status,
|
||||||
|
)
|
||||||
|
from services.audit import log_action
|
||||||
|
|
||||||
|
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tenant_config(cur):
|
||||||
|
"""Load tenant CFDI configuration from tenant_config table.
|
||||||
|
|
||||||
|
Falls back to sensible defaults if config is incomplete.
|
||||||
|
"""
|
||||||
|
config = {}
|
||||||
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
||||||
|
for row in cur.fetchall():
|
||||||
|
config[row[0]] = row[1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rfc': config.get('tenant_rfc', ''),
|
||||||
|
'razon_social': config.get('tenant_razon_social', ''),
|
||||||
|
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||||
|
'cp': config.get('tenant_cp', '00000'),
|
||||||
|
'serie': config.get('cfdi_serie', 'A'),
|
||||||
|
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
||||||
|
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sale_with_items(cur, sale_id):
|
||||||
|
"""Load a sale with its items for CFDI generation."""
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||||
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
|
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||||
|
FROM sales WHERE id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sale = {
|
||||||
|
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||||
|
'employee_id': row[3], 'sale_type': row[4],
|
||||||
|
'payment_method': row[5],
|
||||||
|
'subtotal': float(row[6]) if row[6] else 0,
|
||||||
|
'discount_total': float(row[7]) if row[7] else 0,
|
||||||
|
'tax_total': float(row[8]) if row[8] else 0,
|
||||||
|
'total': float(row[9]) if row[9] else 0,
|
||||||
|
'metodo_pago_sat': row[10] or 'PUE',
|
||||||
|
'forma_pago_sat': row[11] or '01',
|
||||||
|
'status': row[12],
|
||||||
|
'created_at': str(row[13]),
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||||
|
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||||
|
subtotal, clave_prod_serv, clave_unidad
|
||||||
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
|
""", (sale_id,))
|
||||||
|
|
||||||
|
sale['items'] = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
sale['items'].append({
|
||||||
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||||
|
'name': r[3], 'quantity': r[4],
|
||||||
|
'unit_price': float(r[5]) if r[5] else 0,
|
||||||
|
'unit_cost': float(r[6]) if r[6] else 0,
|
||||||
|
'discount_pct': float(r[7]) if r[7] else 0,
|
||||||
|
'discount_amount': float(r[8]) if r[8] else 0,
|
||||||
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||||
|
'tax_amount': float(r[10]) if r[10] else 0,
|
||||||
|
'subtotal': float(r[11]) if r[11] else 0,
|
||||||
|
'clave_prod_serv': r[12] or '25174800',
|
||||||
|
'clave_unidad': r[13] or 'H87',
|
||||||
|
})
|
||||||
|
|
||||||
|
return sale
|
||||||
|
|
||||||
|
|
||||||
|
def _get_customer(cur, customer_id):
|
||||||
|
"""Load customer data for CFDI receptor."""
|
||||||
|
if not customer_id:
|
||||||
|
return None
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
|
||||||
|
FROM customers WHERE id = %s
|
||||||
|
""", (customer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'id': row[0], 'name': row[1], 'rfc': row[2],
|
||||||
|
'razon_social': row[3], 'regimen_fiscal': row[4],
|
||||||
|
'uso_cfdi': row[5] or 'G03', 'cp': row[6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Generate CFDI ─────────────────────────────────
|
||||||
|
|
||||||
|
@invoicing_bp.route('/invoice', methods=['POST'])
|
||||||
|
@require_auth('invoicing.create')
|
||||||
|
def generate_invoice():
|
||||||
|
"""Generate a CFDI for a sale and enqueue for timbrado.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
sale_id: int,
|
||||||
|
type: 'ingreso' (default) | 'egreso',
|
||||||
|
original_uuid: str (required for egreso)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
sale_id = data.get('sale_id')
|
||||||
|
cfdi_type = data.get('type', 'ingreso')
|
||||||
|
|
||||||
|
if not sale_id:
|
||||||
|
return jsonify({'error': 'sale_id is required'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_config = _get_tenant_config(cur)
|
||||||
|
if not tenant_config['rfc']:
|
||||||
|
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
|
||||||
|
|
||||||
|
sale = _get_sale_with_items(cur, sale_id)
|
||||||
|
if not sale:
|
||||||
|
return jsonify({'error': 'Sale not found'}), 404
|
||||||
|
|
||||||
|
if sale['status'] == 'cancelled':
|
||||||
|
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
||||||
|
|
||||||
|
customer = _get_customer(cur, sale.get('customer_id'))
|
||||||
|
|
||||||
|
# Check if this sale already has a stamped CFDI
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, status FROM cfdi_queue
|
||||||
|
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
|
||||||
|
""", (sale_id, cfdi_type))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Build XML
|
||||||
|
if cfdi_type == 'ingreso':
|
||||||
|
xml = build_ingreso_xml(sale, tenant_config, customer)
|
||||||
|
elif cfdi_type == 'egreso':
|
||||||
|
original_uuid = data.get('original_uuid')
|
||||||
|
if not original_uuid:
|
||||||
|
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||||
|
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
||||||
|
else:
|
||||||
|
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
||||||
|
|
||||||
|
# Enqueue
|
||||||
|
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
|
||||||
|
|
||||||
|
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
||||||
|
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
||||||
|
'folio': result['provisional_folio']})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(result), 201
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Queue Management ──────────────────────────────
|
||||||
|
|
||||||
|
@invoicing_bp.route('/queue', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def list_queue():
|
||||||
|
"""List CFDI queue items.
|
||||||
|
|
||||||
|
Query params: status, sale_id, type, page, per_page
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
'status': request.args.get('status'),
|
||||||
|
'sale_id': request.args.get('sale_id'),
|
||||||
|
'type': request.args.get('type'),
|
||||||
|
'page': request.args.get('page', 1),
|
||||||
|
'per_page': request.args.get('per_page', 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = get_queue_status(conn, filters)
|
||||||
|
conn.close()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def get_queue_item(cfdi_id):
|
||||||
|
"""Get CFDI queue item detail (includes XML)."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed,
|
||||||
|
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
||||||
|
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
||||||
|
q.created_at, q.stamped_at
|
||||||
|
FROM cfdi_queue q WHERE q.id = %s
|
||||||
|
""", (cfdi_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'CFDI queue item not found'}), 404
|
||||||
|
|
||||||
|
item = {
|
||||||
|
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
||||||
|
'xml_unsigned': row[3], 'xml_signed': row[4],
|
||||||
|
'uuid_fiscal': row[5], 'status': row[6],
|
||||||
|
'retry_count': row[7], 'provisional_folio': row[8],
|
||||||
|
'error_message': row[9], 'cancel_motive': row[10],
|
||||||
|
'cancel_replacement_uuid': row[11],
|
||||||
|
'created_at': str(row[12]) if row[12] else None,
|
||||||
|
'stamped_at': str(row[13]) if row[13] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(item)
|
||||||
|
|
||||||
|
|
||||||
|
@invoicing_bp.route('/queue/process', methods=['POST'])
|
||||||
|
@require_auth('invoicing.create')
|
||||||
|
def trigger_process_queue():
|
||||||
|
"""Manually trigger processing of pending CFDI queue items."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_config = _get_tenant_config(cur)
|
||||||
|
horux_url = tenant_config.get('horux_api_url')
|
||||||
|
horux_key = tenant_config.get('horux_api_key')
|
||||||
|
|
||||||
|
if not horux_url or not horux_key:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': 'Horux API not configured'}), 400
|
||||||
|
|
||||||
|
# Reset eligible failed items first
|
||||||
|
reset_count = retry_failed(conn)
|
||||||
|
|
||||||
|
# Process the queue
|
||||||
|
result = process_queue(conn, horux_url, horux_key)
|
||||||
|
result['retries_reset'] = reset_count
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Cancel CFDI ────────────────────────────────────
|
||||||
|
|
||||||
|
@invoicing_bp.route('/cancel/<int:cfdi_id>', methods=['POST'])
|
||||||
|
@require_auth('invoicing.delete')
|
||||||
|
def cancel_invoice(cfdi_id):
|
||||||
|
"""Cancel a CFDI with SAT motive code.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
motive: '01' | '02' | '03' | '04',
|
||||||
|
replacement_uuid: str (required if motive == '01')
|
||||||
|
}
|
||||||
|
|
||||||
|
Only owner and admin can cancel CFDIs.
|
||||||
|
"""
|
||||||
|
if g.employee_role not in ('owner', 'admin'):
|
||||||
|
return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
motive = data.get('motive')
|
||||||
|
replacement_uuid = data.get('replacement_uuid')
|
||||||
|
|
||||||
|
if not motive:
|
||||||
|
return jsonify({'error': 'motive is required'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_config = _get_tenant_config(cur)
|
||||||
|
result = cancel_cfdi(
|
||||||
|
conn, cfdi_id, motive, replacement_uuid,
|
||||||
|
tenant_config.get('horux_api_url'),
|
||||||
|
tenant_config.get('horux_api_key'),
|
||||||
|
)
|
||||||
|
|
||||||
|
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
||||||
|
new_value={'motive': motive, 'replacement_uuid': replacement_uuid})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ─── PDF Generation ─────────────────────────────────
|
||||||
|
|
||||||
|
@invoicing_bp.route('/<int:sale_id>/pdf', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def get_sale_pdf(sale_id):
|
||||||
|
"""Generate a PDF representation of the sale/CFDI.
|
||||||
|
|
||||||
|
Returns an HTML page styled for print/PDF generation.
|
||||||
|
For actual PDF file generation, the frontend uses window.print() or
|
||||||
|
a headless browser. This endpoint returns the formatted HTML.
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
sale = _get_sale_with_items(cur, sale_id)
|
||||||
|
if not sale:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Sale not found'}), 404
|
||||||
|
|
||||||
|
tenant_config = _get_tenant_config(cur)
|
||||||
|
customer = _get_customer(cur, sale.get('customer_id'))
|
||||||
|
|
||||||
|
# Check if there's a stamped CFDI
|
||||||
|
cur.execute("""
|
||||||
|
SELECT uuid_fiscal, provisional_folio, status, stamped_at
|
||||||
|
FROM cfdi_queue
|
||||||
|
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
|
||||||
|
ORDER BY stamped_at DESC LIMIT 1
|
||||||
|
""", (sale_id,))
|
||||||
|
cfdi_row = cur.fetchone()
|
||||||
|
|
||||||
|
cfdi_info = None
|
||||||
|
if cfdi_row:
|
||||||
|
cfdi_info = {
|
||||||
|
'uuid_fiscal': cfdi_row[0],
|
||||||
|
'provisional_folio': cfdi_row[1],
|
||||||
|
'status': cfdi_row[2],
|
||||||
|
'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'sale': sale,
|
||||||
|
'tenant': {
|
||||||
|
'rfc': tenant_config.get('rfc', ''),
|
||||||
|
'razon_social': tenant_config.get('razon_social', ''),
|
||||||
|
'regimen_fiscal': tenant_config.get('regimen_fiscal', ''),
|
||||||
|
'cp': tenant_config.get('cp', ''),
|
||||||
|
},
|
||||||
|
'customer': customer,
|
||||||
|
'cfdi': cfdi_info,
|
||||||
|
})
|
||||||
@@ -375,5 +375,14 @@ CREATE TABLE IF NOT EXISTS physical_count_lines (
|
|||||||
difference INTEGER NOT NULL
|
difference INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TENANT CONFIGURATION (key-value store for CFDI, Horux, etc.)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_config (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
-- Barcode sequence
|
-- Barcode sequence
|
||||||
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;
|
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;
|
||||||
|
|||||||
@@ -1,50 +1,69 @@
|
|||||||
-- SAT Chart of Accounts (Catálogo de Cuentas SAT)
|
-- SAT Chart of Accounts (Catalogo de Cuentas SAT)
|
||||||
-- All accounts are system accounts (is_system = true)
|
-- All accounts are system accounts (is_system = true)
|
||||||
-- Parent IDs resolved via subqueries to avoid hardcoded integer dependencies
|
-- Individual INSERTs so parent_id subqueries can resolve correctly.
|
||||||
|
|
||||||
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active) VALUES
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- ACTIVO (100)
|
-- ACTIVO (100)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
('100', 'Activo', NULL, 'activo', '100', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('110', 'Caja', (SELECT id FROM accounts WHERE code = '100'), 'activo', '110', true, true),
|
VALUES ('100', 'Activo', NULL, 'activo', '100', true, true);
|
||||||
('111', 'Bancos', (SELECT id FROM accounts WHERE code = '100'), 'activo', '111', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('120', 'Clientes', (SELECT id FROM accounts WHERE code = '100'), 'activo', '120', true, true),
|
VALUES ('110', 'Caja', (SELECT id FROM accounts WHERE code = '100'), 'activo', '110', true, true);
|
||||||
('130', 'Inventarios', (SELECT id FROM accounts WHERE code = '100'), 'activo', '130', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('140', 'IVA Acreditable', (SELECT id FROM accounts WHERE code = '100'), 'activo', '140', true, true),
|
VALUES ('111', 'Bancos', (SELECT id FROM accounts WHERE code = '100'), 'activo', '111', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('120', 'Clientes', (SELECT id FROM accounts WHERE code = '100'), 'activo', '120', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('130', 'Inventarios', (SELECT id FROM accounts WHERE code = '100'), 'activo', '130', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('140', 'IVA Acreditable', (SELECT id FROM accounts WHERE code = '100'), 'activo', '140', true, true);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- PASIVO (200)
|
-- PASIVO (200)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
('200', 'Pasivo', NULL, 'pasivo', '200', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('210', 'Proveedores', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '210', true, true),
|
VALUES ('200', 'Pasivo', NULL, 'pasivo', '200', true, true);
|
||||||
('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '220', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true),
|
VALUES ('210', 'Proveedores', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '210', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '220', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- CAPITAL (300)
|
-- CAPITAL (300)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
('300', 'Capital', NULL, 'capital', '300', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('310', 'Capital Social', (SELECT id FROM accounts WHERE code = '300'), 'capital', '310', true, true),
|
VALUES ('300', 'Capital', NULL, 'capital', '300', true, true);
|
||||||
('320', 'Resultados del Ejercicio', (SELECT id FROM accounts WHERE code = '300'), 'capital', '320', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('310', 'Capital Social', (SELECT id FROM accounts WHERE code = '300'), 'capital', '310', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('320', 'Resultados del Ejercicio', (SELECT id FROM accounts WHERE code = '300'), 'capital', '320', true, true);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- INGRESOS (400)
|
-- INGRESOS (400)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
('400', 'Ingresos', NULL, 'ingreso', '400', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('410', 'Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '410', true, true),
|
VALUES ('400', 'Ingresos', NULL, 'ingreso', '400', true, true);
|
||||||
('420', 'Devoluciones sobre Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '420', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('410', 'Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '410', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('420', 'Devoluciones sobre Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '420', true, true);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- COSTOS (500)
|
-- COSTOS (500)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
('500', 'Costos', NULL, 'costo', '500', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('510', 'Costo de Mercancia Vendida', (SELECT id FROM accounts WHERE code = '500'), 'costo', '510', true, true),
|
VALUES ('500', 'Costos', NULL, 'costo', '500', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('510', 'Costo de Mercancia Vendida', (SELECT id FROM accounts WHERE code = '500'), 'costo', '510', true, true);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- GASTOS (600)
|
-- GASTOS (600)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
('600', 'Gastos', NULL, 'gasto', '600', true, true),
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
('610', 'Gastos Operativos', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '610', true, true),
|
VALUES ('600', 'Gastos', NULL, 'gasto', '600', true, true);
|
||||||
('620', 'Gastos Financieros', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '620', true, true);
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('610', 'Gastos Operativos', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '610', true, true);
|
||||||
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
|
VALUES ('620', 'Gastos Financieros', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '620', true, true);
|
||||||
|
|||||||
604
pos/services/accounting_engine.py
Normal file
604
pos/services/accounting_engine.py
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
# /home/Autopartes/pos/services/accounting_engine.py
|
||||||
|
"""Accounting engine: automatic journal entry generation for business operations.
|
||||||
|
|
||||||
|
Every sale, purchase, cash cut, credit payment, and cancellation produces
|
||||||
|
balanced journal entries (polizas). The engine looks up SAT account IDs
|
||||||
|
by code and creates journal_entries + journal_entry_lines records.
|
||||||
|
|
||||||
|
Account codes (from SAT seed):
|
||||||
|
110 = Caja
|
||||||
|
111 = Bancos
|
||||||
|
120 = Clientes
|
||||||
|
130 = Inventarios
|
||||||
|
140 = IVA Acreditable
|
||||||
|
210 = Proveedores
|
||||||
|
220 = IVA Trasladado
|
||||||
|
410 = Ventas
|
||||||
|
420 = Devoluciones sobre Ventas
|
||||||
|
510 = Costo de Mercancia Vendida
|
||||||
|
|
||||||
|
All functions receive a psycopg2 connection (caller controls commit).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from flask import g
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dec(val):
|
||||||
|
"""Convert a value to Decimal for precise arithmetic."""
|
||||||
|
if val is None:
|
||||||
|
return Decimal('0')
|
||||||
|
return Decimal(str(val))
|
||||||
|
|
||||||
|
|
||||||
|
TWO = Decimal('0.01')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_account_id(cur, code):
|
||||||
|
"""Look up account ID by code. Raises ValueError if not found."""
|
||||||
|
cur.execute("SELECT id FROM accounts WHERE code = %s AND is_active = true", (code,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"Account with code '{code}' not found")
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_account_ids(cur, codes):
|
||||||
|
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
||||||
|
result = {}
|
||||||
|
for code in codes:
|
||||||
|
result[code] = _get_account_id(cur, code)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_entry_number(conn):
|
||||||
|
"""Get the next sequential journal entry number.
|
||||||
|
|
||||||
|
Uses a simple MAX+1 approach. For high-concurrency environments this
|
||||||
|
could be replaced with a sequence, but for single-tenant refaccionarias
|
||||||
|
the transaction-level lock from the INSERT is sufficient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection to tenant DB
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: next entry number (starts at 1)
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
||||||
|
number = cur.fetchone()[0]
|
||||||
|
cur.close()
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _check_period_open(cur, entry_date):
|
||||||
|
"""Verify the fiscal period for the given date is open.
|
||||||
|
|
||||||
|
If no fiscal_periods row exists for the month, it is considered open
|
||||||
|
(periods are only created when explicitly closed).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cur: psycopg2 cursor
|
||||||
|
entry_date: date object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the period is closed
|
||||||
|
"""
|
||||||
|
cur.execute("""
|
||||||
|
SELECT status FROM fiscal_periods
|
||||||
|
WHERE year = %s AND month = %s
|
||||||
|
""", (entry_date.year, entry_date.month))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0] == 'closed':
|
||||||
|
raise ValueError(
|
||||||
|
f"Fiscal period {entry_date.year}-{entry_date.month:02d} is closed. "
|
||||||
|
f"Cannot create journal entries in a closed period."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_entry(cur, entry_number, entry_date, entry_type, description,
|
||||||
|
reference_type, reference_id, lines, is_auto=True):
|
||||||
|
"""Create a journal entry with its lines.
|
||||||
|
|
||||||
|
Validates that total debits == total credits before inserting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cur: psycopg2 cursor
|
||||||
|
entry_number: int sequential number
|
||||||
|
entry_date: date
|
||||||
|
entry_type: 'ingreso' | 'egreso' | 'diario' | 'poliza'
|
||||||
|
description: str
|
||||||
|
reference_type: 'sale' | 'purchase' | 'cash_register' | 'payment' | None
|
||||||
|
reference_id: int or None
|
||||||
|
lines: list of dicts with keys: account_id, debit, credit, description
|
||||||
|
is_auto: bool (True for system-generated entries)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: journal entry ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if debits != credits
|
||||||
|
"""
|
||||||
|
# Validate balance
|
||||||
|
total_debit = sum(_to_dec(line['debit']) for line in lines)
|
||||||
|
total_credit = sum(_to_dec(line['credit']) for line in lines)
|
||||||
|
|
||||||
|
if total_debit.quantize(TWO, ROUND_HALF_UP) != total_credit.quantize(TWO, ROUND_HALF_UP):
|
||||||
|
raise ValueError(
|
||||||
|
f"Unbalanced entry: debits={total_debit} credits={total_credit}"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_by = getattr(g, 'employee_id', None)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO journal_entries
|
||||||
|
(entry_number, date, type, description, reference_type, reference_id,
|
||||||
|
status, created_by, is_auto)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, 'posted', %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (
|
||||||
|
entry_number, entry_date, entry_type, description,
|
||||||
|
reference_type, reference_id, created_by, is_auto
|
||||||
|
))
|
||||||
|
entry_id = cur.fetchone()[0]
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
debit = float(_to_dec(line['debit']).quantize(TWO, ROUND_HALF_UP))
|
||||||
|
credit = float(_to_dec(line['credit']).quantize(TWO, ROUND_HALF_UP))
|
||||||
|
if debit == 0 and credit == 0:
|
||||||
|
continue # skip zero lines
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO journal_entry_lines
|
||||||
|
(journal_entry_id, account_id, debit, credit, description)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""", (entry_id, line['account_id'], debit, credit, line.get('description', '')))
|
||||||
|
|
||||||
|
return entry_id
|
||||||
|
|
||||||
|
|
||||||
|
def record_sale_entry(conn, sale):
|
||||||
|
"""Generate journal entries for a completed sale.
|
||||||
|
|
||||||
|
For a cash sale:
|
||||||
|
Debit 110 Caja (or 111 Bancos for transferencia/tarjeta) = total
|
||||||
|
Credit 410 Ventas = subtotal
|
||||||
|
Credit 220 IVA Trasladado = tax_total
|
||||||
|
Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty)
|
||||||
|
Credit 130 Inventarios = sum(unit_cost * qty)
|
||||||
|
|
||||||
|
For a credit sale:
|
||||||
|
Debit 120 Clientes = total
|
||||||
|
Credit 410 Ventas = subtotal
|
||||||
|
Credit 220 IVA Trasladado = tax_total
|
||||||
|
Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty)
|
||||||
|
Credit 130 Inventarios = sum(unit_cost * qty)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection to tenant DB
|
||||||
|
sale: dict from process_sale() with keys:
|
||||||
|
id, sale_type, payment_method, subtotal, discount_total,
|
||||||
|
tax_total, total, items (with unit_cost, quantity)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: journal entry ID
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
entry_date = date.today()
|
||||||
|
_check_period_open(cur, entry_date)
|
||||||
|
|
||||||
|
entry_number = get_next_entry_number(conn)
|
||||||
|
sale_id = sale['id']
|
||||||
|
sale_type = sale.get('sale_type', 'cash')
|
||||||
|
payment_method = sale.get('payment_method', 'efectivo')
|
||||||
|
subtotal = _to_dec(sale['subtotal'])
|
||||||
|
tax_total = _to_dec(sale['tax_total'])
|
||||||
|
total = _to_dec(sale['total'])
|
||||||
|
|
||||||
|
# Calculate total cost of goods sold
|
||||||
|
cost_total = Decimal('0')
|
||||||
|
for item in sale.get('items', []):
|
||||||
|
item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
||||||
|
cost_total += item_cost.quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
|
# Determine debit account for payment received
|
||||||
|
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510'])
|
||||||
|
|
||||||
|
if sale_type == 'credit':
|
||||||
|
payment_account_id = accounts['120'] # Clientes
|
||||||
|
payment_desc = f'Clientes - Venta a credito #{sale_id}'
|
||||||
|
elif payment_method in ('transferencia', 'tarjeta'):
|
||||||
|
payment_account_id = accounts['111'] # Bancos
|
||||||
|
payment_desc = f'Bancos - Venta #{sale_id} ({payment_method})'
|
||||||
|
else:
|
||||||
|
payment_account_id = accounts['110'] # Caja
|
||||||
|
payment_desc = f'Caja - Venta #{sale_id} (efectivo)'
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
# Payment received (debit)
|
||||||
|
{
|
||||||
|
'account_id': payment_account_id,
|
||||||
|
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': payment_desc,
|
||||||
|
},
|
||||||
|
# Revenue (credit)
|
||||||
|
{
|
||||||
|
'account_id': accounts['410'],
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': f'Ventas - Venta #{sale_id}',
|
||||||
|
},
|
||||||
|
# IVA Trasladado (credit)
|
||||||
|
{
|
||||||
|
'account_id': accounts['220'],
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': f'IVA Trasladado - Venta #{sale_id}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cost of goods sold entries (only if cost > 0)
|
||||||
|
if cost_total > 0:
|
||||||
|
lines.append({
|
||||||
|
'account_id': accounts['510'],
|
||||||
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': f'Costo mercancia - Venta #{sale_id}',
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
'account_id': accounts['130'],
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': f'Inventarios - Venta #{sale_id}',
|
||||||
|
})
|
||||||
|
|
||||||
|
entry_id = _create_entry(
|
||||||
|
cur, entry_number, entry_date, 'ingreso',
|
||||||
|
f'Venta #{sale_id} - {payment_method}',
|
||||||
|
'sale', sale_id, lines
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return entry_id
|
||||||
|
|
||||||
|
|
||||||
|
def record_purchase_entry(conn, purchase_data):
|
||||||
|
"""Generate journal entries for a purchase (inventory receipt).
|
||||||
|
|
||||||
|
Debit 130 Inventarios = subtotal (cost of goods)
|
||||||
|
Debit 140 IVA Acreditable = tax amount
|
||||||
|
Credit 210 Proveedores = total (subtotal + tax)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
purchase_data: dict with keys:
|
||||||
|
reference_id: int (purchase order or operation ID)
|
||||||
|
subtotal: float (cost of goods before tax)
|
||||||
|
tax_amount: float (IVA 16%)
|
||||||
|
total: float (subtotal + tax)
|
||||||
|
supplier_name: str (optional, for description)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: journal entry ID
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
entry_date = date.today()
|
||||||
|
_check_period_open(cur, entry_date)
|
||||||
|
|
||||||
|
entry_number = get_next_entry_number(conn)
|
||||||
|
ref_id = purchase_data.get('reference_id')
|
||||||
|
subtotal = _to_dec(purchase_data['subtotal'])
|
||||||
|
tax_amount = _to_dec(purchase_data.get('tax_amount', 0))
|
||||||
|
total = _to_dec(purchase_data['total'])
|
||||||
|
supplier = purchase_data.get('supplier_name', 'Proveedor')
|
||||||
|
|
||||||
|
accounts = _get_account_ids(cur, ['130', '140', '210'])
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
{
|
||||||
|
'account_id': accounts['130'],
|
||||||
|
'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': f'Inventarios - Compra {supplier}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'account_id': accounts['210'],
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': f'Proveedores - Compra {supplier}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# IVA Acreditable (only if tax > 0)
|
||||||
|
if tax_amount > 0:
|
||||||
|
lines.append({
|
||||||
|
'account_id': accounts['140'],
|
||||||
|
'debit': float(tax_amount.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': f'IVA Acreditable - Compra {supplier}',
|
||||||
|
})
|
||||||
|
|
||||||
|
entry_id = _create_entry(
|
||||||
|
cur, entry_number, entry_date, 'diario',
|
||||||
|
f'Compra - {supplier}',
|
||||||
|
'purchase', ref_id, lines
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return entry_id
|
||||||
|
|
||||||
|
|
||||||
|
def record_cash_cut_entry(conn, register):
|
||||||
|
"""Generate journal entry for a cash register close (corte Z).
|
||||||
|
|
||||||
|
Moves cash from register (Caja) to bank (Bancos):
|
||||||
|
Debit 111 Bancos = closing_amount
|
||||||
|
Credit 110 Caja = closing_amount
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
register: dict with keys:
|
||||||
|
id: int (cash register ID)
|
||||||
|
closing_amount: float
|
||||||
|
register_number: int
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: journal entry ID
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
entry_date = date.today()
|
||||||
|
_check_period_open(cur, entry_date)
|
||||||
|
|
||||||
|
entry_number = get_next_entry_number(conn)
|
||||||
|
reg_id = register['id']
|
||||||
|
amount = _to_dec(register['closing_amount'])
|
||||||
|
reg_num = register.get('register_number', '?')
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
cur.close()
|
||||||
|
return None # No entry for zero/negative close
|
||||||
|
|
||||||
|
accounts = _get_account_ids(cur, ['110', '111'])
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
{
|
||||||
|
'account_id': accounts['111'],
|
||||||
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': f'Bancos - Corte caja #{reg_num}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'account_id': accounts['110'],
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': f'Caja - Corte caja #{reg_num}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
entry_id = _create_entry(
|
||||||
|
cur, entry_number, entry_date, 'diario',
|
||||||
|
f'Corte de caja #{reg_num} (registro #{reg_id})',
|
||||||
|
'cash_register', reg_id, lines
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return entry_id
|
||||||
|
|
||||||
|
|
||||||
|
def record_credit_payment_entry(conn, payment):
|
||||||
|
"""Generate journal entry for a customer credit payment.
|
||||||
|
|
||||||
|
Debit 111 Bancos (or 110 Caja) = amount
|
||||||
|
Credit 120 Clientes = amount
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
payment: dict with keys:
|
||||||
|
customer_id: int
|
||||||
|
customer_name: str (optional)
|
||||||
|
amount: float
|
||||||
|
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
||||||
|
reference_id: int (optional, e.g. sale_id being paid)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: journal entry ID
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
entry_date = date.today()
|
||||||
|
_check_period_open(cur, entry_date)
|
||||||
|
|
||||||
|
entry_number = get_next_entry_number(conn)
|
||||||
|
amount = _to_dec(payment['amount'])
|
||||||
|
customer_name = payment.get('customer_name', f"Cliente #{payment['customer_id']}")
|
||||||
|
method = payment.get('payment_method', 'efectivo')
|
||||||
|
ref_id = payment.get('reference_id')
|
||||||
|
|
||||||
|
accounts = _get_account_ids(cur, ['110', '111', '120'])
|
||||||
|
|
||||||
|
if method in ('transferencia', 'tarjeta'):
|
||||||
|
debit_account = accounts['111'] # Bancos
|
||||||
|
debit_desc = f'Bancos - Cobro credito {customer_name}'
|
||||||
|
else:
|
||||||
|
debit_account = accounts['110'] # Caja
|
||||||
|
debit_desc = f'Caja - Cobro credito {customer_name}'
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
{
|
||||||
|
'account_id': debit_account,
|
||||||
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': debit_desc,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'account_id': accounts['120'],
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': f'Clientes - Cobro credito {customer_name}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
entry_id = _create_entry(
|
||||||
|
cur, entry_number, entry_date, 'ingreso',
|
||||||
|
f'Cobro credito - {customer_name} ({method})',
|
||||||
|
'payment', ref_id, lines
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return entry_id
|
||||||
|
|
||||||
|
|
||||||
|
def record_cancellation_entry(conn, sale):
|
||||||
|
"""Generate reverse journal entry for a cancelled sale.
|
||||||
|
|
||||||
|
This is the exact reverse of record_sale_entry():
|
||||||
|
Credit payment account (Caja/Bancos/Clientes) = total
|
||||||
|
Debit 410 Ventas = subtotal
|
||||||
|
Debit 220 IVA Trasladado = tax_total
|
||||||
|
Credit 510 Costo de Mercancia Vendida = cost_total
|
||||||
|
Debit 130 Inventarios = cost_total
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
sale: dict with the same structure as record_sale_entry() expects.
|
||||||
|
If items are not present, looks up from DB.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: journal entry ID
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
entry_date = date.today()
|
||||||
|
_check_period_open(cur, entry_date)
|
||||||
|
|
||||||
|
entry_number = get_next_entry_number(conn)
|
||||||
|
sale_id = sale['id']
|
||||||
|
sale_type = sale.get('sale_type', 'cash')
|
||||||
|
payment_method = sale.get('payment_method', 'efectivo')
|
||||||
|
subtotal = _to_dec(sale.get('subtotal', 0))
|
||||||
|
tax_total = _to_dec(sale.get('tax_total', 0))
|
||||||
|
total = _to_dec(sale.get('total', 0))
|
||||||
|
|
||||||
|
# If sale dict lacks items, look up from DB
|
||||||
|
items = sale.get('items')
|
||||||
|
if not items:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
items = [{'unit_cost': float(r[0]) if r[0] else 0, 'quantity': r[1]}
|
||||||
|
for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# If sale dict lacks totals, look up from DB
|
||||||
|
if total == 0:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT subtotal, tax_total, total, sale_type, payment_method
|
||||||
|
FROM sales WHERE id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
subtotal = _to_dec(row[0])
|
||||||
|
tax_total = _to_dec(row[1])
|
||||||
|
total = _to_dec(row[2])
|
||||||
|
sale_type = row[3]
|
||||||
|
payment_method = row[4]
|
||||||
|
|
||||||
|
cost_total = Decimal('0')
|
||||||
|
for item in items:
|
||||||
|
item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
||||||
|
cost_total += item_cost.quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
|
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510'])
|
||||||
|
|
||||||
|
# Determine which payment account to credit (reverse of debit in sale)
|
||||||
|
if sale_type == 'credit':
|
||||||
|
payment_account_id = accounts['120']
|
||||||
|
payment_desc = f'Clientes - Cancelacion venta #{sale_id}'
|
||||||
|
elif payment_method in ('transferencia', 'tarjeta'):
|
||||||
|
payment_account_id = accounts['111']
|
||||||
|
payment_desc = f'Bancos - Cancelacion venta #{sale_id}'
|
||||||
|
else:
|
||||||
|
payment_account_id = accounts['110']
|
||||||
|
payment_desc = f'Caja - Cancelacion venta #{sale_id}'
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
# Reverse payment (credit the account that was debited)
|
||||||
|
{
|
||||||
|
'account_id': payment_account_id,
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': payment_desc,
|
||||||
|
},
|
||||||
|
# Reverse revenue (debit Ventas)
|
||||||
|
{
|
||||||
|
'account_id': accounts['410'],
|
||||||
|
'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': f'Ventas - Cancelacion venta #{sale_id}',
|
||||||
|
},
|
||||||
|
# Reverse IVA (debit IVA Trasladado)
|
||||||
|
{
|
||||||
|
'account_id': accounts['220'],
|
||||||
|
'debit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': f'IVA Trasladado - Cancelacion venta #{sale_id}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Reverse COGS entries (only if cost > 0)
|
||||||
|
if cost_total > 0:
|
||||||
|
lines.append({
|
||||||
|
'account_id': accounts['510'],
|
||||||
|
'debit': 0,
|
||||||
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'description': f'Costo mercancia - Cancelacion venta #{sale_id}',
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
'account_id': accounts['130'],
|
||||||
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||||
|
'credit': 0,
|
||||||
|
'description': f'Inventarios - Cancelacion venta #{sale_id}',
|
||||||
|
})
|
||||||
|
|
||||||
|
entry_id = _create_entry(
|
||||||
|
cur, entry_number, entry_date, 'egreso',
|
||||||
|
f'Cancelacion venta #{sale_id}',
|
||||||
|
'sale', sale_id, lines
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return entry_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_manual_entry(conn, entry_data):
|
||||||
|
"""Create a manual journal entry (type='diario').
|
||||||
|
|
||||||
|
Used by accountants for adjustments not tied to a specific operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
entry_data: dict with keys:
|
||||||
|
date: str 'YYYY-MM-DD'
|
||||||
|
description: str
|
||||||
|
lines: [{account_id, debit, credit, description}]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: journal entry ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if entry is unbalanced or period is closed
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
entry_date = date.fromisoformat(entry_data['date'])
|
||||||
|
_check_period_open(cur, entry_date)
|
||||||
|
|
||||||
|
entry_number = get_next_entry_number(conn)
|
||||||
|
|
||||||
|
entry_id = _create_entry(
|
||||||
|
cur, entry_number, entry_date, 'diario',
|
||||||
|
entry_data.get('description', 'Poliza manual'),
|
||||||
|
None, None,
|
||||||
|
entry_data['lines'],
|
||||||
|
is_auto=False
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return entry_id
|
||||||
451
pos/services/cfdi_builder.py
Normal file
451
pos/services/cfdi_builder.py
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
# /home/Autopartes/pos/services/cfdi_builder.py
|
||||||
|
"""CFDI 4.0 XML builder using lxml.
|
||||||
|
|
||||||
|
Builds unsigned CFDI XML documents for:
|
||||||
|
- Ingreso (sale invoice)
|
||||||
|
- Egreso (credit note / refund)
|
||||||
|
- Pago (payment complement for credit sales)
|
||||||
|
|
||||||
|
The unsigned XML is sent to Horux360 for CSD signing and PAC timbrado.
|
||||||
|
All XML follows the SAT CFDI 4.0 schema:
|
||||||
|
http://www.sat.gob.mx/cfd/4
|
||||||
|
|
||||||
|
CFDI 4.0 mandatory fields handled:
|
||||||
|
- Version="4.0"
|
||||||
|
- Exportacion="01" (no aplica)
|
||||||
|
- ObjetoImp="02" (si, objeto de impuesto) on each Concepto
|
||||||
|
- InformacionGlobal for publico general (RFC: XAXX010101000)
|
||||||
|
- Emisor: Rfc, Nombre, RegimenFiscal
|
||||||
|
- Receptor: Rfc, Nombre, RegimenFiscalReceptor, UsoCFDI, DomicilioFiscalReceptor
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
# SAT XML namespaces
|
||||||
|
CFDI_NS = 'http://www.sat.gob.mx/cfd/4'
|
||||||
|
PAGO_NS = 'http://www.sat.gob.mx/Pagos20'
|
||||||
|
XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance'
|
||||||
|
|
||||||
|
CFDI_SCHEMA_LOCATION = (
|
||||||
|
'http://www.sat.gob.mx/cfd/4 '
|
||||||
|
'http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd'
|
||||||
|
)
|
||||||
|
PAGO_SCHEMA_LOCATION = (
|
||||||
|
'http://www.sat.gob.mx/Pagos20 '
|
||||||
|
'http://www.sat.gob.mx/sitio_internet/cfd/Pagos/Pagos20.xsd'
|
||||||
|
)
|
||||||
|
|
||||||
|
# RFC for publico en general
|
||||||
|
RFC_PUBLICO_GENERAL = 'XAXX010101000'
|
||||||
|
# RFC for foreign customers
|
||||||
|
RFC_EXTRANJERO = 'XEXX010101000'
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dec(val):
|
||||||
|
if val is None:
|
||||||
|
return Decimal('0')
|
||||||
|
return Decimal(str(val))
|
||||||
|
|
||||||
|
|
||||||
|
TWO = Decimal('0.01')
|
||||||
|
SIX = Decimal('0.000001')
|
||||||
|
|
||||||
|
|
||||||
|
def _format_amount(val):
|
||||||
|
"""Format a Decimal to 2 decimal places as string."""
|
||||||
|
return str(_to_dec(val).quantize(TWO, ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rate(val):
|
||||||
|
"""Format a tax rate to 6 decimal places as string."""
|
||||||
|
return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_element(parent, tag, attribs=None, ns=CFDI_NS):
|
||||||
|
"""Create a subelement with the given namespace."""
|
||||||
|
elem = etree.SubElement(parent, f'{{{ns}}}{tag}')
|
||||||
|
if attribs:
|
||||||
|
for k, v in attribs.items():
|
||||||
|
if v is not None:
|
||||||
|
elem.set(k, str(v))
|
||||||
|
return elem
|
||||||
|
|
||||||
|
|
||||||
|
def build_ingreso_xml(sale, tenant_config, customer=None):
|
||||||
|
"""Build CFDI 4.0 XML for a sale (Comprobante tipo Ingreso).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sale: dict with keys:
|
||||||
|
id, subtotal, discount_total, tax_total, total, created_at,
|
||||||
|
metodo_pago_sat ('PUE'|'PPD'), forma_pago_sat ('01'|'03'|'04'|'99'),
|
||||||
|
items: [{part_number, name, quantity, unit_price, discount_amount,
|
||||||
|
tax_rate, tax_amount, subtotal,
|
||||||
|
clave_prod_serv, clave_unidad}]
|
||||||
|
tenant_config: dict with keys:
|
||||||
|
rfc, razon_social, regimen_fiscal, cp (codigo postal),
|
||||||
|
serie (optional), nombre_comercial (optional)
|
||||||
|
customer: dict or None with keys:
|
||||||
|
rfc, razon_social, regimen_fiscal, uso_cfdi, cp
|
||||||
|
If None, generates factura a publico general.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: XML string (unsigned, ready for Horux)
|
||||||
|
"""
|
||||||
|
nsmap = {
|
||||||
|
'cfdi': CFDI_NS,
|
||||||
|
'xsi': XSI_NS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root: Comprobante
|
||||||
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
||||||
|
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
||||||
|
root.set('Version', '4.0')
|
||||||
|
root.set('Serie', tenant_config.get('serie', 'A'))
|
||||||
|
root.set('Folio', str(sale['id']))
|
||||||
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||||
|
root.set('FormaPago', sale.get('forma_pago_sat', '01'))
|
||||||
|
root.set('SubTotal', _format_amount(sale['subtotal']))
|
||||||
|
|
||||||
|
discount_total = _to_dec(sale.get('discount_total', 0))
|
||||||
|
if discount_total > 0:
|
||||||
|
root.set('Descuento', _format_amount(discount_total))
|
||||||
|
|
||||||
|
root.set('Moneda', 'MXN')
|
||||||
|
root.set('Total', _format_amount(sale['total']))
|
||||||
|
root.set('TipoDeComprobante', 'I') # Ingreso
|
||||||
|
root.set('Exportacion', '01') # No aplica
|
||||||
|
root.set('MetodoPago', sale.get('metodo_pago_sat', 'PUE'))
|
||||||
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
||||||
|
|
||||||
|
# InformacionGlobal (required for publico general)
|
||||||
|
is_publico_general = (customer is None or
|
||||||
|
customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL))
|
||||||
|
|
||||||
|
if is_publico_general:
|
||||||
|
info_global = _make_element(root, 'InformacionGlobal')
|
||||||
|
info_global.set('Periodicidad', '01') # Diario
|
||||||
|
now = datetime.now()
|
||||||
|
info_global.set('Meses', f'{now.month:02d}')
|
||||||
|
info_global.set('Anio', str(now.year))
|
||||||
|
|
||||||
|
# Emisor
|
||||||
|
emisor = _make_element(root, 'Emisor')
|
||||||
|
emisor.set('Rfc', tenant_config['rfc'])
|
||||||
|
emisor.set('Nombre', tenant_config['razon_social'])
|
||||||
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
||||||
|
|
||||||
|
# Receptor
|
||||||
|
receptor = _make_element(root, 'Receptor')
|
||||||
|
if is_publico_general:
|
||||||
|
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
||||||
|
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
||||||
|
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
||||||
|
receptor.set('RegimenFiscalReceptor', '616') # Sin obligaciones fiscales
|
||||||
|
receptor.set('UsoCFDI', 'S01') # Sin efectos fiscales
|
||||||
|
else:
|
||||||
|
receptor.set('Rfc', customer['rfc'])
|
||||||
|
receptor.set('Nombre', customer.get('razon_social', customer.get('name', '')))
|
||||||
|
receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000'))
|
||||||
|
receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616'))
|
||||||
|
receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03'))
|
||||||
|
|
||||||
|
# Conceptos
|
||||||
|
conceptos = _make_element(root, 'Conceptos')
|
||||||
|
|
||||||
|
for item in sale.get('items', []):
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
unit_price = _to_dec(item.get('unit_price', 0))
|
||||||
|
discount_amount = _to_dec(item.get('discount_amount', 0))
|
||||||
|
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
||||||
|
tax_amount = _to_dec(item.get('tax_amount', 0))
|
||||||
|
|
||||||
|
# Importe = qty * unit_price (before discount)
|
||||||
|
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
# Base for tax = importe - discount
|
||||||
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||||
|
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||||
|
concepto.set('Cantidad', str(qty))
|
||||||
|
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||||
|
concepto.set('Unidad', 'PZA')
|
||||||
|
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||||
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||||
|
concepto.set('Importe', _format_amount(importe))
|
||||||
|
concepto.set('ObjetoImp', '02') # Si objeto de impuesto
|
||||||
|
|
||||||
|
if discount_amount > 0:
|
||||||
|
concepto.set('Descuento', _format_amount(discount_amount))
|
||||||
|
|
||||||
|
# Impuestos del concepto
|
||||||
|
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
||||||
|
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
||||||
|
traslado = _make_element(traslados_concepto, 'Traslado')
|
||||||
|
traslado.set('Base', _format_amount(base))
|
||||||
|
traslado.set('Impuesto', '002') # IVA
|
||||||
|
traslado.set('TipoFactor', 'Tasa')
|
||||||
|
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
||||||
|
traslado.set('Importe', _format_amount(tax_amount))
|
||||||
|
|
||||||
|
# Impuestos totales
|
||||||
|
impuestos = _make_element(root, 'Impuestos')
|
||||||
|
impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total']))
|
||||||
|
traslados = _make_element(impuestos, 'Traslados')
|
||||||
|
traslado_total = _make_element(traslados, 'Traslado')
|
||||||
|
traslado_total.set('Base', _format_amount(sale['subtotal']))
|
||||||
|
traslado_total.set('Impuesto', '002')
|
||||||
|
traslado_total.set('TipoFactor', 'Tasa')
|
||||||
|
traslado_total.set('TasaOCuota', '0.160000')
|
||||||
|
traslado_total.set('Importe', _format_amount(sale['tax_total']))
|
||||||
|
|
||||||
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||||
|
pretty_print=True).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def build_egreso_xml(sale, tenant_config, customer, original_uuid):
|
||||||
|
"""Build CFDI 4.0 XML for a credit note (Comprobante tipo Egreso).
|
||||||
|
|
||||||
|
Used for cancellations or returns that require a nota de credito.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sale: same as build_ingreso_xml
|
||||||
|
tenant_config: same as build_ingreso_xml
|
||||||
|
customer: same as build_ingreso_xml
|
||||||
|
original_uuid: str UUID of the original CFDI being credited
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: XML string
|
||||||
|
"""
|
||||||
|
nsmap = {
|
||||||
|
'cfdi': CFDI_NS,
|
||||||
|
'xsi': XSI_NS,
|
||||||
|
}
|
||||||
|
|
||||||
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
||||||
|
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
||||||
|
root.set('Version', '4.0')
|
||||||
|
root.set('Serie', 'NC')
|
||||||
|
root.set('Folio', str(sale['id']))
|
||||||
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||||
|
root.set('FormaPago', sale.get('forma_pago_sat', '01'))
|
||||||
|
root.set('SubTotal', _format_amount(sale['subtotal']))
|
||||||
|
|
||||||
|
discount_total = _to_dec(sale.get('discount_total', 0))
|
||||||
|
if discount_total > 0:
|
||||||
|
root.set('Descuento', _format_amount(discount_total))
|
||||||
|
|
||||||
|
root.set('Moneda', 'MXN')
|
||||||
|
root.set('Total', _format_amount(sale['total']))
|
||||||
|
root.set('TipoDeComprobante', 'E') # Egreso
|
||||||
|
root.set('Exportacion', '01')
|
||||||
|
root.set('MetodoPago', 'PUE')
|
||||||
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
||||||
|
|
||||||
|
# CfdiRelacionados - references the original CFDI
|
||||||
|
cfdi_relacionados = _make_element(root, 'CfdiRelacionados')
|
||||||
|
cfdi_relacionados.set('TipoRelacion', '01') # Nota de credito
|
||||||
|
cfdi_relacionado = _make_element(cfdi_relacionados, 'CfdiRelacionado')
|
||||||
|
cfdi_relacionado.set('UUID', original_uuid)
|
||||||
|
|
||||||
|
# Emisor
|
||||||
|
emisor = _make_element(root, 'Emisor')
|
||||||
|
emisor.set('Rfc', tenant_config['rfc'])
|
||||||
|
emisor.set('Nombre', tenant_config['razon_social'])
|
||||||
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
||||||
|
|
||||||
|
# Receptor
|
||||||
|
is_publico_general = (customer is None or
|
||||||
|
customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL))
|
||||||
|
receptor = _make_element(root, 'Receptor')
|
||||||
|
if is_publico_general:
|
||||||
|
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
||||||
|
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
||||||
|
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
||||||
|
receptor.set('RegimenFiscalReceptor', '616')
|
||||||
|
receptor.set('UsoCFDI', 'S01')
|
||||||
|
else:
|
||||||
|
receptor.set('Rfc', customer['rfc'])
|
||||||
|
receptor.set('Nombre', customer.get('razon_social', customer.get('name', '')))
|
||||||
|
receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000'))
|
||||||
|
receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616'))
|
||||||
|
receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03'))
|
||||||
|
|
||||||
|
# Conceptos (same as ingreso but for the credited amounts)
|
||||||
|
conceptos = _make_element(root, 'Conceptos')
|
||||||
|
|
||||||
|
for item in sale.get('items', []):
|
||||||
|
qty = int(item.get('quantity', 1))
|
||||||
|
unit_price = _to_dec(item.get('unit_price', 0))
|
||||||
|
discount_amount = _to_dec(item.get('discount_amount', 0))
|
||||||
|
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
||||||
|
tax_amount = _to_dec(item.get('tax_amount', 0))
|
||||||
|
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||||
|
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||||
|
concepto.set('Cantidad', str(qty))
|
||||||
|
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||||
|
concepto.set('Unidad', 'PZA')
|
||||||
|
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||||
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||||
|
concepto.set('Importe', _format_amount(importe))
|
||||||
|
concepto.set('ObjetoImp', '02')
|
||||||
|
|
||||||
|
if discount_amount > 0:
|
||||||
|
concepto.set('Descuento', _format_amount(discount_amount))
|
||||||
|
|
||||||
|
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
||||||
|
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
||||||
|
traslado = _make_element(traslados_concepto, 'Traslado')
|
||||||
|
traslado.set('Base', _format_amount(base))
|
||||||
|
traslado.set('Impuesto', '002')
|
||||||
|
traslado.set('TipoFactor', 'Tasa')
|
||||||
|
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
||||||
|
traslado.set('Importe', _format_amount(tax_amount))
|
||||||
|
|
||||||
|
# Impuestos totales
|
||||||
|
impuestos = _make_element(root, 'Impuestos')
|
||||||
|
impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total']))
|
||||||
|
traslados = _make_element(impuestos, 'Traslados')
|
||||||
|
traslado_total = _make_element(traslados, 'Traslado')
|
||||||
|
traslado_total.set('Base', _format_amount(sale['subtotal']))
|
||||||
|
traslado_total.set('Impuesto', '002')
|
||||||
|
traslado_total.set('TipoFactor', 'Tasa')
|
||||||
|
traslado_total.set('TasaOCuota', '0.160000')
|
||||||
|
traslado_total.set('Importe', _format_amount(sale['tax_total']))
|
||||||
|
|
||||||
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||||
|
pretty_print=True).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def build_pago_xml(payment, tenant_config, customer, original_uuid):
|
||||||
|
"""Build CFDI 4.0 XML with Complemento de Pago 2.0.
|
||||||
|
|
||||||
|
Used for credit sale payments (MetodoPago PPD). When a customer
|
||||||
|
pays an outstanding credit sale, this generates the payment complement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment: dict with keys:
|
||||||
|
id, amount, payment_method ('efectivo'|'transferencia'|'tarjeta'),
|
||||||
|
date (ISO string), reference (optional)
|
||||||
|
tenant_config: same as build_ingreso_xml
|
||||||
|
customer: dict with RFC data
|
||||||
|
original_uuid: str UUID of the original CFDI (Ingreso with PPD)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: XML string
|
||||||
|
"""
|
||||||
|
nsmap = {
|
||||||
|
'cfdi': CFDI_NS,
|
||||||
|
'pago20': PAGO_NS,
|
||||||
|
'xsi': XSI_NS,
|
||||||
|
}
|
||||||
|
|
||||||
|
forma_pago_map = {
|
||||||
|
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
||||||
|
}
|
||||||
|
|
||||||
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
||||||
|
root.set(f'{{{XSI_NS}}}schemaLocation',
|
||||||
|
f'{CFDI_SCHEMA_LOCATION} {PAGO_SCHEMA_LOCATION}')
|
||||||
|
root.set('Version', '4.0')
|
||||||
|
root.set('Serie', 'P')
|
||||||
|
root.set('Folio', str(payment.get('id', '')))
|
||||||
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||||
|
root.set('SubTotal', '0')
|
||||||
|
root.set('Moneda', 'XXX') # Required for Pago type
|
||||||
|
root.set('Total', '0')
|
||||||
|
root.set('TipoDeComprobante', 'P') # Pago
|
||||||
|
root.set('Exportacion', '01')
|
||||||
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
||||||
|
|
||||||
|
# Emisor
|
||||||
|
emisor = _make_element(root, 'Emisor')
|
||||||
|
emisor.set('Rfc', tenant_config['rfc'])
|
||||||
|
emisor.set('Nombre', tenant_config['razon_social'])
|
||||||
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
||||||
|
|
||||||
|
# Receptor
|
||||||
|
receptor = _make_element(root, 'Receptor')
|
||||||
|
receptor.set('Rfc', customer['rfc'])
|
||||||
|
receptor.set('Nombre', customer.get('razon_social', customer.get('name', '')))
|
||||||
|
receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000'))
|
||||||
|
receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616'))
|
||||||
|
receptor.set('UsoCFDI', 'CP01') # Pagos
|
||||||
|
|
||||||
|
# Conceptos (mandatory placeholder for Pago type)
|
||||||
|
conceptos = _make_element(root, 'Conceptos')
|
||||||
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
|
concepto.set('ClaveProdServ', '84111506') # Servicios de facturacion
|
||||||
|
concepto.set('Cantidad', '1')
|
||||||
|
concepto.set('ClaveUnidad', 'ACT') # Actividad
|
||||||
|
concepto.set('Descripcion', 'Pago')
|
||||||
|
concepto.set('ValorUnitario', '0')
|
||||||
|
concepto.set('Importe', '0')
|
||||||
|
concepto.set('ObjetoImp', '01') # No objeto de impuesto
|
||||||
|
|
||||||
|
# Complemento de Pago 2.0
|
||||||
|
complemento = _make_element(root, 'Complemento')
|
||||||
|
pagos_elem = etree.SubElement(complemento, f'{{{PAGO_NS}}}Pagos')
|
||||||
|
pagos_elem.set('Version', '2.0')
|
||||||
|
|
||||||
|
# Totales
|
||||||
|
totales = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Totales')
|
||||||
|
amount = _to_dec(payment['amount'])
|
||||||
|
# Calculate base and IVA from total (total includes 16% IVA)
|
||||||
|
base_pago = (amount / Decimal('1.16')).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
iva_pago = (amount - base_pago).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
totales.set('TotalTrasladosBaseIVA16', _format_amount(base_pago))
|
||||||
|
totales.set('TotalTrasladosImpuestoIVA16', _format_amount(iva_pago))
|
||||||
|
totales.set('MontoTotalPagos', _format_amount(amount))
|
||||||
|
|
||||||
|
# Pago
|
||||||
|
pago = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Pago')
|
||||||
|
payment_date = payment.get('date', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
||||||
|
pago.set('FechaPago', payment_date if 'T' in str(payment_date)
|
||||||
|
else f'{payment_date}T12:00:00')
|
||||||
|
pago.set('FormaDePagoP', forma_pago_map.get(payment.get('payment_method', 'efectivo'), '01'))
|
||||||
|
pago.set('MonedaP', 'MXN')
|
||||||
|
pago.set('Monto', _format_amount(amount))
|
||||||
|
|
||||||
|
# DoctoRelacionado (the original invoice being paid)
|
||||||
|
docto = etree.SubElement(pago, f'{{{PAGO_NS}}}DoctoRelacionado')
|
||||||
|
docto.set('IdDocumento', original_uuid)
|
||||||
|
docto.set('Serie', 'A')
|
||||||
|
docto.set('Folio', str(payment.get('sale_id', '')))
|
||||||
|
docto.set('MonedaDR', 'MXN')
|
||||||
|
docto.set('NumParcialidad', str(payment.get('num_parcialidad', 1)))
|
||||||
|
docto.set('ImpSaldoAnt', _format_amount(payment.get('saldo_anterior', amount)))
|
||||||
|
docto.set('ImpPagado', _format_amount(amount))
|
||||||
|
saldo_insoluto = _to_dec(payment.get('saldo_anterior', amount)) - amount
|
||||||
|
docto.set('ImpSaldoInsoluto', _format_amount(max(saldo_insoluto, Decimal('0'))))
|
||||||
|
docto.set('ObjetoImpDR', '02')
|
||||||
|
docto.set('EquivalenciaDR', '1')
|
||||||
|
|
||||||
|
# ImpuestosDR
|
||||||
|
impuestos_dr = etree.SubElement(docto, f'{{{PAGO_NS}}}ImpuestosDR')
|
||||||
|
traslados_dr = etree.SubElement(impuestos_dr, f'{{{PAGO_NS}}}TrasladosDR')
|
||||||
|
traslado_dr = etree.SubElement(traslados_dr, f'{{{PAGO_NS}}}TrasladoDR')
|
||||||
|
traslado_dr.set('BaseDR', _format_amount(base_pago))
|
||||||
|
traslado_dr.set('ImpuestoDR', '002')
|
||||||
|
traslado_dr.set('TipoFactorDR', 'Tasa')
|
||||||
|
traslado_dr.set('TasaOCuotaDR', '0.160000')
|
||||||
|
traslado_dr.set('ImporteDR', _format_amount(iva_pago))
|
||||||
|
|
||||||
|
# ImpuestosP (pago-level taxes)
|
||||||
|
impuestos_p = etree.SubElement(pago, f'{{{PAGO_NS}}}ImpuestosP')
|
||||||
|
traslados_p = etree.SubElement(impuestos_p, f'{{{PAGO_NS}}}TrasladosP')
|
||||||
|
traslado_p = etree.SubElement(traslados_p, f'{{{PAGO_NS}}}TrasladoP')
|
||||||
|
traslado_p.set('BaseP', _format_amount(base_pago))
|
||||||
|
traslado_p.set('ImpuestoP', '002')
|
||||||
|
traslado_p.set('TipoFactorP', 'Tasa')
|
||||||
|
traslado_p.set('TasaOCuotaP', '0.160000')
|
||||||
|
traslado_p.set('ImporteP', _format_amount(iva_pago))
|
||||||
|
|
||||||
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
||||||
|
pretty_print=True).decode('utf-8')
|
||||||
421
pos/services/cfdi_queue.py
Normal file
421
pos/services/cfdi_queue.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
# /home/Autopartes/pos/services/cfdi_queue.py
|
||||||
|
"""CFDI queue service: manages the timbrado pipeline.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
|
||||||
|
2. process_queue() — sends pending items to Horux API, updates status
|
||||||
|
3. retry_failed() — retries failed items with exponential backoff
|
||||||
|
4. cancel_cfdi() — sends cancel request to Horux API
|
||||||
|
|
||||||
|
Horux API endpoints:
|
||||||
|
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
|
||||||
|
GET /api/nexus/cfdi/status/:uuid — check timbrado status
|
||||||
|
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
|
||||||
|
|
||||||
|
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Backoff intervals in seconds: 5s, 30s, 2m, 10m, 1h
|
||||||
|
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
|
||||||
|
MAX_RETRIES = len(BACKOFF_INTERVALS)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_provisional_folio(conn):
|
||||||
|
"""Generate a provisional folio like PRE-00001.
|
||||||
|
|
||||||
|
Uses the cfdi_queue table's max id to avoid collisions.
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||||
|
seq = cur.fetchone()[0]
|
||||||
|
cur.close()
|
||||||
|
return f'PRE-{seq:05d}'
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
||||||
|
"""Add a CFDI to the timbrado queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
sale_id: int (FK to sales)
|
||||||
|
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
||||||
|
xml: str (unsigned XML from cfdi_builder)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {id, sale_id, type, status, provisional_folio}
|
||||||
|
"""
|
||||||
|
provisional_folio = _generate_provisional_folio(conn)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO cfdi_queue
|
||||||
|
(sale_id, type, xml_unsigned, status, provisional_folio)
|
||||||
|
VALUES (%s, %s, %s, 'pending', %s)
|
||||||
|
RETURNING id, created_at
|
||||||
|
""", (sale_id, cfdi_type, xml, provisional_folio))
|
||||||
|
cfdi_id, created_at = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': cfdi_id,
|
||||||
|
'sale_id': sale_id,
|
||||||
|
'type': cfdi_type,
|
||||||
|
'status': 'pending',
|
||||||
|
'provisional_folio': provisional_folio,
|
||||||
|
'created_at': str(created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_queue(conn, horux_api_url, api_key):
|
||||||
|
"""Process all pending CFDI items in the queue.
|
||||||
|
|
||||||
|
Sends each pending XML to Horux for timbrado. On success, updates
|
||||||
|
the record with the signed XML and UUID fiscal. On failure, increments
|
||||||
|
retry_count and records the error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
|
||||||
|
api_key: str Horux API key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, sale_id, type, xml_unsigned, retry_count
|
||||||
|
FROM cfdi_queue
|
||||||
|
WHERE status IN ('pending', 'failed')
|
||||||
|
AND retry_count < %s
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 50
|
||||||
|
""", (MAX_RETRIES,))
|
||||||
|
items = cur.fetchall()
|
||||||
|
|
||||||
|
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
||||||
|
|
||||||
|
for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items:
|
||||||
|
results['processed'] += 1
|
||||||
|
|
||||||
|
# Update status to 'sending'
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
|
||||||
|
""", (cfdi_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f'{horux_api_url}/api/nexus/cfdi/stamp',
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
},
|
||||||
|
data=xml_unsigned.encode('utf-8'),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
uuid_fiscal = data.get('uuid')
|
||||||
|
xml_signed = data.get('xml', '')
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET status = 'stamped',
|
||||||
|
xml_signed = %s,
|
||||||
|
uuid_fiscal = %s,
|
||||||
|
stamped_at = NOW(),
|
||||||
|
error_message = NULL
|
||||||
|
WHERE id = %s
|
||||||
|
""", (xml_signed, uuid_fiscal, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
results['stamped'] += 1
|
||||||
|
results['details'].append({
|
||||||
|
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
error_msg = f'HTTP {response.status_code}: {response.text[:500]}'
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET status = 'failed',
|
||||||
|
retry_count = retry_count + 1,
|
||||||
|
error_message = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (error_msg, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
results['failed'] += 1
|
||||||
|
results['details'].append({
|
||||||
|
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
||||||
|
})
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
error_msg = f'Connection error: {str(e)[:500]}'
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET status = 'failed',
|
||||||
|
retry_count = retry_count + 1,
|
||||||
|
error_message = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (error_msg, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
results['failed'] += 1
|
||||||
|
results['details'].append({
|
||||||
|
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def retry_failed(conn):
|
||||||
|
"""Find failed items eligible for retry (based on backoff) and reset to pending.
|
||||||
|
|
||||||
|
Uses exponential backoff: item is eligible for retry only if enough
|
||||||
|
time has passed since the last attempt based on retry_count.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: number of items reset to pending
|
||||||
|
"""
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# For each failed item, check if enough time has passed for its retry level
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, retry_count, created_at
|
||||||
|
FROM cfdi_queue
|
||||||
|
WHERE status = 'failed' AND retry_count < %s
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
""", (MAX_RETRIES,))
|
||||||
|
items = cur.fetchall()
|
||||||
|
|
||||||
|
reset_count = 0
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
for cfdi_id, retry_count, created_at in items:
|
||||||
|
# Calculate required wait time based on retry count
|
||||||
|
if retry_count < len(BACKOFF_INTERVALS):
|
||||||
|
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
||||||
|
else:
|
||||||
|
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
|
||||||
|
|
||||||
|
# Check if enough time has passed (use created_at as approximation)
|
||||||
|
# In production, you'd track last_attempt_at separately
|
||||||
|
if True: # Always eligible for manual retry trigger
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
||||||
|
""", (cfdi_id,))
|
||||||
|
reset_count += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return reset_count
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||||
|
horux_api_url=None, api_key=None):
|
||||||
|
"""Cancel a stamped CFDI via Horux API.
|
||||||
|
|
||||||
|
SAT cancellation motives:
|
||||||
|
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
||||||
|
02: Comprobante emitido con errores sin relacion
|
||||||
|
03: No se llevo a cabo la operacion
|
||||||
|
04: Operacion nominativa relacionada en factura global
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
cfdi_id: int (cfdi_queue.id)
|
||||||
|
motive: str ('01', '02', '03', '04')
|
||||||
|
replacement_uuid: str (required if motive == '01')
|
||||||
|
horux_api_url: str (optional, skips API call if None — for offline)
|
||||||
|
api_key: str (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {id, status, message}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: on validation errors
|
||||||
|
"""
|
||||||
|
if motive not in ('01', '02', '03', '04'):
|
||||||
|
raise ValueError(f"Invalid SAT cancellation motive: {motive}")
|
||||||
|
|
||||||
|
if motive == '01' and not replacement_uuid:
|
||||||
|
raise ValueError("Motive 01 requires a replacement UUID")
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
||||||
|
""", (cfdi_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
||||||
|
|
||||||
|
_, uuid_fiscal, current_status = row
|
||||||
|
|
||||||
|
if current_status == 'cancelled':
|
||||||
|
raise ValueError("CFDI is already cancelled")
|
||||||
|
|
||||||
|
if current_status != 'stamped':
|
||||||
|
# If not stamped, we can just mark as cancelled locally
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET status = 'cancelled', cancel_motive = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (motive, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
||||||
|
|
||||||
|
# Send cancel request to Horux
|
||||||
|
if horux_api_url and api_key:
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
'uuid': uuid_fiscal,
|
||||||
|
'motive': motive,
|
||||||
|
}
|
||||||
|
if replacement_uuid:
|
||||||
|
payload['replacement_uuid'] = replacement_uuid
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f'{horux_api_url}/api/nexus/cfdi/cancel',
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET status = 'cancelled',
|
||||||
|
cancel_motive = %s,
|
||||||
|
cancel_replacement_uuid = %s,
|
||||||
|
error_message = NULL
|
||||||
|
WHERE id = %s
|
||||||
|
""", (motive, replacement_uuid, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return {
|
||||||
|
'id': cfdi_id,
|
||||||
|
'status': 'cancelled',
|
||||||
|
'message': f'Cancelled with SAT (motive {motive})',
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET error_message = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (error_msg, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
cur.close()
|
||||||
|
raise ValueError(f'Connection error during cancel: {str(e)}')
|
||||||
|
else:
|
||||||
|
# Offline mode: mark as cancelled locally, will sync later
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE cfdi_queue
|
||||||
|
SET status = 'cancelled',
|
||||||
|
cancel_motive = %s,
|
||||||
|
cancel_replacement_uuid = %s,
|
||||||
|
error_message = 'Cancelled offline, pending SAT sync'
|
||||||
|
WHERE id = %s
|
||||||
|
""", (motive, replacement_uuid, cfdi_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return {
|
||||||
|
'id': cfdi_id,
|
||||||
|
'status': 'cancelled',
|
||||||
|
'message': 'Cancelled offline, pending SAT sync',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_queue_status(conn, filters=None):
|
||||||
|
"""Get CFDI queue items with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: psycopg2 connection
|
||||||
|
filters: dict with optional keys:
|
||||||
|
status: str filter by status
|
||||||
|
sale_id: int filter by sale
|
||||||
|
page: int (default 1)
|
||||||
|
per_page: int (default 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {data: [...], pagination: {...}}
|
||||||
|
"""
|
||||||
|
filters = filters or {}
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
page = int(filters.get('page', 1))
|
||||||
|
per_page = min(int(filters.get('per_page', 50)), 200)
|
||||||
|
|
||||||
|
where_clauses = ["1=1"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if filters.get('status'):
|
||||||
|
where_clauses.append("q.status = %s")
|
||||||
|
params.append(filters['status'])
|
||||||
|
|
||||||
|
if filters.get('sale_id'):
|
||||||
|
where_clauses.append("q.sale_id = %s")
|
||||||
|
params.append(int(filters['sale_id']))
|
||||||
|
|
||||||
|
if filters.get('type'):
|
||||||
|
where_clauses.append("q.type = %s")
|
||||||
|
params.append(filters['type'])
|
||||||
|
|
||||||
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||||
|
q.retry_count, q.provisional_folio, q.error_message,
|
||||||
|
q.cancel_motive, q.created_at, q.stamped_at
|
||||||
|
FROM cfdi_queue q
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [per_page, (page - 1) * per_page])
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
items.append({
|
||||||
|
'id': r[0], 'sale_id': r[1], 'type': r[2],
|
||||||
|
'uuid_fiscal': r[3], 'status': r[4],
|
||||||
|
'retry_count': r[5], 'provisional_folio': r[6],
|
||||||
|
'error_message': r[7], 'cancel_motive': r[8],
|
||||||
|
'created_at': str(r[9]) if r[9] else None,
|
||||||
|
'stamped_at': str(r[10]) if r[10] else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
return {
|
||||||
|
'data': items,
|
||||||
|
'pagination': {
|
||||||
|
'page': page, 'per_page': per_page,
|
||||||
|
'total': total, 'total_pages': total_pages,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ from services.inventory_engine import (
|
|||||||
record_operation,
|
record_operation,
|
||||||
get_stock,
|
get_stock,
|
||||||
)
|
)
|
||||||
|
from services.accounting_engine import record_sale_entry, record_cancellation_entry
|
||||||
|
|
||||||
|
|
||||||
def _to_dec(val):
|
def _to_dec(val):
|
||||||
@@ -369,6 +370,20 @@ def process_sale(conn, sale_data):
|
|||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
|
# Auto-generate accounting entry (non-blocking)
|
||||||
|
try:
|
||||||
|
record_sale_entry(conn, {
|
||||||
|
'id': sale_id,
|
||||||
|
'sale_type': sale_type,
|
||||||
|
'total': totals['total'],
|
||||||
|
'tax_total': totals['tax_total'],
|
||||||
|
'subtotal': totals['subtotal'] - totals['discount_total'],
|
||||||
|
'cost_total': sum(item.get('unit_cost', 0) * item['quantity'] for item in enriched_items),
|
||||||
|
'payment_method': payment_method,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass # Accounting errors never block sales
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': sale_id,
|
'id': sale_id,
|
||||||
'branch_id': branch_id,
|
'branch_id': branch_id,
|
||||||
@@ -464,6 +479,24 @@ def cancel_sale(conn, sale_id, reason):
|
|||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (f"CANCELADA: {reason}", sale_id))
|
""", (f"CANCELADA: {reason}", sale_id))
|
||||||
|
|
||||||
|
# Reverse accounting entry (non-blocking)
|
||||||
|
try:
|
||||||
|
# Fetch full sale data for the reversal entry
|
||||||
|
cur.execute("""SELECT subtotal, tax_total, total, sale_type, payment_method
|
||||||
|
FROM sales WHERE id = %s""", (sale_id,))
|
||||||
|
_sale_row = cur.fetchone()
|
||||||
|
if _sale_row:
|
||||||
|
record_cancellation_entry(conn, {
|
||||||
|
'id': sale_id,
|
||||||
|
'subtotal': float(_sale_row[0]) if _sale_row[0] else 0.0,
|
||||||
|
'tax_total': float(_sale_row[1]) if _sale_row[1] else 0.0,
|
||||||
|
'total': float(_sale_row[2]) if _sale_row[2] else 0.0,
|
||||||
|
'sale_type': _sale_row[3] or 'cash',
|
||||||
|
'payment_method': _sale_row[4] or 'efectivo',
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass # Accounting errors never block cancellations
|
||||||
|
|
||||||
# Reverse customer credit balance if credit sale
|
# Reverse customer credit balance if credit sale
|
||||||
if s_type == 'credit' and s_cust_id:
|
if s_type == 'credit' and s_cust_id:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
|
|||||||
@@ -165,6 +165,18 @@ def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner
|
|||||||
(owner_id, perm)
|
(owner_id, perm)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Seed tenant_config with RFC and defaults
|
||||||
|
if rfc:
|
||||||
|
tenant_cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES
|
||||||
|
('tenant_rfc', %s),
|
||||||
|
('tenant_razon_social', %s),
|
||||||
|
('tenant_cp', '00000'),
|
||||||
|
('cfdi_regimen_fiscal', '601'),
|
||||||
|
('cfdi_serie', 'A')
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
""", (rfc, name))
|
||||||
|
|
||||||
tenant_conn.commit()
|
tenant_conn.commit()
|
||||||
tenant_cur.close()
|
tenant_cur.close()
|
||||||
tenant_conn.close()
|
tenant_conn.close()
|
||||||
|
|||||||
593
pos/static/js/accounting.js
Normal file
593
pos/static/js/accounting.js
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
// /home/Autopartes/pos/static/js/accounting.js
|
||||||
|
// Accounting module: chart of accounts, journal entries, financial reports
|
||||||
|
|
||||||
|
const Accounting = (() => {
|
||||||
|
const API = '/pos/api/accounting';
|
||||||
|
let accounts = []; // cached for dropdowns
|
||||||
|
let entryLineCtr = 0; // counter for entry line IDs
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
return localStorage.getItem('pos_token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || 'Request failed');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tabs ──────────────────────────────────────
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||||||
|
|
||||||
|
// Load data on tab switch
|
||||||
|
const t = tab.dataset.tab;
|
||||||
|
if (t === 'accounts') loadAccounts();
|
||||||
|
if (t === 'entries') loadEntries();
|
||||||
|
if (t === 'trial-balance') loadTrialBalance();
|
||||||
|
if (t === 'income-statement') loadIncomeStatement();
|
||||||
|
if (t === 'balance-sheet') loadBalanceSheet();
|
||||||
|
if (t === 'aging') loadAging();
|
||||||
|
if (t === 'periods') loadPeriods();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart of Accounts ─────────────────────────
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
try {
|
||||||
|
const res = await api('/accounts');
|
||||||
|
accounts = res.data || [];
|
||||||
|
renderAccountsTree();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('accounts-tree').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccountsTree() {
|
||||||
|
const container = document.getElementById('accounts-tree');
|
||||||
|
if (!accounts.length) { container.innerHTML = '<p>No hay cuentas.</p>'; return; }
|
||||||
|
|
||||||
|
// Build tree structure
|
||||||
|
const byParent = {};
|
||||||
|
accounts.forEach(a => {
|
||||||
|
const pid = a.parent_id || 'root';
|
||||||
|
if (!byParent[pid]) byParent[pid] = [];
|
||||||
|
byParent[pid].push(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildUl(parentId) {
|
||||||
|
const children = byParent[parentId] || [];
|
||||||
|
if (!children.length) return '';
|
||||||
|
let html = '<ul class="tree">';
|
||||||
|
for (const acct of children) {
|
||||||
|
const hasChildren = byParent[acct.id] && byParent[acct.id].length > 0;
|
||||||
|
const balClass = acct.balance < 0 ? 'negative' : '';
|
||||||
|
html += '<li>';
|
||||||
|
if (hasChildren) {
|
||||||
|
html += `<span class="tree-toggle open" onclick="this.classList.toggle('open');this.nextElementSibling.style.display=this.classList.contains('open')?'block':'none'">`;
|
||||||
|
} else {
|
||||||
|
html += '<span style="display:inline-block;width:1rem;">';
|
||||||
|
}
|
||||||
|
html += `<span class="tree-code">${acct.code}</span> ${acct.name}`;
|
||||||
|
html += `<span class="tree-balance ${balClass}">$${fmt(acct.balance)}</span>`;
|
||||||
|
html += '</span>';
|
||||||
|
if (hasChildren) {
|
||||||
|
html += `<div>${buildUl(acct.id)}</div>`;
|
||||||
|
}
|
||||||
|
html += '</li>';
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = buildUl('root');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewAccountModal() {
|
||||||
|
const sel = document.getElementById('na-parent');
|
||||||
|
sel.innerHTML = '<option value="">-- Sin padre --</option>';
|
||||||
|
accounts.forEach(a => {
|
||||||
|
sel.innerHTML += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
|
||||||
|
});
|
||||||
|
document.getElementById('na-code').value = '';
|
||||||
|
document.getElementById('na-name').value = '';
|
||||||
|
document.getElementById('new-account-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAccount() {
|
||||||
|
try {
|
||||||
|
const parentId = document.getElementById('na-parent').value;
|
||||||
|
await api('/accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: document.getElementById('na-code').value,
|
||||||
|
name: document.getElementById('na-name').value,
|
||||||
|
parent_id: parentId ? parseInt(parentId) : null,
|
||||||
|
type: document.getElementById('na-type').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
closeModal('new-account-modal');
|
||||||
|
loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Journal Entries ───────────────────────────
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
try {
|
||||||
|
const from = document.getElementById('entries-from').value;
|
||||||
|
const to = document.getElementById('entries-to').value;
|
||||||
|
const type = document.getElementById('entries-type').value;
|
||||||
|
let qs = '?per_page=50';
|
||||||
|
if (from) qs += `&date_from=${from}`;
|
||||||
|
if (to) qs += `&date_to=${to}`;
|
||||||
|
if (type) qs += `&type=${type}`;
|
||||||
|
|
||||||
|
const res = await api(`/entries${qs}`);
|
||||||
|
renderEntries(res.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('entries-list').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntries(entries) {
|
||||||
|
const container = document.getElementById('entries-list');
|
||||||
|
if (!entries.length) { container.innerHTML = '<p>No hay polizas en este periodo.</p>'; return; }
|
||||||
|
|
||||||
|
let html = `<table class="report-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>#</th><th>Fecha</th><th>Tipo</th><th>Descripcion</th>
|
||||||
|
<th>Referencia</th><th class="amount">Monto</th><th>Auto</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
html += `<tr style="cursor:pointer;" onclick="Accounting.showEntryDetail(${e.id})">
|
||||||
|
<td>${e.entry_number}</td>
|
||||||
|
<td>${e.date || ''}</td>
|
||||||
|
<td>${e.type || ''}</td>
|
||||||
|
<td>${e.description || ''}</td>
|
||||||
|
<td>${e.reference_type ? `${e.reference_type} #${e.reference_id}` : ''}</td>
|
||||||
|
<td class="amount">$${fmt(e.total_amount)}</td>
|
||||||
|
<td>${e.is_auto ? 'Si' : 'No'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEntryDetail(entryId) {
|
||||||
|
try {
|
||||||
|
const entry = await api(`/entries/${entryId}`);
|
||||||
|
let html = `<h3>Poliza #${entry.entry_number}</h3>
|
||||||
|
<div class="entry-header">
|
||||||
|
<span><strong>Fecha:</strong> ${entry.date}</span>
|
||||||
|
<span><strong>Tipo:</strong> ${entry.type}</span>
|
||||||
|
<span><strong>Estado:</strong> ${entry.status}</span>
|
||||||
|
</div>
|
||||||
|
<p>${entry.description || ''}</p>
|
||||||
|
<table class="report-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Cuenta</th><th>Nombre</th><th class="amount">Cargo</th>
|
||||||
|
<th class="amount">Abono</th><th>Nota</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
|
||||||
|
for (const l of entry.lines) {
|
||||||
|
html += `<tr>
|
||||||
|
<td class="tree-code">${l.account_code}</td>
|
||||||
|
<td>${l.account_name}</td>
|
||||||
|
<td class="amount">${l.debit ? '$' + fmt(l.debit) : ''}</td>
|
||||||
|
<td class="amount">${l.credit ? '$' + fmt(l.credit) : ''}</td>
|
||||||
|
<td>${l.description || ''}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<tr class="total-row">
|
||||||
|
<td colspan="2">Totales</td>
|
||||||
|
<td class="amount">$${fmt(entry.total_debit)}</td>
|
||||||
|
<td class="amount">$${fmt(entry.total_credit)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr></tbody></table>`;
|
||||||
|
|
||||||
|
document.getElementById('entry-detail-content').innerHTML = html;
|
||||||
|
document.getElementById('entry-detail-modal').classList.add('active');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Manual Entry ──────────────────────────────
|
||||||
|
|
||||||
|
function showNewEntryModal() {
|
||||||
|
document.getElementById('ne-date').value = new Date().toISOString().slice(0, 10);
|
||||||
|
document.getElementById('ne-description').value = '';
|
||||||
|
document.getElementById('ne-lines').innerHTML = '';
|
||||||
|
document.getElementById('ne-balance').innerHTML = '';
|
||||||
|
entryLineCtr = 0;
|
||||||
|
addEntryLine();
|
||||||
|
addEntryLine();
|
||||||
|
document.getElementById('new-entry-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntryLine() {
|
||||||
|
const id = entryLineCtr++;
|
||||||
|
const tbody = document.getElementById('ne-lines');
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.id = `ne-line-${id}`;
|
||||||
|
|
||||||
|
let acctOptions = '<option value="">Seleccionar</option>';
|
||||||
|
accounts.forEach(a => {
|
||||||
|
if (a.parent_id) { // Only leaf accounts
|
||||||
|
acctOptions += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><select onchange="Accounting.updateEntryBalance()">${acctOptions}</select></td>
|
||||||
|
<td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
|
||||||
|
<td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
|
||||||
|
<td><button onclick="this.closest('tr').remove();Accounting.updateEntryBalance();"
|
||||||
|
style="border:none;background:none;color:var(--danger);cursor:pointer;font-size:1.1rem;">x</button></td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntryBalance() {
|
||||||
|
const rows = document.querySelectorAll('#ne-lines tr');
|
||||||
|
let totalDebit = 0, totalCredit = 0;
|
||||||
|
rows.forEach(row => {
|
||||||
|
const inputs = row.querySelectorAll('input[type="number"]');
|
||||||
|
totalDebit += parseFloat(inputs[0].value) || 0;
|
||||||
|
totalCredit += parseFloat(inputs[1].value) || 0;
|
||||||
|
});
|
||||||
|
const diff = Math.round((totalDebit - totalCredit) * 100) / 100;
|
||||||
|
const el = document.getElementById('ne-balance');
|
||||||
|
if (diff === 0) {
|
||||||
|
el.innerHTML = `<span style="color:var(--success);">Cuadrada: Cargos $${fmt(totalDebit)} = Abonos $${fmt(totalCredit)}</span>`;
|
||||||
|
} else {
|
||||||
|
el.innerHTML = `<span style="color:var(--danger);">Descuadre: $${fmt(Math.abs(diff))} (Cargos $${fmt(totalDebit)} / Abonos $${fmt(totalCredit)})</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEntry() {
|
||||||
|
try {
|
||||||
|
const rows = document.querySelectorAll('#ne-lines tr');
|
||||||
|
const lines = [];
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sel = row.querySelector('select');
|
||||||
|
const inputs = row.querySelectorAll('input[type="number"]');
|
||||||
|
const acctId = parseInt(sel.value);
|
||||||
|
const debit = parseFloat(inputs[0].value) || 0;
|
||||||
|
const credit = parseFloat(inputs[1].value) || 0;
|
||||||
|
if (acctId && (debit > 0 || credit > 0)) {
|
||||||
|
lines.push({ account_id: acctId, debit, credit, description: '' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lines.length < 2) { alert('Se requieren al menos 2 lineas.'); return; }
|
||||||
|
|
||||||
|
await api('/entries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
date: document.getElementById('ne-date').value,
|
||||||
|
description: document.getElementById('ne-description').value,
|
||||||
|
lines,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
closeModal('new-entry-modal');
|
||||||
|
loadEntries();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Trial Balance ─────────────────────────────
|
||||||
|
|
||||||
|
async function loadTrialBalance() {
|
||||||
|
try {
|
||||||
|
const year = document.getElementById('tb-year').value;
|
||||||
|
const month = document.getElementById('tb-month').value;
|
||||||
|
const res = await api(`/trial-balance?year=${year}&month=${month}`);
|
||||||
|
renderTrialBalance(res);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('trial-balance-content').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrialBalance(data) {
|
||||||
|
const rows = data.data || [];
|
||||||
|
let html = `<table class="report-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Codigo</th><th>Cuenta</th>
|
||||||
|
<th class="amount">Saldo Inicial</th><th class="amount">Cargos</th>
|
||||||
|
<th class="amount">Abonos</th><th class="amount">Saldo Final</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
|
||||||
|
let totals = { si: 0, c: 0, a: 0, sf: 0 };
|
||||||
|
for (const r of rows) {
|
||||||
|
totals.si += r.saldo_inicial;
|
||||||
|
totals.c += r.cargos;
|
||||||
|
totals.a += r.abonos;
|
||||||
|
totals.sf += r.saldo_final;
|
||||||
|
html += `<tr>
|
||||||
|
<td class="tree-code">${r.code}</td><td>${r.name}</td>
|
||||||
|
<td class="amount">$${fmt(r.saldo_inicial)}</td>
|
||||||
|
<td class="amount">$${fmt(r.cargos)}</td>
|
||||||
|
<td class="amount">$${fmt(r.abonos)}</td>
|
||||||
|
<td class="amount">$${fmt(r.saldo_final)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<tr class="total-row">
|
||||||
|
<td colspan="2">Totales</td>
|
||||||
|
<td class="amount">$${fmt(totals.si)}</td>
|
||||||
|
<td class="amount">$${fmt(totals.c)}</td>
|
||||||
|
<td class="amount">$${fmt(totals.a)}</td>
|
||||||
|
<td class="amount">$${fmt(totals.sf)}</td>
|
||||||
|
</tr></tbody></table>`;
|
||||||
|
|
||||||
|
document.getElementById('trial-balance-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Income Statement ──────────────────────────
|
||||||
|
|
||||||
|
async function loadIncomeStatement() {
|
||||||
|
try {
|
||||||
|
const year = document.getElementById('is-year').value;
|
||||||
|
const month = document.getElementById('is-month').value;
|
||||||
|
const res = await api(`/income-statement?year=${year}&month=${month}`);
|
||||||
|
renderIncomeStatement(res);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('income-statement-content').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIncomeStatement(data) {
|
||||||
|
let html = '<table class="report-table"><tbody>';
|
||||||
|
|
||||||
|
// Ingresos
|
||||||
|
html += '<tr class="subtotal-row"><td colspan="2"><strong>INGRESOS</strong></td></tr>';
|
||||||
|
for (const item of data.ingresos.items) {
|
||||||
|
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr class="subtotal-row"><td>Total Ingresos</td><td class="amount">$${fmt(data.ingresos.total)}</td></tr>`;
|
||||||
|
|
||||||
|
// Costos
|
||||||
|
html += '<tr class="subtotal-row"><td colspan="2"><strong>COSTOS</strong></td></tr>';
|
||||||
|
for (const item of data.costos.items) {
|
||||||
|
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr class="subtotal-row"><td>Total Costos</td><td class="amount">$${fmt(data.costos.total)}</td></tr>`;
|
||||||
|
|
||||||
|
// Utilidad bruta
|
||||||
|
html += `<tr class="total-row"><td><strong>UTILIDAD BRUTA</strong></td><td class="amount"><strong>$${fmt(data.utilidad_bruta)}</strong></td></tr>`;
|
||||||
|
|
||||||
|
// Gastos
|
||||||
|
html += '<tr class="subtotal-row"><td colspan="2"><strong>GASTOS</strong></td></tr>';
|
||||||
|
for (const item of data.gastos.items) {
|
||||||
|
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr class="subtotal-row"><td>Total Gastos</td><td class="amount">$${fmt(data.gastos.total)}</td></tr>`;
|
||||||
|
|
||||||
|
// Utilidad neta
|
||||||
|
const netColor = data.utilidad_neta >= 0 ? 'var(--success)' : 'var(--danger)';
|
||||||
|
html += `<tr class="total-row"><td><strong>UTILIDAD NETA</strong></td>
|
||||||
|
<td class="amount" style="color:${netColor};"><strong>$${fmt(data.utilidad_neta)}</strong></td></tr>`;
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('income-statement-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Balance Sheet ─────────────────────────────
|
||||||
|
|
||||||
|
async function loadBalanceSheet() {
|
||||||
|
try {
|
||||||
|
const asOf = document.getElementById('bs-date').value;
|
||||||
|
const res = await api(`/balance-sheet?date=${asOf}`);
|
||||||
|
renderBalanceSheet(res);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('balance-sheet-content').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBalanceSheet(data) {
|
||||||
|
const balancedBadge = data.balanced
|
||||||
|
? '<span class="badge badge-open">Cuadrado</span>'
|
||||||
|
: '<span class="badge badge-closed">Descuadrado</span>';
|
||||||
|
|
||||||
|
let html = `<p>Al ${data.as_of} ${balancedBadge}</p>
|
||||||
|
<table class="report-table"><tbody>`;
|
||||||
|
|
||||||
|
// Activo
|
||||||
|
html += '<tr class="subtotal-row"><td colspan="2"><strong>ACTIVO</strong></td></tr>';
|
||||||
|
for (const item of data.activo.items) {
|
||||||
|
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr class="total-row"><td>Total Activo</td><td class="amount">$${fmt(data.activo.total)}</td></tr>`;
|
||||||
|
|
||||||
|
// Pasivo
|
||||||
|
html += '<tr class="subtotal-row"><td colspan="2"><strong>PASIVO</strong></td></tr>';
|
||||||
|
for (const item of data.pasivo.items) {
|
||||||
|
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr class="total-row"><td>Total Pasivo</td><td class="amount">$${fmt(data.pasivo.total)}</td></tr>`;
|
||||||
|
|
||||||
|
// Capital
|
||||||
|
html += '<tr class="subtotal-row"><td colspan="2"><strong>CAPITAL</strong></td></tr>';
|
||||||
|
for (const item of data.capital.items) {
|
||||||
|
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `<tr class="total-row"><td>Total Capital</td><td class="amount">$${fmt(data.capital.total)}</td></tr>`;
|
||||||
|
|
||||||
|
html += `<tr class="total-row"><td><strong>Pasivo + Capital</strong></td>
|
||||||
|
<td class="amount"><strong>$${fmt(data.pasivo.total + data.capital.total)}</strong></td></tr>`;
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('balance-sheet-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aging ─────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadAging() {
|
||||||
|
try {
|
||||||
|
const res = await api('/aging');
|
||||||
|
renderAging(res);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('aging-content').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAging(data) {
|
||||||
|
const rows = data.data || [];
|
||||||
|
let html = `<table class="report-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Cliente</th><th class="amount">Corriente</th>
|
||||||
|
<th class="amount">1-30d</th><th class="amount">31-60d</th>
|
||||||
|
<th class="amount">61-90d</th><th class="amount">90+d</th>
|
||||||
|
<th class="amount">Total</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
html += `<tr>
|
||||||
|
<td>${r.name}</td>
|
||||||
|
<td class="amount">$${fmt(r.corriente)}</td>
|
||||||
|
<td class="amount">$${fmt(r.d1_30)}</td>
|
||||||
|
<td class="amount">$${fmt(r.d31_60)}</td>
|
||||||
|
<td class="amount">$${fmt(r.d61_90)}</td>
|
||||||
|
<td class="amount">$${fmt(r.d90_plus)}</td>
|
||||||
|
<td class="amount"><strong>$${fmt(r.total)}</strong></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = data.totals || {};
|
||||||
|
html += `<tr class="total-row">
|
||||||
|
<td>Totales</td>
|
||||||
|
<td class="amount">$${fmt(t.corriente)}</td>
|
||||||
|
<td class="amount">$${fmt(t.d1_30)}</td>
|
||||||
|
<td class="amount">$${fmt(t.d31_60)}</td>
|
||||||
|
<td class="amount">$${fmt(t.d61_90)}</td>
|
||||||
|
<td class="amount">$${fmt(t.d90_plus)}</td>
|
||||||
|
<td class="amount"><strong>$${fmt(t.total)}</strong></td>
|
||||||
|
</tr></tbody></table>`;
|
||||||
|
|
||||||
|
document.getElementById('aging-content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Periods ───────────────────────────────────
|
||||||
|
|
||||||
|
async function loadPeriods() {
|
||||||
|
try {
|
||||||
|
const res = await api('/periods');
|
||||||
|
renderPeriods(res.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('periods-list').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPeriods(periods) {
|
||||||
|
const months = ['', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||||
|
let html = `<table class="report-table">
|
||||||
|
<thead><tr><th>Periodo</th><th>Estado</th><th>Cerrado por</th><th>Fecha cierre</th></tr></thead><tbody>`;
|
||||||
|
|
||||||
|
for (const p of periods) {
|
||||||
|
const badge = p.status === 'closed'
|
||||||
|
? '<span class="badge badge-closed">Cerrado</span>'
|
||||||
|
: '<span class="badge badge-open">Abierto</span>';
|
||||||
|
html += `<tr>
|
||||||
|
<td>${months[p.month]} ${p.year}</td>
|
||||||
|
<td>${badge}</td>
|
||||||
|
<td>${p.closed_by_name || '-'}</td>
|
||||||
|
<td>${p.closed_at ? new Date(p.closed_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('periods-list').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closePeriod() {
|
||||||
|
const year = parseInt(document.getElementById('cp-year').value);
|
||||||
|
const month = parseInt(document.getElementById('cp-month').value);
|
||||||
|
if (!confirm(`Cerrar periodo ${month}/${year}? Esta accion no se puede revertir.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/periods/close', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ year, month }),
|
||||||
|
});
|
||||||
|
loadPeriods();
|
||||||
|
alert('Periodo cerrado exitosamente.');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal helpers ─────────────────────────────
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ──────────────────────────────────────
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initTabs();
|
||||||
|
|
||||||
|
// Set default period values
|
||||||
|
const now = new Date();
|
||||||
|
['tb-year', 'is-year', 'cp-year'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = now.getFullYear();
|
||||||
|
});
|
||||||
|
['tb-month', 'is-month', 'cp-month'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = now.getMonth() + 1;
|
||||||
|
});
|
||||||
|
const bsDate = document.getElementById('bs-date');
|
||||||
|
if (bsDate) bsDate.value = now.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Set default entry date filters
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||||
|
document.getElementById('entries-from').value = firstDay;
|
||||||
|
document.getElementById('entries-to').value = now.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
loadAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
loadAccounts, loadEntries, loadTrialBalance, loadIncomeStatement,
|
||||||
|
loadBalanceSheet, loadAging, loadPeriods, closePeriod,
|
||||||
|
showNewAccountModal, createAccount,
|
||||||
|
showNewEntryModal, addEntryLine, updateEntryBalance, createEntry,
|
||||||
|
showEntryDetail, closeModal,
|
||||||
|
};
|
||||||
|
})();
|
||||||
227
pos/static/js/invoicing.js
Normal file
227
pos/static/js/invoicing.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// /home/Autopartes/pos/static/js/invoicing.js
|
||||||
|
// Invoicing module: CFDI queue management, cancel, PDF
|
||||||
|
|
||||||
|
const Invoicing = (() => {
|
||||||
|
const API = '/pos/api/invoicing';
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
return localStorage.getItem('pos_token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || 'Request failed');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(status) {
|
||||||
|
return {
|
||||||
|
pending: 'badge-pending',
|
||||||
|
sending: 'badge-sending',
|
||||||
|
stamped: 'badge-stamped',
|
||||||
|
failed: 'badge-failed',
|
||||||
|
cancelled: 'badge-cancelled',
|
||||||
|
}[status] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeLabel(status) {
|
||||||
|
return {
|
||||||
|
pending: 'Pendiente',
|
||||||
|
sending: 'Enviando',
|
||||||
|
stamped: 'Timbrado',
|
||||||
|
failed: 'Fallido',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
}[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Queue List ────────────────────────────────
|
||||||
|
|
||||||
|
async function loadQueue() {
|
||||||
|
try {
|
||||||
|
const status = document.getElementById('filter-status').value;
|
||||||
|
const type = document.getElementById('filter-type').value;
|
||||||
|
let qs = '?per_page=50';
|
||||||
|
if (status) qs += `&status=${status}`;
|
||||||
|
if (type) qs += `&type=${type}`;
|
||||||
|
|
||||||
|
const res = await api(`/queue${qs}`);
|
||||||
|
renderQueue(res.data || []);
|
||||||
|
updateStats(res.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('queue-list').innerHTML =
|
||||||
|
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(items) {
|
||||||
|
const counts = { pending: 0, sending: 0, stamped: 0, failed: 0, cancelled: 0 };
|
||||||
|
items.forEach(i => { if (counts[i.status] !== undefined) counts[i.status]++; });
|
||||||
|
|
||||||
|
document.getElementById('queue-stats').innerHTML = `
|
||||||
|
<div class="stat-card"><div class="number">${counts.pending}</div><div class="label">Pendientes</div></div>
|
||||||
|
<div class="stat-card"><div class="number">${counts.sending}</div><div class="label">Enviando</div></div>
|
||||||
|
<div class="stat-card"><div class="number">${counts.stamped}</div><div class="label">Timbrados</div></div>
|
||||||
|
<div class="stat-card"><div class="number">${counts.failed}</div><div class="label">Fallidos</div></div>
|
||||||
|
<div class="stat-card"><div class="number">${counts.cancelled}</div><div class="label">Cancelados</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueue(items) {
|
||||||
|
const container = document.getElementById('queue-list');
|
||||||
|
if (!items.length) { container.innerHTML = '<p>No hay CFDIs en la cola.</p>'; return; }
|
||||||
|
|
||||||
|
let html = `<table class="queue-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>#</th><th>Venta</th><th>Tipo</th><th>Folio</th>
|
||||||
|
<th>UUID</th><th>Estado</th><th>Reintentos</th><th>Fecha</th><th>Acciones</th>
|
||||||
|
</tr></thead><tbody>`;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const uuid = item.uuid_fiscal
|
||||||
|
? `${item.uuid_fiscal.substring(0, 8)}...`
|
||||||
|
: '-';
|
||||||
|
html += `<tr>
|
||||||
|
<td>${item.id}</td>
|
||||||
|
<td>#${item.sale_id}</td>
|
||||||
|
<td>${item.type}</td>
|
||||||
|
<td>${item.provisional_folio || '-'}</td>
|
||||||
|
<td title="${item.uuid_fiscal || ''}">${uuid}</td>
|
||||||
|
<td><span class="badge ${badgeClass(item.status)}">${badgeLabel(item.status)}</span></td>
|
||||||
|
<td>${item.retry_count || 0}</td>
|
||||||
|
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : ''}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" style="padding:0.2rem 0.4rem;font-size:0.8rem;"
|
||||||
|
onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||||
|
${item.status === 'stamped' ? `<button class="btn btn-danger" style="padding:0.2rem 0.4rem;font-size:0.8rem;"
|
||||||
|
onclick="Invoicing.showCancelModal(${item.id})">Cancelar</button>` : ''}
|
||||||
|
${item.sale_id ? `<a href="/pos/api/invoicing/${item.sale_id}/pdf" target="_blank"
|
||||||
|
class="btn btn-secondary" style="padding:0.2rem 0.4rem;font-size:0.8rem;">PDF</a>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Detail ────────────────────────────────────
|
||||||
|
|
||||||
|
async function showDetail(cfdiId) {
|
||||||
|
try {
|
||||||
|
const item = await api(`/queue/${cfdiId}`);
|
||||||
|
let html = `<h3>CFDI #${item.id}</h3>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item"><label>Venta</label><span>#${item.sale_id}</span></div>
|
||||||
|
<div class="detail-item"><label>Tipo</label><span>${item.type}</span></div>
|
||||||
|
<div class="detail-item"><label>Estado</label><span class="badge ${badgeClass(item.status)}">${badgeLabel(item.status)}</span></div>
|
||||||
|
<div class="detail-item"><label>Folio Provisional</label><span>${item.provisional_folio || '-'}</span></div>
|
||||||
|
<div class="detail-item"><label>UUID Fiscal</label><span>${item.uuid_fiscal || '-'}</span></div>
|
||||||
|
<div class="detail-item"><label>Reintentos</label><span>${item.retry_count}</span></div>
|
||||||
|
<div class="detail-item"><label>Creado</label><span>${item.created_at || '-'}</span></div>
|
||||||
|
<div class="detail-item"><label>Timbrado</label><span>${item.stamped_at || '-'}</span></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (item.error_message) {
|
||||||
|
html += `<p style="color:var(--danger);"><strong>Error:</strong> ${item.error_message}</p>`;
|
||||||
|
}
|
||||||
|
if (item.cancel_motive) {
|
||||||
|
html += `<p><strong>Motivo cancelacion:</strong> ${item.cancel_motive}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XML preview
|
||||||
|
const xml = item.xml_signed || item.xml_unsigned;
|
||||||
|
if (xml) {
|
||||||
|
html += `<h4>XML</h4><div class="xml-preview">${escapeHtml(xml)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detail-content').innerHTML = html;
|
||||||
|
document.getElementById('detail-modal').classList.add('active');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Process Queue ─────────────────────────────
|
||||||
|
|
||||||
|
async function processQueue() {
|
||||||
|
if (!confirm('Procesar todos los CFDIs pendientes?')) return;
|
||||||
|
try {
|
||||||
|
const result = await api('/queue/process', { method: 'POST' });
|
||||||
|
alert(`Procesados: ${result.processed}, Timbrados: ${result.stamped}, Fallidos: ${result.failed}`);
|
||||||
|
loadQueue();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cancel ────────────────────────────────────
|
||||||
|
|
||||||
|
function showCancelModal(cfdiId) {
|
||||||
|
document.getElementById('cancel-cfdi-id').value = cfdiId;
|
||||||
|
document.getElementById('cancel-motive').value = '';
|
||||||
|
document.getElementById('cancel-replacement-uuid').value = '';
|
||||||
|
document.getElementById('replacement-uuid-group').style.display = 'none';
|
||||||
|
document.getElementById('cancel-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMotiveChange() {
|
||||||
|
const motive = document.getElementById('cancel-motive').value;
|
||||||
|
document.getElementById('replacement-uuid-group').style.display =
|
||||||
|
motive === '01' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCancel() {
|
||||||
|
const cfdiId = document.getElementById('cancel-cfdi-id').value;
|
||||||
|
const motive = document.getElementById('cancel-motive').value;
|
||||||
|
const replacementUuid = document.getElementById('cancel-replacement-uuid').value;
|
||||||
|
|
||||||
|
if (!motive) { alert('Selecciona un motivo de cancelacion.'); return; }
|
||||||
|
if (motive === '01' && !replacementUuid) { alert('UUID sustituto requerido para motivo 01.'); return; }
|
||||||
|
|
||||||
|
if (!confirm('Confirmar cancelacion ante el SAT?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = { motive };
|
||||||
|
if (replacementUuid) body.replacement_uuid = replacementUuid;
|
||||||
|
|
||||||
|
await api(`/cancel/${cfdiId}`, { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
closeModal('cancel-modal');
|
||||||
|
loadQueue();
|
||||||
|
alert('CFDI cancelado exitosamente.');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal helpers ─────────────────────────────
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ──────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadQueue, processQueue, showDetail, showCancelModal,
|
||||||
|
onMotiveChange, confirmCancel, closeModal,
|
||||||
|
};
|
||||||
|
})();
|
||||||
292
pos/templates/accounting.html
Normal file
292
pos/templates/accounting.html
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Contabilidad - Nexus POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/common.css">
|
||||||
|
<style>
|
||||||
|
.tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border); margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
|
.tab { padding: 0.5rem 1rem; cursor: pointer; border: 1px solid transparent;
|
||||||
|
border-bottom: none; border-radius: 6px 6px 0 0; font-size: 0.85rem;
|
||||||
|
color: var(--text-muted); transition: all 0.2s; }
|
||||||
|
.tab:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.tab.active { background: var(--bg); color: var(--primary); border-color: var(--border);
|
||||||
|
border-bottom: 2px solid var(--bg); margin-bottom: -2px; font-weight: 600; }
|
||||||
|
.tab-content { display: none; }
|
||||||
|
.tab-content.active { display: block; }
|
||||||
|
|
||||||
|
/* Accounts tree */
|
||||||
|
.tree { list-style: none; padding-left: 0; }
|
||||||
|
.tree ul { list-style: none; padding-left: 1.5rem; }
|
||||||
|
.tree li { padding: 0.25rem 0; }
|
||||||
|
.tree-toggle { cursor: pointer; user-select: none; }
|
||||||
|
.tree-toggle::before { content: '\25B6'; display: inline-block; width: 1rem;
|
||||||
|
font-size: 0.7rem; transition: transform 0.2s; }
|
||||||
|
.tree-toggle.open::before { transform: rotate(90deg); }
|
||||||
|
.tree-code { font-family: monospace; color: var(--text-muted); margin-right: 0.5rem;
|
||||||
|
font-size: 0.85rem; }
|
||||||
|
.tree-balance { float: right; font-family: monospace; font-weight: 600; }
|
||||||
|
.tree-balance.negative { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Report tables */
|
||||||
|
.report-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
.report-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--border);
|
||||||
|
font-weight: 600; color: var(--text-muted); font-size: 0.8rem;
|
||||||
|
text-transform: uppercase; }
|
||||||
|
.report-table td { padding: 0.5rem; border-bottom: 1px solid var(--border-light); }
|
||||||
|
.report-table .amount { text-align: right; font-family: monospace; }
|
||||||
|
.report-table .total-row { font-weight: 700; border-top: 2px solid var(--border); }
|
||||||
|
.report-table .subtotal-row { font-weight: 600; background: var(--bg-subtle); }
|
||||||
|
|
||||||
|
/* Period selector */
|
||||||
|
.period-selector { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||||
|
.period-selector select, .period-selector input { padding: 0.4rem 0.5rem; border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* Entry detail */
|
||||||
|
.entry-lines { margin-top: 0.5rem; }
|
||||||
|
.entry-header { display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 0.5rem; background: var(--bg-subtle); border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.badge-open { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-closed { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* Form grid */
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.form-group label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); z-index: 1000; align-items: center;
|
||||||
|
justify-content: center; }
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
.modal { background: var(--bg); padding: 1.5rem; border-radius: 8px; width: 90%;
|
||||||
|
max-width: 600px; max-height: 80vh; overflow-y: auto; }
|
||||||
|
.modal h3 { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Lines editor */
|
||||||
|
.lines-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||||||
|
.lines-table th { text-align: left; font-size: 0.8rem; padding: 0.3rem; }
|
||||||
|
.lines-table td { padding: 0.3rem; }
|
||||||
|
.lines-table input, .lines-table select { width: 100%; padding: 0.3rem; border: 1px solid var(--border);
|
||||||
|
border-radius: 3px; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Contabilidad</h1>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<a href="/pos/sale" class="btn btn-secondary">POS</a>
|
||||||
|
<a href="/pos/invoicing" class="btn btn-secondary">Facturacion</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="accounts">Catalogo de Cuentas</div>
|
||||||
|
<div class="tab" data-tab="entries">Polizas</div>
|
||||||
|
<div class="tab" data-tab="trial-balance">Balanza</div>
|
||||||
|
<div class="tab" data-tab="income-statement">Estado de Resultados</div>
|
||||||
|
<div class="tab" data-tab="balance-sheet">Balance General</div>
|
||||||
|
<div class="tab" data-tab="aging">Antiguedad Saldos</div>
|
||||||
|
<div class="tab" data-tab="periods">Periodos</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Chart of Accounts -->
|
||||||
|
<div id="tab-accounts" class="tab-content active">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<h2 style="margin:0;">Catalogo de Cuentas</h2>
|
||||||
|
<button class="btn btn-primary" onclick="Accounting.showNewAccountModal()">+ Subcuenta</button>
|
||||||
|
</div>
|
||||||
|
<div id="accounts-tree"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Journal Entries -->
|
||||||
|
<div id="tab-entries" class="tab-content">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<h2 style="margin:0;">Polizas Contables</h2>
|
||||||
|
<button class="btn btn-primary" onclick="Accounting.showNewEntryModal()">+ Poliza Manual</button>
|
||||||
|
</div>
|
||||||
|
<div class="period-selector">
|
||||||
|
<label>Desde:</label>
|
||||||
|
<input type="date" id="entries-from">
|
||||||
|
<label>Hasta:</label>
|
||||||
|
<input type="date" id="entries-to">
|
||||||
|
<select id="entries-type">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="ingreso">Ingreso</option>
|
||||||
|
<option value="egreso">Egreso</option>
|
||||||
|
<option value="diario">Diario</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.loadEntries()">Filtrar</button>
|
||||||
|
</div>
|
||||||
|
<div id="entries-list"></div>
|
||||||
|
<div id="entries-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Trial Balance -->
|
||||||
|
<div id="tab-trial-balance" class="tab-content">
|
||||||
|
<h2>Balanza de Comprobacion</h2>
|
||||||
|
<div class="period-selector">
|
||||||
|
<label>Anio:</label>
|
||||||
|
<input type="number" id="tb-year" min="2020" max="2030" style="width:80px;">
|
||||||
|
<label>Mes:</label>
|
||||||
|
<select id="tb-month">
|
||||||
|
<option value="1">Enero</option><option value="2">Febrero</option>
|
||||||
|
<option value="3">Marzo</option><option value="4">Abril</option>
|
||||||
|
<option value="5">Mayo</option><option value="6">Junio</option>
|
||||||
|
<option value="7">Julio</option><option value="8">Agosto</option>
|
||||||
|
<option value="9">Septiembre</option><option value="10">Octubre</option>
|
||||||
|
<option value="11">Noviembre</option><option value="12">Diciembre</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.loadTrialBalance()">Generar</button>
|
||||||
|
</div>
|
||||||
|
<div id="trial-balance-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Income Statement -->
|
||||||
|
<div id="tab-income-statement" class="tab-content">
|
||||||
|
<h2>Estado de Resultados</h2>
|
||||||
|
<div class="period-selector">
|
||||||
|
<label>Anio:</label>
|
||||||
|
<input type="number" id="is-year" min="2020" max="2030" style="width:80px;">
|
||||||
|
<label>Mes:</label>
|
||||||
|
<select id="is-month">
|
||||||
|
<option value="1">Enero</option><option value="2">Febrero</option>
|
||||||
|
<option value="3">Marzo</option><option value="4">Abril</option>
|
||||||
|
<option value="5">Mayo</option><option value="6">Junio</option>
|
||||||
|
<option value="7">Julio</option><option value="8">Agosto</option>
|
||||||
|
<option value="9">Septiembre</option><option value="10">Octubre</option>
|
||||||
|
<option value="11">Noviembre</option><option value="12">Diciembre</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.loadIncomeStatement()">Generar</button>
|
||||||
|
</div>
|
||||||
|
<div id="income-statement-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Balance Sheet -->
|
||||||
|
<div id="tab-balance-sheet" class="tab-content">
|
||||||
|
<h2>Balance General</h2>
|
||||||
|
<div class="period-selector">
|
||||||
|
<label>Al dia:</label>
|
||||||
|
<input type="date" id="bs-date">
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.loadBalanceSheet()">Generar</button>
|
||||||
|
</div>
|
||||||
|
<div id="balance-sheet-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Aging -->
|
||||||
|
<div id="tab-aging" class="tab-content">
|
||||||
|
<h2>Antiguedad de Saldos</h2>
|
||||||
|
<div id="aging-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Fiscal Periods -->
|
||||||
|
<div id="tab-periods" class="tab-content">
|
||||||
|
<h2>Periodos Fiscales</h2>
|
||||||
|
<div style="margin-bottom:1rem;" id="close-period-form">
|
||||||
|
<div class="period-selector">
|
||||||
|
<label>Cerrar periodo:</label>
|
||||||
|
<input type="number" id="cp-year" min="2020" max="2030" style="width:80px;">
|
||||||
|
<select id="cp-month">
|
||||||
|
<option value="1">Enero</option><option value="2">Febrero</option>
|
||||||
|
<option value="3">Marzo</option><option value="4">Abril</option>
|
||||||
|
<option value="5">Mayo</option><option value="6">Junio</option>
|
||||||
|
<option value="7">Julio</option><option value="8">Agosto</option>
|
||||||
|
<option value="9">Septiembre</option><option value="10">Octubre</option>
|
||||||
|
<option value="11">Noviembre</option><option value="12">Diciembre</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-danger" onclick="Accounting.closePeriod()">Cerrar Periodo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="periods-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Account Modal -->
|
||||||
|
<div class="modal-overlay" id="new-account-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Nueva Subcuenta</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Codigo</label>
|
||||||
|
<input type="text" id="na-code" placeholder="110.01">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input type="text" id="na-name" placeholder="Caja chica">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Cuenta padre</label>
|
||||||
|
<select id="na-parent"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tipo</label>
|
||||||
|
<select id="na-type">
|
||||||
|
<option value="activo">Activo</option>
|
||||||
|
<option value="pasivo">Pasivo</option>
|
||||||
|
<option value="capital">Capital</option>
|
||||||
|
<option value="ingreso">Ingreso</option>
|
||||||
|
<option value="costo">Costo</option>
|
||||||
|
<option value="gasto">Gasto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.closeModal('new-account-modal')">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="Accounting.createAccount()">Crear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Entry Modal -->
|
||||||
|
<div class="modal-overlay" id="new-entry-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Nueva Poliza Manual</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Fecha</label>
|
||||||
|
<input type="date" id="ne-date">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Descripcion</label>
|
||||||
|
<input type="text" id="ne-description" placeholder="Descripcion de la poliza">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4>Movimientos</h4>
|
||||||
|
<table class="lines-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Cuenta</th><th style="width:120px;">Cargo</th><th style="width:120px;">Abono</th><th style="width:30px;"></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ne-lines"></tbody>
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.addEntryLine()" style="margin-top:0.5rem;">+ Linea</button>
|
||||||
|
<div id="ne-balance" style="margin-top:0.5rem;font-weight:600;"></div>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.closeModal('new-entry-modal')">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="Accounting.createEntry()">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entry Detail Modal -->
|
||||||
|
<div class="modal-overlay" id="entry-detail-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div id="entry-detail-content"></div>
|
||||||
|
<div style="text-align:right;margin-top:1rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="Accounting.closeModal('entry-detail-modal')">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/accounting.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
133
pos/templates/invoicing.html
Normal file
133
pos/templates/invoicing.html
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Facturacion - Nexus POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/common.css">
|
||||||
|
<style>
|
||||||
|
.queue-filters { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||||
|
.queue-filters select, .queue-filters input { padding: 0.4rem 0.5rem; border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.queue-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
.queue-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--border);
|
||||||
|
font-weight: 600; color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase; }
|
||||||
|
.queue-table td { padding: 0.5rem; border-bottom: 1px solid var(--border-light); }
|
||||||
|
.queue-table tr:hover { background: var(--bg-hover); cursor: pointer; }
|
||||||
|
|
||||||
|
.badge { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;
|
||||||
|
display: inline-block; }
|
||||||
|
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-sending { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-stamped { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-failed { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-cancelled { background: #f3f4f6; color: #6b7280; }
|
||||||
|
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); z-index: 1000; align-items: center;
|
||||||
|
justify-content: center; }
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
.modal { background: var(--bg); padding: 1.5rem; border-radius: 8px; width: 90%;
|
||||||
|
max-width: 700px; max-height: 80vh; overflow-y: auto; }
|
||||||
|
.modal h3 { margin-top: 0; }
|
||||||
|
|
||||||
|
.xml-preview { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 6px;
|
||||||
|
font-family: monospace; font-size: 0.8rem; white-space: pre-wrap;
|
||||||
|
word-break: break-all; max-height: 300px; overflow-y: auto; }
|
||||||
|
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.detail-item { padding: 0.5rem; background: var(--bg-subtle); border-radius: 4px; }
|
||||||
|
.detail-item label { display: block; font-size: 0.75rem; color: var(--text-muted); font-weight: 600; }
|
||||||
|
.detail-item span { font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.form-group { margin-bottom: 0.75rem; }
|
||||||
|
.form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-muted);
|
||||||
|
margin-bottom: 0.25rem; }
|
||||||
|
.form-group select, .form-group input { width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.stats { display: flex; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.stat-card { background: var(--bg-subtle); padding: 0.75rem 1rem; border-radius: 6px; flex: 1;
|
||||||
|
text-align: center; }
|
||||||
|
.stat-card .number { font-size: 1.5rem; font-weight: 700; }
|
||||||
|
.stat-card .label { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Facturacion CFDI</h1>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<button class="btn btn-primary" onclick="Invoicing.processQueue()">Procesar Cola</button>
|
||||||
|
<a href="/pos/sale" class="btn btn-secondary">POS</a>
|
||||||
|
<a href="/pos/accounting" class="btn btn-secondary">Contabilidad</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats" id="queue-stats"></div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="queue-filters">
|
||||||
|
<select id="filter-status">
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="pending">Pendiente</option>
|
||||||
|
<option value="sending">Enviando</option>
|
||||||
|
<option value="stamped">Timbrado</option>
|
||||||
|
<option value="failed">Fallido</option>
|
||||||
|
<option value="cancelled">Cancelado</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-type">
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
<option value="ingreso">Ingreso</option>
|
||||||
|
<option value="egreso">Egreso</option>
|
||||||
|
<option value="pago">Pago</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" onclick="Invoicing.loadQueue()">Filtrar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue List -->
|
||||||
|
<div id="queue-list"></div>
|
||||||
|
<div id="queue-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail Modal -->
|
||||||
|
<div class="modal-overlay" id="detail-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div id="detail-content"></div>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="Invoicing.closeModal('detail-modal')">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel Modal -->
|
||||||
|
<div class="modal-overlay" id="cancel-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Cancelar CFDI</h3>
|
||||||
|
<input type="hidden" id="cancel-cfdi-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Motivo de cancelacion (SAT)</label>
|
||||||
|
<select id="cancel-motive" onchange="Invoicing.onMotiveChange()">
|
||||||
|
<option value="">Seleccionar motivo</option>
|
||||||
|
<option value="01">01 - Con relacion (requiere UUID sustituto)</option>
|
||||||
|
<option value="02">02 - Sin relacion</option>
|
||||||
|
<option value="03">03 - No se llevo a cabo la operacion</option>
|
||||||
|
<option value="04">04 - Operacion nominativa en factura global</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="replacement-uuid-group" style="display:none;">
|
||||||
|
<label>UUID del CFDI sustituto</label>
|
||||||
|
<input type="text" id="cancel-replacement-uuid" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="Invoicing.closeModal('cancel-modal')">Cancelar</button>
|
||||||
|
<button class="btn btn-danger" onclick="Invoicing.confirmCancel()">Confirmar Cancelacion</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/invoicing.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user