From 92e1f7275e2dd5751aeaa2617e5b3645b4119c2a Mon Sep 17 00:00:00 2001 From: DXC Date: Mon, 25 May 2026 17:52:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=A5=E5=9B=BE=E6=90=9C=E5=9B=BE?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20business=5Fdata=20=E5=8C=85=E5=90=AB=20nam?= =?UTF-8?q?e/spec=5Fmodel/url=EF=BC=8C=E6=94=AF=E6=8C=81=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/v1/common/image_search.py | 215 ++++++++++++------ inventory-web/src/api/common/upload.ts | 21 ++ .../src/components/ImageSearchDialog.vue | 14 +- 3 files changed, 179 insertions(+), 71 deletions(-) diff --git a/inventory-backend/app/api/v1/common/image_search.py b/inventory-backend/app/api/v1/common/image_search.py index 0213fda..d0605a0 100644 --- a/inventory-backend/app/api/v1/common/image_search.py +++ b/inventory-backend/app/api/v1/common/image_search.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """ 以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索 +数据源:image_embeddings 表(统一向量存储) """ import os @@ -10,6 +11,10 @@ from flask import Blueprint, request, jsonify from sqlalchemy import text from app.extensions import db from app.utils.ai_vision import load_clip_model, get_image_embedding +from app.models.inbound.buy import StockBuy +from app.models.inbound.semi import StockSemi +from app.models.inbound.product import StockProduct +from app.models.base import MaterialBase # 注册蓝图 image_search_bp = Blueprint('image_search', __name__) @@ -71,88 +76,158 @@ def image_search(): print(f"⚠️ [ImageSearch] 临时文件删除失败: {e}") # --------------------------------------------------------- - # 5. pgvector 余弦相似度检索(跨表联合检索) + # 5. pgvector 余弦相似度检索(统一查 image_embeddings 表) # --------------------------------------------------------- try: query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']' sql = text(""" - SELECT id, name, spec_model, image_url, - (1 - (vec <=> :query_vector)) AS similarity - FROM ( - -- 1. 基础物料表 - SELECT id, name, spec_model, product_image AS image_url, img_embedding AS vec - FROM material_base - WHERE img_embedding IS NOT NULL - - UNION ALL - - -- 2. 采购入库表 (通过 base_id 关联拿真实物料信息) - SELECT mb.id, mb.name, mb.spec_model, sb.arrival_photo AS image_url, sb.arrival_image_embedding AS vec - FROM stock_buy sb - JOIN material_base mb ON sb.base_id = mb.id - WHERE sb.arrival_image_embedding IS NOT NULL - - UNION ALL - - -- 3. 半成品入库表 (通过 base_id 关联拿真实物料信息) - SELECT mb.id, mb.name, mb.spec_model, ss.arrival_photo AS image_url, ss.arrival_image_embedding AS vec - FROM stock_semi ss - JOIN material_base mb ON ss.base_id = mb.id - WHERE ss.arrival_image_embedding IS NOT NULL - - UNION ALL - - -- 4. 成品入库表 (通过 base_id 关联拿真实物料信息) - SELECT mb.id, mb.name, mb.spec_model, sp.product_photo AS image_url, sp.arrival_image_embedding AS vec - FROM stock_product sp - JOIN material_base mb ON sp.base_id = mb.id - WHERE sp.arrival_image_embedding IS NOT NULL - ) AS combined - - -- 核心:计算余弦距离并排序,取最接近的前 50 个! - ORDER BY vec <=> :query_vector LIMIT 50 + SELECT + ie.id AS embedding_id, + ie.module_name, + ie.target_id, + ie.image_url, + (1 - (ie.embedding <=> :query_vector)) AS similarity + FROM image_embeddings ie + WHERE ie.embedding IS NOT NULL + ORDER BY ie.embedding <=> :query_vector + LIMIT 200 """) - # 执行查询 - records = db.session.execute(sql, {"query_vector": query_vector_str}).fetchall() + raw_records = db.session.execute(sql, {"query_vector": query_vector_str}).fetchall() + if not raw_records: + return jsonify({"code": 200, "data": []}) + # 按 (module_name, target_id) 去重,每业务记录只保留最相似的那张图 + seen = {} + for row in raw_records: + key = (row.module_name, row.target_id) + if key not in seen: + seen[key] = row + + # 批量回填业务数据 + target_ids_by_module = {} + for row in seen.values(): + target_ids_by_module.setdefault(row.module_name, []).append(row.target_id) + + business_map = {} + + # 回填 StockBuy + if 'stock_buy' in target_ids_by_module: + ids = target_ids_by_module['stock_buy'] + records = ( + db.session.query(StockBuy) + .filter(StockBuy.id.in_(ids)) + .outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id) + .all() + ) + for r in records: + business_map[('stock_buy', r.id)] = { + 'record_id': r.id, + 'name': r.base.name if r.base else None, + 'spec_model': r.base.spec_model if r.base else None, + 'sku': r.sku, + 'barcode': r.barcode, + 'serial_number': r.serial_number, + 'batch_number': r.batch_number, + 'status': r.status, + 'warehouse_location': r.warehouse_location, + 'stock_quantity': r.stock_quantity, + 'module_name': 'stock_buy', + 'url': '/inventory/buy', + } + + # 回填 StockSemi + if 'stock_semi' in target_ids_by_module: + ids = target_ids_by_module['stock_semi'] + records = ( + db.session.query(StockSemi) + .filter(StockSemi.id.in_(ids)) + .outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id) + .all() + ) + for r in records: + business_map[('stock_semi', r.id)] = { + 'record_id': r.id, + 'name': r.base.name if r.base else None, + 'spec_model': r.base.spec_model if r.base else None, + 'sku': r.sku, + 'barcode': r.barcode, + 'serial_number': r.serial_number, + 'batch_number': r.batch_number, + 'status': r.status, + 'warehouse_location': r.warehouse_location, + 'stock_quantity': r.stock_quantity, + 'module_name': 'stock_semi', + 'url': '/inventory/semi', + } + + # 回填 StockProduct + if 'stock_product' in target_ids_by_module: + ids = target_ids_by_module['stock_product'] + records = ( + db.session.query(StockProduct) + .filter(StockProduct.id.in_(ids)) + .outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id) + .all() + ) + for r in records: + business_map[('stock_product', r.id)] = { + 'record_id': r.id, + 'name': r.base.name if r.base else None, + 'spec_model': r.base.spec_model if r.base else None, + 'sku': r.sku, + 'barcode': r.barcode, + 'serial_number': r.serial_number, + 'batch_number': r.batch_number, + 'status': r.status, + 'warehouse_location': r.warehouse_location, + 'stock_quantity': r.stock_quantity, + 'sale_price': r.sale_price, + 'module_name': 'stock_product', + 'url': '/inventory/product', + } + + # 回填 MaterialBase + if 'material_base' in target_ids_by_module: + ids = target_ids_by_module['material_base'] + records = MaterialBase.query.filter(MaterialBase.id.in_(ids)).all() + for r in records: + business_map[('material_base', r.id)] = { + 'record_id': r.id, + 'name': r.name, + 'spec_model': r.spec_model, + 'common_name': r.common_name, + 'category': r.category, + 'material_type': r.material_type, + 'unit': r.unit, + 'module_name': 'material_base', + 'url': '/material/index', + } + + # 组装最终返回 results = [] - seen_product_ids = set() # 【新增】用来记录已经添加过的物料 ID - - for row in records: - # 【新增】如果这个物料已经在这个列表里了,直接跳过它 - if row.id in seen_product_ids: - continue - - # 记录这个物料 ID,保证下次不会再重复添加 - seen_product_ids.add(row.id) - - # 1. 提取原始 URL - raw_url = row.image_url - clean_url = "" - - if raw_url: - if raw_url.startswith('[') and raw_url.endswith(']'): - import json - try: - url_list = json.loads(raw_url) - clean_url = url_list[0] if url_list else "" - except: - clean_url = raw_url - else: - clean_url = raw_url - - # 2. 组装返回结果 + for row in seen.values(): + key = (row.module_name, row.target_id) + biz = business_map.get(key, {}) + raw_url = row.image_url or '' + clean_url = raw_url + if raw_url.startswith('['): + try: + url_list = json.loads(raw_url) + clean_url = url_list[0] if url_list else '' + except: + pass results.append({ - "product_id": row.id, - "product_name": row.name, - "spec_model": row.spec_model, + "module_name": row.module_name, + "target_id": row.target_id, "image_url": clean_url, - "similarity": round(float(row.similarity), 4) + "similarity": round(float(row.similarity), 4), + "product_name": biz.get('name') or biz.get('material_name') or '未命名物料', + "product_id": row.target_id, + "spec_model": biz.get('spec_model') or '', + "business_data": biz, }) - - # 修改后:只要凑够了 10 个完全不同的物料,就立刻结束循环 if len(results) >= 10: break diff --git a/inventory-web/src/api/common/upload.ts b/inventory-web/src/api/common/upload.ts index eafa4fc..7142b10 100644 --- a/inventory-web/src/api/common/upload.ts +++ b/inventory-web/src/api/common/upload.ts @@ -47,6 +47,27 @@ export interface ImageSearchItem { spec_model: string image_url: string similarity: number + module_name: string + target_id: number + business_data: { + record_id: number + name?: string + spec_model?: string + sku?: string + barcode?: string + serial_number?: string + batch_number?: string + status?: string + warehouse_location?: string + stock_quantity?: number + sale_price?: number + common_name?: string + category?: string + material_type?: string + unit?: string + module_name: string + url: string + } } /** 以图搜图响应结构 */ diff --git a/inventory-web/src/components/ImageSearchDialog.vue b/inventory-web/src/components/ImageSearchDialog.vue index f5a62a1..902f91e 100644 --- a/inventory-web/src/components/ImageSearchDialog.vue +++ b/inventory-web/src/components/ImageSearchDialog.vue @@ -99,10 +99,13 @@