diff --git a/app.py b/app.py index e83f65e..c0dea52 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,11 @@ +"""库存管理 Flask 应用主文件。 + +中文说明: +1. 这个文件同时承担了配置加载、数据库模型、数据修复、页面路由、统计计算、AI 补货建议等职责。 +2. 为了便于你后续阅读和维护,关键函数上方会保留中文解释,说明“这个函数做什么”以及“为什么这样做”。 +3. 这些中文解释属于代码可读性的一部分,不应在后续维护中随意删除。 +""" + import os import re import csv @@ -24,11 +32,13 @@ DB_DIR = os.path.join(BASE_DIR, "data") os.makedirs(DB_DIR, exist_ok=True) DB_PATH = os.path.join(DB_DIR, "inventory.db") +# Flask 和 SQLAlchemy 基础初始化。 app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db = SQLAlchemy(app) +# 这里集中放全局常量,避免后面函数里散落硬编码。 LOW_STOCK_THRESHOLD = 5 BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json") AI_SETTINGS_PATH = os.path.join(DB_DIR, "ai_settings.json") @@ -86,6 +96,11 @@ BOX_TYPES = deepcopy(DEFAULT_BOX_TYPES) def _apply_box_type_overrides() -> None: + """加载盒型覆盖配置。 + + 中文说明:默认盒型写在代码里;如果用户在页面上修改了名称、描述、前缀, + 会写入 data/box_types.json,这里负责把这些覆盖项合并回运行时配置。 + """ if not os.path.exists(BOX_TYPES_OVERRIDE_PATH): return @@ -156,6 +171,11 @@ def _save_ai_settings(settings: dict) -> None: def _get_ai_settings() -> dict: + """读取并清洗 AI / 立创接口配置。 + + 中文说明:这个函数不只是“读文件”,还会把超时、阈值、布尔值这些字段 + 统一修正成安全可用的格式,避免后面的业务逻辑反复判空和转类型。 + """ settings = _load_ai_settings() try: @@ -226,6 +246,11 @@ def _extract_lcsc_product_id_from_input(raw_identifier: str) -> int | None: def _fetch_lcsc_product_basic(product_identifier: str, settings: dict) -> dict: + """调用立创开放接口,按商品详情页链接读取基础资料。 + + 中文说明:当前业务只接受“商品详情页链接”,先从链接里提取 productId, + 再按 JOP 鉴权规则签名请求接口,最后返回接口中的第一条商品记录。 + """ raw_identifier = (product_identifier or "").strip() if not raw_identifier: raise RuntimeError("立创商品链接不能为空") @@ -299,6 +324,11 @@ def _fetch_lcsc_product_basic(product_identifier: str, settings: dict) -> dict: def _map_lcsc_product_to_component(product: dict) -> dict: + """把立创商品字段映射为系统内部元件字段。 + + 中文说明:接口原始字段很多,这里只提取库存系统真正会用到的料号、名称、 + 规格和备注,尽量保持结果简洁且便于搜索。 + """ product_model = str(product.get("productModel") or "").strip() product_code = str(product.get("productCode") or "").strip() product_id = product.get("productId") @@ -364,6 +394,7 @@ def _map_lcsc_product_to_component(product: dict) -> dict: } +# Box 表示一个容器/盒子;Component 表示盒内某个位置上的元件。 class Box(db.Model): __tablename__ = "boxes" @@ -421,6 +452,11 @@ def _add_column_if_missing(table_name: str, column_name: str, ddl: str) -> None: def ensure_schema() -> None: + """对旧数据库做最小增量迁移。 + + 中文说明:项目早期数据库可能缺少新字段,这里用“缺什么补什么”的方式补列, + 这样升级时不会强制删库重建。 + """ _add_column_if_missing( "boxes", "box_type", @@ -499,6 +535,11 @@ def _find_enabled_material_conflict( exclude_component_id: int = None, exclude_part_no: str = "", ): + """查找“同名 + 同规格”的启用中物料冲突。 + + 中文说明:有些元件料号不同,但实际是同一种物料,所以不能只按 part_no 去重; + 这里会把名称和规格做标准化后比对,避免重复建库存位。 + """ target_key = _material_identity_key(name, specification) if not target_key: return None @@ -549,6 +590,13 @@ def _merge_into_existing_component( source_component: Component = None, source_box: Box = None, ) -> None: + """把一个待保存/待导入的元件合并进已有元件记录。 + + 中文说明:合并时会做三件事: + 1. 把名称、规格、备注等信息补到目标记录上。 + 2. 把数量累加,并记录库存事件。 + 3. 如果来源位置本来就有旧记录,则删除旧记录,避免同一物料保留两份。 + """ old_target_enabled_qty = int(target.quantity or 0) if target.is_enabled else 0 target.name = incoming_name or target.name @@ -709,6 +757,11 @@ def _parse_non_negative_int(raw_value: str, default_value: int = 0) -> int: def normalize_legacy_data() -> None: + """修复历史数据,使旧数据满足当前业务规则。 + + 中文说明:这个函数主要解决历史版本遗留问题,例如空 box_type、空前缀、 + 旧数据没有 is_enabled 字段,以及确保“袋装清单”这个固定容器一定存在。 + """ db.session.execute( db.text( "UPDATE boxes SET box_type = 'small_28' WHERE box_type IS NULL OR box_type = ''" @@ -898,6 +951,11 @@ def bad_request(message: str, box_type: str = ""): def render_box_page(box: Box, error: str = "", notice: str = ""): + """统一渲染盒子详情页。 + + 中文说明:很多路由最终都要返回 box.html,这里集中准备模板所需的公共数据, + 避免每个路由重复组织 slots、提示信息和阈值参数。 + """ slots = slot_data_for_box(box) return render_template( "box.html", @@ -939,6 +997,11 @@ def log_inventory_event( component: Component = None, part_no: str = "", ): + """记录库存变动日志。 + + 中文说明:统计页的趋势图、最近活动、净变化等都依赖这张事件表, + 所以凡是入库、出库、删除、合并等会改变库存的操作,都尽量走这里记账。 + """ event = InventoryEvent( box_id=box.id if box else (component.box_id if component else None), box_type=box.box_type if box else None, @@ -1090,6 +1153,11 @@ def recent_events(limit: int = 20, box_type_filter: str = "all"): def build_dashboard_context(): + """构建首页/分类页需要的聚合数据。 + + 中文说明:这里会一次性整理盒子分组、库存统计、低库存清单等信息, + 让模板层尽量只负责展示,不承担复杂的数据拼装逻辑。 + """ boxes = Box.query.all() box_by_id = {box.id: box for box in boxes} boxes.sort(key=box_sort_key) @@ -1176,6 +1244,11 @@ def build_dashboard_context(): def _build_restock_payload(*, limit: int = 20, threshold: int = LOW_STOCK_THRESHOLD) -> dict: + """生成补货分析输入数据。 + + 中文说明:AI 不直接读数据库,而是读取这里整理好的低库存清单和近 30 天出库热点, + 这样可以稳定控制输入格式,也方便 AI 异常时用同一份数据做规则兜底。 + """ boxes = Box.query.all() box_by_id = {box.id: box for box in boxes} enabled_components = Component.query.filter_by(is_enabled=True).all() @@ -2397,6 +2470,11 @@ def search_page(): @app.route("/ai/restock-plan", methods=["POST"]) def ai_restock_plan(): + """生成 AI 补货建议。 + + 中文说明:优先调用 AI 输出 JSON 结构化补货方案;如果 AI 调用失败或返回格式异常, + 会自动退回到规则生成的兜底方案,保证页面始终有结果可展示。 + """ ai_settings = _get_ai_settings() data = _build_restock_payload( limit=ai_settings["restock_limit"], @@ -2677,6 +2755,11 @@ def quick_outbound(component_id: int): @app.route("/stats") def stats_page(): + """统计页。 + + 中文说明:这里会按筛选条件汇总当前库存、低库存数量、操作次数、趋势线和分类排行, + 属于展示层用到的集中统计入口。 + """ days = parse_days_value(request.args.get("days", "7")) box_type_filter = parse_box_type_filter(request.args.get("box_type", "all")) notice = request.args.get("notice", "").strip() @@ -2915,6 +2998,10 @@ def clear_stats_logs(): def bootstrap() -> None: + """应用启动时初始化数据库。 + + 中文说明:启动顺序是“建表 -> 补字段 -> 修历史数据”,这样新旧数据库都能正常启动。 + """ with app.app_context(): db.create_all() ensure_schema()