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'])
@require_auth('catalog.view')
def years():
model_id = request.args.get('model_id', type=int)
if not model_id:
model_id_param = request.args.get('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
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
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 _with_conns(_do)
@@ -176,13 +182,19 @@ def years_all():
@catalog_bp.route('/engines', methods=['GET'])
@require_auth('catalog.view')
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)
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
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):
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 _with_conns(_do)

View File

@@ -285,20 +285,24 @@ 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 (display_name, raw name) so distinct body-style variants
# (e.g. AVEO vs AVEO SALOON) remain selectable.
seen = set()
results = []
# Group by display_name so body-style/generation variants
# (e.g. AVEO Saloon, AVEO Hatchback) are shown as a single model.
groups = {}
for r in filtered:
display = _clean_model_name(r[1])
key = (display, r[1])
if key not in seen:
seen.add(key)
results.append({
'id_model': r[0],
'name_model': r[1],
'display_name': display,
})
groups.setdefault(display, []).append(r)
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({
'id_model': canonical[0],
'name_model': canonical[1],
'display_name': display,
'variant_ids': [v[0] for v in variants],
})
# Sort by display_name
results.sort(key=lambda x: x['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):
"""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()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
if mye_ids:
cur.execute("""
SELECT DISTINCT y.id_year, y.year_car
FROM years y
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
""", (model_id, mye_ids))
""", (list(model_ids), mye_ids))
else:
cur.execute("""
SELECT DISTINCT y.id_year, y.year_car
FROM years y
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
""", (model_id,))
""", (list(model_ids),))
rows = cur.fetchall()
cur.close()
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):
"""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()
model_ids = model_id if isinstance(model_id, (list, tuple, set)) else [model_id]
mye_filter = ""
params = [model_id, year_id]
params = [list(model_ids), year_id]
if mye_ids:
mye_filter = " AND mye.id_mye = ANY(%s)"
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
FROM model_year_engine mye
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
""", tuple(params))
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() {
nav.level = 'models';
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; }
navGrid.className = 'nav-grid';
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>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
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();
});
});
@@ -462,7 +469,7 @@
setupLevelFilter(false);
showLoading();
apiFetch(API + '/years?model_id=' + nav.model.id).then(function (data) {
apiFetch(API + '/years?model_id=' + modelIdsParam(nav.model)).then(function (data) {
hideLoading();
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';
@@ -489,7 +496,7 @@
setupLevelFilter(false);
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();
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;
vsModel.innerHTML = '<option value="">Modelo...</option>' +
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('');
});
}
@@ -1837,13 +1845,17 @@
function vsModelChanged() {
var modelId = vsModel.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.disabled = true;
if (!modelId || !yearVal) return;
if (!modelId || !yearVal || !variantIds.length) return;
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;
if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' +
@@ -1869,8 +1881,12 @@
var modelText = vsModel.options[vsModel.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.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.engine = { id_mye: parseInt(myeId), name: engineText };
nav.level = 'categories';
@@ -2058,14 +2074,29 @@
if (!models) return;
vsModel.innerHTML = '<option value="">Modelo...</option>' +
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('');
vsModel.disabled = false;
if (match.model_id) {
vsModel.value = String(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);
}
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
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);
if (!engines) return;
vsEngine.innerHTML = '<option value="">Motor...</option>' +