From b5610de1f108ada542ef63f407774e3a2e6fcbc4 Mon Sep 17 00:00:00 2001 From: DXC Date: Tue, 24 Mar 2026 09:29:20 +0800 Subject: [PATCH] feat: restructure basic info menu, add kitting monitor table, and implement user preferences api --- inventory-backend/app/api/v1/__init__.py | 2 + inventory-backend/app/api/v1/bom.py | 4 +- inventory-backend/app/api/v1/user.py | 65 ++ inventory-backend/app/models/system.py | 2 + inventory-backend/app/services/bom_service.py | 79 +- inventory-web/src/api/bom.ts | 37 +- inventory-web/src/api/user.ts | 18 + inventory-web/src/router/index.ts | 23 +- .../src/views/basic/kitting/index.vue | 1002 ++++++++++------- 9 files changed, 775 insertions(+), 457 deletions(-) create mode 100644 inventory-backend/app/api/v1/user.py create mode 100644 inventory-web/src/api/user.ts diff --git a/inventory-backend/app/api/v1/__init__.py b/inventory-backend/app/api/v1/__init__.py index 0313ea0..045df88 100644 --- a/inventory-backend/app/api/v1/__init__.py +++ b/inventory-backend/app/api/v1/__init__.py @@ -1,7 +1,9 @@ from flask import Blueprint from .inbound import inbound_bp from .bom import bom_bp +from .user import user_bp v1_bp = Blueprint('v1', __name__) v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound') v1_bp.register_blueprint(bom_bp, url_prefix='/bom') +v1_bp.register_blueprint(user_bp, url_prefix='/user') diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index ff40578..a6707ce 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -375,11 +375,11 @@ def calculate_kitting(): if not entries or not isinstance(entries, list): return jsonify({'code': 400, 'msg': '参数格式错误,需要数组'}), 400 - results = BomService.calculate_kitting(entries) + result = BomService.calculate_kitting(entries) return jsonify({ 'code': 200, 'msg': '计算成功', - 'data': results + 'data': result }) except Exception as e: current_app.logger.error(f'MRP齐套计算失败: {str(e)}') diff --git a/inventory-backend/app/api/v1/user.py b/inventory-backend/app/api/v1/user.py new file mode 100644 index 0000000..1ca5e14 --- /dev/null +++ b/inventory-backend/app/api/v1/user.py @@ -0,0 +1,65 @@ +from flask import Blueprint, request, jsonify, current_app +from app.models.system import SysUser +from app.extensions import db +from flask_jwt_extended import jwt_required, get_jwt_identity +from app.utils.decorators import audit_log + +user_bp = Blueprint('user', __name__) + + +# ============================================================================== +# 用户偏好配置 API +# ============================================================================== + +@user_bp.route('/preferences', methods=['GET']) +@jwt_required() +def get_preferences(): + """ + 读取当前用户的 preferences 字段 + GET /api/v1/user/preferences + """ + try: + user_id = get_jwt_identity() + user = SysUser.query.get(user_id) + if not user: + return jsonify({'code': 404, 'msg': '用户不存在'}), 404 + return jsonify({ + 'code': 200, + 'msg': 'success', + 'data': user.preferences or {} + }) + except Exception as e: + current_app.logger.error(f'读取用户偏好失败: {str(e)}') + return jsonify({'code': 500, 'msg': f'读取失败: {str(e)}'}), 500 + + +@user_bp.route('/preferences', methods=['PUT']) +@jwt_required() +@audit_log(module_name='系统', action_type='修改偏好配置') +def save_preferences(): + """ + 保存/更新当前用户的 preferences 字段 + PUT /api/v1/user/preferences + 入参: 任意 JSON 字典 + """ + try: + user_id = get_jwt_identity() + user = SysUser.query.get(user_id) + if not user: + return jsonify({'code': 404, 'msg': '用户不存在'}), 404 + + new_prefs = request.get_json() + if new_prefs is None or not isinstance(new_prefs, dict): + return jsonify({'code': 400, 'msg': '参数必须是 JSON 对象'}), 400 + + user.preferences = new_prefs + db.session.commit() + return jsonify({ + 'code': 200, + 'msg': '保存成功', + 'data': user.preferences + }) + except Exception as e: + db.session.rollback() + current_app.logger.error(f'保存用户偏好失败: {str(e)}') + return jsonify({'code': 500, 'msg': f'保存失败: {str(e)}'}), 500 diff --git a/inventory-backend/app/models/system.py b/inventory-backend/app/models/system.py index d1901e8..4246ba0 100644 --- a/inventory-backend/app/models/system.py +++ b/inventory-backend/app/models/system.py @@ -22,6 +22,7 @@ class SysUser(db.Model): role = db.Column(db.String(50)) status = db.Column(db.String(20), default='active') password_hash = db.Column(db.Text) + preferences = db.Column(db.JSON, default=dict) # 用户偏好/个性化配置(如齐套监控列表) created_at = db.Column(db.DateTime, default=beijing_time) def set_password(self, password): @@ -61,6 +62,7 @@ class SysUser(db.Model): 'department': self.department, 'role': self.role, 'status': self.status, + 'preferences': self.preferences or {}, 'created_at': self.created_at.isoformat() if self.created_at else None } diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index 1e19515..276ad61 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -252,7 +252,7 @@ class BomService: # ====================== MRP 齐套模拟计算 ====================== @staticmethod - def calculate_kitting(entries: list) -> list: + def calculate_kitting(entries: list) -> dict: """ MRP 齐套模拟计算 @@ -261,13 +261,20 @@ class BomService: 2. 按子件 base_id 合并需求量:需求量 = dosage * (1 + loss_rate/100) * target_qty 3. 跨 StockBuy / StockSemi / StockProduct 聚合可用库存 4. 计算缺口:shortage = available_quantity - required_quantity + 5. 计算每个 BOM 的"当前库存可生产最大套数"(木桶效应) :param entries: [{"bom_no": "BOM-001", "target_qty": 10}, ...] - :return: [{base_id, name, spec, unit, required_qty, available_qty, shortage, children: [...]}, ...] + :return: { + "bom_summary": [{bom_no, parent_name, max_producible}], + "materials": [{base_id, material_name, spec, unit, required_qty, available_qty, shortage, bom_sources}] + } """ # Step 1: 展开所有 BOM 的子件,聚合需求量 demand_map = {} # child_id -> {base_id, material_name, spec, unit, required_qty, bom_sources: []} + # 记录每个 entry 的元信息(用于后续 per-BOM 产量计算) + entry_meta = {} # bom_no -> {parent_name, version, children: {child_id: {dosage, loss_rate}}} + for entry in entries: bom_no = entry.get('bom_no') target_qty = float(entry.get('target_qty', 0) or 0) @@ -286,6 +293,18 @@ class BomService: if not latest_version: continue + # 获取父件名称 + parent_row = db.session.query( + BomTable.parent_id, MaterialBase.name + ).join( + MaterialBase, BomTable.parent_id == MaterialBase.id + ).filter( + BomTable.bom_no == bom_no, + BomTable.version == latest_version + ).first() + + parent_name = parent_row.name if parent_row else '' + # 查询该 BOM 所有子件 rows = db.session.query( BomTable, MaterialBase.name, MaterialBase.spec_model, MaterialBase.unit @@ -297,12 +316,26 @@ class BomService: BomTable.is_enabled == True ).all() + entry_meta[bom_no] = { + 'parent_name': parent_name, + 'version': latest_version, + 'children': {} + } + for bom, child_name, child_spec, child_unit in rows: dosage = float(bom.dosage or 0) loss_rate = float(bom.loss_rate or 0) adj_dosage = dosage * (1 + loss_rate / 100.0) qty_needed = adj_dosage * target_qty + # 记录 per-unit 用量(用于 max_producible 计算) + entry_meta[bom_no]['children'][bom.child_id] = { + 'dosage': dosage, + 'loss_rate': loss_rate, + 'adj_dosage': adj_dosage, + 'per_unit': adj_dosage # 每生产1套该BOM所需的此子件数量 + } + if bom.child_id not in demand_map: demand_map[bom.child_id] = { 'base_id': bom.child_id, @@ -323,14 +356,11 @@ class BomService: # Step 2: 批量查询三张库存表的可用库存 child_ids = list(demand_map.keys()) if not child_ids: - return [] + return {'bom_summary': [], 'materials': []} - # StockBuy.available_quantity, StockSemi.available_quantity, StockProduct.available_quantity available_map = {cid: 0.0 for cid in child_ids} for model_cls in (StockBuy, StockSemi, StockProduct): - if model_cls is None: - continue rows = db.session.query( model_cls.base_id, func.coalesce(model_cls.available_quantity, 0) @@ -341,12 +371,12 @@ class BomService: if base_id in available_map: available_map[base_id] += float(qty) - # Step 3: 构造结果,计算缺口 - results = [] + # Step 3: 构造物料结果,计算缺口 + materials = [] for base_id, info in demand_map.items(): avail = available_map.get(base_id, 0.0) shortage = avail - info['required_qty'] - results.append({ + materials.append({ 'base_id': base_id, 'material_name': info['material_name'], 'spec': info['spec'], @@ -357,9 +387,34 @@ class BomService: 'bom_sources': info['bom_sources'] }) - # 按缺件数量降序排列(最缺的排前面) - results.sort(key=lambda x: x['shortage']) - return results + # 按缺件数量升序(最缺的排前面) + materials.sort(key=lambda x: x['shortage']) + + # Step 4: 计算每个 BOM 的"当前库存可生产最大套数"(木桶效应) + # 算法:对每个 BOM 的所有子件,计算 Floor(available_qty / per_unit_demand), + # 取最小值 = 该 BOM 的最大可生产套数 + bom_summary = [] + for bom_no, meta in entry_meta.items(): + min_producible = float('inf') + for child_id, child_info in meta['children'].items(): + avail = available_map.get(child_id, 0.0) + per_unit = child_info['adj_dosage'] + if per_unit > 0: + producible = int(avail // per_unit) + if producible < min_producible: + min_producible = producible + max_prod = int(min_producible) if min_producible != float('inf') else 0 + bom_summary.append({ + 'bom_no': bom_no, + 'parent_name': meta['parent_name'], + 'version': meta['version'], + 'max_producible': max_prod + }) + + return { + 'bom_summary': bom_summary, + 'materials': materials + } # ====================== 兼容旧接口 ====================== @staticmethod diff --git a/inventory-web/src/api/bom.ts b/inventory-web/src/api/bom.ts index 85f6d6e..306117f 100644 --- a/inventory-web/src/api/bom.ts +++ b/inventory-web/src/api/bom.ts @@ -44,8 +44,41 @@ export function deleteBom(bomNo: string, version: string) { } // MRP 齐套模拟计算 -export function calculateKitting(entries: { bom_no: string; target_qty: number }[]) { - return request({ +export interface BomKittingEntry { + bom_no: string + target_qty: number +} + +export interface BomSummary { + bom_no: string + parent_name: string + version: string + max_producible: number +} + +export interface KittingMaterial { + base_id: number + material_name: string + spec: string + unit: string + required_qty: number + available_qty: number + shortage: number + bom_sources: Array<{ + bom_no: string + dosage: number + loss_rate: number + target_qty: number + }> +} + +export interface KittingResult { + bom_summary: BomSummary[] + materials: KittingMaterial[] +} + +export function calculateKitting(entries: BomKittingEntry[]) { + return request({ url: '/v1/bom/calculate-kitting', method: 'post', data: entries diff --git a/inventory-web/src/api/user.ts b/inventory-web/src/api/user.ts new file mode 100644 index 0000000..3423330 --- /dev/null +++ b/inventory-web/src/api/user.ts @@ -0,0 +1,18 @@ +import request from '@/utils/request' + +// 读取当前用户的 preferences +export function getUserPreferences() { + return request({ + url: '/v1/user/preferences', + method: 'get' + }) +} + +// 保存/更新当前用户的 preferences +export function saveUserPreferences(data: Record) { + return request({ + url: '/v1/user/preferences', + method: 'put', + data + }) +} diff --git a/inventory-web/src/router/index.ts b/inventory-web/src/router/index.ts index 2c17939..ae4db22 100644 --- a/inventory-web/src/router/index.ts +++ b/inventory-web/src/router/index.ts @@ -39,17 +39,24 @@ const routes: Array = [ ] }, - // 3. 基础信息 + // 3. 基础信息(父级菜单) { - path: '/material', + path: '/basic', component: Layout, - redirect: '/material/index', + meta: { title: '基础信息', icon: 'Box' }, + redirect: '/basic/material', children: [ { - path: 'index', + path: 'material', name: 'MaterialBase', component: () => import('@/views/material/list.vue'), - meta: { title: '基础信息', icon: 'Box' } + meta: { title: '物料基础信息', icon: 'Box' } + }, + { + path: 'kitting', + name: 'BasicKitting', + component: () => import('@/views/basic/kitting/index.vue'), + meta: { title: '产能与齐套分析', icon: 'Cpu' } } ] }, @@ -159,12 +166,6 @@ const routes: Array = [ name: 'BomManage', component: BomManage, meta: { title: 'BOM配方管理', icon: 'list' } - }, - { - path: 'kitting', - name: 'BomKitting', - component: () => import('@/views/basic/kitting/index.vue'), - meta: { title: '齐套计算器', icon: 'Cpu' } } ] }, diff --git a/inventory-web/src/views/basic/kitting/index.vue b/inventory-web/src/views/basic/kitting/index.vue index 047eae8..e47478a 100644 --- a/inventory-web/src/views/basic/kitting/index.vue +++ b/inventory-web/src/views/basic/kitting/index.vue @@ -1,501 +1,643 @@