feat(contratos): gestión de códigos de pago + asignación por contrato
- Nueva tabla SQLite codigos_pago (codigo, nombre) con seed CIAC/CR/MU - Columna cod_forma_pago en contratos (override por contrato) - Página contratos: tarjeta para agregar/eliminar códigos, select inline por fila - generar_factura_venta acepta forma_pago_override; si asignado lo usa, sino auto CIAC/CR - Rutas: /codigos-pago/create, /codigos-pago/delete, /set-forma-pago Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
551dec9c11
commit
91dbcc8135
@@ -61,6 +61,8 @@ def _migrate(conn):
|
||||
conn.execute("ALTER TABLE contratos ADD COLUMN sin_contrato INTEGER NOT NULL DEFAULT 0")
|
||||
if "excluir_ventas" not in contrato_cols:
|
||||
conn.execute("ALTER TABLE contratos ADD COLUMN excluir_ventas INTEGER NOT NULL DEFAULT 0")
|
||||
if "cod_forma_pago" not in contrato_cols:
|
||||
conn.execute("ALTER TABLE contratos ADD COLUMN cod_forma_pago TEXT NOT NULL DEFAULT ''")
|
||||
# Ampliar CHECK constraint de queries para incluir 'ventas'
|
||||
q_sql = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='queries'").fetchone()
|
||||
if q_sql and "'ventas'" not in q_sql[0]:
|
||||
@@ -110,6 +112,17 @@ def _migrate(conn):
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.commit()
|
||||
|
||||
if "codigos_pago" not in tables:
|
||||
conn.execute("""
|
||||
CREATE TABLE codigos_pago (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
codigo TEXT UNIQUE NOT NULL,
|
||||
nombre TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
for codigo, nombre in [("CIAC", "Contado inmediato"), ("CR", "Crédito"), ("MU", "Mixto")]:
|
||||
conn.execute("INSERT OR IGNORE INTO codigos_pago (codigo, nombre) VALUES (?,?)", (codigo, nombre))
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
+64
-2
@@ -141,13 +141,30 @@ def load_excluded_ventas_set() -> set:
|
||||
return result
|
||||
|
||||
|
||||
def load_forma_pago_map() -> dict:
|
||||
"""Devuelve {numero_contrato: cod_forma_pago} para contratos con forma de pago asignada."""
|
||||
conn = get_connection()
|
||||
rows = conn.execute("SELECT numero_contrato, cod_forma_pago FROM contratos WHERE cod_forma_pago != ''").fetchall()
|
||||
conn.close()
|
||||
result = {}
|
||||
for r in rows:
|
||||
nc = r["numero_contrato"].strip()
|
||||
result[nc] = r["cod_forma_pago"].strip()
|
||||
nc_s = nc.lstrip("0") or nc
|
||||
if nc_s != nc:
|
||||
result[nc_s] = r["cod_forma_pago"].strip()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def contratos_page(request: Request, user: dict = Depends(get_current_user)):
|
||||
conn = get_connection()
|
||||
rows = conn.execute("SELECT * FROM contratos ORDER BY numero_contrato").fetchall()
|
||||
codigos_pago = conn.execute("SELECT * FROM codigos_pago ORDER BY codigo").fetchall()
|
||||
conn.close()
|
||||
return request.app.state.templates.TemplateResponse("contratos.html", {
|
||||
"request": request, "user": user, "contratos": rows,
|
||||
"codigos_pago": codigos_pago,
|
||||
})
|
||||
|
||||
|
||||
@@ -190,11 +207,12 @@ async def contrato_update(
|
||||
excluir: str = Form("0"),
|
||||
sin_contrato: str = Form("0"),
|
||||
excluir_ventas: str = Form("0"),
|
||||
cod_forma_pago: str = Form(""),
|
||||
):
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"UPDATE contratos SET numero_contrato=?, nit_empresa=?, tipo_usuario=?, descripcion=?, excluir=?, sin_contrato=?, excluir_ventas=? WHERE id=?",
|
||||
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato), int(excluir_ventas), contrato_id),
|
||||
"UPDATE contratos SET numero_contrato=?, nit_empresa=?, tipo_usuario=?, descripcion=?, excluir=?, sin_contrato=?, excluir_ventas=?, cod_forma_pago=? WHERE id=?",
|
||||
(numero_contrato.strip(), nit_empresa.strip(), tipo_usuario.strip(), descripcion.strip(), int(excluir), int(sin_contrato), int(excluir_ventas), cod_forma_pago.strip(), contrato_id),
|
||||
)
|
||||
conn.commit()
|
||||
log_activity(user["user_id"], user["username"], "contrato_editado",
|
||||
@@ -252,6 +270,50 @@ async def toggle_excluir_ventas(contrato_id: int, request: Request, user: dict =
|
||||
return RedirectResponse("/contratos", status_code=302)
|
||||
|
||||
|
||||
@router.post("/set-forma-pago/{contrato_id}")
|
||||
async def set_forma_pago(
|
||||
contrato_id: int, request: Request, user: dict = Depends(get_current_user),
|
||||
cod_forma_pago: str = Form(""),
|
||||
):
|
||||
conn = get_connection()
|
||||
conn.execute("UPDATE contratos SET cod_forma_pago=? WHERE id=?", (cod_forma_pago.strip(), contrato_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT numero_contrato FROM contratos WHERE id=?", (contrato_id,)).fetchone()
|
||||
if row:
|
||||
log_activity(user["user_id"], user["username"], "contrato_forma_pago",
|
||||
f"Contrato {row['numero_contrato']} forma_pago→{cod_forma_pago.strip() or '(auto)'}",
|
||||
get_ip(request))
|
||||
conn.close()
|
||||
return RedirectResponse("/contratos", status_code=302)
|
||||
|
||||
|
||||
@router.post("/codigos-pago/create")
|
||||
async def codigo_pago_create(
|
||||
request: Request, user: dict = Depends(get_current_user),
|
||||
codigo: str = Form(...), nombre: str = Form(""),
|
||||
):
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("INSERT INTO codigos_pago (codigo, nombre) VALUES (?,?)",
|
||||
(codigo.strip().upper(), nombre.strip()))
|
||||
conn.commit()
|
||||
log_activity(user["user_id"], user["username"], "codigo_pago_creado",
|
||||
f"{codigo.strip().upper()} — {nombre.strip()}", get_ip(request))
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
return RedirectResponse("/contratos", status_code=302)
|
||||
|
||||
|
||||
@router.post("/codigos-pago/delete/{cp_id}")
|
||||
async def codigo_pago_delete(cp_id: int, user: dict = Depends(get_current_user)):
|
||||
conn = get_connection()
|
||||
conn.execute("DELETE FROM codigos_pago WHERE id=?", (cp_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/contratos", status_code=302)
|
||||
|
||||
|
||||
@router.post("/delete/{contrato_id}")
|
||||
async def contrato_delete(contrato_id: int, user: dict = Depends(get_current_user)):
|
||||
conn = get_connection()
|
||||
|
||||
+12
-6
@@ -9,7 +9,7 @@ from app.services.firebird_service import get_firebird_from_config
|
||||
from app.services.json_generator import generar_factura_venta
|
||||
from app.services.api_client import get_tns_token, TNS_BASE
|
||||
from app.utils.activity import log_activity, get_ip
|
||||
from app.routes.contratos import load_excluded_ventas_set
|
||||
from app.routes.contratos import load_excluded_ventas_set, load_forma_pago_map
|
||||
|
||||
router = APIRouter(prefix="/ventas", tags=["ventas"])
|
||||
|
||||
@@ -103,11 +103,14 @@ def _agrupar(rows):
|
||||
return dict(grupos)
|
||||
|
||||
|
||||
def _build_venta(grupo_rows, cfg):
|
||||
def _build_venta(grupo_rows, cfg, forma_pago_map=None):
|
||||
prefijo_def = cfg.get("prefijo_tns_default", "00")
|
||||
num_fac = str(grupo_rows[0].get("NUM_FACTURA") or "").strip()
|
||||
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||
fp_override = (forma_pago_map or {}).get(contrato, "")
|
||||
return generar_factura_venta(grupo_rows, default_vendedor="00",
|
||||
default_prefijo=prefijo_def, numero_override=num_fac)
|
||||
default_prefijo=prefijo_def, numero_override=num_fac,
|
||||
forma_pago_override=fp_override)
|
||||
|
||||
|
||||
def _is_sent(factura_key):
|
||||
@@ -177,6 +180,7 @@ async def preview_ventas(
|
||||
return JSONResponse({"success": False, "message": "Sin datos para ese rango de fechas"})
|
||||
|
||||
excluded_ventas = load_excluded_ventas_set()
|
||||
fp_map = load_forma_pago_map()
|
||||
grupos_all = _agrupar(rows)
|
||||
grupos = {k: v for k, v in grupos_all.items()
|
||||
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded_ventas}
|
||||
@@ -184,7 +188,7 @@ async def preview_ventas(
|
||||
items = []
|
||||
for factura_key, grupo_rows in grupos.items():
|
||||
enviado = _is_sent(factura_key)
|
||||
venta_json = _build_venta(grupo_rows, cfg)
|
||||
venta_json = _build_venta(grupo_rows, cfg, fp_map)
|
||||
items.append({
|
||||
"factura_key": factura_key,
|
||||
"factura": factura_key,
|
||||
@@ -242,7 +246,8 @@ async def send_one(
|
||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
||||
api_sucursal = cfg.get("api_sucursal", "") or "00"
|
||||
endpoint = f"{TNS_BASE}/v2/facturacion/Ventas/Crear?codigosucursal={api_sucursal}"
|
||||
venta_json = _build_venta(grupo_rows, cfg)
|
||||
fp_map = load_forma_pago_map()
|
||||
venta_json = _build_venta(grupo_rows, cfg, fp_map)
|
||||
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||
|
||||
raw_resp = ""
|
||||
@@ -279,6 +284,7 @@ async def send_ventas(
|
||||
return JSONResponse({"success": False, "message": "Sin datos"})
|
||||
|
||||
excluded_ventas = load_excluded_ventas_set()
|
||||
fp_map = load_forma_pago_map()
|
||||
grupos_all = _agrupar(rows)
|
||||
grupos = {k: v for k, v in grupos_all.items()
|
||||
if str(v[0].get("CODCONTRATO") or "").strip() not in excluded_ventas}
|
||||
@@ -298,7 +304,7 @@ async def send_ventas(
|
||||
resultados = []
|
||||
async with httpx.AsyncClient(timeout=int(cfg.get("api_timeout", 30))) as client:
|
||||
for factura_key, grupo_rows in grupos.items():
|
||||
venta_json = _build_venta(grupo_rows, cfg)
|
||||
venta_json = _build_venta(grupo_rows, cfg, fp_map)
|
||||
contrato = str(grupo_rows[0].get("CODCONTRATO") or "").strip()
|
||||
raw_resp = ""
|
||||
ok_v = False
|
||||
|
||||
@@ -271,7 +271,8 @@ def _fecha_add_dias(fecha_val, dias: int) -> str:
|
||||
|
||||
|
||||
def generar_factura_venta(rows: list, default_vendedor: str = "00",
|
||||
default_prefijo: str = "00", numero_override: str = "") -> dict:
|
||||
default_prefijo: str = "00", numero_override: str = "",
|
||||
forma_pago_override: str = "") -> dict:
|
||||
if not rows:
|
||||
return {}
|
||||
h = rows[0]
|
||||
@@ -312,12 +313,11 @@ def generar_factura_venta(rows: list, default_vendedor: str = "00",
|
||||
prefijo_real = str(h.get("PREFIJO") or "").strip() or default_prefijo or "00"
|
||||
|
||||
diasvenc = int(h.get("DIASVENC") or 30)
|
||||
if total == 0:
|
||||
cod_forma_pago = "CIAC"
|
||||
cod_forma_pago = forma_pago_override.strip() if forma_pago_override else ("CIAC" if total == 0 else "CR")
|
||||
if cod_forma_pago == "CIAC":
|
||||
plazo_dias = 0
|
||||
fecha_vence = fecha
|
||||
else:
|
||||
cod_forma_pago = "CR"
|
||||
plazo_dias = diasvenc
|
||||
fecha_vence = _fecha_add_dias(fecha_raw, diasvenc)
|
||||
|
||||
|
||||
@@ -50,6 +50,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Códigos de pago -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="font-semibold text-gray-800"><i class="fas fa-tags mr-2 text-indigo-500"></i>Códigos de Pago</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{% for cp in codigos_pago %}
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg text-sm">
|
||||
<span class="font-mono font-semibold text-indigo-700">{{ cp.codigo }}</span>
|
||||
<span class="text-gray-600">{{ cp.nombre }}</span>
|
||||
<form method="POST" action="/contratos/codigos-pago/delete/{{ cp.id }}" class="inline"
|
||||
onsubmit="return confirm('¿Eliminar código {{ cp.codigo }}?')">
|
||||
<button type="submit" class="text-red-400 hover:text-red-600 ml-1">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="POST" action="/contratos/codigos-pago/create" class="flex items-end gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Código</label>
|
||||
<input type="text" name="codigo" required placeholder="Ej: CIAC" maxlength="10"
|
||||
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm uppercase focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre</label>
|
||||
<input type="text" name="nombre" placeholder="Ej: Contado inmediato"
|
||||
class="w-56 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<i class="fas fa-plus mr-1"></i> Agregar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla de contratos -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
@@ -70,6 +109,7 @@
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Excluir RDA</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Excluir Ventas</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Sin Contrato</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Forma Pago</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -133,7 +173,18 @@
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button onclick="openEdit({{ c.id }}, '{{ c.numero_contrato }}', '{{ c.nit_empresa }}', '{{ c.tipo_usuario }}', '{{ c.descripcion }}', {{ c.excluir }}, {{ c.sin_contrato }}, {{ c.excluir_ventas }})"
|
||||
<form method="POST" action="/contratos/set-forma-pago/{{ c.id }}" class="inline">
|
||||
<select name="cod_forma_pago" onchange="this.form.submit()"
|
||||
class="text-xs border border-gray-200 rounded-lg px-2 py-1 focus:ring-2 focus:ring-indigo-400 bg-white {% if c.cod_forma_pago %}text-indigo-700 font-semibold{% else %}text-gray-400{% endif %}">
|
||||
<option value="" {% if not c.cod_forma_pago %}selected{% endif %}>— auto —</option>
|
||||
{% for cp in codigos_pago %}
|
||||
<option value="{{ cp.codigo }}" {% if c.cod_forma_pago == cp.codigo %}selected{% endif %}>{{ cp.codigo }} — {{ cp.nombre }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button onclick="openEdit({{ c.id }}, '{{ c.numero_contrato }}', '{{ c.nit_empresa }}', '{{ c.tipo_usuario }}', '{{ c.descripcion }}', {{ c.excluir }}, {{ c.sin_contrato }}, {{ c.excluir_ventas }}, '{{ c.cod_forma_pago }}')"
|
||||
class="text-blue-600 hover:text-blue-800 mr-3 text-xs">
|
||||
<i class="fas fa-edit"></i> Editar
|
||||
</button>
|
||||
@@ -189,6 +240,16 @@
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Forma de Pago (Ventas)</label>
|
||||
<select name="cod_forma_pago" id="edit-forma-pago"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500">
|
||||
<option value="">— automático (CIAC si total=0, CR si total>0) —</option>
|
||||
{% for cp in codigos_pago %}
|
||||
<option value="{{ cp.codigo }}">{{ cp.codigo }} — {{ cp.nombre }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="excluir" id="edit-excluir" value="1" class="rounded">
|
||||
@@ -218,12 +279,13 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openEdit(id, nc, nit, tu, desc, excluir, sinContrato, excluirVentas) {
|
||||
function openEdit(id, nc, nit, tu, desc, excluir, sinContrato, excluirVentas, formaPago) {
|
||||
document.getElementById('edit-form').action = '/contratos/update/' + id;
|
||||
document.getElementById('edit-nc').value = nc;
|
||||
document.getElementById('edit-nit').value = nit;
|
||||
document.getElementById('edit-tu').value = tu;
|
||||
document.getElementById('edit-desc').value = desc;
|
||||
document.getElementById('edit-forma-pago').value = formaPago || '';
|
||||
document.getElementById('edit-excluir').checked = excluir === 1;
|
||||
document.getElementById('edit-sin-contrato').checked = sinContrato === 1;
|
||||
document.getElementById('edit-excluir-ventas').checked = excluirVentas === 1;
|
||||
|
||||
Reference in New Issue
Block a user