feat: Fase 1-3 completas - precios proveedor, multi-sucursal, factura global

Fase 1: Lista de precios de proveedor
- Tabla supplier_catalog_prices en master DB
- Endpoints GET/POST/PUT/DELETE /supplier-catalog/prices
- Upload CSV/Excel de precios de proveedor
- Visualizacion de supplier_price en catalogo y POS

Fase 2: Multi-sucursal completo
- Migracion v4.0: inventory.branch_id=NULL, tabla inventory_stock
- Campos fiscales en branches (RFC, regimen, CP, serie CFDI, certificados)
- Trigger trg_update_inventory_stock para sincronizar stock por sucursal
- Backend config_bp.py con CRUD de sucursales fiscales
- Backend inventory_bp.py y pos_bp.py refactorizados para inventario compartido
- Backend invoicing_bp.py usa datos fiscales de la sucursal de la venta
- Frontend config.html/js con modal de sucursales expandido

Fase 3: Factura global mensual
- Migracion v4.1: tablas global_invoice_sales, sales.global_invoiced_at
- build_global_invoice_xml() con InformacionGlobal SAT-compliant
- Servicio global_invoice.py para agrupar ventas PUE <=000
- Endpoints POST/GET /global-invoice y /global-invoice/eligible-sales
- Frontend invoicing.html/js con boton y modal de factura global
This commit is contained in:
2026-06-11 08:59:56 +00:00
parent ea29cc31c0
commit 2b73c2c6db
23 changed files with 1665 additions and 230 deletions

View File

@@ -95,8 +95,8 @@ def _clean_model_name(name):
s = re.sub(r'\s*\([^)]*\)\s*', '', s)
# Remove Roman numeral generation suffixes: I, II, III, IV, V, VI, VII, VIII, IX, X
s = re.sub(r'\s+(?:VIII|VII|VI|IV|IX|III|II|V|X|I)(?:\s|$)', ' ', s)
# Remove body type suffixes
s = re.sub(r'\s+(?:Estate|Saloon|Hatchback|Van|Coupe|Coupé|Convertible|Wagon|Pickup|Cab|Sedan|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE)
# Remove body type suffixes (keep Saloon/Hatchback/Sedan/Wagon as they distinguish variants)
s = re.sub(r'\s+(?:Estate|Van|Coupe|Coupé|Convertible|Pickup|Cab|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE)
# Remove truck cab/bed suffixes: CREW, EXTENDED, STANDARD, HD, etc.
s = re.sub(r'\s+(?:CREW|EXTENDED|STANDARD|CUTAWAY|PASSENGER|CARGO)\b', '', s, flags=re.IGNORECASE)
# Remove "HD", "&" suffixes that create fake variants
@@ -285,13 +285,15 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
# Filter to North America models only, add clean display name, deduplicate
filtered = [r for r in rows if is_na_model(brand_name, r[1])]
# Group by clean name — keep all id_models but show one display name
seen = {} # display_name → first row
# Group by (display_name, raw name) so distinct body-style variants
# (e.g. AVEO vs AVEO SALOON) remain selectable.
seen = set()
results = []
for r in filtered:
display = _clean_model_name(r[1])
if display not in seen:
seen[display] = True
key = (display, r[1])
if key not in seen:
seen.add(key)
results.append({
'id_model': r[0],
'name_model': r[1],
@@ -508,7 +510,7 @@ _SPANISH_KEYWORDS = [
(("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"),
["bieleta", "estabilizador"]),
(("Steering & Suspension Parts", "Steering Linkages, Rods & Arms", "Steering Tie Rod End"),
["terminal", "rotula direccion", "rotula de direccion"]),
["terminal", "rotula direccion", "rotula de direccion", "espiga"]),
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Ball Joint"),
["rotula"]),
(("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm Bushing"),
@@ -617,7 +619,7 @@ def _spanish_name_to_nexpart(name, category=None):
"""
if not name:
return None
name_lower = name.lower().replace('_', ' ')
name_lower = name.lower().replace('_', ' ').replace('\n', ' ').replace('\r', '')
# 1. Keyword match (most specific first)
for triple, keywords in _SPANISH_KEYWORDS:
@@ -1134,7 +1136,7 @@ def _local_name_matches_part_type(name, part_type_slug):
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
part_type_slug, tenant_conn, branch_id,
page=1, per_page=30):
page=1, per_page=30, tenant_id=None):
"""Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple.
Steps:
@@ -1242,9 +1244,14 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
WHERE id = ANY(%s)
ORDER BY name
""", (sc_id_values,))
for row in cur.fetchall():
sc_rows = cur.fetchall()
cur.close()
sc_prices = _get_supplier_prices(master_conn, tenant_id, sc_id_values)
for row in sc_rows:
sc_id, supplier, sku, name, category, desc, img = row
result['data'].append({
item = {
'id_part': f'sc:{sc_id}',
'id_aftermarket': None,
'oem_part_number': sku,
@@ -1262,8 +1269,12 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
'in_stock_network': False,
'price_usd': None,
'source': 'supplier_catalog',
})
cur.close()
}
p = sc_prices.get(sc_id)
if p:
item['supplier_price'] = p['price']
item['supplier_currency'] = p['currency']
result['data'].append(item)
# Sort combined list and paginate in Python
all_items = result['data']
@@ -1299,11 +1310,13 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
if not subgroup_data:
return []
# Pull a sample image for each part type — single query, all part_ids at once
# Pull a sample image for each part type — single query, all OEM part_ids at once
# Only integer IDs exist in the TecDoc parts table; skip inv: and sc: prefixed IDs.
all_part_ids = [
pid
for pids in subgroup_data.values()
for pid in pids
if isinstance(pid, int)
]
image_map = {}
if all_part_ids:
@@ -1902,7 +1915,7 @@ def _search_meili_fallback(master_conn, q, limit):
return None
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
"""Search parts by part number or text. Enriches with local stock.
Strategy:
@@ -1945,8 +1958,8 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
break
# ── Inject supplier catalog items ───────────────────────────────────────
if tenant_conn:
supplier_items = _search_supplier_catalog(tenant_conn, q, mye_id, limit)
if master_conn:
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
for si in supplier_items:
if f"sc:{si['id']}" in seen_local_ids:
continue
@@ -1957,6 +1970,8 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
'image_url': si['image_url'],
'local_stock': None,
'local_price': None,
'supplier_price': si.get('supplier_price'),
'supplier_currency': si.get('supplier_currency'),
'vehicle_info': si['category'] or '',
'source': 'supplier_catalog',
})
@@ -1967,14 +1982,36 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None):
return results
def _search_supplier_catalog(tenant_conn, q, mye_id, limit):
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
"""Return a dict catalog_id -> {price, currency} for the current active price."""
if master_conn is None or not tenant_id or not catalog_ids:
return {}
cur = master_conn.cursor()
cur.execute("""
SELECT DISTINCT ON (catalog_id)
catalog_id, price, currency
FROM supplier_catalog_prices
WHERE tenant_id = %s AND catalog_id = ANY(%s) AND is_active = true
AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
ORDER BY catalog_id, effective_from DESC
""", (tenant_id, list(catalog_ids)))
prices = {}
for r in cur.fetchall():
prices[r[0]] = {'price': float(r[1]) if r[1] is not None else None,
'currency': r[2] or 'MXN'}
cur.close()
return prices
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
"""Search supplier catalog items by SKU or name.
If mye_id is provided, only returns items compatible with that vehicle.
Enriches each item with the tenant-specific supplier price when tenant_id is given.
"""
if tenant_conn is None:
if master_conn is None:
return []
cur = tenant_conn.cursor()
cur = master_conn.cursor()
clean_q = q.replace(' ', '').upper()
_SQL_UNACCENT = """
@@ -2016,10 +2053,19 @@ def _search_supplier_catalog(tenant_conn, q, mye_id, limit):
rows = cur.fetchall()
cur.close()
return [
{'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]}
for r in rows
]
catalog_ids = [r[0] for r in rows]
prices = _get_supplier_prices(master_conn, tenant_id, catalog_ids)
results = []
for r in rows:
item = {'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]}
p = prices.get(r[0])
if p:
item['supplier_price'] = p['price']
item['supplier_currency'] = p['currency']
results.append(item)
return results
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):