mengchang пре 2 месеци
родитељ
комит
0ecfec5734
3 измењених фајлова са 610 додато и 105 уклоњено
  1. 276 105
      backend/routers/estimate.py
  2. 62 0
      tools/import_damage_override.py
  3. 272 0
      tools/init_price_option_factor.py

+ 276 - 105
backend/routers/estimate.py

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Request, Depends, Form
 from fastapi.responses import HTMLResponse
 from sqlalchemy.orm import Session
 from sqlalchemy import text
+from collections import defaultdict
 from database import SessionLocal, redis_client
 from services.template_service import build_template
 from services.price_service import apply_adjust
@@ -948,113 +949,283 @@ async def simulate_calc(
         }
     )
 
-    
-@router.post("/estimate2", response_class=HTMLResponse)
-def estimate_submit(
-    request: Request,
-    machine_id: int = Form(...),
-    capacity: str = Form(...),
-    option_ids: list[int] = Form([]),
-    db: Session = Depends(get_db)
-):
-    # ---------- 基础价格 ----------
-    base_price = db.execute(
-        text("""
-            SELECT base_price
-            FROM machine_base_price
-            WHERE machine_id=:mid AND capacity=:cap
-        """),
-        {"mid": machine_id, "cap": capacity}
-    ).scalar() or 0
-
-    price = float(base_price)
-
-    # ---------- 扣减项 ----------
-    for oid in option_ids:
-        r = db.execute(
-            text("""
-                SELECT factor, absolute_deduct
-                FROM price_option_factor
-                WHERE option_id=:oid
-            """),
-            {"oid": oid}
-        ).fetchone()
-
-        if r:
-            price = price * float(r.factor) - float(r.absolute_deduct)
-
-    # ---------- 调节系数 ----------
-    brand_id = db.execute(
-        text("""
-            SELECT brand_id
-            FROM t_machine
-            WHERE machine_id=:mid
-        """),
-        {"mid": machine_id}
-    ).scalar()
-
-    price = apply_adjust(db, machine_id, brand_id, price)
-    price = round(max(price, 0), 2)
-
-    # ---------- 估价版本 ----------
-    version_no = str(uuid.uuid4())[:8]
-
-    db.execute(
-        text("""
-            INSERT INTO estimate_record
-            (machine_id, capacity, option_ids, final_price, version_no)
-            VALUES (:m, :c, :o, :p, :v)
-        """),
-        {
-            "m": machine_id,
-            "c": capacity,
-            "o": ",".join(map(str, option_ids)),
-            "p": price,
-            "v": version_no
+
+SPECIAL_DISCOUNT_RATIO = 0.70   # 特殊项触发后,对剩余价格打7折
+
+
+REPAIR_LEVEL_CAP = {
+    3: 0.60,   # 核心维修,最多吃掉基础价60%
+    2: 0.35,   # 重要维修
+    1: 0.15,   # 次要维修
+}
+
+
+def calculate_price(db, base_price: float, selected_option_ids: list[int]):
+
+    if not selected_option_ids:
+        return {
+            "base_price": base_price,
+            "final_price": base_price,
+            "details": []
         }
-    )
-    db.commit()
 
-    # ---------- 模板(Redis 缓存) ----------
-    cache_key = f"template:{machine_id}"
-    cached = redis_client.get(cache_key) if redis_client else None
+    # -------------------------------------------------
+    # 1. 读取 option 规则
+    # -------------------------------------------------
 
-    if cached:
-        template = json.loads(cached)
-    else:
-        rows = db.execute(text("SELECT * FROM release_option")).fetchall()
-        template = build_template(rows)
-        if redis_client:
-            redis_client.set(cache_key, json.dumps(template), ex=3600)
-
-    # ---------- 重新加载页面 ----------
-    machines = db.execute(
-        text("""
-            SELECT machine_id, name
-            FROM t_machine
-            ORDER BY brand_name, name
-        """)
-    ).fetchall()
-
-    capacities = db.execute(
-        text("""
-            SELECT capacity
-            FROM machine_base_price
-            WHERE machine_id=:mid
-        """),
-        {"mid": machine_id}
-    ).fetchall()
+    rows = db.execute(text("""
+        SELECT
+            f.option_id,
+            f.factor,
+            f.absolute_deduct,
+            f.group_code,
+            f.severity_level,
+            f.sub_weight,
+            f.is_special,
+            f.repair_level,
+            o.option_name
+        FROM price_option_factor f
+        LEFT JOIN release_option o
+            ON o.option_id = f.option_id
+        WHERE f.option_id IN :ids
+    """), {
+        "ids": tuple(selected_option_ids)
+    }).mappings().all()
+
+    # -------------------------------------------------
+    # 2. 分组
+    # -------------------------------------------------
+
+    group_map = defaultdict(list)
+    special_items = []
+    repair_items = []
 
-    return request.app.state.templates.TemplateResponse(
-        "estimate.html",
-        {
-            "request": request,
-            "machines": machines,
-            "capacities": capacities,
-            "template": template,
-            "price": price,
-            "version": version_no,
-            "selected_machine": machine_id,
-            "selected_capacity": capacity
+    for r in rows:
+        r = dict(r)
+
+        if r["is_special"]:
+            special_items.append(r)
+
+        if r["repair_level"] and r["repair_level"] > 0:
+            repair_items.append(r)
+
+        group_code = r["group_code"] or "default"
+        group_map[group_code].append(r)
+
+    # -------------------------------------------------
+    # 3. 读取分组配置
+    # -------------------------------------------------
+
+    group_rows = db.execute(text("""
+        SELECT
+            group_code,
+            cap_ratio,
+            group_weight
+        FROM price_damage_group
+    """)).mappings().all()
+
+    group_conf = {r["group_code"]: r for r in group_rows}
+
+    # -------------------------------------------------
+    # 4. 读取组联动
+    # -------------------------------------------------
+
+    override_rows = db.execute(text("""
+        SELECT
+            trigger_group_code,
+            target_group_code,
+            override_type,
+            override_value
+        FROM price_group_override
+    """)).mappings().all()
+
+    overrides = defaultdict(list)
+    for r in override_rows:
+        overrides[r["trigger_group_code"]].append(r)
+
+    # -------------------------------------------------
+    # 5. 普通损伤组计算(不含repair组)
+    # -------------------------------------------------
+
+    group_result = {}
+
+    for group_code, items in group_map.items():
+
+        if group_code == "repair":
+            continue
+
+        effective = [
+            i for i in items
+            if (i["factor"] or 0) > 0 or (i["absolute_deduct"] or 0) > 0
+        ]
+
+        if not effective:
+            continue
+
+        effective.sort(
+            key=lambda x: (
+                x["severity_level"] or 0,
+                x["factor"] or 0
+            ),
+            reverse=True
+        )
+
+        main = effective[0]
+        minors = effective[1:]
+
+        main_deduct = base_price * (main["factor"] or 0) + (main["absolute_deduct"] or 0)
+
+        minor_sum = 0
+        for m in minors:
+            w = m["sub_weight"] if m["sub_weight"] is not None else 0.3
+            minor_sum += base_price * (m["factor"] or 0) * w
+
+        raw = main_deduct + minor_sum
+
+        conf = group_conf.get(group_code)
+        if conf:
+            cap = main_deduct * float(conf["cap_ratio"])
+            deduct = min(raw, cap)
+            deduct *= float(conf["group_weight"])
+        else:
+            deduct = raw
+
+        group_result[group_code] = {
+            "deduct": deduct,
+            "main": main,
+            "has_effect": deduct > 0
         }
-    )
+
+    # -------------------------------------------------
+    # 6. repair 组分级模型
+    # -------------------------------------------------
+
+    repair_deduct = 0
+    repair_detail = []
+
+    if repair_items:
+
+        by_level = defaultdict(list)
+        for r in repair_items:
+            by_level[r["repair_level"]].append(r)
+
+        for level, items in by_level.items():
+
+            items = [
+                i for i in items
+                if (i["factor"] or 0) > 0 or (i["absolute_deduct"] or 0) > 0
+            ]
+
+            if not items:
+                continue
+
+            # 同级只取最严重
+            items.sort(key=lambda x: x["factor"] or 0, reverse=True)
+
+            main = items[0]
+
+            d = base_price * (main["factor"] or 0) + (main["absolute_deduct"] or 0)
+
+            cap_ratio = REPAIR_LEVEL_CAP.get(level, 1.0)
+
+            d = min(d, base_price * cap_ratio)
+
+            repair_deduct += d
+
+            repair_detail.append({
+                "repair_level": level,
+                "option_name": main["option_name"],
+                "deduct": round(d, 2)
+            })
+
+        group_result["repair"] = {
+            "deduct": repair_deduct,
+            "main": None,
+            "has_effect": repair_deduct > 0,
+            "detail": repair_detail
+        }
+
+    # -------------------------------------------------
+    # 7. 组联动
+    # -------------------------------------------------
+
+    disabled_groups = set()
+    group_weight_override = {}
+
+    for trigger, rules in overrides.items():
+
+        if trigger not in group_result:
+            continue
+
+        if not group_result[trigger]["has_effect"]:
+            continue
+
+        for rule in rules:
+
+            target = rule["target_group_code"]
+
+            if rule["override_type"] == "skip":
+                disabled_groups.add(target)
+
+            elif rule["override_type"] == "weight":
+                group_weight_override[target] = float(rule["override_value"])
+
+    # -------------------------------------------------
+    # 8. 汇总普通扣减
+    # -------------------------------------------------
+
+    total_deduct = 0
+    details = []
+
+    for group_code, g in group_result.items():
+
+        if group_code in disabled_groups:
+            continue
+
+        d = g["deduct"]
+
+        if group_code in group_weight_override:
+            d *= group_weight_override[group_code]
+
+        total_deduct += d
+
+        if group_code == "repair":
+            details.append({
+                "group": "repair",
+                "detail": g["detail"],
+                "group_deduct": round(d, 2)
+            })
+        else:
+            details.append({
+                "group": group_code,
+                "option_name": g["main"]["option_name"],
+                "group_deduct": round(d, 2)
+            })
+
+    price_after_damage = base_price - total_deduct
+    if price_after_damage < 0:
+        price_after_damage = 0
+
+    # -------------------------------------------------
+    # 9. 特殊项整体折价
+    # -------------------------------------------------
+
+    special_applied = []
+
+    if special_items:
+
+        # 你后面可以在这里按 option 决定不同折扣
+        price_after_damage *= SPECIAL_DISCOUNT_RATIO
+
+        for s in special_items:
+            special_applied.append(s["option_name"])
+
+    final_price = round(price_after_damage, 2)
+
+    return {
+        "base_price": round(base_price, 2),
+        "final_price": final_price,
+        "damage_deduct": round(total_deduct, 2),
+        "special_discount_applied": special_applied,
+        "details": details
+    }

+ 62 - 0
tools/import_damage_override.py

@@ -0,0 +1,62 @@
+import pymysql
+
+conn = pymysql.connect(
+    host="127.0.0.1",
+    user="root",
+    password="root",
+    database="recycle",
+    charset="utf8mb4"
+)
+
+damage_groups = [
+    ("boot",          "开机状态组",   1.00, 1.00),
+    ("body",          "机身外观组",   1.20, 1.00),
+    ("screen",        "屏幕综合组",   1.25, 1.10),
+    ("account",       "账号状态组",   1.00, 1.00),
+    ("function",      "功能异常组",   1.15, 1.00),
+    ("hinge",         "转轴结构组",   1.10, 1.00),
+    ("repair",        "维修履历组",   1.30, 1.10),
+    ("other_special", "其他功能问题组", 1.00, 1.00),
+]
+
+group_overrides = [
+    # 有维修 → 屏幕组权重下降
+    ("repair", "screen", "weight", 0.85),
+
+    # 无法开机类问题存在时,功能异常组跳过
+    ("boot", "function", "skip", None),
+]
+
+
+with conn.cursor() as cur:
+
+    # ----------------------------
+    # price_damage_group
+    # ----------------------------
+    for g in damage_groups:
+        cur.execute("""
+            INSERT INTO price_damage_group
+                (group_code, group_name, cap_ratio, group_weight)
+            VALUES (%s,%s,%s,%s)
+            ON DUPLICATE KEY UPDATE
+                group_name=VALUES(group_name),
+                cap_ratio=VALUES(cap_ratio),
+                group_weight=VALUES(group_weight)
+        """, g)
+
+    # ----------------------------
+    # price_group_override
+    # ----------------------------
+    for o in group_overrides:
+        cur.execute("""
+            INSERT INTO price_group_override
+                (trigger_group_code, target_group_code, override_type, override_value)
+            VALUES (%s,%s,%s,%s)
+            ON DUPLICATE KEY UPDATE
+                override_type=VALUES(override_type),
+                override_value=VALUES(override_value)
+        """, o)
+
+    conn.commit()
+
+print("price_damage_group / price_group_override 初始化完成")

+ 272 - 0
tools/init_price_option_factor.py

@@ -0,0 +1,272 @@
+# import pymysql
+
+# DB = dict(
+#     host="127.0.0.1",
+#     user="root",
+#     password="root",
+#     database="recycle",
+#     charset="utf8mb4"
+# )
+
+# GROUP_MAP = {
+#     "账号情况": "account",
+#     "机身外观": "body",
+#     "屏幕外观": "screen_appearance",
+#     "屏幕显示": "screen_display",
+#     "触摸": "touch",
+#     "拍摄": "camera",
+#     "WiFi/蓝牙": "wifi",
+#     "通话": "call",
+#     "面容/指纹": "biometrics",
+#     "其他功能问题(可多选或不选)": "other_function",
+#     "整机维修(可多选或不选)": "repair",
+#     "转轴情况": "hinge",
+# }
+
+# SPECIAL_OPTION_IDS = {
+#     100560,   # 已开启丢失模式
+# }
+
+# # `repair_level` tinyint DEFAULT '0' COMMENT '维修分级:0非维修 1次要 2重要 3核心',
+# repair_level_map = {
+#     "core": 3,
+#     "important": 2,
+#     "minor": 1
+# }
+# REPAIR_LEVEL_MAP = {
+#     # core
+#     100384: repair_level_map["core"],
+
+#     # important
+#     100346: repair_level_map["important"],
+#     100530: repair_level_map["important"],
+#     100382: repair_level_map["important"],
+#     100383: repair_level_map["important"],
+
+#     # minor
+#     100381: repair_level_map["minor"],
+#     100410: repair_level_map["minor"],
+#     100529: repair_level_map["minor"],
+# }
+
+# def calc_severity(factor: float) -> int:
+#     if factor >= 0.40:
+#         return 3
+#     if factor >= 0.20:
+#         return 2
+#     if factor > 0:
+#         return 1
+#     return 0
+
+
+# def main():
+
+#     conn = pymysql.connect(**DB)
+#     cur = conn.cursor(pymysql.cursors.DictCursor)
+
+#     # 取出所有 option + factor + 维度名称
+#     cur.execute("""
+#         SELECT
+#             f.id,
+#             f.option_id,
+#             f.factor,
+#             o.option_key_name
+#         FROM price_option_factor f
+#         LEFT JOIN release_option o
+#             ON f.option_id = o.option_id
+#     """)
+
+#     rows = cur.fetchall()
+
+#     print("rows:", len(rows))
+
+#     for r in rows:
+
+#         option_id = int(r["option_id"])
+#         factor = float(r["factor"] or 0)
+#         option_key_name = r["option_key_name"]
+
+#         group_code = GROUP_MAP.get(option_key_name, "other")
+#         severity_level = calc_severity(factor)
+
+#         # 次瑕疵折扣系数(统一先给 0.4)
+#         sub_weight = 0.4
+
+#         is_special = 1 if option_id in SPECIAL_OPTION_IDS else 0
+
+#         repair_level = REPAIR_LEVEL_MAP.get(option_id)
+
+#         cur.execute("""
+#             UPDATE price_option_factor
+#             SET
+#                 group_code = %s,
+#                 severity_level = %s,
+#                 sub_weight = %s,
+#                 is_special = %s,
+#                 repair_level = %s
+#             WHERE id = %s
+#         """, (
+#             group_code,
+#             severity_level,
+#             sub_weight,
+#             is_special,
+#             repair_level,
+#             r["id"]
+#         ))
+
+#     conn.commit()
+#     cur.close()
+#     conn.close()
+
+#     print("done.")
+
+
+
+import pymysql
+
+conn = pymysql.connect(
+    host="127.0.0.1",
+    user="root",
+    password="root",
+    database="recycle",
+    charset="utf8mb4",
+    cursorclass=pymysql.cursors.DictCursor
+)
+
+GROUP_MAP = {
+    "boot": ["开机情况"],
+
+    "body": ["机身外观"],
+
+    # 屏幕综合组 = 外观 + 显示 + 触摸
+    "screen": ["屏幕外观", "屏幕显示", "触摸"],
+
+    "account": ["账号情况"],
+
+    "function": ["WiFi/蓝牙", "面容/指纹", "拍摄", "通话"],
+
+    "hinge": ["转轴情况"],
+
+    "repair": ["整机维修(可多选或不选)"],
+
+    "other_special": ["其他功能问题(可多选或不选)"]
+}
+
+repair_level_map = {
+    "core": 3,
+    "important": 2,
+    "minor": 1
+}
+
+
+def get_group_code(row):
+    for g, keys in GROUP_MAP.items():
+        if row["option_key_name"] in keys:
+            return g
+    return None
+
+
+def calc_severity(factor):
+    if factor is None:
+        return 0
+
+    f = float(factor)
+
+    if f <= 0:
+        return 0
+    elif f < 0.06:
+        return 1
+    elif f < 0.12:
+        return 2
+    elif f < 0.25:
+        return 3
+    elif f < 0.4:
+        return 4
+    else:
+        return 5
+
+
+def calc_repair_level(option_name):
+    if "无维修" in option_name:
+        return 0
+
+    if "主板" in option_name:
+        return "core"
+
+    if "屏幕维修" in option_name:
+        return "important"
+
+    if "更换原厂屏" in option_name:
+        return "important"
+
+    if "摄像头" in option_name:
+        return "important"
+
+    if "电池" in option_name:
+        return "minor"
+
+    if "后壳" in option_name:
+        return "minor"
+
+    if "配件" in option_name:
+        return "minor"
+
+    return None
+
+
+def is_special(row):
+    return row["option_key_name"] == "其他功能问题(可多选或不选)"
+
+def main():
+    with conn.cursor() as cur:
+
+        cur.execute("""
+            SELECT
+                r.option_id,
+                r.option_name,
+                r.option_key_name,
+                p.factor
+            FROM release_option r
+            JOIN price_option_factor p
+              ON r.option_id = p.option_id
+        """)
+
+        rows = cur.fetchall()
+
+        for r in rows:
+
+            group_code = get_group_code(r)
+            severity = calc_severity(r["factor"])
+            sub_weight = 0.5 if group_code in ("screen", "body", "function", "hinge") else None
+            special = 1 if is_special(r) else 0
+
+            repair_level = None
+            if group_code == "repair":
+                repair_level = calc_repair_level(r["option_name"])
+                if repair_level:
+                    repair_level = repair_level_map[repair_level]
+            cur.execute("""
+                UPDATE price_option_factor
+                SET
+                    group_code = %s,
+                    severity_level = %s,
+                    sub_weight = %s,
+                    is_special = %s,
+                    repair_level = %s
+                WHERE option_id = %s
+            """, (
+                group_code,
+                severity,
+                sub_weight,
+                special,
+                repair_level,
+                r["option_id"]
+            ))
+
+        conn.commit()
+
+    print("done.")
+
+
+if __name__ == "__main__":
+    main()