feat(atlas): import catalog, customers and historical sales + viewer
- Add scripts/import_atlas_data.py to load Atlas data from Excel files - Import 6,206 inventory items, 251 customers and 4,582 historical sales - Create historical_sales table in tenant DB - Add /pos/historical-sales page and /pos/api/historical-sales endpoint - Link in reports sidebar for easy access
This commit is contained in:
@@ -201,6 +201,10 @@ def create_app():
|
|||||||
def pos_marketplace_external_callback():
|
def pos_marketplace_external_callback():
|
||||||
return render_template('marketplace_external.html')
|
return render_template('marketplace_external.html')
|
||||||
|
|
||||||
|
@app.route('/pos/historical-sales')
|
||||||
|
def pos_historical_sales():
|
||||||
|
return render_template('historical_sales.html')
|
||||||
|
|
||||||
@app.route('/pos/static/<path:filename>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
@@ -232,6 +232,83 @@ def list_sales():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route('/historical-sales', methods=['GET'])
|
||||||
|
@require_auth('pos.view')
|
||||||
|
def list_historical_sales():
|
||||||
|
"""List imported historical sales (read-only reference).
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
date_from: YYYY-MM-DD
|
||||||
|
date_to: YYYY-MM-DD
|
||||||
|
customer: partial customer name
|
||||||
|
page: int (default 1)
|
||||||
|
per_page: int (default 50, max 200)
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||||
|
|
||||||
|
where_clauses = ["1=1"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
date_from = request.args.get('date_from')
|
||||||
|
date_to = request.args.get('date_to')
|
||||||
|
customer = request.args.get('customer')
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
where_clauses.append("sale_date >= %s")
|
||||||
|
params.append(date_from)
|
||||||
|
if date_to:
|
||||||
|
where_clauses.append("sale_date <= %s")
|
||||||
|
params.append(date_to)
|
||||||
|
if customer:
|
||||||
|
where_clauses.append("customer_name ILIKE %s")
|
||||||
|
params.append(f"%{customer}%")
|
||||||
|
|
||||||
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
cur.execute(f"SELECT count(*) FROM historical_sales WHERE {where}", params)
|
||||||
|
total = cur.fetchone()[0]
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT id, external_document_id, document_no, sale_date, customer_name,
|
||||||
|
total, subtotal, amount_paid, payment_method, discount, balance,
|
||||||
|
raw_payment_code
|
||||||
|
FROM historical_sales
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY sale_date DESC, id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [per_page, (page - 1) * per_page])
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
rows.append({
|
||||||
|
'id': r[0],
|
||||||
|
'external_document_id': r[1],
|
||||||
|
'document_no': r[2],
|
||||||
|
'sale_date': str(r[3]) if r[3] else None,
|
||||||
|
'customer_name': r[4],
|
||||||
|
'total': float(r[5]) if r[5] else 0,
|
||||||
|
'subtotal': float(r[6]) if r[6] else 0,
|
||||||
|
'amount_paid': float(r[7]) if r[7] else 0,
|
||||||
|
'payment_method': r[8],
|
||||||
|
'discount': float(r[9]) if r[9] else 0,
|
||||||
|
'balance': float(r[10]) if r[10] else 0,
|
||||||
|
'raw_payment_code': r[11],
|
||||||
|
})
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total_pages = (total + per_page - 1) // per_page
|
||||||
|
return jsonify({
|
||||||
|
'data': rows,
|
||||||
|
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_sale(sale_id):
|
def get_sale(sale_id):
|
||||||
|
|||||||
160
pos/templates/historical_sales.html
Normal file
160
pos/templates/historical_sales.html
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ventas Históricas - Atlas</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/pos.css">
|
||||||
|
<style>
|
||||||
|
:root { --header-h: 56px; }
|
||||||
|
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f6f8; color: #1f2937; }
|
||||||
|
.header { position: fixed; top: 0; left: 0; right: 0; height: var(--header-h); background: #111827; color: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; z-index: 100; }
|
||||||
|
.header h1 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||||
|
.header a { color: #9ca3af; text-decoration: none; font-size: 13px; }
|
||||||
|
.header a:hover { color: #fff; }
|
||||||
|
.container { padding: calc(var(--header-h) + 16px) 16px 24px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.filters { background: #fff; padding: 12px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 16px; }
|
||||||
|
.filters label { font-size: 12px; color: #6b7280; display: block; margin-bottom: 4px; }
|
||||||
|
.filters input { padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 13px; }
|
||||||
|
.filters button { padding: 7px 14px; background: #2563eb; color: #fff; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; }
|
||||||
|
.filters button:hover { background: #1d4ed8; }
|
||||||
|
.summary { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||||
|
.card { background: #fff; padding: 12px 16px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); flex: 1; }
|
||||||
|
.card .label { font-size: 12px; color: #6b7280; }
|
||||||
|
.card .value { font-size: 18px; font-weight: 700; color: #111827; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); font-size: 13px; }
|
||||||
|
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
th { background: #f9fafb; font-weight: 600; color: #374151; }
|
||||||
|
tr:hover { background: #f9fafb; }
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e5e7eb; font-size: 11px; color: #374151; }
|
||||||
|
.empty { text-align: center; padding: 40px; color: #6b7280; }
|
||||||
|
.pagination { display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 16px; }
|
||||||
|
.pagination button { padding: 6px 12px; border: 1px solid #d1d5db; background: #fff; border-radius: 4px; cursor: pointer; }
|
||||||
|
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.pagination span { font-size: 13px; color: #4b5563; }
|
||||||
|
.loading { text-align: center; padding: 40px; color: #6b7280; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>📊 Ventas Históricas - Atlas</h1>
|
||||||
|
<a href="/pos/sale">← Regresar al POS</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="filters">
|
||||||
|
<div>
|
||||||
|
<label>Desde</label>
|
||||||
|
<input type="date" id="dateFrom">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Hasta</label>
|
||||||
|
<input type="date" id="dateTo">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Cliente</label>
|
||||||
|
<input type="text" id="customerFilter" placeholder="Nombre del cliente..." style="width:220px;">
|
||||||
|
</div>
|
||||||
|
<button onclick="loadData(1)">Buscar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Total de ventas</div>
|
||||||
|
<div class="value" id="totalCount">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Total vendido</div>
|
||||||
|
<div class="value" id="totalSold">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Saldo pendiente</div>
|
||||||
|
<div class="value" id="totalBalance">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/api.js"></script>
|
||||||
|
<script>
|
||||||
|
const fmt = n => n == null ? '-' : '$' + Number(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||||
|
const fmtDate = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('es-MX') : '-';
|
||||||
|
|
||||||
|
async function loadData(page = 1) {
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
content.innerHTML = '<div class="loading">Cargando...</div>';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', page);
|
||||||
|
params.set('per_page', 50);
|
||||||
|
const from = document.getElementById('dateFrom').value;
|
||||||
|
const to = document.getElementById('dateTo').value;
|
||||||
|
const customer = document.getElementById('customerFilter').value.trim();
|
||||||
|
if (from) params.set('date_from', from);
|
||||||
|
if (to) params.set('date_to', to);
|
||||||
|
if (customer) params.set('customer', customer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/pos/api/historical-sales?' + params.toString());
|
||||||
|
render(res.data || [], res.pagination || {});
|
||||||
|
} catch (e) {
|
||||||
|
content.innerHTML = '<div class="empty">Error: ' + esc(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(rows, pagination) {
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
if (!rows.length) {
|
||||||
|
content.innerHTML = '<div class="empty">No se encontraron ventas históricas</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSold = 0, totalBalance = 0;
|
||||||
|
rows.forEach(r => { totalSold += r.total || 0; totalBalance += r.balance || 0; });
|
||||||
|
|
||||||
|
document.getElementById('totalCount').textContent = pagination.total || rows.length;
|
||||||
|
document.getElementById('totalSold').textContent = fmt(totalSold);
|
||||||
|
document.getElementById('totalBalance').textContent = fmt(totalBalance);
|
||||||
|
|
||||||
|
let html = '<table><thead><tr>' +
|
||||||
|
'<th>Fecha</th><th>Documento</th><th>Cliente</th><th>Forma de pago</th>' +
|
||||||
|
'<th class="num">Subtotal</th><th class="num">Total</th><th class="num">Pagado</th><th class="num">Saldo</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
rows.forEach(r => {
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + fmtDate(r.sale_date) + '</td>' +
|
||||||
|
'<td>' + esc(r.document_no || r.external_document_id || '-') + '</td>' +
|
||||||
|
'<td>' + esc(r.customer_name || '-') + '</td>' +
|
||||||
|
'<td><span class="badge">' + esc(r.payment_method || '-') + '</span></td>' +
|
||||||
|
'<td class="num">' + fmt(r.subtotal) + '</td>' +
|
||||||
|
'<td class="num">' + fmt(r.total) + '</td>' +
|
||||||
|
'<td class="num">' + fmt(r.amount_paid) + '</td>' +
|
||||||
|
'<td class="num">' + fmt(r.balance) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
const totalPages = pagination.total_pages || 1;
|
||||||
|
const page = pagination.page || 1;
|
||||||
|
html += '<div class="pagination">' +
|
||||||
|
'<button onclick="loadData(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>Anterior</button>' +
|
||||||
|
'<span>Página ' + page + ' de ' + totalPages + '</span>' +
|
||||||
|
'<button onclick="loadData(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>Siguiente</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
content.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData(1);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -130,6 +130,16 @@
|
|||||||
<span>Reportes</span>
|
<span>Reportes</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/pos/historical-sales" class="nav-item">
|
||||||
|
<svg class="nav-item__icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="1" y="4" width="16" height="12" rx="1"/>
|
||||||
|
<line x1="1" y1="8" x2="17" y2="8"/>
|
||||||
|
<line x1="5" y1="2" x2="5" y2="4"/>
|
||||||
|
<line x1="13" y1="2" x2="13" y2="4"/>
|
||||||
|
</svg>
|
||||||
|
<span>Ventas Históricas</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-label">Sistema</div>
|
<div class="nav-section-label">Sistema</div>
|
||||||
|
|
||||||
<a href="/pos/config" class="nav-item">
|
<a href="/pos/config" class="nav-item">
|
||||||
|
|||||||
299
scripts/import_atlas_data.py
Normal file
299
scripts/import_atlas_data.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import Atlas data into Nexus POS tenant_refaccionaria_atlas.
|
||||||
|
|
||||||
|
Sources (expected in /home/):
|
||||||
|
- Articulos Atlas.xlsx -> inventory catalog (stock = 0)
|
||||||
|
- Clientes.xlsx -> customers
|
||||||
|
- Historico V.xlsx -> historical_sales (read-only reference)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
BASE_DIR = "/home"
|
||||||
|
TENANT_DB = "tenant_refaccionaria_atlas"
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_conn():
|
||||||
|
dsn = os.environ.get("TENANT_DB_URL", f"postgresql://postgres@localhost/{TENANT_DB}")
|
||||||
|
return psycopg2.connect(dsn)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(val, max_len=None):
|
||||||
|
if pd.isna(val):
|
||||||
|
return None
|
||||||
|
s = str(val).strip()
|
||||||
|
if s in ("", "nan", "None"):
|
||||||
|
return None
|
||||||
|
if max_len:
|
||||||
|
s = s[:max_len]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_price(val):
|
||||||
|
if pd.isna(val):
|
||||||
|
return Decimal("0")
|
||||||
|
try:
|
||||||
|
return Decimal(str(float(val))).quantize(Decimal("0.01"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_part_number(val):
|
||||||
|
s = normalize_text(val, max_len=100)
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# Remove problematic chars but keep basic alphanumeric + dash/underscore
|
||||||
|
s = re.sub(r"[^\w\-./]", "", s)
|
||||||
|
return s[:100] or None
|
||||||
|
|
||||||
|
|
||||||
|
def import_inventory(conn):
|
||||||
|
path = os.path.join(BASE_DIR, "Articulos Atlas.xlsx")
|
||||||
|
df = pd.read_excel(path)
|
||||||
|
print(f"[inventory] Read {len(df)} rows from {path}")
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
inserted = 0
|
||||||
|
skipped_dup = 0
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
part_number = clean_part_number(row.get("Clave"))
|
||||||
|
if not part_number:
|
||||||
|
continue
|
||||||
|
if part_number in seen:
|
||||||
|
skipped_dup += 1
|
||||||
|
continue
|
||||||
|
seen.add(part_number)
|
||||||
|
|
||||||
|
name = normalize_text(row.get("Producto"), max_len=300) or part_number
|
||||||
|
description = normalize_text(row.get("Nombre Alterno"), max_len=500)
|
||||||
|
category = normalize_text(row.get("Categoría 1"), max_len=100)
|
||||||
|
subcategory = normalize_text(row.get("Categoría 2"), max_len=100)
|
||||||
|
unit = normalize_text(row.get("Unidad"), max_len=20)
|
||||||
|
barcode = clean_part_number(row.get("Código de barras"))
|
||||||
|
price = normalize_price(row.get("Precio lista"))
|
||||||
|
|
||||||
|
# Concatenate category info into description if present
|
||||||
|
extra_info = " | ".join(filter(None, [category, subcategory, unit]))
|
||||||
|
if extra_info:
|
||||||
|
description = f"{description or ''} [{extra_info}]".strip() if description else f"[{extra_info}]"
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO inventory
|
||||||
|
(part_number, name, description, cost, price_1, tax_rate,
|
||||||
|
unit, barcode, brand, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (part_number) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
part_number,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
Decimal("0"),
|
||||||
|
price,
|
||||||
|
Decimal("0.16"),
|
||||||
|
unit,
|
||||||
|
barcode,
|
||||||
|
category,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
print(f"[inventory] Inserted: {inserted}, Duplicates skipped: {skipped_dup}")
|
||||||
|
|
||||||
|
|
||||||
|
def import_customers(conn):
|
||||||
|
path = os.path.join(BASE_DIR, "Clientes.xlsx")
|
||||||
|
df = pd.read_excel(path)
|
||||||
|
print(f"[customers] Read {len(df)} rows from {path}")
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
name = normalize_text(row.get("Empresa"), max_len=200)
|
||||||
|
if not name:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip generic "Publico" row if it has no real name
|
||||||
|
if name.lower() in ("publico", "publico en general", "público", "público en general"):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
credit_limit = normalize_price(row.get("Límite de crédito"))
|
||||||
|
customer_type = normalize_text(row.get("Tipo de cliente"), max_len=50)
|
||||||
|
segment = normalize_text(row.get("Segmento"), max_len=50)
|
||||||
|
payment_terms = normalize_text(row.get("Condiciones de Pago"), max_len=100)
|
||||||
|
notes = " | ".join(filter(None, [customer_type, segment, payment_terms]))
|
||||||
|
|
||||||
|
# Avoid duplicates by name
|
||||||
|
cur.execute("SELECT 1 FROM customers WHERE name = %s LIMIT 1", (name,))
|
||||||
|
if cur.fetchone():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO customers
|
||||||
|
(name, razon_social, credit_limit, price_tier, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
credit_limit,
|
||||||
|
1,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
print(f"[customers] Inserted: {inserted}, Skipped: {skipped}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_historical_sales_table(conn):
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS historical_sales (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
external_document_id VARCHAR(50),
|
||||||
|
document_no VARCHAR(50),
|
||||||
|
sale_date DATE,
|
||||||
|
customer_name VARCHAR(200),
|
||||||
|
total NUMERIC(12,2),
|
||||||
|
subtotal NUMERIC(12,2),
|
||||||
|
amount_paid NUMERIC(12,2),
|
||||||
|
payment_method VARCHAR(50),
|
||||||
|
discount NUMERIC(12,2) DEFAULT 0,
|
||||||
|
balance NUMERIC(12,2) DEFAULT 0,
|
||||||
|
raw_payment_code VARCHAR(20),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_historical_sales_date
|
||||||
|
ON historical_sales(sale_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_historical_sales_customer
|
||||||
|
ON historical_sales(customer_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_historical_sales_document
|
||||||
|
ON historical_sales(document_no);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def payment_label(code):
|
||||||
|
mapping = {
|
||||||
|
"1": "Efectivo",
|
||||||
|
"3": "Tarjeta",
|
||||||
|
"4": "Transferencia",
|
||||||
|
"6": "Cheque",
|
||||||
|
"28": "Crédito",
|
||||||
|
"99": "Por definir",
|
||||||
|
}
|
||||||
|
return mapping.get(str(code).strip(), f"Código {code}")
|
||||||
|
|
||||||
|
|
||||||
|
def import_historical_sales(conn):
|
||||||
|
path = os.path.join(BASE_DIR, "Historico V.xlsx")
|
||||||
|
df = pd.read_excel(path)
|
||||||
|
print(f"[historical_sales] Read {len(df)} rows from {path}")
|
||||||
|
|
||||||
|
create_historical_sales_table(conn)
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
doc_id = normalize_text(row.get("ID Documento"), max_len=50)
|
||||||
|
doc_no = normalize_text(row.get("Documento No."), max_len=50)
|
||||||
|
|
||||||
|
fecha = row.get("Fecha")
|
||||||
|
if pd.isna(fecha):
|
||||||
|
sale_date = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
sale_date = pd.to_datetime(fecha).date()
|
||||||
|
except Exception:
|
||||||
|
sale_date = None
|
||||||
|
|
||||||
|
customer = normalize_text(row.get("Cliente"), max_len=200)
|
||||||
|
total = normalize_price(row.get("Total"))
|
||||||
|
subtotal = normalize_price(row.get("SubTotal"))
|
||||||
|
paid = normalize_price(row.get("Total Pagado"))
|
||||||
|
discount = normalize_price(row.get("Descuento"))
|
||||||
|
balance = normalize_price(row.get("Saldo"))
|
||||||
|
raw_payment = normalize_text(row.get("Forma de Pago"), max_len=20)
|
||||||
|
payment_label_str = payment_label(raw_payment) if raw_payment else None
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO historical_sales
|
||||||
|
(external_document_id, document_no, sale_date, customer_name,
|
||||||
|
total, subtotal, amount_paid, payment_method, discount, balance,
|
||||||
|
raw_payment_code)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
doc_id,
|
||||||
|
doc_no,
|
||||||
|
sale_date,
|
||||||
|
customer,
|
||||||
|
total,
|
||||||
|
subtotal,
|
||||||
|
paid,
|
||||||
|
payment_label_str,
|
||||||
|
discount,
|
||||||
|
balance,
|
||||||
|
raw_payment,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += cur.rowcount
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
print(f"[historical_sales] Inserted: {inserted}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Connecting to tenant {TENANT_DB}...")
|
||||||
|
conn = get_tenant_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import_inventory(conn)
|
||||||
|
import_customers(conn)
|
||||||
|
import_historical_sales(conn)
|
||||||
|
print("\nImport completed successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user