From 8928da392967add01f3935b43d711b2e036ed5cb Mon Sep 17 00:00:00 2001 From: wangbeihong Date: Thu, 12 Mar 2026 14:09:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=94=81=E4=BB=93?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E8=AE=BE=E7=BD=AE=EF=BC=8C=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E7=89=A9=E6=96=99=E5=90=88=E5=B9=B6=E5=92=8C=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 489 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 409 insertions(+), 80 deletions(-) diff --git a/app.py b/app.py index 1d772c6..e83f65e 100644 --- a/app.py +++ b/app.py @@ -51,6 +51,7 @@ AI_SETTINGS_DEFAULT = { "lcsc_app_id": os.environ.get("LCSC_APP_ID", ""), "lcsc_access_key": os.environ.get("LCSC_ACCESS_KEY", ""), "lcsc_secret_key": os.environ.get("LCSC_SECRET_KEY", ""), + "lock_storage_mode": False, } @@ -185,6 +186,7 @@ def _get_ai_settings() -> dict: settings["lcsc_app_id"] = (settings.get("lcsc_app_id") or "").strip() settings["lcsc_access_key"] = (settings.get("lcsc_access_key") or "").strip() settings["lcsc_secret_key"] = (settings.get("lcsc_secret_key") or "").strip() + settings["lock_storage_mode"] = bool(settings.get("lock_storage_mode", False)) return settings @@ -458,6 +460,137 @@ def slot_range_label(box: Box) -> str: return f"{start_code}-{end_code}" +def _find_enabled_part_no_conflict(part_no: str, exclude_component_id: int = None): + normalized = (part_no or "").strip() + if not normalized: + return None + + query = Component.query.filter( + Component.part_no == normalized, + Component.is_enabled.is_(True), + ) + if exclude_component_id is not None: + query = query.filter(Component.id != exclude_component_id) + + return query.order_by(Component.box_id.asc(), Component.slot_index.asc()).first() + + +def _normalize_material_text(text: str) -> str: + raw = (text or "").upper().strip() + if not raw: + return "" + raw = raw.replace("(", "(").replace(")", ")") + raw = re.sub(r"[\s\-_/,|;:,。;:]+", "", raw) + raw = re.sub(r"[()\[\]{}]", "", raw) + return raw + + +def _material_identity_key(name: str, specification: str) -> str: + name_key = _normalize_material_text(name) + spec_key = _normalize_material_text(specification) + if not name_key and not spec_key: + return "" + return f"{name_key}|{spec_key}" + + +def _find_enabled_material_conflict( + name: str, + specification: str, + exclude_component_id: int = None, + exclude_part_no: str = "", +): + target_key = _material_identity_key(name, specification) + if not target_key: + return None + + query = Component.query.filter(Component.is_enabled.is_(True)) + if exclude_component_id is not None: + query = query.filter(Component.id != exclude_component_id) + + normalized_exclude_part_no = (exclude_part_no or "").strip() + for candidate in query.order_by(Component.box_id.asc(), Component.slot_index.asc()).all(): + if normalized_exclude_part_no and (candidate.part_no or "").strip() == normalized_exclude_part_no: + continue + if _material_identity_key(candidate.name, candidate.specification) == target_key: + return candidate + return None + + +def _is_truthy_form_value(value: str) -> bool: + return (value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _append_merge_part_no_note(note: str, part_no: str) -> str: + normalized = (part_no or "").strip() + if not normalized: + return note or "" + line = f"合并料号: {normalized}" + current = note or "" + if line in current: + return current + return f"{current}\n{line}".strip() + + +def _is_slot_part_replacement(component: Component, new_part_no: str) -> bool: + if component is None: + return False + old_part_no = (component.part_no or "").strip() + target_part_no = (new_part_no or "").strip() + return bool(old_part_no and target_part_no and old_part_no != target_part_no) + + +def _merge_into_existing_component( + target: Component, + incoming_part_no: str, + incoming_name: str, + incoming_specification: str, + incoming_note: str, + incoming_quantity: int, + source_component: Component = None, + source_box: Box = None, +) -> None: + old_target_enabled_qty = int(target.quantity or 0) if target.is_enabled else 0 + + target.name = incoming_name or target.name + if incoming_specification: + target.specification = incoming_specification + if incoming_note: + target.note = incoming_note + if incoming_part_no and incoming_part_no != target.part_no: + target.note = _append_merge_part_no_note(target.note, incoming_part_no) + + target.quantity = int(target.quantity or 0) + int(incoming_quantity or 0) + target.is_enabled = True + + target_delta = int(target.quantity or 0) - old_target_enabled_qty + if target_delta: + log_inventory_event( + event_type="component_merge_confirmed", + delta=target_delta, + box=target.box, + component=target, + part_no=target.part_no, + ) + + if source_component is not None and source_component.id != target.id: + if source_component.is_enabled and source_component.quantity: + log_inventory_event( + event_type="component_merge_cleanup", + delta=-int(source_component.quantity), + box=source_box, + component=source_component, + part_no=source_component.part_no, + ) + db.session.delete(source_component) + + +def _format_component_position(component: Component) -> str: + target_box = Box.query.get(component.box_id) + if not target_box: + return f"盒子#{component.box_id} 位置#{component.slot_index}" + return f"{target_box.name} {slot_code_for_box(target_box, component.slot_index)}" + + def compose_box_name(base_name: str, prefix: str, start_number: int, slot_capacity: int) -> str: base = (base_name or "").strip() if not base: @@ -1556,10 +1689,8 @@ def quick_inbound(box_id: int): components = Component.query.filter_by(box_id=box.id).all() occupied_slots = {c.slot_index for c in components} - by_part_no = {c.part_no: c for c in components if c.part_no} added_count = 0 - merged_count = 0 skipped_lines = [] changed = False @@ -1581,28 +1712,24 @@ def quick_inbound(box_id: int): skipped_lines.append(f"第 {line_no} 行") continue - existing = by_part_no.get(part_no) - if existing: - old_enabled_qty = existing.quantity if existing.is_enabled else 0 - existing.quantity += quantity - existing.name = name or existing.name - if specification: - existing.specification = specification - if note: - existing.note = note - existing.is_enabled = True - new_enabled_qty = existing.quantity - delta = new_enabled_qty - old_enabled_qty - if delta: - log_inventory_event( - event_type="quick_inbound_merge", - delta=delta, - box=box, - component=existing, - part_no=part_no, - ) - merged_count += 1 - changed = True + part_no_conflict = _find_enabled_part_no_conflict(part_no) + if part_no_conflict: + skipped_lines.append( + f"第 {line_no} 行({part_no} 已在 {_format_component_position(part_no_conflict)},需人工确认后再合并)" + ) + continue + + material_conflict = _find_enabled_material_conflict( + name=name, + specification=specification, + exclude_part_no=part_no, + ) + if material_conflict: + skipped_lines.append( + "第 " + f"{line_no} 行(检测到同参数物料: {material_conflict.part_no} 已在 " + f"{_format_component_position(material_conflict)},需人工确认后再合并)" + ) continue slot_index = _next_empty_slot_index(box, occupied_slots) @@ -1630,20 +1757,19 @@ def quick_inbound(box_id: int): part_no=part_no, ) occupied_slots.add(slot_index) - by_part_no[part_no] = item added_count += 1 changed = True if changed: db.session.commit() - if added_count == 0 and merged_count == 0: + if added_count == 0: return render_box_page( box, error="快速入库失败: 没有可导入的数据,请检查格式", ) - message = f"快速入库完成: 新增 {added_count} 条,合并 {merged_count} 条" + message = f"快速入库完成: 新增 {added_count} 条" if skipped_lines: message += ";跳过: " + ", ".join(skipped_lines) return render_box_page(box, notice=message) @@ -1668,28 +1794,30 @@ def add_bag_item(box_id: int): except ValueError: return render_box_page(box, error="袋装新增失败: 数量必须是大于等于 0 的整数") - existing = Component.query.filter_by(box_id=box.id, part_no=part_no).first() - if existing: - old_enabled_qty = existing.quantity if existing.is_enabled else 0 - existing.name = name or existing.name - existing.quantity += quantity - existing.specification = specification or existing.specification - existing.note = note or existing.note - existing.is_enabled = True + part_no_conflict = _find_enabled_part_no_conflict(part_no) + if part_no_conflict: + return render_box_page( + box, + error=( + f"袋装新增失败: 料号 {part_no} 已存在于 " + f"{_format_component_position(part_no_conflict)},需人工确认后再合并" + ), + ) - new_enabled_qty = existing.quantity - delta = int(new_enabled_qty - old_enabled_qty) - if delta: - log_inventory_event( - event_type="bag_merge", - delta=delta, - box=box, - component=existing, - part_no=part_no, - ) - - db.session.commit() - return render_box_page(box, notice="同料号已存在,已合并到原袋位") + material_conflict = _find_enabled_material_conflict( + name=name, + specification=specification, + exclude_part_no=part_no, + ) + if material_conflict: + return render_box_page( + box, + error=( + "袋装新增失败: 检测到同参数物料 " + f"{material_conflict.part_no} 已在 " + f"{_format_component_position(material_conflict)},需人工确认后再合并" + ), + ) next_slot = ( db.session.query(db.func.max(Component.slot_index)) @@ -1736,7 +1864,7 @@ def add_bag_items_batch(box_id: int): if not lines: return render_box_page(box, error="批量新增失败: 请至少输入一行") - invalid_lines = [] + skipped_lines = [] added_count = 0 next_slot = ( db.session.query(db.func.max(Component.slot_index)) @@ -1744,11 +1872,6 @@ def add_bag_items_batch(box_id: int): .scalar() or 0 ) + 1 - existing_by_part_no = { - c.part_no: c for c in Component.query.filter_by(box_id=box.id).all() if c.part_no - } - merged_count = 0 - for line_no, line in enumerate(lines, start=1): parts = [p.strip() for p in re.split(r"[,\t]", line)] while len(parts) < 5: @@ -1761,34 +1884,33 @@ def add_bag_items_batch(box_id: int): note = parts[4] if not part_no or not name: - invalid_lines.append(f"第 {line_no} 行") + skipped_lines.append(f"第 {line_no} 行(料号或名称为空)") continue try: quantity = _parse_non_negative_int(quantity_raw, 0) except ValueError: - invalid_lines.append(f"第 {line_no} 行") + skipped_lines.append(f"第 {line_no} 行(数量格式错误)") continue - existing = existing_by_part_no.get(part_no) - if existing: - old_enabled_qty = existing.quantity if existing.is_enabled else 0 - existing.name = name or existing.name - existing.quantity += quantity - existing.specification = specification or existing.specification - existing.note = note or existing.note - existing.is_enabled = True + part_no_conflict = _find_enabled_part_no_conflict(part_no) + if part_no_conflict: + skipped_lines.append( + f"第 {line_no} 行({part_no} 已在 {_format_component_position(part_no_conflict)},需人工确认后再合并)" + ) + continue - delta = int(existing.quantity - old_enabled_qty) - if delta: - log_inventory_event( - event_type="bag_batch_merge", - delta=delta, - box=box, - component=existing, - part_no=part_no, - ) - merged_count += 1 + material_conflict = _find_enabled_material_conflict( + name=name, + specification=specification, + exclude_part_no=part_no, + ) + if material_conflict: + skipped_lines.append( + "第 " + f"{line_no} 行(检测到同参数物料: {material_conflict.part_no} 已在 " + f"{_format_component_position(material_conflict)},需人工确认后再合并)" + ) continue new_component = Component( @@ -1802,7 +1924,6 @@ def add_bag_items_batch(box_id: int): is_enabled=True, ) db.session.add(new_component) - existing_by_part_no[part_no] = new_component if quantity: log_inventory_event( event_type="bag_batch_add", @@ -1818,19 +1939,19 @@ def add_bag_items_batch(box_id: int): box.slot_capacity = max(box.slot_capacity, next_slot - 1) db.session.commit() - if invalid_lines and added_count == 0 and merged_count == 0: + if skipped_lines and added_count == 0: return render_box_page( box, - error="批量新增失败: 数据格式不正确(请检查: " + ", ".join(invalid_lines) + ")", + error="批量新增失败: 没有可导入的数据(请检查: " + ", ".join(skipped_lines) + ")", ) - if invalid_lines: + if skipped_lines: return render_box_page( box, - notice=f"已新增 {added_count} 条,合并 {merged_count} 条,以下行被跳过: " + ", ".join(invalid_lines), + notice=f"已新增 {added_count} 条,以下行被跳过: " + ", ".join(skipped_lines), ) - return render_box_page(box, notice=f"批量新增成功:新增 {added_count} 条,合并 {merged_count} 条") + return render_box_page(box, notice=f"批量新增成功:新增 {added_count} 条") @app.route("/edit//", methods=["GET", "POST"]) @@ -1843,13 +1964,43 @@ def edit_component(box_id: int, slot: int): notice = request.args.get("notice", "").strip() error = request.args.get("error", "").strip() component = Component.query.filter_by(box_id=box.id, slot_index=slot).first() + settings = _get_ai_settings() + lock_storage_mode = bool(settings.get("lock_storage_mode", False)) if request.method == "POST": action = request.form.get("action", "save") search_query_post = request.form.get("q", "").strip() search_query_effective = search_query_post or search_query + delete_confirm_slot = request.form.get("delete_confirm_slot", "").strip().upper() + expected_slot_code = slot_code_for_box(box, slot).strip().upper() if action == "delete": + if lock_storage_mode: + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + error="锁仓模式已开启,禁止删除位置绑定。", + notice=notice, + search_query=search_query_post, + lock_storage_mode=lock_storage_mode, + ) + + if delete_confirm_slot != expected_slot_code: + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + error=f"删除确认失败: 请输入当前位置编号 {expected_slot_code}", + notice=notice, + search_query=search_query_post, + lock_storage_mode=lock_storage_mode, + ) + if component: if component.is_enabled and component.quantity: log_inventory_event( @@ -1897,6 +2048,8 @@ def edit_component(box_id: int, slot: int): specification = request.form.get("specification", "").strip() quantity_raw = request.form.get("quantity", "0").strip() note = request.form.get("note", "").strip() + confirm_merge = _is_truthy_form_value(request.form.get("confirm_merge", "")) + confirm_position_change = _is_truthy_form_value(request.form.get("confirm_position_change", "")) if not part_no or not name: error = "料号和名称不能为空" @@ -1909,6 +2062,7 @@ def edit_component(box_id: int, slot: int): error=error, notice=notice, search_query=search_query_post, + lock_storage_mode=lock_storage_mode, ) try: @@ -1924,8 +2078,97 @@ def edit_component(box_id: int, slot: int): error=error, notice=notice, search_query=search_query_post, + lock_storage_mode=lock_storage_mode, ) + if lock_storage_mode and _is_slot_part_replacement(component, part_no): + error = "锁仓模式已开启,禁止替换当前位置绑定料号。" + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + error=error, + notice=notice, + search_query=search_query_post, + lock_storage_mode=lock_storage_mode, + ) + + if _is_slot_part_replacement(component, part_no) and not confirm_position_change: + error = ( + "当前位已绑定到固定料号,检测到替换操作。" + "如需变更位置绑定,请勾选“我确认替换当前位物料”后再保存" + ) + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + error=error, + notice=notice, + search_query=search_query_post, + lock_storage_mode=lock_storage_mode, + ) + + current_component_id = component.id if component is not None else None + part_no_conflict = _find_enabled_part_no_conflict(part_no, exclude_component_id=current_component_id) + material_conflict = _find_enabled_material_conflict( + name=name, + specification=specification, + exclude_component_id=current_component_id, + exclude_part_no=part_no, + ) + conflict = part_no_conflict or material_conflict + if conflict and not confirm_merge: + conflict_reason = "同料号" if part_no_conflict else "同参数" + error = ( + f"检测到{conflict_reason}物料已存在于 {_format_component_position(conflict)};" + "请勾选“人工确认后合并”再保存" + ) + return render_template( + "edit.html", + box=box, + slot=slot, + slot_code=slot_code_for_box(box, slot), + component=component, + error=error, + notice=notice, + search_query=search_query_post, + lock_storage_mode=lock_storage_mode, + ) + + if conflict and confirm_merge: + _merge_into_existing_component( + target=conflict, + incoming_part_no=part_no, + incoming_name=name, + incoming_specification=specification, + incoming_note=note, + incoming_quantity=quantity, + source_component=component, + source_box=box, + ) + db.session.commit() + + target_box = Box.query.get(conflict.box_id) + target_slot = conflict.slot_index + notice_text = ( + f"已人工确认合并到 {_format_component_position(conflict)}," + f"累计数量 {conflict.quantity}" + ) + if target_box: + return redirect( + url_for( + "edit_component", + box_id=target_box.id, + slot=target_slot, + notice=notice_text, + ) + ) + return redirect(url_for("view_box", box_id=box.id, notice=notice_text)) + old_enabled_qty = 0 if component is not None and component.is_enabled: old_enabled_qty = int(component.quantity) @@ -1967,6 +2210,7 @@ def edit_component(box_id: int, slot: int): error=error, notice=notice, search_query=search_query, + lock_storage_mode=lock_storage_mode, ) @@ -1993,6 +2237,88 @@ def lcsc_import_to_edit_slot(box_id: int, slot: int): return redirect(url_for("edit_component", box_id=box.id, slot=slot, error="立创导入失败: 商品信息缺少料号或名称")) component = Component.query.filter_by(box_id=box.id, slot_index=slot).first() + confirm_merge = _is_truthy_form_value(request.form.get("confirm_merge", "")) + confirm_position_change = _is_truthy_form_value(request.form.get("confirm_position_change", "")) + + if bool(settings.get("lock_storage_mode", False)) and _is_slot_part_replacement(component, mapped["part_no"]): + return redirect( + url_for( + "edit_component", + box_id=box.id, + slot=slot, + error="立创导入失败: 锁仓模式已开启,禁止替换当前位置绑定料号。", + ) + ) + + if _is_slot_part_replacement(component, mapped["part_no"]) and not confirm_position_change: + return redirect( + url_for( + "edit_component", + box_id=box.id, + slot=slot, + error=( + "立创导入失败: 当前位已绑定固定料号。" + "如需替换,请勾选“我确认替换当前位物料”后再导入" + ), + ) + ) + + current_component_id = component.id if component is not None else None + part_no_conflict = _find_enabled_part_no_conflict( + mapped["part_no"], + exclude_component_id=current_component_id, + ) + material_conflict = _find_enabled_material_conflict( + name=mapped["name"], + specification=mapped["specification"], + exclude_component_id=current_component_id, + exclude_part_no=mapped["part_no"], + ) + conflict = part_no_conflict or material_conflict + if conflict and not confirm_merge: + conflict_reason = "同料号" if part_no_conflict else "同参数" + return redirect( + url_for( + "edit_component", + box_id=box.id, + slot=slot, + error=( + f"立创导入失败: 检测到{conflict_reason}物料已存在于 " + f"{_format_component_position(conflict)},请勾选人工确认后合并" + ), + ) + ) + + if conflict and confirm_merge: + _merge_into_existing_component( + target=conflict, + incoming_part_no=mapped["part_no"], + incoming_name=mapped["name"], + incoming_specification=mapped["specification"], + incoming_note=mapped["note"], + incoming_quantity=quantity, + source_component=component, + source_box=box, + ) + db.session.commit() + + target_box = Box.query.get(conflict.box_id) + target_slot = conflict.slot_index + notice_text = ( + f"已人工确认合并到 {_format_component_position(conflict)}," + f"累计数量 {conflict.quantity}" + ) + if target_box: + return redirect( + url_for( + "edit_component", + box_id=target_box.id, + slot=target_slot, + notice=notice_text, + ) + ) + return redirect(url_for("view_box", box_id=box.id, notice=notice_text)) + old_enabled_qty = int(component.quantity or 0) if component and component.is_enabled else 0 if component is None: @@ -2225,6 +2551,7 @@ def ai_settings_page(): lcsc_app_id = request.form.get("lcsc_app_id", "").strip() lcsc_access_key = request.form.get("lcsc_access_key", "").strip() lcsc_secret_key = request.form.get("lcsc_secret_key", "").strip() + lock_storage_mode = _is_truthy_form_value(request.form.get("lock_storage_mode", "")) try: timeout = int((request.form.get("timeout", "30") or "30").strip()) @@ -2282,6 +2609,7 @@ def ai_settings_page(): "lcsc_app_id": lcsc_app_id, "lcsc_access_key": lcsc_access_key, "lcsc_secret_key": lcsc_secret_key, + "lock_storage_mode": lock_storage_mode, } _save_ai_settings(settings) return redirect(url_for("ai_settings_page", notice="AI参数已保存")) @@ -2300,6 +2628,7 @@ def ai_settings_page(): "lcsc_app_id": lcsc_app_id, "lcsc_access_key": lcsc_access_key, "lcsc_secret_key": lcsc_secret_key, + "lock_storage_mode": lock_storage_mode, } )