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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user