fix(catalog): unifica modelos duplicados por variante de carroceria/generacion

- catalog_service.get_models ahora agrupa variantes (p. ej. AVEO Saloon,
  AVEO Hatchback) bajo un unico display_name y devuelve variant_ids.
- Se elige el id_model mas bajo como canonico para presentacion.
- /catalog/years y /catalog/engines aceptan model_id como lista separada
  por comas para consultar todos los MYEs de las variantes agrupadas.
- catalog.js usa variant_ids al cargar años/motores y en el selector
  desplegable (incluyendo carga desde VIN).
This commit is contained in:
2026-06-15 18:24:58 +00:00
parent 85ecf52561
commit f5711ae22f
3 changed files with 87 additions and 37 deletions

View File

@@ -150,12 +150,18 @@ def models():
@catalog_bp.route('/years', methods=['GET']) @catalog_bp.route('/years', methods=['GET'])
@require_auth('catalog.view') @require_auth('catalog.view')
def years(): def years():
model_id = request.args.get('model_id', type=int) model_id_param = request.args.get('model_id', '')
if not model_id: if not model_id_param:
return jsonify({'error': 'model_id required'}), 400
try:
model_ids = [int(x) for x in model_id_param.split(',') if x]
except ValueError:
return jsonify({'error': 'model_id must be a comma-separated list of integers'}), 400
if not model_ids:
return jsonify({'error': 'model_id required'}), 400 return jsonify({'error': 'model_id required'}), 400
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids) data = catalog_service.get_years(master, model_ids, mye_ids=mye_ids)
return jsonify({'data': data}) return jsonify({'data': data})
return _with_conns(_do) return _with_conns(_do)
@@ -176,13 +182,19 @@ def years_all():
@catalog_bp.route('/engines', methods=['GET']) @catalog_bp.route('/engines', methods=['GET'])
@require_auth('catalog.view') @require_auth('catalog.view')
def engines(): def engines():
model_id = request.args.get('model_id', type=int) model_id_param = request.args.get('model_id', '')
year_id = request.args.get('year_id', type=int) year_id = request.args.get('year_id', type=int)
if not model_id or not year_id: if not model_id_param or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400 return jsonify({'error': 'model_id and year_id required'}), 400
try:
model_ids = [int(x) for x in model_id_param.split(',') if x]
except ValueError:
return jsonify({'error': 'model_id must be a comma-separated list of integers'}), 400
if not model_ids:
return jsonify({'error': 'model_id required'}), 400
def _do(master, tenant, branch_id): def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids) data = catalog_service.get_engines(master, model_ids, year_id, mye_ids=mye_ids)
return jsonify({'data': data}) return jsonify({'data': data})
return _with_conns(_do) return _with_conns(_do)

View File

@@ -285,19 +285,23 @@ 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 # 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])] filtered = [r for r in rows if is_na_model(brand_name, r[1])]
# Group by (display_name, raw name) so distinct body-style variants # Group by display_name so body-style/generation variants
# (e.g. AVEO vs AVEO SALOON) remain selectable. # (e.g. AVEO Saloon, AVEO Hatchback) are shown as a single model.
seen = set() groups = {}
results = []
for r in filtered: for r in filtered:
display = _clean_model_name(r[1]) display = _clean_model_name(r[1])
key = (display, r[1]) groups.setdefault(display, []).append(r)
if key not in seen:
seen.add(key) results = []
for display, variants in groups.items():
# Sort by raw model id ascending; first becomes the canonical id.
variants.sort(key=lambda x: x[0])
canonical = variants[0]
results.append({ results.append({
'id_model': r[0], 'id_model': canonical[0],
'name_model': r[1], 'name_model': canonical[1],
'display_name': display, 'display_name': display,
'variant_ids': [v[0] for v in variants],
}) })
# Sort by display_name # Sort by display_name
@@ -306,34 +310,37 @@ def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=Non
def get_years(master_conn, model_id, mye_ids=None): def get_years(master_conn, model_id, mye_ids=None):
"""Get distinct years for a model via MYE (fast, no vehicle_parts scan). Ordered DESC.""" """Get distinct years for a model (or list of model variants) via MYE.
Ordered DESC."""
cur = master_conn.cursor() cur = master_conn.cursor()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
if mye_ids: if mye_ids:
cur.execute(""" cur.execute("""
SELECT DISTINCT y.id_year, y.year_car SELECT DISTINCT y.id_year, y.year_car
FROM years y FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = %s AND mye.id_mye = ANY(%s) WHERE mye.model_id = ANY(%s) AND mye.id_mye = ANY(%s)
ORDER BY y.year_car DESC ORDER BY y.year_car DESC
""", (model_id, mye_ids)) """, (list(model_ids), mye_ids))
else: else:
cur.execute(""" cur.execute("""
SELECT DISTINCT y.id_year, y.year_car SELECT DISTINCT y.id_year, y.year_car
FROM years y FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = %s WHERE mye.model_id = ANY(%s)
ORDER BY y.year_car DESC ORDER BY y.year_car DESC
""", (model_id,)) """, (list(model_ids),))
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
return [{'id_year': r[0], 'year_car': r[1]} for r in rows] return [{'id_year': r[0], 'year_car': r[1]} for r in rows]
def get_engines(master_conn, model_id, year_id, mye_ids=None): def get_engines(master_conn, model_id, year_id, mye_ids=None):
"""Get MYE entries (engine + trim) for a model+year combo.""" """Get MYE entries (engine + trim) for a model (or list of variants) + year combo."""
cur = master_conn.cursor() cur = master_conn.cursor()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
mye_filter = "" mye_filter = ""
params = [model_id, year_id] params = [list(model_ids), year_id]
if mye_ids: if mye_ids:
mye_filter = " AND mye.id_mye = ANY(%s)" mye_filter = " AND mye.id_mye = ANY(%s)"
params.append(mye_ids) params.append(mye_ids)
@@ -341,7 +348,7 @@ def get_engines(master_conn, model_id, year_id, mye_ids=None):
SELECT mye.id_mye, e.name_engine, mye.trim_level SELECT mye.id_mye, e.name_engine, mye.trim_level
FROM model_year_engine mye FROM model_year_engine mye
JOIN engines e ON e.id_engine = mye.engine_id JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.model_id = %s AND mye.year_id = %s{mye_filter} WHERE mye.model_id = ANY(%s) AND mye.year_id = %s{mye_filter}
ORDER BY e.name_engine, mye.trim_level ORDER BY e.name_engine, mye.trim_level
""", tuple(params)) """, tuple(params))
rows = cur.fetchall() rows = cur.fetchall()

View File

@@ -427,6 +427,12 @@
}); });
} }
function modelIdsParam(model) {
if (!model) return '';
if (model.variant_ids && model.variant_ids.length) return model.variant_ids.join(',');
return String(model.id);
}
function loadModels() { function loadModels() {
nav.level = 'models'; nav.level = 'models';
pushNavState(); pushNavState();
@@ -440,14 +446,15 @@
if (!data || !data.data || !data.data.length) { showEmpty('Sin modelos', 'No hay modelos con partes para ' + nav.brand.name); return; } if (!data || !data.data || !data.data.length) { showEmpty('Sin modelos', 'No hay modelos con partes para ' + nav.brand.name); return; }
navGrid.className = 'nav-grid'; navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (m) { navGrid.innerHTML = data.data.map(function (m) {
return '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-name="' + esc(m.display_name || m.name_model) + '">' + return '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-variant-ids="' + esc((m.variant_ids || [m.id_model]).join(',')) + '" data-name="' + esc(m.display_name || m.name_model) + '">' +
'<div class="nav-card__name">' + esc(m.display_name || m.name_model) + '</div>' + '<div class="nav-card__name">' + esc(m.display_name || m.name_model) + '</div>' +
'</div>'; '</div>';
}).join(''); }).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) { navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () { card.addEventListener('click', function () {
nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name }; var variantIds = (this.dataset.variantIds || this.dataset.modelId).split(',').map(function(x){ return parseInt(x); });
nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name, variant_ids: variantIds };
loadYears(); loadYears();
}); });
}); });
@@ -462,7 +469,7 @@
setupLevelFilter(false); setupLevelFilter(false);
showLoading(); showLoading();
apiFetch(API + '/years?model_id=' + nav.model.id).then(function (data) { apiFetch(API + '/years?model_id=' + modelIdsParam(nav.model)).then(function (data) {
hideLoading(); hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin anios', 'No hay anios con partes para este modelo.'); return; } if (!data || !data.data || !data.data.length) { showEmpty('Sin anios', 'No hay anios con partes para este modelo.'); return; }
navGrid.className = 'nav-grid nav-grid--years'; navGrid.className = 'nav-grid nav-grid--years';
@@ -489,7 +496,7 @@
setupLevelFilter(false); setupLevelFilter(false);
showLoading(); showLoading();
apiFetch(API + '/engines?model_id=' + nav.model.id + '&year_id=' + nav.year.id).then(function (data) { apiFetch(API + '/engines?model_id=' + modelIdsParam(nav.model) + '&year_id=' + nav.year.id).then(function (data) {
hideLoading(); hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin motores', 'No hay configuraciones de motor para esta combinacion.'); return; } if (!data || !data.data || !data.data.length) { showEmpty('Sin motores', 'No hay configuraciones de motor para esta combinacion.'); return; }
@@ -1829,7 +1836,8 @@
if (!models) return; if (!models) return;
vsModel.innerHTML = '<option value="">Modelo...</option>' + vsModel.innerHTML = '<option value="">Modelo...</option>' +
models.map(function (m) { models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>'; var variants = (m.variant_ids || [m.id_model]).join(',');
return '<option value="' + m.id_model + '" data-variant-ids="' + esc(variants) + '">' + esc(m.display_name || m.name_model) + '</option>';
}).join(''); }).join('');
}); });
} }
@@ -1837,13 +1845,17 @@
function vsModelChanged() { function vsModelChanged() {
var modelId = vsModel.value; var modelId = vsModel.value;
var yearVal = vsYear.value; var yearVal = vsYear.value;
var selectedOption = vsModel.options[vsModel.selectedIndex];
var variantIds = selectedOption && selectedOption.dataset.variantIds
? selectedOption.dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: (modelId ? [parseInt(modelId)] : []);
vsEngine.innerHTML = '<option value="">Motor...</option>'; vsEngine.innerHTML = '<option value="">Motor...</option>';
vsEngine.disabled = true; vsEngine.disabled = true;
if (!modelId || !yearVal) return; if (!modelId || !yearVal || !variantIds.length) return;
vsEngine.disabled = false; vsEngine.disabled = false;
apiFetch(API + '/engines?model_id=' + modelId + '&year_id=' + yearVal).then(function (data) { apiFetch(API + '/engines?model_id=' + variantIds.join(',') + '&year_id=' + yearVal).then(function (data) {
var engines = data.data || data; var engines = data.data || data;
if (!engines) return; if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' + vsEngine.innerHTML = '<option value="">Motor...</option>' +
@@ -1869,8 +1881,12 @@
var modelText = vsModel.options[vsModel.selectedIndex].text; var modelText = vsModel.options[vsModel.selectedIndex].text;
var engineText = vsEngine.options[vsEngine.selectedIndex].text; var engineText = vsEngine.options[vsEngine.selectedIndex].text;
var selectedModelOption = vsModel.options[vsModel.selectedIndex];
var modelVariantIds = selectedModelOption && selectedModelOption.dataset.variantIds
? selectedModelOption.dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: [parseInt(vsModel.value)];
nav.brand = { id: parseInt(vsBrand.value), name: brandText }; nav.brand = { id: parseInt(vsBrand.value), name: brandText };
nav.model = { id: parseInt(vsModel.value), name: modelText }; nav.model = { id: parseInt(vsModel.value), name: modelText, variant_ids: modelVariantIds };
nav.year = { id: parseInt(vsYear.value), year: yearText }; nav.year = { id: parseInt(vsYear.value), year: yearText };
nav.engine = { id_mye: parseInt(myeId), name: engineText }; nav.engine = { id_mye: parseInt(myeId), name: engineText };
nav.level = 'categories'; nav.level = 'categories';
@@ -2058,14 +2074,29 @@
if (!models) return; if (!models) return;
vsModel.innerHTML = '<option value="">Modelo...</option>' + vsModel.innerHTML = '<option value="">Modelo...</option>' +
models.map(function (m) { models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>'; var variants = (m.variant_ids || [m.id_model]).join(',');
return '<option value="' + m.id_model + '" data-variant-ids="' + esc(variants) + '">' + esc(m.display_name || m.name_model) + '</option>';
}).join(''); }).join('');
vsModel.disabled = false; vsModel.disabled = false;
if (match.model_id) { if (match.model_id) {
// The VIN match may point to a variant that is now grouped under
// a canonical model; select the option whose variants include it.
var matchedOption = Array.from(vsModel.options).find(function (opt) {
if (!opt.value) return false;
var vids = (opt.dataset.variantIds || opt.value).split(',').map(function(x){ return parseInt(x); });
return vids.indexOf(match.model_id) !== -1;
});
if (matchedOption) {
vsModel.value = matchedOption.value;
} else {
vsModel.value = String(match.model_id); vsModel.value = String(match.model_id);
}
var selectedVariantIds = vsModel.selectedIndex >= 0 && vsModel.options[vsModel.selectedIndex].dataset.variantIds
? vsModel.options[vsModel.selectedIndex].dataset.variantIds.split(',').map(function(x){ return parseInt(x); })
: [match.model_id];
// Load engines // Load engines
apiFetch(API + '/engines?model_id=' + match.model_id + '&year_id=' + match.year_id).then(function (engData) { apiFetch(API + '/engines?model_id=' + selectedVariantIds.join(',') + '&year_id=' + match.year_id).then(function (engData) {
var engines = engData && (engData.data || engData); var engines = engData && (engData.data || engData);
if (!engines) return; if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' + vsEngine.innerHTML = '<option value="">Motor...</option>' +